chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/auto_scroll_handler.ts

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

/**
 * @fileoverview Handles auto scrolling on navigation.
 */
import {AutomationPredicate} from '/common/automation_predicate.js';
import {AutomationUtil} from '/common/automation_util.js';
import {constants} from '/common/constants.js';
import {CursorUnit} from '/common/cursors/cursor.js';
import {CursorRange} from '/common/cursors/range.js';
import {EventHandler} from '/common/event_handler.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {Command} from '../common/command.js';
import {TtsSpeechProperties} from '../common/tts_types.js';

import {ChromeVoxRange} from './chromevox_range.js';
import {CommandHandlerInterface} from './input/command_handler_interface.js';

// setTimeout and its clean-up are referencing each other. So, we need to set
// "ignoreReadBeforeAssign" in this file. ESLint doesn't support per-line rule
// option modification.
/* eslint prefer-const: ["error", {"ignoreReadBeforeAssign": true}] */

type AutomationNode = chrome.automation.AutomationNode;
import Dir = constants.Dir;
import EventType = chrome.automation.EventType;
type Unary = AutomationPredicate.Unary;
const RoleType = chrome.automation.RoleType;

/**
 * Handles scrolling, based either on a user command or an event firing.
 * Most of the logic is to support ARC++.
 */
export class AutoScrollHandler {
  private isScrolling_ = false;
  private scrollingNode_: AutomationNode | null = null;
  private rangeBeforeScroll_: CursorRange | null = null;
  private lastScrolledTime_ = new Date(0);
  private relatedFocusEventHappened_ = false;
  private allowWebContentsForTesting_ = false;

  static instance: AutoScrollHandler;

  static init(): void {
    AutoScrollHandler.instance = new AutoScrollHandler();
  }

  /**
   * This should be called before any command triggers ChromeVox navigation.
   *
   * @param target The range that is going to be navigated
   *     before scrolling.
   * @param dir The direction to navigate.
   * @param pred The predicate to match.
   * @param unit The unit to navigate by.
   * @param speechProps The optional speech properties
   *     given to |navigateTo| to provide feedback from the current command.
   * @param rootPred The predicate that expresses
   *     the current navigation root.
   * @param retryCommandFunc The callback used to retry the command
   *     with refreshed tree after scrolling.
   * @return True if the given navigation can be executed. False if the given
   *     navigation shouldn't happen, and AutoScrollHandler handles the command
   *     instead.
   */
  onCommandNavigation(
      target: CursorRange, dir: Dir, pred: Unary | null,
      unit: CursorUnit | null, speechProps: TtsSpeechProperties | null,
      rootPred: Unary, retryCommandFunc: Function): boolean {
    if (this.isScrolling_) {
      // Prevent interrupting the current scrolling.
      return false;
    }

    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (!target.start?.node || !ChromeVoxRange.current!.start.node) {
      return true;
    }

    const rangeBeforeScroll = ChromeVoxRange.current;
    let scrollable: AutomationNode | null | undefined =
        this.findScrollableAncestor_(target);

    // At the beginning or the end of the document, there is a case where the
    // range stays there. It's worth trying scrolling the containing scrollable.
    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (!scrollable && target.equals(rangeBeforeScroll!) &&
        (unit === CursorUnit.WORD || unit === CursorUnit.CHARACTER)) {
      scrollable =
          this.tryFindingContainingScrollableIfAtEdge_(
              target, dir, scrollable ?? undefined);
    }

    if (!scrollable) {
      return true;
    }

    this.isScrolling_ = true;
    this.scrollingNode_ = scrollable;
    this.rangeBeforeScroll_ = rangeBeforeScroll;
    this.lastScrolledTime_ = new Date();
    this.relatedFocusEventHappened_ = false;

    this.scrollForCommandNavigation_(
            target, dir, pred, unit, speechProps ?? undefined, rootPred,
            retryCommandFunc)
        .catch(() => this.isScrolling_ = false);
    return false;
  }

