chromium/chrome/browser/resources/chromeos/accessibility/switch_access/switch_access.ts

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

import {AsyncUtil} from '/common/async_util.js';
import {EventHandler} from '/common/event_handler.js';
import {FlagName, Flags} from '/common/flags.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {Navigator} from './navigator.js';
import {KeyboardRootNode} from './nodes/keyboard_node.js';
import {ErrorType, Mode} from './switch_access_constants.js';

type AutomationEvent = chrome.automation.AutomationEvent;
type AutomationNode = chrome.automation.AutomationNode;
const EventType = chrome.automation.EventType;
type FindParams = chrome.automation.FindParams;
const RoleType = chrome.automation.RoleType;

let readyCallback: VoidFunction;
const readyPromise: Promise<void> =
    new Promise(resolve => readyCallback = resolve);

/**
 * The top-level class for the Switch Access accessibility feature. Handles
 * initialization and small matters that don't fit anywhere else in the
 * codebase.
 */
export class SwitchAccess {
  static instance?: SwitchAccess;
  static mode = Mode.ITEM_SCAN;
  private constructor() {}

  static async init(desktop: AutomationNode): Promise<void> {
    if (SwitchAccess.instance) {
      throw new Error('Cannot create two SwitchAccess.instances');
    }
    SwitchAccess.instance = new SwitchAccess();

    const currentFocus = await AsyncUtil.getFocus();
    await SwitchAccess.instance.waitForFocus_(desktop, currentFocus);
  }

  /** Starts Switch Access behavior. */
  static start(): void {
    KeyboardRootNode.startWatchingVisibility();
    Navigator.byItem.start();
    readyCallback();
  }

  static async ready(): Promise<void> {
    return readyPromise;
  }

  /**
   * Returns whether or not the feature flag
   * for improved text input is enabled.
   */
  static improvedTextInputEnabled(): boolean {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    return Flags.isEnabled(FlagName.SWITCH_ACCESS_TEXT)!;
  }

  /**
   * Helper function to robustly find a node fitting a given FindParams, even if
   * that node has not yet been created.
   * Used to find the menu and back button.
   */
  static findNodeMatching(
      findParams: FindParams,
      foundCallback: (node: AutomationNode) => void): void {
    const desktop = Navigator.byItem.desktopNode;
    // First, check if the node is currently in the tree.
    let node = desktop.find(findParams);
    if (node) {
      foundCallback(node);
      return;
    }
    // If it's not currently in the tree, listen for changes to the desktop
    // tree.
    const eventHandler = new EventHandler(
        desktop, EventType.CHILDREN_CHANGED, (_evt: AutomationEvent) => {});

    const onEvent = (event: AutomationEvent): void => {
      if (event.target.matches(findParams)) {
        // If the event target is the node we're looking for, we've found it.
        eventHandler.stop();
        foundCallback(event.target);
      } else if (event.target.children.length > 0) {
        // Otherwise, see if one of its children is the node we're looking for.
        node = event.target.find(findParams);
        if (node) {
          eventHandler.stop();
          foundCallback(node);
        }
      }
    };

    eventHandler.setCallback(onEvent);
    eventHandler.start();
  }

  /** Creates and records the specified error. */
  static error(
      errorType: ErrorType, errorString: string,
      shouldRecover = false): Error {
    if (shouldRecover) {
      setTimeout(Navigator.byItem.moveToValidNode.bind(Navigator.byItem), 0);
    }
    const errorTypeCountForUMA = Object.keys(ErrorType).length;
    chrome.metricsPrivate.recordEnumerationValue(
        'Accessibility.CrosSwitchAccess.Error', errorType,
        errorTypeCountForUMA);
    return new Error(errorString);
  }

  private async waitForFocus_(
      desktop: AutomationNode,
      currentFocus: AutomationNode | undefined): Promise<void> {
    return new Promise(resolve => {
      // Focus is available. Finish init without waiting for further events.
      // Disallow web view nodes, which indicate a root web area is still
      // loading and pending focus.
      if (currentFocus && currentFocus.role !== RoleType.WEB_VIEW) {
        resolve();
        return;
      }

      // Wait for the focus to be sent. If |currentFocus| was undefined, this is
      // guaranteed. Otherwise, also set a timed callback to ensure we do
      // eventually init.
      let callbackId = 0;
      const listener = (maybeEvent: AutomationEvent | undefined): void => {
        if (maybeEvent && maybeEvent.target.role === RoleType.WEB_VIEW) {
          return;
        }

        desktop.removeEventListener(EventType.FOCUS, listener, false);
        clearTimeout(callbackId);

        resolve();
      };

      desktop.addEventListener(EventType.FOCUS, listener, false);
      callbackId = setTimeout(listener, 5000);
    });
  }
}

TestImportManager.exportForTesting(SwitchAccess);