chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/live_regions.js

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

/**
 * @fileoverview Implements support for live regions in ChromeVox.
 */
import {AutomationPredicate} from '/common/automation_predicate.js';
import {AutomationUtil} from '/common/automation_util.js';
import {CursorRange} from '/common/cursors/range.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {QueueMode, TtsCategory} from '../common/tts_types.js';

import {ChromeVoxRange} from './chromevox_range.js';
import {Output} from './output/output.js';
import {OutputCustomEvent} from './output/output_types.js';

const AutomationNode = chrome.automation.AutomationNode;
const EventType = chrome.automation.EventType;
const RoleType = chrome.automation.RoleType;
const StateType = chrome.automation.StateType;
const TreeChange = chrome.automation.TreeChange;
const TreeChangeObserverFilter = chrome.automation.TreeChangeObserverFilter;
const TreeChangeType = chrome.automation.TreeChangeType;

/**
 * Handles events and announcements associated with "live regions", which are
 * regions marked as likely to change and important to announce.
 */
export class LiveRegions {
  /** @private */
  constructor() {
    /** @private {!Date} */
    this.lastDesktopLiveRegionChangedTime_ = new Date(0);

    /** @private {string} */
    this.lastDesktopLiveRegionChangedText_ = '';

    /**
     * The time the last live region event was output.
     * @type {!Date}
     * @private
     */
    this.lastLiveRegionTime_ = new Date(0);

    /**
     * Set of nodes that have been announced as part of a live region since
     *     |this.lastLiveRegionTime_|, to prevent duplicate announcements.
     * @type {!WeakSet<AutomationNode>}
     * @private
     */
    this.liveRegionNodeSet_ = new WeakSet();

    /**
     * A list of nodes that have changed as part of one atomic tree update.
     * @private {!Array}
     */
    this.changedNodes_ = [];

    chrome.automation.addTreeChangeObserver(
        TreeChangeObserverFilter.LIVE_REGION_TREE_CHANGES,
        treeChange => this.onTreeChange(treeChange));
  }

  static init() {
    if (LiveRegions.instance) {
      throw 'Error: Trying to create two instances of singleton LiveRegions';
    }
    LiveRegions.instance = new LiveRegions();
  }

  /** @param {!AutomationNode} area */
  static announceDesktopLiveRegionChanged(area) {
    const desktopOrApplication =
        AutomationPredicate.roles([RoleType.DESKTOP, RoleType.APPLICATION]);
    if (!area.root || !desktopOrApplication(area.root)) {
      return;
    }

    const output = new Output();
    if (area.containerLiveStatus === 'assertive') {
      output.withQueueMode(QueueMode.CATEGORY_FLUSH);
    } else if (area.containerLiveStatus === 'polite') {
      output.withQueueMode(QueueMode.QUEUE);
    } else {
      return;
    }

    const withinDelay =
        (new Date() - LiveRegions.instance.lastDesktopLiveRegionChangedTime_) <
        DESKTOP_CHANGE_DELAY_MS;

    output
        .withRichSpeechAndBraille(
            CursorRange.fromNode(area), null, EventType.LIVE_REGION_CHANGED)
        .withSpeechCategory(TtsCategory.LIVE);
    if (withinDelay &&
        output.toString() ===
            LiveRegions.instance.lastDesktopLiveRegionChangedText_) {
      return;
    }

    LiveRegions.instance.lastDesktopLiveRegionChangedTime_ = new Date();
    LiveRegions.instance.lastDesktopLiveRegionChangedText_ = output.toString();
    output.go();
  }

  /**
   * Called when the automation tree is changed.
   * @param {TreeChange} treeChange
   */
  onTreeChange(treeChange) {
    const type = treeChange.type;
    const node = treeChange.target;
    if ((!node.containerLiveStatus || node.containerLiveStatus === 'off') &&
        type !== TreeChangeType.SUBTREE_UPDATE_END) {
      return;
    }

    if (this.shouldIgnoreLiveRegion_(node)) {
      return;
    }

    const relevant = node.containerLiveRelevant || '';
    const additions = relevant.indexOf('additions') >= 0;
    const text = relevant.indexOf('text') >= 0;
    const removals = relevant.indexOf('removals') >= 0;
    const all = relevant.indexOf('all') >= 0;

    if (all ||
        (additions &&
         (type === TreeChangeType.NODE_CREATED ||
          type === TreeChangeType.SUBTREE_CREATED))) {
      this.queueLiveRegionChange_(node);
    } else if (all || (text && type === TreeChangeType.TEXT_CHANGED)) {
      this.queueLiveRegionChange_(node);
    }

    if ((all || removals) && type === TreeChangeType.NODE_REMOVED) {
      this.outputLiveRegionChange_(node, '@live_regions_removed');
    }

    if (type === TreeChangeType.SUBTREE_UPDATE_END) {
      this.processQueuedTreeChanges_();
    }
  }

  /**
   * @param {!AutomationNode} node
   * @private
   */
  queueLiveRegionChange_(node) {
    this.changedNodes_.push(node);
  }