  /**
   * This function will scroll to find nodes that are offscreen and not in the
   * tree.
   * @param bound The current cell node.
   * @param command the command handler command.
   * @param dir the direction of movement
   * @return True if the given navigation can be executed. False if
   *     the given navigation shouldn't happen, and AutoScrollHandler handles
   *     the command instead.
   */
  scrollToFindNodes(
      bound: AutomationNode, command: Command, target: CursorRange, dir: Dir,
      postScrollCallback: Function): boolean {
    if (bound.parent?.hasHiddenOffscreenNodes && target) {
      let pred = null;
      // Handle grids going over edge.

      if (bound.parent?.role === RoleType.GRID) {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        const currentRow = bound.tableCellRowIndex;
        const totalRows = bound.parent!.tableRowCount!;
        const currentCol = bound.tableCellColumnIndex;
        const totalCols = bound.parent!.tableColumnCount!;
        if (command === Command.NEXT_ROW || command === Command.PREVIOUS_ROW) {
          if (dir === Dir.BACKWARD && currentRow === 0) {
            return true;
          } else if (
              dir === Dir.FORWARD && currentRow === (totalRows - 1)) {
            return true;
          }
          // Create predicate
          pred = AutomationPredicate.makeTableCellPredicate(bound, {
            row: true,
            dir,
          });
        } else if (
            command === Command.NEXT_COL || command === Command.PREVIOUS_COL) {
          if (dir === Dir.BACKWARD && currentCol === 0) {
            return true;
          } else if (
              dir === Dir.FORWARD && currentCol === (totalCols - 1)) {
            return true;
          }
          // Create predicate
          pred = AutomationPredicate.makeTableCellPredicate(bound, {
            col: true,
            dir,
          });
        }
      }

      return this.onCommandNavigation(
          target, dir, pred, null, null, AutomationPredicate.root,
          postScrollCallback);
    }
    return true;
  }

  /** @param target The range that is going to be navigated before scrolling. */
  private findScrollableAncestor_(target: CursorRange): AutomationNode | null {
    let ancestors;
    if (ChromeVoxRange.current && target.equals(ChromeVoxRange.current)) {
      ancestors = AutomationUtil.getAncestors(target.start.node);
    } else {
      // TODO(b/314203187): Not null asserted, check that this is correct.
      ancestors = AutomationUtil.getUniqueAncestors(
          target.start.node, ChromeVoxRange.current!.start.node);
    }
    // Check if we are in ARC++. Scrolling behavior should only happen there,
    // where additional nodes are not loaded until the user scrolls.
    if (!this.allowWebContentsForTesting_ &&
        !ancestors.find(
            node => node.role === RoleType.APPLICATION)) {
      return null;
    }
    const scrollable =
        ancestors.find(node => AutomationPredicate.autoScrollable(node));
    return scrollable ?? null;
  }

  private tryFindingContainingScrollableIfAtEdge_(
      target: CursorRange, dir: Dir, scrollable: AutomationNode | undefined):
      AutomationNode | undefined {
    let current = target.start.node;
    let parent = current.parent;
    while (parent?.root === current.root) {
      if (!(dir === Dir.BACKWARD && parent?.firstChild === current) &&
          !(dir === Dir.FORWARD && parent?.lastChild === current)) {
        // Currently on non-edge node. Don't try scrolling.
        return undefined;
      }
      if (AutomationPredicate.autoScrollable(current)) {
        scrollable = current;
      }
      // TODO(b/314203187): Not null asserted, check that this is correct.
      current = parent!;
      parent = current.parent;
    }
    return scrollable;
  }

