blob: 89b523811850836ddf5af77141b1a94e1d2fa3d6 [file] [log] [blame]
// Copyright 2016 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.
/**
* @fileoverview ChromeVox commands.
*/
import {AutomationPredicate} from '../../common/automation_predicate.js';
import {AutomationUtil} from '../../common/automation_util.js';
import {Cursor, CursorUnit} from '../../common/cursors/cursor.js';
import {CursorRange} from '../../common/cursors/range.js';
import {EventGenerator} from '../../common/event_generator.js';
import {KeyCode} from '../../common/key_code.js';
import {Earcon} from '../common/abstract_earcons.js';
import {AbstractTts} from '../common/abstract_tts.js';
import {NavBraille} from '../common/braille/nav_braille.js';
import {BridgeConstants} from '../common/bridge_constants.js';
import {BridgeHelper} from '../common/bridge_helper.js';
import {CommandStore} from '../common/command_store.js';
import {ChromeVoxEvent, CustomAutomationEvent} from '../common/custom_automation_event.js';
import {EventSourceType} from '../common/event_source_type.js';
import {GestureGranularity} from '../common/gesture_command_data.js';
import {ChromeVoxKbHandler} from '../common/keyboard_handler.js';
import {LogType} from '../common/log_types.js';
import {Msgs} from '../common/msgs.js';
import {PanelCommand, PanelCommandType} from '../common/panel_command.js';
import {TreeDumper} from '../common/tree_dumper.js';
import {QueueMode, TtsSpeechProperties} from '../common/tts_interface.js';
import {AutoScrollHandler} from './auto_scroll_handler.js';
import {BrailleBackground} from './braille/braille_background.js';
import {BrailleCaptionsBackground} from './braille/braille_captions_background.js';
import {ChromeVox} from './chromevox.js';
import {ChromeVoxState} from './chromevox_state.js';
import {ChromeVoxBackground} from './classic_background.js';
import {Color} from './color.js';
import {CommandHandlerInterface} from './command_handler_interface.js';
import {DesktopAutomationInterface} from './desktop_automation_interface.js';
import {TypingEcho} from './editing/editable_text_base.js';
import {EventSourceState} from './event_source.js';
import {GestureInterface} from './gesture_interface.js';
import {LogStore} from './logging/log_store.js';
import {Output} from './output/output.js';
import {OutputEventType} from './output/output_types.js';
import {PhoneticData} from './phonetic_data.js';
import {ChromeVoxPrefs} from './prefs.js';
import {SmartStickyMode} from './smart_sticky_mode.js';
const AutomationNode = chrome.automation.AutomationNode;
const Dir = constants.Dir;
const EventType = chrome.automation.EventType;
const RoleType = chrome.automation.RoleType;
export class CommandHandler extends CommandHandlerInterface {
/** @private */
constructor() {
super();
/**
* To support viewGraphicAsBraille_(), the current image node.
* @type {?AutomationNode}
*/
this.imageNode_;
/** @private {boolean} */
this.isIncognito_ = Boolean(chrome.runtime.getManifest()['incognito']);
/** @private {boolean} */
this.isKioskSession_ = false;
/** @private {boolean} */
this.languageLoggingEnabled_ = false;
/**
* Handles toggling sticky mode when encountering editables.
* @private {!SmartStickyMode}
*/
this.smartStickyMode_ = new SmartStickyMode();
this.init();
}
/** @override */
onCommand(command) {
// Check for a command denied in incognito contexts and kiosk.
if (!this.isAllowed_(command)) {
return true;
}
// Check for loss of focus which results in us invalidating our current
// range. Note this call is synchronous.
chrome.automation.getFocus(focus => this.checkForLossOfFocus_(focus));
// These commands don't require a current range.
switch (command) {
case 'speakTimeAndDate':
chrome.automation.getDesktop(function(d) {
// First, try speaking the on-screen time.
const allTime = d.findAll({role: RoleType.TIME});
allTime.filter(function(t) {
return t.root.role === RoleType.DESKTOP;
});
let timeString = '';
allTime.forEach(function(t) {
if (t.name) {
timeString = t.name;
}
});
if (timeString) {
ChromeVox.tts.speak(timeString, QueueMode.FLUSH);
ChromeVox.braille.write(NavBraille.fromText(timeString));
} else {
// Fallback to the old way of speaking time.
const output = new Output();
const dateTime = new Date();
output
.withString(
dateTime.toLocaleTimeString() + ', ' +
dateTime.toLocaleDateString())
.go();
}
});
return false;
case 'showOptionsPage':
chrome.runtime.openOptionsPage();
break;
case 'toggleStickyMode':
ChromeVoxBackground.setPref('sticky', !ChromeVox.isStickyPrefOn, true);
if (ChromeVoxState.instance.currentRange) {
this.smartStickyMode_.onStickyModeCommand(
ChromeVoxState.instance.currentRange);
}
return false;
case 'passThroughMode':
ChromeVox.passThroughMode = true;
ChromeVox.tts.speak(Msgs.getMsg('pass_through_key'), QueueMode.QUEUE);
return true;
case 'showLearnModePage':
const explorerPage = {
url: 'chromevox/learn_mode/learn_mode.html',
type: 'panel',
};
chrome.windows.create(explorerPage);
break;
case 'showLogPage':
const logPage = {url: 'chromevox/log_page/log.html'};
chrome.tabs.create(logPage);
break;
case 'enableLogging': {
for (const type in ChromeVoxPrefs.loggingPrefs) {
ChromeVoxPrefs.instance.setLoggingPrefs(
ChromeVoxPrefs.loggingPrefs[type], true);
}
} break;
case 'disableLogging': {
for (const type in ChromeVoxPrefs.loggingPrefs) {
ChromeVoxPrefs.instance.setLoggingPrefs(
ChromeVoxPrefs.loggingPrefs[type], false);
}
} break;
case 'dumpTree':
chrome.automation.getDesktop(function(root) {
LogStore.getInstance().writeTreeLog(new TreeDumper(root));
});
break;
case 'decreaseTtsRate':
this.increaseOrDecreaseSpeechProperty_(AbstractTts.RATE, false);
return false;
case 'increaseTtsRate':
this.increaseOrDecreaseSpeechProperty_(AbstractTts.RATE, true);
return false;
case 'decreaseTtsPitch':
this.increaseOrDecreaseSpeechProperty_(AbstractTts.PITCH, false);
return false;
case 'increaseTtsPitch':
this.increaseOrDecreaseSpeechProperty_(AbstractTts.PITCH, true);
return false;
case 'decreaseTtsVolume':
this.increaseOrDecreaseSpeechProperty_(AbstractTts.VOLUME, false);
return false;
case 'increaseTtsVolume':
this.increaseOrDecreaseSpeechProperty_(AbstractTts.VOLUME, true);
return false;
case 'stopSpeech':
ChromeVox.tts.stop();
ChromeVoxState.instance.isReadingContinuously = false;
return false;
case 'toggleEarcons': {
ChromeVox.earcons.enabled = !ChromeVox.earcons.enabled;
const announce = ChromeVox.earcons.enabled ? Msgs.getMsg('earcons_on') :
Msgs.getMsg('earcons_off');
ChromeVox.tts.speak(
announce, QueueMode.FLUSH, AbstractTts.PERSONALITY_ANNOTATION);
}
return false;
case 'cycleTypingEcho': {
ChromeVoxState.instance.typingEcho =
TypingEcho.cycle(ChromeVoxState.instance.typingEcho);
let announce = '';
switch (ChromeVoxState.instance.typingEcho) {
case TypingEcho.CHARACTER:
announce = Msgs.getMsg('character_echo');
break;
case TypingEcho.WORD:
announce = Msgs.getMsg('word_echo');
break;
case TypingEcho.CHARACTER_AND_WORD:
announce = Msgs.getMsg('character_and_word_echo');
break;
case TypingEcho.NONE:
announce = Msgs.getMsg('none_echo');
break;
}
ChromeVox.tts.speak(
announce, QueueMode.FLUSH, AbstractTts.PERSONALITY_ANNOTATION);
}
return false;
case 'cyclePunctuationEcho':
ChromeVox.tts.speak(
Msgs.getMsg(
ChromeVoxState.instance.backgroundTts.cyclePunctuationEcho()),
QueueMode.FLUSH);
return false;
case 'reportIssue':
let url = 'https://code.google.com/p/chromium/issues/entry?' +
'labels=Type-Bug,Pri-2,OS-Chrome&' +
'components=OS>Accessibility>ChromeVox&' +
'description=';
const description = {};
description['Version'] = chrome.runtime.getManifest().version;
description['Reproduction Steps'] = '%0a1.%0a2.%0a3.';
for (const key in description) {
url += key + ':%20' + description[key] + '%0a';
}
chrome.tabs.create({url});
return false;
case 'toggleBrailleCaptions':
BrailleCaptionsBackground.setActive(
!BrailleCaptionsBackground.isEnabled());
return false;
case 'toggleBrailleTable': {
let brailleTableType = localStorage['brailleTableType'];
let output = '';
if (brailleTableType === 'brailleTable6') {
brailleTableType = 'brailleTable8';
// This label reads "switch to 8 dot braille".
output = '@OPTIONS_BRAILLE_TABLE_TYPE_6';
} else {
brailleTableType = 'brailleTable6';
// This label reads "switch to 6 dot braille".
output = '@OPTIONS_BRAILLE_TABLE_TYPE_8';
}
localStorage['brailleTable'] = localStorage[brailleTableType];
localStorage['brailleTableType'] = brailleTableType;
BrailleBackground.instance.getTranslatorManager().refresh(
localStorage[brailleTableType]);
new Output().format(output).go();
}
return false;
case 'help':
(new PanelCommand(PanelCommandType.TUTORIAL)).send();
return false;
case 'toggleScreen':
const oldState = sessionStorage.getItem('darkScreen');
const newState = (oldState === 'true') ? false : true;
if (newState && localStorage['acceptToggleScreen'] !== 'true') {
// If this is the first time, show a confirmation dialog.
chrome.accessibilityPrivate.showConfirmationDialog(
Msgs.getMsg('toggle_screen_title'),
Msgs.getMsg('toggle_screen_description'), confirmed => {
if (confirmed) {
sessionStorage.setItem('darkScreen', 'true');
localStorage['acceptToggleScreen'] = true;
chrome.accessibilityPrivate.darkenScreen(true);
new Output().format('@toggle_screen_off').go();
}
});
} else {
sessionStorage.setItem('darkScreen', (newState) ? 'true' : 'false');
chrome.accessibilityPrivate.darkenScreen(newState);
new Output()
.format((newState) ? '@toggle_screen_off' : '@toggle_screen_on')
.go();
}
return false;
case 'toggleSpeechOnOrOff':
const state = ChromeVox.tts.toggleSpeechOnOrOff();
new Output().format(state ? '@speech_on' : '@speech_off').go();
return false;
case 'enableChromeVoxArcSupportForCurrentApp':
chrome.accessibilityPrivate.setNativeChromeVoxArcSupportForCurrentApp(
true, response => {});
break;
case 'disableChromeVoxArcSupportForCurrentApp':
chrome.accessibilityPrivate.setNativeChromeVoxArcSupportForCurrentApp(
false, response => {
if (response ===
chrome.accessibilityPrivate.SetNativeChromeVoxResponse
.TALKBACK_NOT_INSTALLED) {
ChromeVox.braille.write(NavBraille.fromText(
Msgs.getMsg('announce_install_talkback')));
ChromeVox.tts.speak(
Msgs.getMsg('announce_install_talkback'), QueueMode.FLUSH);
} else if (
response ===
chrome.accessibilityPrivate.SetNativeChromeVoxResponse
.NEED_DEPRECATION_CONFIRMATION) {
ChromeVox.braille.write(NavBraille.fromText(
Msgs.getMsg('announce_talkback_deprecation')));
ChromeVox.tts.speak(
Msgs.getMsg('announce_talkback_deprecation'),
QueueMode.FLUSH);
}
});
break;
case 'showTalkBackKeyboardShortcuts':
chrome.tabs.create({
url:
'https://support.google.com/accessibility/android/answer/6110948',
});
return false;
case 'showTtsSettings':
chrome.accessibilityPrivate.openSettingsSubpage(
'manageAccessibility/tts');
break;
default:
break;
case 'toggleKeyboardHelp':
(new PanelCommand(PanelCommandType.OPEN_MENUS)).send();
return false;
case 'showPanelMenuMostRecent':
(new PanelCommand(PanelCommandType.OPEN_MENUS_MOST_RECENT)).send();
return false;
case 'nextGranularity':
case 'previousGranularity': {
const backwards = command === 'previousGranularity';
let gran = GestureInterface.getGranularity();
const next = backwards ?
(--gran >= 0 ? gran : GestureGranularity.COUNT - 1) :
++gran % GestureGranularity.COUNT;
GestureInterface.setGranularity(
/** @type {GestureGranularity} */ (next));
let announce = '';
switch (GestureInterface.getGranularity()) {
case GestureGranularity.CHARACTER:
announce = Msgs.getMsg('character_granularity');
break;
case GestureGranularity.WORD:
announce = Msgs.getMsg('word_granularity');
break;
case GestureGranularity.LINE:
announce = Msgs.getMsg('line_granularity');
break;
case GestureGranularity.HEADING:
announce = Msgs.getMsg('heading_granularity');
break;
case GestureGranularity.LINK:
announce = Msgs.getMsg('link_granularity');
break;
case GestureGranularity.FORM_FIELD_CONTROL:
announce = Msgs.getMsg('form_field_control_granularity');
break;
}
ChromeVox.tts.speak(announce, QueueMode.FLUSH);
}
return false;
case 'announceBatteryDescription':
chrome.accessibilityPrivate.getBatteryDescription(function(
batteryDescription) {
new Output()
.withString(batteryDescription)
.withQueueMode(QueueMode.FLUSH)
.go();
});
break;
case 'resetTextToSpeechSettings':
ChromeVox.tts.resetTextToSpeechSettings();
return false;
case 'copy':
EventGenerator.sendKeyPress(KeyCode.C, {ctrl: true});
// The above command doesn't trigger document clipboard events, so we
// need to set this manually.
ChromeVoxState.instance.readNextClipboardDataChange();
return false;
case 'toggleDictation':
EventGenerator.sendKeyPress(KeyCode.D, {search: true});
return false;
}
// Require a current range.
if (!ChromeVoxState.instance.currentRange) {
if (!ChromeVoxState.instance.talkBackEnabled) {
new Output()
.withString(Msgs.getMsg(
EventSourceState.get() === EventSourceType.TOUCH_GESTURE ?
'no_focus_touch' :
'no_focus'))
.withQueueMode(QueueMode.FLUSH)
.go();
}
return true;
}
// Allow edit commands first.
if (!this.onEditCommand_(command)) {
return false;
}
let current = ChromeVoxState.instance.currentRange;
let node = current.start.node;
// If true, will check if the predicate matches the current node.
let matchCurrent = false;
let dir = Dir.FORWARD;
let pred = null;
let predErrorMsg = undefined;
let rootPred = AutomationPredicate.rootOrEditableRoot;
let unit = null;
let shouldWrap = true;
const speechProps = new TtsSpeechProperties();
let skipSync = false;
let didNavigate = false;
let tryScrolling = true;
let skipSettingSelection = false;
let skipInitialAncestry = true;
switch (command) {
case 'nextCharacter':
didNavigate = true;
speechProps.phoneticCharacters = true;
unit = CursorUnit.CHARACTER;
current = current.move(CursorUnit.CHARACTER, Dir.FORWARD);
break;
case 'previousCharacter':
dir = Dir.BACKWARD;
didNavigate = true;
speechProps.phoneticCharacters = true;
unit = CursorUnit.CHARACTER;
current = current.move(CursorUnit.CHARACTER, dir);
break;
case 'nativeNextCharacter':
case 'nativePreviousCharacter':
if (DesktopAutomationInterface.instance.textEditHandler) {
DesktopAutomationInterface.instance.textEditHandler
.injectInferredIntents([{
command: chrome.automation.IntentCommandType.MOVE_SELECTION,
textBoundary:
chrome.automation.IntentTextBoundaryType.CHARACTER,
}]);
}
return true;
case 'nextWord':
didNavigate = true;
unit = CursorUnit.WORD;
current = current.move(CursorUnit.WORD, Dir.FORWARD);
break;
case 'previousWord':
dir = Dir.BACKWARD;
didNavigate = true;
unit = CursorUnit.WORD;
current = current.move(CursorUnit.WORD, dir);
break;
case 'nativeNextWord':
case 'nativePreviousWord':
if (DesktopAutomationInterface.instance.textEditHandler) {
DesktopAutomationInterface.instance.textEditHandler
.injectInferredIntents([{
command: chrome.automation.IntentCommandType.MOVE_SELECTION,
textBoundary: command === 'nativeNextWord' ?
chrome.automation.IntentTextBoundaryType.WORD_END :
chrome.automation.IntentTextBoundaryType.WORD_START,
}]);
}
return true;
case 'forward':
case 'nextLine':
didNavigate = true;
unit = CursorUnit.LINE;
current = current.move(CursorUnit.LINE, Dir.FORWARD);
break;
case 'backward':
case 'previousLine':
dir = Dir.BACKWARD;
didNavigate = true;
unit = CursorUnit.LINE;
current = current.move(CursorUnit.LINE, dir);
break;
case 'nextButton':
dir = Dir.FORWARD;
pred = AutomationPredicate.button;
predErrorMsg = 'no_next_button';
break;
case 'previousButton':
dir = Dir.BACKWARD;
pred = AutomationPredicate.button;
predErrorMsg = 'no_previous_button';
break;
case 'nextCheckbox':
pred = AutomationPredicate.checkBox;
predErrorMsg = 'no_next_checkbox';
break;
case 'previousCheckbox':
dir = Dir.BACKWARD;
pred = AutomationPredicate.checkBox;
predErrorMsg = 'no_previous_checkbox';
break;
case 'nextComboBox':
pred = AutomationPredicate.comboBox;
predErrorMsg = 'no_next_combo_box';
break;
case 'previousComboBox':
dir = Dir.BACKWARD;
pred = AutomationPredicate.comboBox;
predErrorMsg = 'no_previous_combo_box';
break;
case 'nextEditText':
skipSettingSelection = true;
pred = AutomationPredicate.editText;
predErrorMsg = 'no_next_edit_text';
this.smartStickyMode_.startIgnoringRangeChanges();
break;
case 'previousEditText':
skipSettingSelection = true;
dir = Dir.BACKWARD;
pred = AutomationPredicate.editText;
predErrorMsg = 'no_previous_edit_text';
this.smartStickyMode_.startIgnoringRangeChanges();
break;
case 'nextFormField':
skipSettingSelection = true;
pred = AutomationPredicate.formField;
predErrorMsg = 'no_next_form_field';
this.smartStickyMode_.startIgnoringRangeChanges();
break;
case 'previousFormField':
skipSettingSelection = true;
dir = Dir.BACKWARD;
pred = AutomationPredicate.formField;
predErrorMsg = 'no_previous_form_field';
this.smartStickyMode_.startIgnoringRangeChanges();
break;
case 'previousGraphic':
skipSettingSelection = true;
dir = Dir.BACKWARD;
pred = AutomationPredicate.image;
predErrorMsg = 'no_previous_graphic';
break;
case 'nextGraphic':
skipSettingSelection = true;
pred = AutomationPredicate.image;
predErrorMsg = 'no_next_graphic';
break;
case 'nextHeading':
pred = AutomationPredicate.heading;
predErrorMsg = 'no_next_heading';
break;
case 'nextHeading1':
pred = AutomationPredicate.makeHeadingPredicate(1);
predErrorMsg = 'no_next_heading_1';
break;
case 'nextHeading2':
pred = AutomationPredicate.makeHeadingPredicate(2);
predErrorMsg = 'no_next_heading_2';
break;
case 'nextHeading3':
pred = AutomationPredicate.makeHeadingPredicate(3);
predErrorMsg = 'no_next_heading_3';
break;
case 'nextHeading4':
pred = AutomationPredicate.makeHeadingPredicate(4);
predErrorMsg = 'no_next_heading_4';
break;
case 'nextHeading5':
pred = AutomationPredicate.makeHeadingPredicate(5);
predErrorMsg = 'no_next_heading_5';
break;
case 'nextHeading6':
pred = AutomationPredicate.makeHeadingPredicate(6);
predErrorMsg = 'no_next_heading_6';
break;
case 'previousHeading':
dir = Dir.BACKWARD;
pred = AutomationPredicate.heading;
predErrorMsg = 'no_previous_heading';
break;
case 'previousHeading1':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeHeadingPredicate(1);
predErrorMsg = 'no_previous_heading_1';
break;
case 'previousHeading2':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeHeadingPredicate(2);
predErrorMsg = 'no_previous_heading_2';
break;
case 'previousHeading3':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeHeadingPredicate(3);
predErrorMsg = 'no_previous_heading_3';
break;
case 'previousHeading4':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeHeadingPredicate(4);
predErrorMsg = 'no_previous_heading_4';
break;
case 'previousHeading5':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeHeadingPredicate(5);
predErrorMsg = 'no_previous_heading_5';
break;
case 'previousHeading6':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeHeadingPredicate(6);
predErrorMsg = 'no_previous_heading_6';
break;
case 'nextLink':
pred = AutomationPredicate.link;
predErrorMsg = 'no_next_link';
break;
case 'previousLink':
dir = Dir.BACKWARD;
pred = AutomationPredicate.link;
predErrorMsg = 'no_previous_link';
break;
case 'nextTable':
pred = AutomationPredicate.table;
predErrorMsg = 'no_next_table';
break;
case 'previousTable':
dir = Dir.BACKWARD;
pred = AutomationPredicate.table;
predErrorMsg = 'no_previous_table';
break;
case 'nextVisitedLink':
pred = AutomationPredicate.visitedLink;
predErrorMsg = 'no_next_visited_link';
break;
case 'previousVisitedLink':
dir = Dir.BACKWARD;
pred = AutomationPredicate.visitedLink;
predErrorMsg = 'no_previous_visited_link';
break;
case 'nextLandmark':
pred = AutomationPredicate.landmark;
predErrorMsg = 'no_next_landmark';
break;
case 'previousLandmark':
dir = Dir.BACKWARD;
pred = AutomationPredicate.landmark;
predErrorMsg = 'no_previous_landmark';
break;
case 'left':
case 'previousObject':
skipSettingSelection = true;
dir = Dir.BACKWARD;
// Falls through.
case 'right':
case 'nextObject':
skipSettingSelection = true;
didNavigate = true;
unit = (EventSourceState.get() === EventSourceType.TOUCH_GESTURE) ?
CursorUnit.GESTURE_NODE :
CursorUnit.NODE;
current = current.move(unit, dir);
current = this.skipLabelOrDescriptionFor(current, dir);
break;
case 'previousGroup':
skipSync = true;
dir = Dir.BACKWARD;
pred = AutomationPredicate.group;
break;
case 'nextGroup':
skipSync = true;
pred = AutomationPredicate.group;
break;
case 'previousPage':
case 'nextPage':
const root = AutomationUtil.getTopLevelRoot(current.start.node);
if (root && root.scrollY !== undefined) {
let page = Math.ceil(root.scrollY / root.location.height) || 1;
page = command === 'nextPage' ? page + 1 : page - 1;
ChromeVox.tts.stop();
root.setScrollOffset(0, page * root.location.height);
}
return false;
case 'previousSimilarItem':
dir = Dir.BACKWARD;
// Falls through.
case 'nextSimilarItem': {
skipSync = true;
const originalNode = node;
// Scan upwards until we get a role we don't want to ignore.
while (node && AutomationPredicate.ignoreDuringJump(node)) {
node = node.parent;
}
const useNode = node || originalNode;
pred = AutomationPredicate.roles([node.role]);
} break;
case 'previousInvalidItem': {
dir = Dir.BACKWARD;
rootPred = AutomationPredicate.root;
pred = AutomationPredicate.isInvalid;
predErrorMsg = 'no_invalid_item';
} break;
case 'nextInvalidItem': {
pred = AutomationPredicate.isInvalid;
rootPred = AutomationPredicate.root;
predErrorMsg = 'no_invalid_item';
} break;
case 'nextList':
pred = AutomationPredicate.makeListPredicate(current.start.node);
predErrorMsg = 'no_next_list';
break;
case 'previousList':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeListPredicate(current.start.node);
predErrorMsg = 'no_previous_list';
skipInitialAncestry = false;
break;
case 'jumpToTop': {
if (!current.start.node || !current.start.node.root) {
break;
}
const node = AutomationUtil.findNodePost(
current.start.node.root, Dir.FORWARD, AutomationPredicate.object);
if (node) {
current = CursorRange.fromNode(node);
}
tryScrolling = false;
} break;
case 'jumpToBottom': {
if (!current.start.node || !current.start.node.root) {
break;
}
const node = AutomationUtil.findLastNode(
current.start.node.root, AutomationPredicate.object);
if (node) {
current = CursorRange.fromNode(node);
}
tryScrolling = false;
} break;
case 'forceClickOnCurrentItem':
if (ChromeVoxState.instance.currentRange) {
let actionNode = ChromeVoxState.instance.currentRange.start.node;
// Scan for a clickable, which overrides the |actionNode|.
let clickable = actionNode;
while (clickable && !clickable.clickable &&
actionNode.root === clickable.root) {
clickable = clickable.parent;
}
if (clickable && actionNode.root === clickable.root) {
clickable.doDefault();
return false;
}
if (EventSourceState.get() === EventSourceType.TOUCH_GESTURE &&
actionNode.state.editable) {
// Dispatch a click to ensure the VK gets shown.
const location = actionNode.location;
EventGenerator.sendMouseClick(
location.left + Math.round(location.width / 2),
location.top + Math.round(location.height / 2));
return false;
}
while (actionNode.role === RoleType.INLINE_TEXT_BOX ||
actionNode.role === RoleType.STATIC_TEXT) {
actionNode = actionNode.parent;
}
if (actionNode.inPageLinkTarget) {
ChromeVoxState.instance.navigateToRange(
CursorRange.fromNode(actionNode.inPageLinkTarget));
} else {
actionNode.doDefault();
}
}
// Skip all other processing; if focus changes, we should get an event
// for that.
return false;
case 'jumpToDetails': {
while (node && !node.details) {
node = node.parent;
}
if (node && node.details.length) {
// TODO currently can only jump to first detail.
current = CursorRange.fromNode(node.details[0]);
}
} break;
case 'readFromHere':
ChromeVoxState.instance.isReadingContinuously = true;
const continueReading = function() {
if (!ChromeVoxState.instance.isReadingContinuously ||
!ChromeVoxState.instance.currentRange) {
return;
}
const prevRange = ChromeVoxState.instance.currentRange;
const newRange = ChromeVoxState.instance.currentRange.move(
CursorUnit.NODE, Dir.FORWARD);
// Stop if we've wrapped back to the document.
const maybeDoc = newRange.start.node;
if (AutomationPredicate.root(maybeDoc)) {
ChromeVoxState.instance.isReadingContinuously = false;
return;
}
ChromeVoxState.instance.setCurrentRange(newRange);
newRange.select();
const o = new Output()
.withoutHints()
.withRichSpeechAndBraille(
ChromeVoxState.instance.currentRange, prevRange,
OutputEventType.NAVIGATE)
.onSpeechEnd(continueReading);
if (!o.hasSpeech) {
continueReading();
return;
}
o.go();
}.bind(this);
{
const startNode = ChromeVoxState.instance.currentRange.start.node;
const collapsedRange = CursorRange.fromNode(startNode);
const o =
new Output()
.withoutHints()
.withRichSpeechAndBraille(
collapsedRange, collapsedRange, OutputEventType.NAVIGATE)
.onSpeechEnd(continueReading);
if (o.hasSpeech) {
o.go();
} else {
continueReading();
}
}
return false;
case 'contextMenu':
EventGenerator.sendKeyPress(KeyCode.APPS);
break;
case 'showHeadingsList':
(new PanelCommand(PanelCommandType.OPEN_MENUS, 'role_heading')).send();
return false;
case 'showFormsList':
(new PanelCommand(
PanelCommandType.OPEN_MENUS, 'panel_menu_form_controls'))
.send();
return false;
case 'showLandmarksList':
(new PanelCommand(PanelCommandType.OPEN_MENUS, 'role_landmark')).send();
return false;
case 'showLinksList':
(new PanelCommand(PanelCommandType.OPEN_MENUS, 'role_link')).send();
return false;
case 'showActionsMenu':
(new PanelCommand(PanelCommandType.OPEN_MENUS, 'panel_menu_actions'))
.send();
return false;
case 'showTablesList':
(new PanelCommand(PanelCommandType.OPEN_MENUS, 'table_strategy'))
.send();
return false;
case 'toggleSearchWidget':
(new PanelCommand(PanelCommandType.SEARCH)).send();
return false;
case 'readCurrentTitle': {
let target = ChromeVoxState.instance.currentRange.start.node;
const output = new Output();
if (!target) {
return false;
}
let firstWindow;
let rootViewWindow;
if (target.root && target.root.role === RoleType.DESKTOP) {
// Search for the first container with a name.
while (target &&
(!target.name || !AutomationPredicate.root(target))) {
target = target.parent;
}
} else {
// Search for a root window with a title.
while (target) {
const isNamedWindow =
Boolean(target.name) && target.role === RoleType.WINDOW;
const isRootView = target.className === 'RootView';
if (isNamedWindow && !firstWindow) {
firstWindow = target;
}
if (isNamedWindow && isRootView) {
rootViewWindow = target;
break;
}
target = target.parent;
}
}
// Re-target with preference for the root.
target = rootViewWindow || firstWindow || target;
if (!target) {
output.format('@no_title');
} else if (target.name) {
output.withString(target.name);
}
output.go();
}
return false;
case 'readCurrentURL':
const output = new Output();
const target = ChromeVoxState.instance.currentRange.start.node.root;
output.withString(target.docUrl || '').go();
return false;
case 'toggleSelection':
if (!ChromeVoxState.instance.pageSel) {
ChromeVoxState.instance.pageSel =
ChromeVoxState.instance.currentRange;
DesktopAutomationInterface.instance.ignoreDocumentSelectionFromAction(
true);
} else {
const root = ChromeVoxState.instance.currentRange.start.node.root;
if (root && root.selectionStartObject && root.selectionEndObject &&
!isNaN(Number(root.selectionStartOffset)) &&
!isNaN(Number(root.selectionEndOffset))) {
const sel = new CursorRange(
new Cursor(
root.selectionStartObject,
/** @type {number} */ (root.selectionStartOffset)),
new Cursor(
root.selectionEndObject,
/** @type {number} */ (root.selectionEndOffset)));
const o =
new Output()
.format('@end_selection')
.withSpeechAndBraille(sel, sel, OutputEventType.NAVIGATE)
.go();
DesktopAutomationInterface.instance
.ignoreDocumentSelectionFromAction(false);
}
ChromeVoxState.instance.pageSel = null;
return false;
}
break;
case 'fullyDescribe':
const o = new Output();
o.withContextFirst()
.withRichSpeechAndBraille(current, null, OutputEventType.NAVIGATE)
.go();
return false;
case 'viewGraphicAsBraille':
this.viewGraphicAsBraille_(current);
return false;
// Table commands.
case 'previousRow': {
skipSync = true;
dir = Dir.BACKWARD;
const tableOpts = {row: true, dir};
pred = AutomationPredicate.makeTableCellPredicate(
current.start.node, tableOpts);
predErrorMsg = 'no_cell_above';
rootPred = AutomationPredicate.table;
shouldWrap = false;
} break;
case 'previousCol': {
skipSync = true;
dir = Dir.BACKWARD;
const tableOpts = {col: true, dir};
pred = AutomationPredicate.makeTableCellPredicate(
current.start.node, tableOpts);
predErrorMsg = 'no_cell_left';
rootPred = AutomationPredicate.row;
shouldWrap = false;
} break;
case 'nextRow': {
skipSync = true;
const tableOpts = {row: true, dir};
pred = AutomationPredicate.makeTableCellPredicate(
current.start.node, tableOpts);
predErrorMsg = 'no_cell_below';
rootPred = AutomationPredicate.table;
shouldWrap = false;
} break;
case 'nextCol': {
skipSync = true;
const tableOpts = {col: true, dir};
pred = AutomationPredicate.makeTableCellPredicate(
current.start.node, tableOpts);
predErrorMsg = 'no_cell_right';
rootPred = AutomationPredicate.row;
shouldWrap = false;
} break;
case 'goToRowFirstCell':
case 'goToRowLastCell': {
skipSync = true;
while (node && node.role !== RoleType.ROW) {
node = node.parent;
}
if (!node) {
break;
}
const end = AutomationUtil.findNodePost(
node, command === 'goToRowLastCell' ? Dir.BACKWARD : Dir.FORWARD,
AutomationPredicate.leaf);
if (end) {
current = CursorRange.fromNode(end);
}
} break;
case 'goToColFirstCell': {
skipSync = true;
while (node && node.role !== RoleType.TABLE) {
node = node.parent;
}
if (!node || !node.firstChild) {
return false;
}
const tableOpts = {col: true, dir, end: true};
pred = AutomationPredicate.makeTableCellPredicate(
current.start.node, tableOpts);
current = CursorRange.fromNode(node.firstChild);
// Should not be outputted.
predErrorMsg = 'no_cell_above';
rootPred = AutomationPredicate.table;
shouldWrap = false;
} break;
case 'goToColLastCell': {
skipSync = true;
dir = Dir.BACKWARD;
while (node && node.role !== RoleType.TABLE) {
node = node.parent;
}
if (!node || !node.lastChild) {
return false;
}
const tableOpts = {col: true, dir, end: true};
pred = AutomationPredicate.makeTableCellPredicate(
current.start.node, tableOpts);
// Try to start on the last cell of the table and allow
// matching that node.
let startNode = node.lastChild;
while (startNode.lastChild &&
!AutomationPredicate.cellLike(startNode)) {
startNode = startNode.lastChild;
}
current = CursorRange.fromNode(startNode);
matchCurrent = true;
// Should not be outputted.
predErrorMsg = 'no_cell_below';
rootPred = AutomationPredicate.table;
shouldWrap = false;
} break;
case 'goToFirstCell':
case 'goToLastCell': {
skipSync = true;
while (node && node.role !== RoleType.TABLE) {
node = node.parent;
}
if (!node) {
break;
}
const end = AutomationUtil.findNodePost(
node, command === 'goToLastCell' ? Dir.BACKWARD : Dir.FORWARD,
AutomationPredicate.leaf);
if (end) {
current = CursorRange.fromNode(end);
}
} break;
// These commands are only available when invoked from touch.
case 'nextAtGranularity':
case 'previousAtGranularity':
const backwards = command === 'previousAtGranularity';
switch (GestureInterface.getGranularity()) {
case GestureGranularity.CHARACTER:
command = backwards ? 'previousCharacter' : 'nextCharacter';
break;
case GestureGranularity.WORD:
command = backwards ? 'previousWord' : 'nextWord';
break;
case GestureGranularity.LINE:
command = backwards ? 'previousLine' : 'nextLine';
break;
case GestureGranularity.HEADING:
command = backwards ? 'previousHeading' : 'nextHeading';
break;
case GestureGranularity.LINK:
command = backwards ? 'previousLink' : 'nextLink';
break;
case GestureGranularity.FORM_FIELD_CONTROL:
command = backwards ? 'previousFormField' : 'nextFormField';
break;
}
this.onCommand(command);
return false;
case 'announceRichTextDescription': {
const optSubs = [];
node.fontSize ? optSubs.push('font size: ' + node.fontSize) :
optSubs.push('');
node.color ? optSubs.push(Color.getColorDescription(node.color)) :
optSubs.push('');
node.bold ? optSubs.push(Msgs.getMsg('bold')) : optSubs.push('');
node.italic ? optSubs.push(Msgs.getMsg('italic')) : optSubs.push('');
node.underline ? optSubs.push(Msgs.getMsg('underline')) :
optSubs.push('');
node.lineThrough ? optSubs.push(Msgs.getMsg('linethrough')) :
optSubs.push('');
node.fontFamily ? optSubs.push('font family: ' + node.fontFamily) :
optSubs.push('');
const richTextDescription =
Msgs.getMsg('rich_text_attributes', optSubs);
new Output()
.withString(richTextDescription)
.withQueueMode(QueueMode.CATEGORY_FLUSH)
.go();
}
return false;
case 'readPhoneticPronunciation': {
// Get node info.
const index = ChromeVoxState.instance.currentRange.start.index;
const name = node.name;
// If there is no text to speak, inform the user and return early.
if (!name) {
new Output()
.withString(Msgs.getMsg('empty_name'))
.withQueueMode(QueueMode.CATEGORY_FLUSH)
.go();
return false;
}
// Get word start and end indices.
let wordStarts;
let wordEnds;
if (node.role === RoleType.INLINE_TEXT_BOX) {
wordStarts = node.wordStarts;
wordEnds = node.wordEnds;
} else {
wordStarts = node.nonInlineTextWordStarts;
wordEnds = node.nonInlineTextWordEnds;
}
// Find the word we want to speak phonetically. If index === -1, then
// the index represents an entire node.
let text = '';
if (index === -1) {
text = name;
} else {
for (let z = 0; z < wordStarts.length; ++z) {
if (wordStarts[z] <= index && wordEnds[z] > index) {
text = name.substring(wordStarts[z], wordEnds[z]);
break;
}
}
}
const language = chrome.i18n.getUILanguage();
const phoneticText = PhoneticData.forText(text, language);
if (phoneticText) {
new Output()
.withString(phoneticText)
.withQueueMode(QueueMode.CATEGORY_FLUSH)
.go();
}
}
return false;
case 'readLinkURL': {
const rootNode = node.root;
while (node && !node.url) {
// URL could be an ancestor of current range.
node = node.parent;
}
// Announce node's URL if it's not the root node; we don't want to
// announce the URL of the current page.
const url = (node && node !== rootNode) ? node.url : '';
new Output()
.withString(
url ? Msgs.getMsg('url_behind_link', [url]) :
Msgs.getMsg('no_url_found'))
.withQueueMode(QueueMode.CATEGORY_FLUSH)
.go();
}
return false;
case 'logLanguageInformationForCurrentNode': {
if (!this.languageLoggingEnabled_) {
return false;
}
const outString = `
Language information for node
Name: ${node.name}
Detected language: ${node.detectedLanguage || 'None'}
Author language: ${node.language || 'None'}
`;
new Output()
.withString(outString)
.withQueueMode(QueueMode.CATEGORY_FLUSH)
.go();
const annotation = node.languageAnnotationForStringAttribute('name');
const logString = outString.concat(`Language spans:
${JSON.stringify(annotation)}`);
console.error(logString);
LogStore.getInstance().writeTextLog(logString, LogType.TEXT);
}
return false;
default:
return true;
}
if (didNavigate) {
chrome.metricsPrivate.recordUserAction(
'Accessibility.ChromeVox.Navigate');
}
// TODO(accessibility): extract this block and remove explicit type casts
// after re-writing.
if (pred) {
chrome.metricsPrivate.recordUserAction('Accessibility.ChromeVox.Jump');
let bound = current.getBound(dir).node;
if (bound) {
let node = null;
if (matchCurrent && pred(bound)) {
node = bound;
}
if (!node) {
node = AutomationUtil.findNextNode(
bound, dir, pred, {skipInitialAncestry, root: rootPred});
}
if (node && !skipSync) {
node = AutomationUtil.findNodePre(
node, Dir.FORWARD, AutomationPredicate.object) ||
node;
}
if (node) {
current = CursorRange.fromNode(node);
} else {
ChromeVox.earcons.playEarcon(Earcon.WRAP);
if (!shouldWrap) {
if (predErrorMsg) {
new Output()
.withString(Msgs.getMsg(predErrorMsg))
.withQueueMode(QueueMode.FLUSH)
.go();
}
this.onFinishCommand();
return false;
}
let root = bound;
while (root && !AutomationPredicate.rootOrEditableRoot(root)) {
root = root.parent;
}
if (!root) {
root = bound.root;
}
if (dir === Dir.FORWARD) {
bound = root;
} else {
bound = AutomationUtil.findNodePost(
/** @type {!AutomationNode} */ (root), dir,
AutomationPredicate.leaf) ||
bound;
}
node = AutomationUtil.findNextNode(
/** @type {!AutomationNode} */ (bound), dir, pred,
{root: rootPred});
if (node && !skipSync) {
node = AutomationUtil.findNodePre(
node, Dir.FORWARD, AutomationPredicate.object) ||
node;
}
if (node) {
current = CursorRange.fromNode(node);
} else if (predErrorMsg) {
new Output()
.withString(Msgs.getMsg(predErrorMsg))
.withQueueMode(QueueMode.FLUSH)
.go();
this.onFinishCommand();
return false;
}
}
}
}
// TODO(accessibility): extract into function.
if (tryScrolling && current &&
!AutoScrollHandler.getInstance().onCommandNavigation(
current, dir, pred, unit, speechProps, rootPred, () => {
this.onCommand(command);
this.onFinishCommand();
})) {
this.onFinishCommand();
return false;
}
if (current) {
if (current.wrapped) {
ChromeVox.earcons.playEarcon(Earcon.WRAP);
}
ChromeVoxState.instance.navigateToRange(
current, undefined, speechProps, skipSettingSelection);
}
this.onFinishCommand();
return false;
}
/**
* Finishes processing of a command.
*/
onFinishCommand() {
this.smartStickyMode_.stopIgnoringRangeChanges();
}
/**
* Increase or decrease a speech property and make an announcement.
* @param {string} propertyName The name of the property to change.
* @param {boolean} increase If true, increases the property value by one
* step size, otherwise decreases.
* @private
*/
increaseOrDecreaseSpeechProperty_(propertyName, increase) {
ChromeVox.tts.increaseOrDecreaseProperty(propertyName, increase);
}
/**
* Called when an image frame is received on a node.
* @param {!ChromeVoxEvent} event The event.
* @private
*/
onImageFrameUpdated_(event) {
const target = event.target;
if (target !== this.imageNode_) {
return;
}
if (!AutomationUtil.isDescendantOf(
ChromeVoxState.instance.currentRange.start.node, this.imageNode_)) {
this.imageNode_.removeEventListener(
EventType.IMAGE_FRAME_UPDATED, this.onImageFrameUpdated_, false);
this.imageNode_ = null;
return;
}
if (target.imageDataUrl) {
ChromeVox.braille.writeRawImage(target.imageDataUrl);
ChromeVox.braille.freeze();
}
}
/**
* Handle the command to view the first graphic within the current range
* as braille.
* @param {!CursorRange} current The current range.
* @private
*/
viewGraphicAsBraille_(current) {
if (this.imageNode_) {
this.imageNode_.removeEventListener(
EventType.IMAGE_FRAME_UPDATED, this.onImageFrameUpdated_, false);
this.imageNode_ = null;
}
// Find the first node within the current range that supports image data.
const imageNode = AutomationUtil.findNodePost(
current.start.node, Dir.FORWARD, AutomationPredicate.supportsImageData);
if (!imageNode) {
return;
}
imageNode.addEventListener(
EventType.IMAGE_FRAME_UPDATED, this.onImageFrameUpdated_, false);
this.imageNode_ = imageNode;
if (imageNode.imageDataUrl) {
const event = new CustomAutomationEvent(
EventType.IMAGE_FRAME_UPDATED, imageNode, {eventFrom: 'page'});
this.onImageFrameUpdated_(event);
} else {
imageNode.getImageData(0, 0);
}
}
/**
* Provides a partial mapping from ChromeVox key combinations to
* Search-as-a-function key as seen in Chrome OS documentation.
* @param {string} command
* @return {boolean} True if the command should propagate.
* @private
*/
onEditCommand_(command) {
if (ChromeVox.isStickyModeOn()) {
return true;
}
const textEditHandler = DesktopAutomationInterface.instance.textEditHandler;
if (!textEditHandler ||
!AutomationUtil.isDescendantOf(
ChromeVoxState.instance.currentRange.start.node,
textEditHandler.node)) {
return true;
}
// Skip customized keys for read only text fields.
if (textEditHandler.node.restriction ===
chrome.automation.Restriction.READ_ONLY) {
return true;
}
// Skips customized keys if they get suppressed in speech.
if (AutomationPredicate.shouldOnlyOutputSelectionChangeInBraille(
textEditHandler.node)) {
return true;
}
const isMultiline = AutomationPredicate.multiline(textEditHandler.node);
switch (command) {
case 'previousCharacter':
EventGenerator.sendKeyPress(KeyCode.HOME, {shift: true});
break;
case 'nextCharacter':
EventGenerator.sendKeyPress(KeyCode.END, {shift: true});
break;
case 'previousWord':
EventGenerator.sendKeyPress(KeyCode.HOME, {shift: true, ctrl: true});
break;
case 'nextWord':
EventGenerator.sendKeyPress(KeyCode.END, {shift: true, ctrl: true});
break;
case 'previousObject':
if (!isMultiline) {
return true;
}
if (textEditHandler.isSelectionOnFirstLine()) {
ChromeVoxState.instance.setCurrentRange(
CursorRange.fromNode(textEditHandler.node));
return true;
}
EventGenerator.sendKeyPress(KeyCode.HOME);
break;
case 'nextObject':
if (!isMultiline) {
return true;
}
if (textEditHandler.isSelectionOnLastLine()) {
textEditHandler.moveToAfterEditText();
return false;
}
EventGenerator.sendKeyPress(KeyCode.END);
break;
case 'previousLine':
if (!isMultiline) {
return true;
}
if (textEditHandler.isSelectionOnFirstLine()) {
ChromeVoxState.instance.setCurrentRange(
CursorRange.fromNode(textEditHandler.node));
return true;
}
EventGenerator.sendKeyPress(KeyCode.PRIOR);
break;
case 'nextLine':
if (!isMultiline) {
return true;
}
if (textEditHandler.isSelectionOnLastLine()) {
textEditHandler.moveToAfterEditText();
return false;
}
EventGenerator.sendKeyPress(KeyCode.NEXT);
break;
case 'jumpToTop':
EventGenerator.sendKeyPress(KeyCode.HOME, {ctrl: true});
break;
case 'jumpToBottom':
EventGenerator.sendKeyPress(KeyCode.END, {ctrl: true});
break;
default:
return true;
}
return false;
}
/** @override */
skipLabelOrDescriptionFor(current, dir) {
if (!current) {
return null;
}
// Keep moving past all nodes acting as labels or descriptions.
while (current && current.start && current.start.node &&
current.start.node.role === RoleType.STATIC_TEXT) {
// We must scan upwards as any ancestor might have a label or description.
let ancestor = current.start.node;
while (ancestor) {
if ((ancestor.labelFor && ancestor.labelFor.length > 0) ||
(ancestor.descriptionFor && ancestor.descriptionFor.length > 0)) {
break;
}
ancestor = ancestor.parent;
}
if (ancestor) {
current = current.move(CursorUnit.NODE, dir);
} else {
break;
}
}
return current;
}
/**
* @param {AutomationNode} focusedNode
* @private
*/
checkForLossOfFocus_(focusedNode) {
const cur = ChromeVoxState.instance.currentRange;
if (cur && !cur.isValid() && focusedNode) {
ChromeVoxState.instance.setCurrentRange(
CursorRange.fromNode(focusedNode));
}
if (!focusedNode) {
ChromeVoxState.instance.setCurrentRange(null);
return;
}
// This case detects when TalkBack (in ARC++) is enabled (which also
// covers when the ARC++ window is active). Clear the ChromeVox range
// so keys get passed through for ChromeVox commands.
if (ChromeVoxState.instance.talkBackEnabled &&
// This additional check is not strictly necessary, but we use it to
// ensure we are never inadvertently losing focus. ARC++ windows set
// "focus" on a root view.
focusedNode.role === RoleType.CLIENT) {
ChromeVoxState.instance.setCurrentRange(null);
}
}
isAllowed_(command) {
if (!this.isIncognito_ && !this.isKioskSession_) {
return true;
}
return !CommandStore.CMD_ALLOWLIST[command] ||
!CommandStore.CMD_ALLOWLIST[command].denySignedOut;
}
/**
* Performs global initialization.
*/
init() {
ChromeVoxKbHandler.commandHandler = command => this.onCommand(command);
chrome.commandLinePrivate.hasSwitch(
'enable-experimental-accessibility-language-detection', enabled => {
if (enabled) {
this.languageLoggingEnabled_ = true;
}
});
chrome.commandLinePrivate.hasSwitch(
'enable-experimental-accessibility-language-detection-dynamic',
enabled => {
if (enabled) {
this.languageLoggingEnabled_ = true;
}
});
chrome.chromeosInfoPrivate.get(['sessionType'], result => {
/** @type {boolean} */
this.isKioskSession_ = result['sessionType'] ===
chrome.chromeosInfoPrivate.SessionType.KIOSK;
});
}
}
CommandHandlerInterface.instance = new CommandHandler();
BridgeHelper.registerHandler(
BridgeConstants.CommandHandler.TARGET,
BridgeConstants.CommandHandler.Action.ON_COMMAND,
command => CommandHandlerInterface.instance.onCommand(command));