  /**
   * @private
   */
  processQueuedTreeChanges_() {
    // Schedule all live regions after all events in the native C++
    // EventBundle.
    this.liveRegionNodeSet_ = new WeakSet();
    for (let i = 0; i < this.changedNodes_.length; i++) {
      const node = this.changedNodes_[i];
      this.outputLiveRegionChange_(node, null);
    }
    this.changedNodes_ = [];
  }

  /**
   * Given a node that needs to be spoken as part of a live region
   * change and an additional optional format string, output the
   * live region description.
   * @param {!AutomationNode} node The changed node.
   * @param {?string=} opt_prependFormatStr If set, a format string for
   *     Output to prepend to the output.
   * @private
   */
  outputLiveRegionChange_(node, opt_prependFormatStr) {
    if (node.containerLiveBusy) {
      return;
    }

    while (node.containerLiveAtomic && !node.liveAtomic && node.parent) {
      node = node.parent;
    }

    if (this.liveRegionNodeSet_.has(node)) {
      this.lastLiveRegionTime_ = new Date();
      return;
    }

    this.outputLiveRegionChangeForNode_(node, opt_prependFormatStr);
  }

  /**
   * @param {!AutomationNode} node The changed node.
   * @param {?string=} opt_prependFormatStr If set, a format string for
   *     Output to prepend to the output.
   * @private
   */
  outputLiveRegionChangeForNode_(node, opt_prependFormatStr) {
    const range = CursorRange.fromNode(node);
    const output = new Output();
    output.withSpeechCategory(TtsCategory.LIVE);

    // Queue live regions coming from background tabs.
    let hostView = AutomationUtil.getTopLevelRoot(node);
    hostView = hostView ? hostView.parent : null;
    const currentRange = ChromeVoxRange.current;
    const forceQueue = !hostView || !hostView.state.focused ||
        (currentRange && currentRange.start.node.root !== node.root) ||
        node.containerLiveStatus === 'polite';

    // Enqueue live region updates that were received at approximately
    // the same time, otherwise flush previous live region updates.
    const queueTime = LiveRegions.LIVE_REGION_QUEUE_TIME_MS;
    const currentTime = new Date();
    const delta = currentTime - this.lastLiveRegionTime_;
    if (delta > queueTime && !forceQueue) {
      output.withQueueMode(QueueMode.CATEGORY_FLUSH);
    } else {
      output.withQueueMode(QueueMode.QUEUE);
    }

    if (opt_prependFormatStr) {
      output.format(opt_prependFormatStr);
    }
    output.withSpeech(range, range, OutputCustomEvent.NAVIGATE);

    if (!output.hasSpeech && node.liveAtomic) {
      output.format('$joinedDescendants', node);
    }

    if (!output.hasSpeech) {
      return;
    }

    // We also have to add recursively the children of this live region node
    // since all children could potentially get described and we don't want to
    // describe their tree changes especially during page load within the
    // LiveRegions.LIVE_REGION_MIN_SAME_NODE_MS to prevent excessive chatter.
    this.addNodeToNodeSetRecursive_(node);
    output.go();
    this.lastLiveRegionTime_ = currentTime;
  }

  /**
   * @param {AutomationNode} root
   * @private
   */
  addNodeToNodeSetRecursive_(root) {
    this.liveRegionNodeSet_.add(root);
    for (let child = root.firstChild; child; child = child.nextSibling) {
      this.addNodeToNodeSetRecursive_(child);
    }
  }

  /**
   * @param {!AutomationNode} node
   * @return {boolean}
   * @private
   */
  shouldIgnoreLiveRegion_(node) {
    if (LiveRegions.announceLiveRegionsFromBackgroundTabs_) {
      return false;
    }

    const currentRange = ChromeVoxRange.current;
    if (currentRange && currentRange.start.node.root === node.root) {
      return false;
    }

    let hostView = AutomationUtil.getTopLevelRoot(node);
    hostView = hostView ? hostView.parent : null;
    if (!hostView) {
      return true;
    }

    if (hostView.role === RoleType.WINDOW &&
        !hostView.state[StateType.INVISIBLE]) {
      return false;
    }

    if (hostView.state.focused) {
      return false;
    }

    return true;
  }
}

/**
 * Live region events received in fewer than this many milliseconds will
 * queue, otherwise they'll be output with a category flush.
 * @const {number}
 */
LiveRegions.LIVE_REGION_QUEUE_TIME_MS = 5000;

/**
 * Live region events received on the same node in fewer than this many
 * milliseconds will be dropped to avoid a stream of constant chatter.
 * @const {number}
 */
LiveRegions.LIVE_REGION_MIN_SAME_NODE_MS = 20;

/**
 * Whether live regions from background tabs should be announced or not.
 * @private {boolean}
 */
LiveRegions.announceLiveRegionsFromBackgroundTabs_ = false;

/** @type {LiveRegions} */
LiveRegions.instance;

/**
 * Time to wait until processing more live region change events on the same
 * text content.
 * @const {number}
 */
const DESKTOP_CHANGE_DELAY_MS = 100;

TestImportManager.exportForTesting(LiveRegions);