  /**
   * @param target The range that is going to be navigated before scrolling.
   * @param dir The direction to navigate.
   * @param pred The predicate to match.
   * @param unit The unit to navigate by.
   * @param speechProps The optional speech properties given to |navigateTo| to
   *     provide feedback for the current command.
   * @param rootPred The predicate that expresses the current navigation root.
   * @param retryCommandFunc The callback used to retry the command with
   *     refreshed tree after scrolling.
   */
  private async scrollForCommandNavigation_(
      target: CursorRange, dir: Dir, pred: Unary | null,
      unit: CursorUnit | null, speechProps: TtsSpeechProperties | undefined,
      rootPred: Unary, retryCommandFunc: Function): Promise<void> {
    const scrollResult =
        await this.scrollInDirection_(this.scrollingNode_ ?? undefined, dir);
    if (!scrollResult) {
      this.isScrolling_ = false;
      ChromeVoxRange.navigateTo(target, false, speechProps);
      return;
    }

    // Wait for a scrolled event or timeout.
    await this.waitForScrollEvent_(this.scrollingNode_ ?? undefined);

    // When a scrolling animation happens, SCROLL_POSITION_CHANGED event can
    // be dispatched multiple times, and there is a chance that the ui tree is
    // in an intermediate state. Just wait for a while so that the UI gets
    // stabilized.
    await new Promise(resolve => setTimeout(resolve, DELAY_HANDLE_SCROLLED_MS));

    this.isScrolling_ = false;

    if (!this.scrollingNode_) {
      throw Error('Illegal state in AutoScrollHandler.');
    }
    if (!this.scrollingNode_.root) {
      // Maybe scrollable node has disappeared. Do nothing.
      return;
    }

    if (this.relatedFocusEventHappened_) {
      const nextRange = this.handleScrollingInAndroidRecyclerView_(
          pred, unit, dir, rootPred, this.scrollingNode_);

      ChromeVoxRange.navigateTo(nextRange ?? target, false, speechProps);
      return;
    }

    // If the focus has been changed for some reason, do nothing to
    // prevent disturbing the latest navigation.
    // TODO(b/314203187): Not null asserted, check that this is correct.
    if (!ChromeVoxRange.current ||
        !ChromeVoxRange.current.equals(this.rangeBeforeScroll_!)) {
      return;
    }

    // Usual case. Retry navigation with a refreshed tree.
    retryCommandFunc();
  }

