chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/input/command_handler.ts

// Copyright 2016 The Chromium Authors
// 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 {BridgeHelper} from '/common/bridge_helper.js';
import {BrowserUtil} from '/common/browser_util.js';
import {constants} from '/common/constants.js';
import {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 {LocalStorage} from '/common/local_storage.js';
import {RectUtil} from '/common/rect_util.js';

import {NavBraille} from '../../common/braille/nav_braille.js';
import {BridgeConstants} from '../../common/bridge_constants.js';
import {Command} from '../../common/command.js';
import {EarconId} from '../../common/earcon_id.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 {Msgs} from '../../common/msgs.js';
import {PanelCommand, PanelCommandType} from '../../common/panel_command.js';
import {PermissionChecker} from '../../common/permission_checker.js';
import {QueueMode, TtsSettings, TtsSpeechProperties} from '../../common/tts_types.js';
import {AutoScrollHandler} from '../auto_scroll_handler.js';
import {BrailleCaptionsBackground} from '../braille/braille_captions_background.js';
import {BrailleTranslatorManager} from '../braille/braille_translator_manager.js';
import {ChromeVox} from '../chromevox.js';
import {ChromeVoxRange} from '../chromevox_range.js';
import {ChromeVoxState} from '../chromevox_state.js';
import {Color} from '../color.js';
import {TypingEcho} from '../editing/typing_echo.js';
import {DesktopAutomationInterface} from '../event/desktop_automation_interface.js';
import {EventSource} from '../event_source.js';
import {LogManager} from '../logging/log_manager.js';
import {Output} from '../output/output.js';
import {OutputCustomEvent} from '../output/output_types.js';
import {PhoneticData} from '../phonetic_data.js';
import {ChromeVoxPrefs} from '../prefs.js';
import {TtsBackground} from '../tts_background.js';

import {BackgroundKeyboardHandler} from './background_keyboard_handler.js';
import {ClipboardHandler} from './clipboard_handler.js';
import {CommandHandlerInterface} from './command_handler_interface.js';
import {GestureInterface} from './gesture_interface.js';
import {SmartStickyMode} from './smart_sticky_mode.js';

import AutomationNode = chrome.automation.AutomationNode;
type CreateType = chrome.windows.CreateType;
import Dir = constants.Dir;
const Restriction = chrome.automation.Restriction;
const RoleType = chrome.automation.RoleType;
const SetNativeChromeVoxResponse =
    chrome.accessibilityPrivate.SetNativeChromeVoxResponse;
const StateType = chrome.automation.StateType;

interface NewRangeData {
  node?: AutomationNode;
  range: CursorRange;
}

/**
 * Maps a Command to the method that will perform that action.
 *
 * To streamline this class, the goal is to move the logic for each command out
 * of this file.
 *
 * When adding new commands, please put the logic in a more relevant spot.
 */
export class CommandHandler implements CommandHandlerInterface {
  private commandList_ = Object.values(Command);

  onCommand(command: Command): boolean {
    // Check for a command denied in incognito contexts and kiosk.
    if (!PermissionChecker.isAllowed(command)) {
      return true;
    }

    chrome.metricsPrivate.recordEnumerationValue(
        'Accessibility.ChromeVox.PerformCommand',
        this.commandList_.indexOf(command), this.commandList_.length);

    ChromeVoxRange.maybeResetFromFocus();

    // These commands don't require a current range.
    switch (command) {
      case Command.SPEAK_TIME_AND_DATE:
        this.speakTimeAndDate_();
        return false;
      case Command.SHOW_OPTIONS_PAGE:
        this.showOptionsPage_();
        break;
      case Command.TOGGLE_STICKY_MODE:
        // TODO(b/314203187): Not null asserted, check that this is correct.
        SmartStickyMode.instance!.toggle();
        return false;
      case Command.PASS_THROUGH_MODE:
        if (ChromeVoxPrefs.isStickyModeOn()) {
          new Output()
              .withString(
                  Msgs.getMsg('pass_through_unavailable_with_sticky_mode'))
              .go();
        } else {
          BackgroundKeyboardHandler.enablePassThroughMode();
        }
        return true;
      case Command.SHOW_LEARN_MODE_PAGE:
        this.showLearnModePage_();
        break;
      case Command.SHOW_LOG_PAGE:
        LogManager.showLogPage();
        break;
      case Command.ENABLE_LOGGING:
        LogManager.setLoggingEnabled(true);
        break;
      case Command.DISABLE_LOGGING:
        LogManager.setLoggingEnabled(false);
        break;
      case Command.DUMP_TREE:
        LogManager.logTreeDump();
        break;
      case Command.DECREASE_TTS_RATE:
        ChromeVox.tts.increaseOrDecreaseProperty(TtsSettings.RATE, false);
        return false;
      case Command.INCREASE_TTS_RATE:
        ChromeVox.tts.increaseOrDecreaseProperty(TtsSettings.RATE, true);
        return false;
      case Command.DECREASE_TTS_PITCH:
        ChromeVox.tts.increaseOrDecreaseProperty(TtsSettings.PITCH, false);
        return false;
      case Command.INCREASE_TTS_PITCH:
        ChromeVox.tts.increaseOrDecreaseProperty(TtsSettings.PITCH, true);
        return false;
      case Command.DECREASE_TTS_VOLUME:
        ChromeVox.tts.increaseOrDecreaseProperty(TtsSettings.VOLUME, false);
        return false;
      case Command.INCREASE_TTS_VOLUME:
        ChromeVox.tts.increaseOrDecreaseProperty(TtsSettings.VOLUME, true);
        return false;
      case Command.STOP_SPEECH:
        ChromeVox.tts.stop();
        // TODO(b/314203187): Not null asserted, check that this is correct.
        ChromeVoxState.instance!.isReadingContinuously = false;
        return false;
      case Command.TOGGLE_EARCONS:
        ChromeVox.earcons.toggle();
        return false;
      case Command.CYCLE_TYPING_ECHO:
        TypingEcho.cycleWithAnnouncement();
        return false;
      case Command.CYCLE_PUNCTUATION_ECHO:
        ChromeVox.tts.speak(
            Msgs.getMsg(TtsBackground.primary.cyclePunctuationEcho()),
            QueueMode.FLUSH);
        return false;
      case Command.REPORT_ISSUE:
        this.reportIssue_();
        return false;
      case Command.TOGGLE_BRAILLE_CAPTIONS:
        BrailleCaptionsBackground.setActive(
            !BrailleCaptionsBackground.isEnabled());
        return false;
      case Command.TOGGLE_BRAILLE_TABLE:
        BrailleTranslatorManager.instance.toggleBrailleTable();
        return false;
      case Command.HELP:
        (new PanelCommand(PanelCommandType.TUTORIAL)).send();
        return false;
      case Command.TOGGLE_SCREEN:
        this.toggleScreen_();
        return false;
      case Command.TOGGLE_SPEECH_ON_OR_OFF:
        TtsBackground.toggleSpeechWithAnnouncement();
        return false;
      case Command.ENABLE_CHROMEVOX_ARC_SUPPORT_FOR_CURRENT_APP:
        this.enableChromeVoxArcSupportForCurrentApp_();
        break;
      case Command.DISABLE_CHROMEVOX_ARC_SUPPORT_FOR_CURRENT_APP:
        this.disableChromeVoxArcSupportForCurrentApp_();
        break;
      case Command.SHOW_TALKBACK_KEYBOARD_SHORTCUTS:
        this.showTalkBackKeyboardShortcuts_();
        return false;
      case Command.SHOW_TTS_SETTINGS:
        chrome.accessibilityPrivate.openSettingsSubpage(
            'manageAccessibility/tts');
        break;
      default:
        break;
      case Command.TOGGLE_KEYBOARD_HELP:
        (new PanelCommand(PanelCommandType.OPEN_MENUS)).send();
        return false;
      case Command.SHOW_PANEL_MENU_MOST_RECENT:
        (new PanelCommand(PanelCommandType.OPEN_MENUS_MOST_RECENT)).send();
        return false;
      case Command.NEXT_GRANULARITY:
      case Command.PREVIOUS_GRANULARITY:
        this.nextOrPreviousGranularity_(
            command === Command.PREVIOUS_GRANULARITY);
        return false;
      case Command.ANNOUNCE_BATTERY_DESCRIPTION:
        this.announceBatteryDescription_();
        break;
      case Command.RESET_TEXT_TO_SPEECH_SETTINGS:
        TtsBackground.resetTextToSpeechSettings();
        return false;
      case Command.COPY:
        EventGenerator.sendKeyPress(KeyCode.C, {ctrl: true});

        // The above command doesn't trigger document clipboard events, so we
        // need to set this manually.
        ClipboardHandler.instance.readNextClipboardDataChange();
        return false;
      case Command.TOGGLE_DICTATION:
        EventGenerator.sendKeyPress(KeyCode.D, {search: true});
        return false;
      case Command.OPEN_KEYBOARD_SHORTCUTS:
        EventGenerator.sendKeyPress(KeyCode.S, {search: true, ctrl: true});
        return false;
    }

    // The remaining commands require a current range.
    if (!ChromeVoxRange.current) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      if (!ChromeVoxState.instance!.talkBackEnabled) {
        this.announceNoCurrentRange_();
      }
      return true;
    }

    // Allow edit commands first.
    if (!this.onEditCommand_(command)) {
      return false;
    }

    let currentRange: CursorRange | null = ChromeVoxRange.current;
    let node: AutomationNode | undefined = currentRange.start.node;

    // If true, will check if the predicate matches the current node.
    let matchCurrent = false;

    let dir = Dir.FORWARD;
    let newRangeData;
    let pred: AutomationPredicate.Unary | null = null;
    let predErrorMsg: string|undefined = 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 Command.NEXT_CHARACTER:
        didNavigate = true;
        speechProps.phoneticCharacters = true;
        unit = CursorUnit.CHARACTER;
        currentRange = currentRange.move(CursorUnit.CHARACTER, Dir.FORWARD);
        break;
      case Command.PREVIOUS_CHARACTER:
        dir = Dir.BACKWARD;
        didNavigate = true;
        speechProps.phoneticCharacters = true;
        unit = CursorUnit.CHARACTER;
        currentRange = currentRange.move(CursorUnit.CHARACTER, dir);
        break;
      case Command.NATIVE_NEXT_CHARACTER:
      case Command.NATIVE_PREVIOUS_CHARACTER:
        // TODO(b/314203187): Not null asserted, check that this is correct.
        DesktopAutomationInterface.instance!.onNativeNextOrPreviousCharacter();
        return true;
      case Command.NEXT_WORD:
        didNavigate = true;
        unit = CursorUnit.WORD;
        currentRange = currentRange.move(CursorUnit.WORD, Dir.FORWARD);
        break;
      case Command.PREVIOUS_WORD:
        dir = Dir.BACKWARD;
        didNavigate = true;
        unit = CursorUnit.WORD;
        currentRange = currentRange.move(CursorUnit.WORD, dir);
        break;
      case Command.NATIVE_NEXT_WORD:
      case Command.NATIVE_PREVIOUS_WORD:
        // TODO(b/314203187): Not null asserted, check that this is correct.
        DesktopAutomationInterface.instance!.onNativeNextOrPreviousWord(
            command === Command.NATIVE_NEXT_WORD);
        return true;
      case Command.FORWARD:
      case Command.NEXT_LINE:
        didNavigate = true;
        unit = CursorUnit.LINE;
        currentRange = currentRange.move(CursorUnit.LINE, Dir.FORWARD);
        break;
      case Command.BACKWARD:
      case Command.PREVIOUS_LINE:
        dir = Dir.BACKWARD;
        didNavigate = true;
        unit = CursorUnit.LINE;
        currentRange = currentRange.move(CursorUnit.LINE, dir);
        break;
      case Command.NEXT_BUTTON:
        dir = Dir.FORWARD;
        pred = AutomationPredicate.button;
        predErrorMsg = 'no_next_button';
        break;
      case Command.PREVIOUS_BUTTON:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.button;
        predErrorMsg = 'no_previous_button';
        break;
      case Command.NEXT_CHECKBOX:
        pred = AutomationPredicate.checkBox;
        predErrorMsg = 'no_next_checkbox';
        break;
      case Command.PREVIOUS_CHECKBOX:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.checkBox;
        predErrorMsg = 'no_previous_checkbox';
        break;
      case Command.NEXT_COMBO_BOX:
        pred = AutomationPredicate.comboBox;
        predErrorMsg = 'no_next_combo_box';
        break;
      case Command.PREVIOUS_COMBO_BOX:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.comboBox;
        predErrorMsg = 'no_previous_combo_box';
        break;
      case Command.NEXT_EDIT_TEXT:
        skipSettingSelection = true;
        pred = AutomationPredicate.editText;
        predErrorMsg = 'no_next_edit_text';
        // TODO(b/314203187): Not null asserted, check that this is correct.
        SmartStickyMode.instance!.startIgnoringRangeChanges();
        break;
      case Command.PREVIOUS_EDIT_TEXT:
        skipSettingSelection = true;
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.editText;
        predErrorMsg = 'no_previous_edit_text';
        // TODO(b/314203187): Not null asserted, check that this is correct.
        SmartStickyMode.instance!.startIgnoringRangeChanges();
        break;
      case Command.NEXT_FORM_FIELD:
        skipSettingSelection = true;
        pred = AutomationPredicate.formField;
        predErrorMsg = 'no_next_form_field';
        // TODO(b/314203187): Not null asserted, check that this is correct.
        SmartStickyMode.instance!.startIgnoringRangeChanges();
        break;
      case Command.PREVIOUS_FORM_FIELD:
        skipSettingSelection = true;
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.formField;
        predErrorMsg = 'no_previous_form_field';
        // TODO(b/314203187): Not null asserted, check that this is correct.
        SmartStickyMode.instance!.startIgnoringRangeChanges();
        break;
      case Command.PREVIOUS_GRAPHIC:
        skipSettingSelection = true;
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.image;
        predErrorMsg = 'no_previous_graphic';
        break;
      case Command.NEXT_GRAPHIC:
        skipSettingSelection = true;
        pred = AutomationPredicate.image;
        predErrorMsg = 'no_next_graphic';
        break;
      case Command.NEXT_HEADING:
        pred = AutomationPredicate.heading;
        predErrorMsg = 'no_next_heading';
        break;
      case Command.NEXT_HEADING_1:
        pred = AutomationPredicate.makeHeadingPredicate(1);
        predErrorMsg = 'no_next_heading_1';
        break;
      case Command.NEXT_HEADING_2:
        pred = AutomationPredicate.makeHeadingPredicate(2);
        predErrorMsg = 'no_next_heading_2';
        break;
      case Command.NEXT_HEADING_3:
        pred = AutomationPredicate.makeHeadingPredicate(3);
        predErrorMsg = 'no_next_heading_3';
        break;
      case Command.NEXT_HEADING_4:
        pred = AutomationPredicate.makeHeadingPredicate(4);
        predErrorMsg = 'no_next_heading_4';
        break;
      case Command.NEXT_HEADING_5:
        pred = AutomationPredicate.makeHeadingPredicate(5);
        predErrorMsg = 'no_next_heading_5';
        break;
      case Command.NEXT_HEADING_6:
        pred = AutomationPredicate.makeHeadingPredicate(6);
        predErrorMsg = 'no_next_heading_6';
        break;
      case Command.PREVIOUS_HEADING:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.heading;
        predErrorMsg = 'no_previous_heading';
        break;
      case Command.PREVIOUS_HEADING_1:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeHeadingPredicate(1);
        predErrorMsg = 'no_previous_heading_1';
        break;
      case Command.PREVIOUS_HEADING_2:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeHeadingPredicate(2);
        predErrorMsg = 'no_previous_heading_2';
        break;
      case Command.PREVIOUS_HEADING_3:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeHeadingPredicate(3);
        predErrorMsg = 'no_previous_heading_3';
        break;
      case Command.PREVIOUS_HEADING_4:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeHeadingPredicate(4);
        predErrorMsg = 'no_previous_heading_4';
        break;
      case Command.PREVIOUS_HEADING_5:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeHeadingPredicate(5);
        predErrorMsg = 'no_previous_heading_5';
        break;
      case Command.PREVIOUS_HEADING_6:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeHeadingPredicate(6);
        predErrorMsg = 'no_previous_heading_6';
        break;
      case Command.NEXT_LINK:
        pred = AutomationPredicate.link;
        predErrorMsg = 'no_next_link';
        break;
      case Command.PREVIOUS_LINK:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.link;
        predErrorMsg = 'no_previous_link';
        break;
      case Command.NEXT_TABLE:
        pred = AutomationPredicate.table;
        predErrorMsg = 'no_next_table';
        break;
      case Command.PREVIOUS_TABLE:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.table;
        predErrorMsg = 'no_previous_table';
        break;
      case Command.NEXT_VISITED_LINK:
        pred = AutomationPredicate.visitedLink;
        predErrorMsg = 'no_next_visited_link';
        break;
      case Command.PREVIOUS_VISITED_LINK:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.visitedLink;
        predErrorMsg = 'no_previous_visited_link';
        break;
      case Command.NEXT_LANDMARK:
        pred = AutomationPredicate.landmark;
        predErrorMsg = 'no_next_landmark';
        break;
      case Command.PREVIOUS_LANDMARK:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.landmark;
        predErrorMsg = 'no_previous_landmark';
        break;
      // falls through.
      case Command.LEFT:
      // @ts-expect-error Fallthrough is disabled by TypeScript.
      case Command.PREVIOUS_OBJECT:
        skipSettingSelection = true;
        dir = Dir.BACKWARD;
        // falls through.
      case Command.RIGHT:
      case Command.NEXT_OBJECT:
        skipSettingSelection = true;
        didNavigate = true;
        unit = (EventSource.get() === EventSourceType.TOUCH_GESTURE) ?
            CursorUnit.GESTURE_NODE :
            CursorUnit.NODE;
        currentRange = currentRange.move(unit, dir);
        currentRange = this.skipLabelOrDescriptionFor(currentRange, dir);
        break;
      case Command.PREVIOUS_GROUP:
        skipSync = true;
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.group;
        break;
      case Command.NEXT_GROUP:
        skipSync = true;
        pred = AutomationPredicate.group;
        break;
      case Command.PREVIOUS_PAGE:
      case Command.NEXT_PAGE:
        this.nextOrPreviousPage_(command, currentRange);
        return false;
      case Command.PREVIOUS_SIMILAR_ITEM:
        dir = Dir.BACKWARD;
        skipSync = true;
        pred = this.getPredicateForNextOrPreviousSimilarItem_(node);
        break;
      case Command.NEXT_SIMILAR_ITEM:
        skipSync = true;
        pred = this.getPredicateForNextOrPreviousSimilarItem_(node);
        break;
      case Command.PREVIOUS_INVALID_ITEM:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.isInvalid;
        rootPred = AutomationPredicate.root;
        predErrorMsg = 'no_invalid_item';
        break;
      case Command.NEXT_INVALID_ITEM:
        pred = AutomationPredicate.isInvalid;
        rootPred = AutomationPredicate.root;
        predErrorMsg = 'no_invalid_item';
        break;
      case Command.NEXT_LIST:
        pred = AutomationPredicate.makeListPredicate(node);
        predErrorMsg = 'no_next_list';
        break;
      case Command.PREVIOUS_LIST:
        dir = Dir.BACKWARD;
        pred = AutomationPredicate.makeListPredicate(node);
        predErrorMsg = 'no_previous_list';
        skipInitialAncestry = false;
        break;
      case Command.JUMP_TO_TOP:
        newRangeData = this.getNewRangeForJumpToTop_(node, currentRange);
        currentRange = newRangeData.range;
        tryScrolling = false;
        break;
      case Command.JUMP_TO_BOTTOM:
        newRangeData = this.getNewRangeForJumpToBottom_(node, currentRange);
        currentRange = newRangeData.range;
        tryScrolling = false;
        break;
      case Command.FORCE_CLICK_ON_CURRENT_ITEM:
        this.forceClickOnCurrentItem_();
        // Skip all other processing; if focus changes, we should get an event
        // for that.
        return false;
      case Command.FORCE_LONG_CLICK_ON_CURRENT_ITEM:
        node.longClick();
        // Skip all other processing; if focus changes, we should get an event
        // for that.
        return false;
      case Command.JUMP_TO_DETAILS:
        newRangeData = this.getNewRangeForJumpToDetails_(node, currentRange);
        node = newRangeData.node;
        currentRange = newRangeData.range;
        break;
      case Command.READ_FROM_HERE:
        this.readFromHere_();
        return false;
      case Command.CONTEXT_MENU:
        EventGenerator.sendKeyPress(KeyCode.APPS);
        break;
      case Command.SHOW_HEADINGS_LIST:
        (new PanelCommand(PanelCommandType.OPEN_MENUS, 'role_heading')).send();
        return false;
      case Command.SHOW_FORMS_LIST:
        (new PanelCommand(
             PanelCommandType.OPEN_MENUS, 'panel_menu_form_controls'))
            .send();
        return false;
      case Command.SHOW_LANDMARKS_LIST:
        (new PanelCommand(PanelCommandType.OPEN_MENUS, 'role_landmark')).send();
        return false;
      case Command.SHOW_LINKS_LIST:
        (new PanelCommand(PanelCommandType.OPEN_MENUS, 'role_link')).send();
        return false;
      case Command.SHOW_ACTIONS_MENU:
        (new PanelCommand(PanelCommandType.OPEN_MENUS, 'panel_menu_actions'))
            .send();
        return false;
      case Command.SHOW_TABLES_LIST:
        (new PanelCommand(PanelCommandType.OPEN_MENUS, 'table_strategy'))
            .send();
        return false;
      case Command.TOGGLE_SEARCH_WIDGET:
        (new PanelCommand(PanelCommandType.SEARCH)).send();
        return false;
      case Command.READ_CURRENT_TITLE:
        this.readCurrentTitle_();
        return false;
      case Command.READ_CURRENT_URL:
        // TODO(b/314203187): Not null asserted, check that this is correct.
        new Output().withString(node.root!.docUrl || '').go();
        return false;
      case Command.TOGGLE_SELECTION:
        // If the selection was toggled off, return.
        if (!ChromeVoxRange.toggleSelection()) {
          return false;
        }
        break;
      case Command.FULLY_DESCRIBE:
        const o = new Output();
        o.withContextFirst()
            .withRichSpeechAndBraille(
                currentRange, undefined, OutputCustomEvent.NAVIGATE)
            .go();
        return false;
      case Command.VIEW_GRAPHIC_AS_BRAILLE:
        this.viewGraphicAsBraille_(currentRange);
        return false;
      // Table commands.
      case Command.PREVIOUS_ROW:
        skipSync = true;
        dir = Dir.BACKWARD;
        pred = this.getPredicateForPreviousRow_(currentRange, dir);
        predErrorMsg = 'no_cell_above';
        rootPred = AutomationPredicate.table;
        shouldWrap = false;
        break;
      case Command.PREVIOUS_COL:
        skipSync = true;
        dir = Dir.BACKWARD;
        pred = this.getPredicateForPreviousCol_(currentRange, dir);
        predErrorMsg = 'no_cell_left';
        rootPred = AutomationPredicate.row;
        shouldWrap = false;
        break;
      case Command.NEXT_ROW:
        skipSync = true;
        pred = this.getPredicateForNextRow_(currentRange, dir);
        predErrorMsg = 'no_cell_below';
        rootPred = AutomationPredicate.table;
        shouldWrap = false;
        break;
      case Command.NEXT_COL:
        skipSync = true;
        pred = this.getPredicateForNextCol_(currentRange, dir);
        predErrorMsg = 'no_cell_right';
        rootPred = AutomationPredicate.row;
        shouldWrap = false;
        break;
      case Command.GO_TO_ROW_FIRST_CELL:
      case Command.GO_TO_ROW_LAST_CELL:
        skipSync = true;
        newRangeData = this.getNewRangeForGoToRowFirstOrLastCell_(
            node, currentRange, command);
        node = newRangeData.node;
        currentRange = newRangeData.range;
        break;
      case Command.GO_TO_COL_FIRST_CELL:
        skipSync = true;
        node = this.getTableNode_(node);
        if (!node || !node.firstChild) {
          return false;
        }
        pred = this.getPredicateForGoToColFirstOrLastCell_(currentRange, dir);
        currentRange = CursorRange.fromNode(node.firstChild);
        // Should not be outputted.
        predErrorMsg = 'no_cell_above';
        rootPred = AutomationPredicate.table;
        shouldWrap = false;
        break;
      case Command.GO_TO_COL_LAST_CELL:
        skipSync = true;
        dir = Dir.BACKWARD;
        node = this.getTableNode_(node);
        if (!node || !node.lastChild) {
          return false;
        }
        pred = this.getPredicateForGoToColFirstOrLastCell_(currentRange, dir);

        newRangeData = this.getNewRangeForGoToColLastCell_(node);
        currentRange = newRangeData.range;
        matchCurrent = true;

        // Should not be outputted.
        predErrorMsg = 'no_cell_below';
        rootPred = AutomationPredicate.table;
        shouldWrap = false;
        break;
      case Command.GO_TO_FIRST_CELL:
      case Command.GO_TO_LAST_CELL:
        skipSync = true;
        node = this.getTableNode_(node);
        if (!node) {
          break;
        }
        newRangeData = this.getNewRangeForGoToFirstOrLastCell_(
            node, currentRange, command);
        currentRange = newRangeData.range;
        break;

      // These commands are only available when invoked from touch.
      case Command.NEXT_AT_GRANULARITY:
      case Command.PREVIOUS_AT_GRANULARITY:
        this.nextOrPreviousAtGranularity_(
            command === Command.PREVIOUS_AT_GRANULARITY);
        return false;
      case Command.ANNOUNCE_RICH_TEXT_DESCRIPTION:
        this.announceRichTextDescription_(node);
        return false;
      case Command.READ_PHONETIC_PRONUNCIATION:
        this.readPhoneticPronunciation_(node);
        return false;
      case Command.READ_LINK_URL:
        this.readLinkUrl_(node);
        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');

      // TODO(b/314203187): Not null asserted, check that this is correct.
      let bound = currentRange!.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});
        }

        // Scroll here for table navigation with arrow keys where some nodes may
        // be hidden. The scroll must happen here because |node| will remain
        // undefined, causing this command to return before the next autoscroll
        // check.
        // TODO(b/314203187): Not null asserted, check that this is correct.
        if (!node &&
            !AutoScrollHandler.instance!.scrollToFindNodes(
                bound, command, currentRange!, dir, () => {
                  this.onCommand(command);
                  this.onFinishCommand();
                })) {
          this.onFinishCommand();
          return false;
        }

        if (node && !skipSync) {
          node = AutomationUtil.findNodePre(
                     node, Dir.FORWARD, AutomationPredicate.object) ??
              node;
        }

        if (node) {
          currentRange = CursorRange.fromNode(node);
        } else {
          ChromeVox.earcons.playEarcon(EarconId.WRAP);
          if (!shouldWrap) {
            if (predErrorMsg) {
              new Output()
                  .withString(Msgs.getMsg(predErrorMsg))
                  .withQueueMode(QueueMode.FLUSH)
                  .go();
            }
            this.onFinishCommand();
            return false;
          }

          let root: AutomationNode | undefined = bound;
          while (root && !AutomationPredicate.rootOrEditableRoot(root)) {
            root = root.parent;
          }

          if (!root) {
            root = bound.root;
          }

          if (dir === Dir.FORWARD) {
            // TODO(b/314203187): Not null asserted, check that this is correct.
            bound = root!;
          } else {
            bound =
                AutomationUtil.findNodePost(
                    root as AutomationNode, dir, AutomationPredicate.leaf) ??
                bound;
          }
          node = AutomationUtil.findNextNode(
              bound as AutomationNode, dir, pred, {root: rootPred});

          if (node && !skipSync) {
            node = AutomationUtil.findNodePre(
                       node, Dir.FORWARD, AutomationPredicate.object) ??
                node;
          }

          if (node) {
            currentRange = 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.
    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (tryScrolling && currentRange &&
        !AutoScrollHandler.instance!.onCommandNavigation(
            currentRange, dir, pred, unit, speechProps, rootPred, () => {
              this.onCommand(command);
              this.onFinishCommand();
            })) {
      this.onFinishCommand();
      return false;
    }

    if (currentRange) {
      if (currentRange.wrapped) {
        ChromeVox.earcons.playEarcon(EarconId.WRAP);
      }

      ChromeVoxRange.navigateTo(
          currentRange, undefined, speechProps, skipSettingSelection);
    }

    this.onFinishCommand();
    return false;
  }

  /** Finishes processing of a command. */
  onFinishCommand(): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    SmartStickyMode.instance!.stopIgnoringRangeChanges();
  }

  /**
   * Handle the command to view the first graphic within the current range
   * as braille.
   */
  private viewGraphicAsBraille_(currentRange: CursorRange): void {
    // Find the first node within the current range that supports image data.
    const imageNode = AutomationUtil.findNodePost(
        currentRange.start.node, Dir.FORWARD,
        AutomationPredicate.supportsImageData);
    if (imageNode) {
      imageNode.getImageData(0, 0);
    }
  }

  /**
   * Provides a partial mapping from ChromeVox key combinations to
   * Search-as-a-function key as seen in Chrome OS documentation.
   * @return True if the command should propagate.
   */
  private onEditCommand_(command: Command): boolean {
    if (ChromeVoxPrefs.isStickyModeOn()) {
      return true;
    }
    // TODO(b/314203187): Not null asserted, check that this is correct.
    const textEditHandler =
        DesktopAutomationInterface.instance!.textEditHandler;
    if (!textEditHandler ||
        !AutomationUtil.isDescendantOf(
            ChromeVoxRange.current!.start.node, textEditHandler.node)) {
      return true;
    }

    // Skip customized keys for read only text fields.
    if (textEditHandler.node.restriction === 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 Command.PREVIOUS_CHARACTER:
        EventGenerator.sendKeyPress(KeyCode.HOME, {shift: true});
        break;
      case Command.NEXT_CHARACTER:
        EventGenerator.sendKeyPress(KeyCode.END, {shift: true});
        break;
      case Command.PREVIOUS_WORD:
        EventGenerator.sendKeyPress(KeyCode.HOME, {shift: true, ctrl: true});
        break;
      case Command.NEXT_WORD:
        EventGenerator.sendKeyPress(KeyCode.END, {shift: true, ctrl: true});
        break;
      case Command.PREVIOUS_OBJECT:
        if (!isMultiline) {
          return true;
        }

        if (textEditHandler.isSelectionOnFirstLine()) {
          ChromeVoxRange.set(CursorRange.fromNode(textEditHandler.node));
          return true;
        }
        EventGenerator.sendKeyPress(KeyCode.HOME);
        break;
      case Command.NEXT_OBJECT:
        if (!isMultiline) {
          return true;
        }

        if (textEditHandler.isSelectionOnLastLine()) {
          textEditHandler.moveToAfterEditText();
          return false;
        }

        EventGenerator.sendKeyPress(KeyCode.END);
        break;
      case Command.PREVIOUS_LINE:
        if (!isMultiline) {
          return true;
        }
        if (textEditHandler.isSelectionOnFirstLine()) {
          ChromeVoxRange.set(CursorRange.fromNode(textEditHandler.node));
          return true;
        }
        EventGenerator.sendKeyPress(KeyCode.PRIOR);
        break;
      case Command.NEXT_LINE:
        if (!isMultiline) {
          return true;
        }

        if (textEditHandler.isSelectionOnLastLine()) {
          textEditHandler.moveToAfterEditText();
          return false;
        }
        EventGenerator.sendKeyPress(KeyCode.NEXT);
        break;
      case Command.JUMP_TO_TOP:
        EventGenerator.sendKeyPress(KeyCode.HOME, {ctrl: true});
        break;
      case Command.JUMP_TO_BOTTOM:
        EventGenerator.sendKeyPress(KeyCode.END, {ctrl: true});
        break;
      default:
        return true;
    }
    return false;
  }

  skipLabelOrDescriptionFor(currentRange: CursorRange, dir: Dir):
      CursorRange | null {
    if (!currentRange) {
      return null;
    }

    // Keep moving past all nodes acting as labels or descriptions.
    while (currentRange?.start?.node?.role === RoleType.STATIC_TEXT) {
      // We must scan upwards as any ancestor might have a label or description.
      let ancestor: AutomationNode | undefined = currentRange.start.node;
      while (ancestor) {
        if (ancestor.labelFor?.length || ancestor.descriptionFor?.length) {
          break;
        }
        ancestor = ancestor.parent;
      }
      if (ancestor) {
        currentRange = currentRange.move(CursorUnit.NODE, dir);
      } else {
        break;
      }
    }

    return currentRange;
  }

  private announceBatteryDescription_(): void {
    chrome.accessibilityPrivate.getBatteryDescription(batteryDescription => {
      new Output()
          .withString(batteryDescription)
          .withQueueMode(QueueMode.FLUSH)
          .go();
    });
  }

  private announceNoCurrentRange_(): void {
    new Output()
        .withString(Msgs.getMsg(
            EventSource.get() === EventSourceType.TOUCH_GESTURE ?
                'no_focus_touch' :
                'no_focus'))
        .withQueueMode(QueueMode.FLUSH)
        .go();
  }

  private announceRichTextDescription_(node: AutomationNode): void {
    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();
  }

  private disableChromeVoxArcSupportForCurrentApp_(): void {
    chrome.accessibilityPrivate.setNativeChromeVoxArcSupportForCurrentApp(
        false, response => {
          if (response === 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 ===
              SetNativeChromeVoxResponse.NEED_DEPRECATION_CONFIRMATION) {
            ChromeVox.braille.write(NavBraille.fromText(
                Msgs.getMsg('announce_talkback_deprecation')));
            ChromeVox.tts.speak(
                Msgs.getMsg('announce_talkback_deprecation'), QueueMode.FLUSH);
          }
        });
  }

  private enableChromeVoxArcSupportForCurrentApp_(): void {
    chrome.accessibilityPrivate.setNativeChromeVoxArcSupportForCurrentApp(
        true, _response => {});
  }

  private forceClickOnCurrentItem_(): void {
    if (!ChromeVoxRange.current) {
      return;
    }
    let actionNode: AutomationNode | undefined =
        ChromeVoxRange.current.start.node;
    // Scan for a clickable, which overrides the |actionNode|.
    let clickableNode: AutomationNode | undefined = actionNode;
    while (!clickableNode?.clickable &&
           actionNode.root === clickableNode?.root) {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      clickableNode = clickableNode!.parent;
    }
    if (actionNode.root === clickableNode?.root) {
      clickableNode?.doDefault();
      return;
    }

    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (EventSource.get() === EventSourceType.TOUCH_GESTURE &&
        actionNode.state![StateType.EDITABLE]) {
      // Dispatch a click to ensure the VK gets shown.
      const center = RectUtil.center(actionNode.location);
      EventGenerator.sendMouseClick(center.x, center.y);
      return;
    }

    while (actionNode && AutomationPredicate.text(actionNode)) {
      actionNode = actionNode.parent;
    }
    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (actionNode!.inPageLinkTarget) {
      ChromeVoxRange.navigateTo(
          CursorRange.fromNode(actionNode!.inPageLinkTarget));
      return;
    }
    actionNode!.doDefault();
  }

  private getNewRangeForGoToColLastCell_(node: AutomationNode): NewRangeData {
    // Try to start on the last cell of the table and allow
    // matching that node.
    let startNode = node.lastChild;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    while (startNode?.lastChild && !AutomationPredicate.cellLike(startNode!)) {
      startNode = startNode.lastChild;
    }
    return {node: startNode, range: CursorRange.fromNode(startNode!)};
  }

  private getNewRangeForGoToFirstOrLastCell_(
      node: AutomationNode, currentRange: CursorRange, command: Command)
      : NewRangeData {
    const end = AutomationUtil.findNodePost(
        node, command === Command.GO_TO_LAST_CELL ? Dir.BACKWARD : Dir.FORWARD,
        AutomationPredicate.leaf);
    if (end) {
      return {node: end, range: CursorRange.fromNode(end)};
    }
    return {node, range: currentRange};
  }

  private getNewRangeForJumpToBottom_(
      node: AutomationNode, currentRange: CursorRange): NewRangeData {
    if (!currentRange.start.node || !currentRange.start.node.root) {
      return {node, range: currentRange};
    }
    const newNode = AutomationUtil.findLastNode(
        currentRange.start.node.root, AutomationPredicate.object);
    if (newNode) {
      return {node: newNode, range: CursorRange.fromNode(newNode)};
    }
    return {node, range: currentRange};
  }

  private getNewRangeForJumpToTop_(
      node: AutomationNode, currentRange: CursorRange): NewRangeData {
    const root = currentRange.start.node?.root;
    if (!root) {
      return {node, range: currentRange};
    }
    const newNode = AutomationUtil.findNodePost(
        root, Dir.FORWARD, AutomationPredicate.object);
    if (newNode) {
      return {node: newNode, range: CursorRange.fromNode(newNode)};
    }
    return {node, range: currentRange};
  }

  private getNewRangeForGoToRowFirstOrLastCell_(
      node: AutomationNode, currentRange: CursorRange, command: Command)
      : NewRangeData {
    let current: AutomationNode | undefined = node;
    while (current && current.role !== RoleType.ROW) {
      current = current.parent;
    }
    if (!current) {
      return {node: current, range: currentRange};
    }
    const end = AutomationUtil.findNodePost(
        current,
        command === Command.GO_TO_ROW_LAST_CELL ? Dir.BACKWARD : Dir.FORWARD,
        AutomationPredicate.leaf);
    if (end) {
      currentRange = CursorRange.fromNode(end);
    }
    return {node: current, range: currentRange};
  }

  private getNewRangeForJumpToDetails_(
      node: AutomationNode, currentRange: CursorRange): NewRangeData {
    let current: AutomationNode | undefined = node;
    while (current && !current.details) {
      current = current.parent;
    }
    if (current?.details?.length) {
      // TODO currently can only jump to first detail.
      currentRange = CursorRange.fromNode(current.details[0]);
    }
    return {node: current, range: currentRange};
  }

  private getPredicateForGoToColFirstOrLastCell_(
      currentRange: CursorRange, dir: Dir): AutomationPredicate.Unary | null {
    const tableOpts = {col: true, dir, end: true};
    return AutomationPredicate.makeTableCellPredicate(
        currentRange.start.node, tableOpts);
  }

  private getPredicateForNextCol_(
      currentRange: CursorRange, dir: Dir): AutomationPredicate.Unary | null {
    const tableOpts = {col: true, dir};
    return AutomationPredicate.makeTableCellPredicate(
        currentRange.start.node, tableOpts);
  }

  private getPredicateForNextRow_(
      currentRange: CursorRange, dir: Dir): AutomationPredicate.Unary | null {
    const tableOpts = {row: true, dir};
    return AutomationPredicate.makeTableCellPredicate(
        currentRange.start.node, tableOpts);
  }

  private getPredicateForNextOrPreviousSimilarItem_(
      node: AutomationNode): AutomationPredicate.Unary | null {
    const originalNode = node;
    let current: AutomationNode | undefined = node;

    // Scan upwards until we get a role we don't want to ignore.
    while (current && AutomationPredicate.ignoreDuringJump(current)) {
      current = current.parent;
    }

    const useNode = current ?? originalNode;
    // TODO(b/314203187): Not null asserted, check that this is correct.
    return AutomationPredicate.roles([useNode.role!]);
  }

  private getPredicateForPreviousCol_(
      currentRange: CursorRange, dir: Dir): AutomationPredicate.Unary | null {
    const tableOpts = {col: true, dir};
    return AutomationPredicate.makeTableCellPredicate(
        currentRange.start.node, tableOpts);
  }

  private getPredicateForPreviousRow_(
      currentRange: CursorRange, dir: Dir): AutomationPredicate.Unary | null {
    const tableOpts = {row: true, dir};
    return AutomationPredicate.makeTableCellPredicate(
        currentRange.start.node, tableOpts);
  }

  private getTableNode_(node: AutomationNode): AutomationNode | undefined {
    let current: AutomationNode | undefined = node;
    while (current && current.role !== RoleType.TABLE) {
      current = current.parent;
    }
    return current;
  }

  private nextOrPreviousAtGranularity_(isPrevious: boolean): void {
    let command;
    switch (GestureInterface.getGranularity()) {
      case GestureGranularity.CHARACTER:
        command =
            isPrevious ? Command.PREVIOUS_CHARACTER : Command.NEXT_CHARACTER;
        break;
      case GestureGranularity.WORD:
        command = isPrevious ? Command.PREVIOUS_WORD : Command.NEXT_WORD;
        break;
      case GestureGranularity.LINE:
        command = isPrevious ? Command.PREVIOUS_LINE : Command.NEXT_LINE;
        break;
      case GestureGranularity.HEADING:
        command = isPrevious ? Command.PREVIOUS_HEADING : Command.NEXT_HEADING;
        break;
      case GestureGranularity.LINK:
        command = isPrevious ? Command.PREVIOUS_LINK : Command.NEXT_LINK;
        break;
      case GestureGranularity.FORM_FIELD_CONTROL:
        command =
            isPrevious ? Command.PREVIOUS_FORM_FIELD : Command.NEXT_FORM_FIELD;
        break;
    }
    if (command) {
      this.onCommand(command);
    }
  }

  private nextOrPreviousPage_(
      command: Command, currentRange: CursorRange): void {
    const root = AutomationUtil.getTopLevelRoot(currentRange.start.node);
    if (root?.scrollY !== undefined) {
      let page = Math.ceil(root.scrollY / root.location.height) || 1;
      page = command === Command.NEXT_PAGE ? page + 1 : page - 1;
      ChromeVox.tts.stop();
      root.setScrollOffset(0, page * root.location.height);
    }
  }

  private readCurrentTitle_(): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    let target: AutomationNode | undefined = ChromeVoxRange.current!.start.node;
    const output = new Output();

    if (!target) {
      return;
    }

    let firstWindow;
    let rootViewWindow;
    if (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();
  }

  private readFromHere_(): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    ChromeVoxState.instance!.isReadingContinuously = true;
    const continueReading = (): void => {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      if (!ChromeVoxState.instance!.isReadingContinuously ||
          !ChromeVoxRange.current) {
        return;
      }

      const prevRange = ChromeVoxRange.current;
      const newRange =
          ChromeVoxRange.current.move(CursorUnit.NODE, Dir.FORWARD);

      // Stop if we've wrapped back to the document.
      const maybeDoc = newRange.start.node;
      if (AutomationPredicate.root(maybeDoc)) {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        ChromeVoxState.instance!.isReadingContinuously = false;
        return;
      }

      ChromeVoxRange.set(newRange);
      newRange.select();

      const o =
          new Output()
              .withoutHints()
              .withRichSpeechAndBraille(
                  ChromeVoxRange.current, prevRange, OutputCustomEvent.NAVIGATE)
              .onSpeechEnd(continueReading);

      if (!o.hasSpeech) {
        continueReading();
        return;
      }

      o.go();
    };

    {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      const startNode = ChromeVoxRange.current!.start.node;
      const collapsedRange = CursorRange.fromNode(startNode);
      const o =
          new Output()
              .withoutHints()
              .withRichSpeechAndBraille(
                  collapsedRange, collapsedRange, OutputCustomEvent.NAVIGATE)
              .onSpeechEnd(continueReading);

      if (o.hasSpeech) {
        o.go();
      } else {
        continueReading();
      }
    }
  }

  private readLinkUrl_(node: AutomationNode): void {
    const rootNode = node.root;
    let current: AutomationNode | undefined = node;
    while (current && !current.url) {
      // URL could be an ancestor of current range.
      current = current.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 = (current && current !== rootNode) ? current.url : '';
    new Output()
        .withString(
            url ? Msgs.getMsg('url_behind_link', [url]) :
                  Msgs.getMsg('no_url_found'))
        .withQueueMode(QueueMode.CATEGORY_FLUSH)
        .go();
  }

  private readPhoneticPronunciation_(node: AutomationNode): void {
    // Get node info.
    // TODO(b/314203187): Not null asserted, check that this is correct.
    const index = ChromeVoxRange.current!.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;
    }

    // Get word start and end indices.
    let wordStarts: number[];
    let wordEnds: number[];
    // TODO(b/314203187): Not null asserted, check that this is correct.
    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();
    }
  }

  private reportIssue_(): void {
    let url =
        'https://issuetracker.google.com/issues/new?component=1272895&type=BUG' +
        '&priority=P2&severity=S2&description=';
    const description: {[key: string]: string} = {};
    description['Chrome OS Version'] = chrome.runtime.getManifest()['version'];
    description['Lacros Version (if applicable)'] =
        '(copy from chrome://version)';
    description['Reproduction Steps'] = '%0a1.%0a2.%0a3.';
    description['Expected result'] = '';
    description['What actually happens'] = '';
    for (const key in description) {
      url += key + ':%20' + description[key] + '%0a';
    }
    BrowserUtil.openBrowserUrl(url);
  }

  private showLearnModePage_(): void {
    const explorerPage = {
      url: 'chromevox/learn_mode/learn_mode.html',
      type: 'panel' as CreateType,
    };
    // Use chrome.windows API to ensure page is opened in Ash-chrome.
    chrome.windows.create(explorerPage);
  }

  private showTalkBackKeyboardShortcuts_(): void {
    BrowserUtil.openBrowserUrl(
        'https://support.google.com/accessibility/android/answer/6110948');
  }

  private speakTimeAndDate_(): void {
    chrome.automation.getDesktop(d => {
      // First, try speaking the on-screen time.
      const allTime = d.findAll({role: RoleType.TIME});
      // TODO(b/314203187): Not null asserted, check that this is correct.
      allTime.filter(time => time.root!.role === RoleType.DESKTOP);

      let timeString = '';
      allTime.forEach(time => {
        if (time.name) {
          timeString = time.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();
      }
    });
  }

  /**
   * Launch ChromeVox options page in settings app.
   * TODO(b/268196299): Add test for showing options page.
   */
  private showOptionsPage_(): void {
    // Launch ChromeVox settings (inside ChromeOS Settings App).
    chrome.accessibilityPrivate.openSettingsSubpage('textToSpeech/chromeVox');
  }

  private nextOrPreviousGranularity_(isPrevious: boolean): void {
    let gran = GestureInterface.getGranularity();
    const next = isPrevious ?
        (--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);
  }

  private toggleScreen_(): void {
    const newState = !ChromeVoxPrefs.darkScreen;
    if (newState && !LocalStorage.get('acceptToggleScreen')) {
      // If this is the first time, show a confirmation dialog.
      chrome.accessibilityPrivate.showConfirmationDialog(
          Msgs.getMsg('toggle_screen_title'),
          Msgs.getMsg('toggle_screen_description'), /*cancelName=*/ undefined,
          confirmed => {
            if (confirmed) {
              ChromeVoxPrefs.darkScreen = true;
              LocalStorage.set('acceptToggleScreen', true);
              chrome.accessibilityPrivate.darkenScreen(true);
              new Output().format('@toggle_screen_off').go();
            }
          });
    } else {
      ChromeVoxPrefs.darkScreen = newState;
      chrome.accessibilityPrivate.darkenScreen(newState);
      new Output()
          .format((newState) ? '@toggle_screen_off' : '@toggle_screen_on')
          .go();
    }
  }

  /** Performs global initialization. */
  static init(): void {
    CommandHandlerInterface.instance = new CommandHandler();
    ChromeVoxKbHandler.commandHandler = command =>
        CommandHandlerInterface.instance.onCommand(command);

    BridgeHelper.registerHandler(
        BridgeConstants.CommandHandler.TARGET,
        BridgeConstants.CommandHandler.Action.ON_COMMAND,
        (command: Command) => {
          if (Object.values(Command).includes(command)) {
            CommandHandlerInterface.instance.onCommand(command);
          } else {
            console.warn('ChromeVox got an unrecognized command: ' + command);
          }
        });
  }
}