  private async scrollInDirection_(
      scrollable: AutomationNode | undefined, dir: Dir): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!scrollable) {
        reject('Scrollable cannot be null or undefined when scrolling');
        return;
      }
      if (dir === Dir.FORWARD) {
        scrollable.scrollForward(resolve);
      } else {
        scrollable.scrollBackward(resolve);
      }
    });
  }

  private async waitForScrollEvent_(
      scrollable: AutomationNode | undefined): Promise<void> {
    return new Promise((resolve, reject) => {
      if (!scrollable) {
        reject('Scrollable cannot be null when waiting for scroll event');
        return;
      }
      let cleanUp: VoidFunction;
      let listener: EventHandler;
      let timeoutId: number;
      const onTimeout = (): void => {
        cleanUp();
        reject('timeout to wait for scrolled event.');
      };
      const onScrolled = (): void => {
        cleanUp();
        resolve();
      };
      cleanUp = () => {
        listener.stop();
        clearTimeout(timeoutId);
      };

      // TODO(b/314203187): Not null asserted, check that this is correct.
      listener = new EventHandler(
          scrollable!, RELATED_SCROLL_EVENT_TYPES, onScrolled, {capture: true});
      listener.start();
      timeoutId = setTimeout(onTimeout, TIMEOUT_SCROLLED_EVENT_MS);
    });
  }

  /**
   * This block handles scrolling on Android RecyclerView.
   * When scrolling happens, RecyclerView can delete an invisible item, which
   * might be the focused node before scrolling, or can re-use the focused node
   * for a newly added node. When one of these happens, the focused node first
   * disappears from the ARC tree and the focus is moved up to the View that has
   * the ARC tree. In this case, navigate to the first or the last matching
   * range in the scrollable.
   *
   * @param pred The predicate to match.
   * @param unit The unit to navigate by.
   * @param dir The direction to navigate.
   * @param rootPred The predicate that expresses the current navigation root.
   */
  private handleScrollingInAndroidRecyclerView_(
      pred: Unary | null, unit: CursorUnit | null, dir: Dir, rootPred: Unary,
      scrollable: AutomationNode): CursorRange | null {
    let nextRange = null;
    if (!pred && unit) {
      nextRange = CursorRange.fromNode(scrollable).sync(unit, dir);
      if (unit === CursorUnit.NODE) {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        nextRange = CommandHandlerInterface.instance.skipLabelOrDescriptionFor(
            nextRange!, dir);
      }
    } else if (pred) {
      let node;
      if (dir === Dir.FORWARD) {
        node = AutomationUtil.findNextNode(
            scrollable, dir, pred, {root: rootPred, skipInitialSubtree: false});
      } else {
        // TODO(b/314203187): Not null asserted, check that this is correct.
        node = AutomationUtil.findNodePost(this.scrollingNode_!, dir, pred);
      }
      if (node) {
        nextRange = CursorRange.fromNode(node);
      }
    }
    return nextRange;
  }

  /**
   * This should be called before the focus event triggers the navigation.
   *
   * On scrolling, sometimes previously focused node disappears.
   * There are two common problematic patterns in ARC tree here:
   * 1. No node has focus. The root ash window now get focus.
   * 2. Another node in the window gets focus.
   * Above two cases interrupt the auto scrolling. So when any of these are
   * happening, this returns false.
   *
   * @param node the node that is the focus event target.
   * @return True if others can handle the event. False if the event shouldn't
   *     be propagated.
   */
  onFocusEventNavigation(node: AutomationNode | undefined): boolean {
    if (!this.scrollingNode_ || !node) {
      return true;
    }
    const elapsedTime = new Date().valueOf() - this.lastScrolledTime_.valueOf();
    if (elapsedTime > TIMEOUT_FOCUS_EVENT_DROP_MS) {
      return true;
    }

    const isUnrelatedEvent = this.scrollingNode_.root !== node.root &&
        !AutomationUtil.isDescendantOf(this.scrollingNode_, node);
    if (isUnrelatedEvent) {
      return true;
    }

    this.relatedFocusEventHappened_ = true;
    return false;
  }
}

// Variables local to the module.

/**
 * An array of Automation event types that AutoScrollHandler observes when
 * performing a scroll action.
 */
const RELATED_SCROLL_EVENT_TYPES: EventType[] = [
  // This is sent by ARC++.
  EventType.SCROLL_POSITION_CHANGED,
  // These two events are sent by Web and Views via AXEventGenerator.
  EventType.SCROLL_HORIZONTAL_POSITION_CHANGED,
  EventType.SCROLL_VERTICAL_POSITION_CHANGED,
];

/**
 * The timeout that we wait for scroll event since scrolling action's callback
 * is executed.
 * TODO(hirokisato): Find an appropriate timeout in our case. TalkBack uses 500
 * milliseconds. See
 * https://github.com/google/talkback/blob/acd0bc7631a3dfbcf183789c7557596a45319e1f/talkback/src/main/java/ScrollEventInterpreter.java#L169
 */
const TIMEOUT_SCROLLED_EVENT_MS = 1500;

/**
 * The timeout that the focused event should be dropped. This is longer than
 * |TIMEOUT_CALLBACK_MS| because a focus event can happen after the scrolling.
 */
const TIMEOUT_FOCUS_EVENT_DROP_MS = 2000;

/**
 * The delay in milliseconds to wait to handle a scrolled event after the event
 * is first dispatched in order to wait for UI stabilized. See also
 * https://github.com/google/talkback/blob/6c0b475b7f52469e309e51bfcc13de58f18176ff/talkback/src/main/java/com/google/android/accessibility/talkback/interpreters/AutoScrollInterpreter.java#L42
 */
const DELAY_HANDLE_SCROLLED_MS = 150;

TestImportManager.exportForTesting(AutoScrollHandler);