chromium/ash/webui/diagnostics_ui/resources/touchscreen_tester.ts

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

import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';

import {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {PolymerElementProperties} from 'chrome://resources/polymer/v3_0/polymer/interfaces.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {CanvasDrawingProvider} from './drawing_provider.js';
import {InputDataProviderInterface, TabletModeObserverReceiver} from './input_data_provider.mojom-webui.js';
import {getInputDataProvider} from './mojo_interface_provider.js';
import {getTemplate} from './touchscreen_tester.html.js';

// To ensure the tester works when the user rotates their screen, we
// need to set both the canvas width and height to be the larger number.
// Rather than looking for the correct display and find their size
// from backend, we take a simpler approach to set it as a very large
// number. The number is based on largest known supported resolution.
export const SCREEN_MAX_LENGTH = 9999;

// The dialog type enum, including intro-dialog and canvas-dialog.
export enum DialogType {
  INTRO = 'intro-dialog',
  CANVAS = 'canvas-dialog',
}

// The touch event type enum.
export enum TouchEventType {
  START = 'touchstart',
  MOVE = 'touchmove',
  END = 'touchend',
}

// The x and y coordinates to describe the touch location.
interface Point {
  x: number;
  y: number;
}

const TouchscreenTesterElementBase = I18nMixin(PolymerElement);

export class TouchscreenTesterElement extends TouchscreenTesterElementBase {
  static get is(): string {
    return 'touchscreen-tester';
  }

  static get template(): HTMLTemplateElement {
    return getTemplate();
  }

  static get properties(): PolymerElementProperties {
    return {
      touchscreenIdUnderTesting: {
        type: Number,
        value: -1,
        notify: true,
      },
    };
  }

  protected touchscreenIdUnderTesting: number;

  // Drawing provider.
  private drawingProvider: CanvasDrawingProvider;

  // A map that stores all the touches.
  // The key is the identifier of the touch. Value is the x and y coordinates
  // of the touch point.
  private touches: Map<number, Point> = new Map<number, Point>();

  // Indicates if the laptop is in tablet mode.
  private isTabletMode: boolean = false;

  // Manages all event listeners.
  private eventTracker: EventTracker = new EventTracker();

  private receiver: TabletModeObserverReceiver|null = null;

  private inputDataProvider: InputDataProviderInterface =
      getInputDataProvider();

  /**
   * For testing only.
   */
  getDrawingProvider(): CanvasDrawingProvider {
    return this.drawingProvider;
  }

  /**
   * For testing only.
   */
  getTouches(): Map<number, Point> {
    return this.touches;
  }

  /**
   * For testing only.
   */
  getIsTabletMode(): boolean {
    return this.isTabletMode;
  }

  /**
   * For testing only.
   */
  getEventTracker(): EventTracker {
    return this.eventTracker;
  }

  getDialog(dialogId: string): CrDialogElement {
    const dialog = this.shadowRoot!.getElementById(dialogId);
    assert(dialog);
    return dialog as CrDialogElement;
  }

  /**
   * Shows the tester's dialog.
   */
  async showTester(evdevId: number): Promise<void> {
    this.inputDataProvider.moveAppToTestingScreen(evdevId);

    this.receiver = new TabletModeObserverReceiver(this);
    const {isTabletMode} = await this.inputDataProvider.observeTabletMode(
        this.receiver.$.bindNewPipeAndPassRemote());
    this.isTabletMode = isTabletMode;

    const introDialog = this.getDialog(DialogType.INTRO);
    await introDialog.requestFullscreen();
    introDialog.showModal();

    this.addListeners();
  }

  /**
   * Add various event listeners.
   */
  private addListeners(): void {
    //  When user presses 'Esc' key, the tester will only exit the fullscreen
    //  mode. However, we want the tester to close when user has exited the
    //  fullscreen mode. Add a event listener to listen to the
    //  'fullscreenchange' event to handle this case.
    this.eventTracker.add(document, 'fullscreenchange', (e: Event) => {
      e.preventDefault();
      if (!document.fullscreenElement &&
          this.touchscreenIdUnderTesting !== -1) {
        this.closeTester();
        // Only when users closes the tester themselves, we call
        // moveAppBackToPreviousScreen function. If the screen is disconnected
        // or untestable, the window movement will be handled by display manager
        // itself.
        this.inputDataProvider.moveAppBackToPreviousScreen();
      }
    });

    // When in tablet mode, pressing volume up button will exit the tester.
    this.eventTracker.add(window, 'keydown', (e: Event) => {
      if ((e as KeyboardEvent).key === 'AudioVolumeUp' && this.isTabletMode) {
        // Exit fullscreen will trigger closing the tester.
        document.exitFullscreen();
      }
    });
  }

  /**
   * Close touchscreen tester.
   */
  closeTester(): void {
    this.getDialog(DialogType.INTRO).close();
    this.getDialog(DialogType.CANVAS).close();
    this.eventTracker.removeAll();
    this.inputDataProvider.setA11yTouchPassthrough(/*enabled=*/ false);
    this.touchscreenIdUnderTesting = -1;
    // Make sure to exit fullscreen if it's not already.
    if (document.fullscreenElement) {
      document.exitFullscreen();
    }
    if (this.receiver) {
      this.receiver.$.close();
    }
  }

  /**
   * Handle when get start button is clicked.
   */
  private onStartClick(): void {
    this.getDialog(DialogType.INTRO).close();
    this.getDialog(DialogType.CANVAS).showModal();

    this.setupCanvas();
    this.inputDataProvider.setA11yTouchPassthrough(/*enabled=*/ true);
  }

  /**
   * Set up canvas width, height and drawing context.
   */
  private setupCanvas(): void {
    const canvas = this.shadowRoot!.querySelector('canvas');
    assert(canvas);

    canvas.width = SCREEN_MAX_LENGTH;
    canvas.height = SCREEN_MAX_LENGTH;

    // CSS in .html file does not have access to this element,
    // therefore adjust it here to make the canvas cover the whole screen.
    const topContainer = this.getDialog(DialogType.CANVAS)!.shadowRoot!
                             .querySelector<HTMLElement>('.top-container');
    topContainer!.style.display = 'none';

    const ctx = canvas.getContext('2d');
    assert(ctx);
    this.drawingProvider = new CanvasDrawingProvider(ctx);
    this.observeDataSource(canvas);
  }

  /**
   * This is the only place that deals with Touch API.
   * In future enhancement to use evdev as data source, this is the place
   * to interact with mojo interface.
   */
  private observeDataSource(canvas: HTMLCanvasElement): void {
    for (const eventType
             of [TouchEventType.START, TouchEventType.MOVE,
                 TouchEventType.END]) {
      this.eventTracker.add(canvas, eventType, (e: Event) => {
        e.preventDefault();
        for (let i = 0; i < (e as TouchEvent).changedTouches.length; i++) {
          const currentTouch = (e as TouchEvent).changedTouches[i];
          const touchPt = {
            x: currentTouch.pageX - canvas.offsetLeft,
            y: currentTouch.pageY - canvas.offsetTop,
          };

          // Call corresponding function to handle those events.
          if (eventType === TouchEventType.START) {
            this.onDrawStart(
                currentTouch.identifier, touchPt, currentTouch.force);
          } else if (eventType === TouchEventType.MOVE) {
            this.onDraw(currentTouch.identifier, touchPt, currentTouch.force);
          } else if (eventType === TouchEventType.END) {
            this.onDrawEnd(currentTouch.identifier, touchPt);
          }
        }
      });
    }
  }

  /**
   * Handle when a 'touchstart' event is fired from Touch API, or a new touch
   * starts from evdev.
   * @param touchId The identifier of a touch.
   * @param touchPt The coordinates of a touch point.
   * @param pressure The pressure of a touch.
   */
  onDrawStart(touchId: number, touchPt: Point, pressure: number): void {
    this.touches.set(touchId, touchPt);
    this.drawingProvider.drawTrailMark(touchPt.x, touchPt.y);
    this.drawingProvider.drawTrail(
        touchPt.x - 1, touchPt.y, touchPt.x, touchPt.y, pressure);
  }

  /**
   * Handle when a 'touchmove' event is fired from Touch API, or an existing
   * touch moves from evdev.
   * @param touchId The identifier of a touch.
   * @param touchPt The coordinates of a touch point.
   * @param pressure The pressure of a touch.
   */
  onDraw(touchId: number, touchPt: Point, pressure: number): void {
    // Previous point of this touch.
    const previousPt = this.touches.get(touchId);
    if (previousPt) {
      this.drawingProvider.drawTrail(
          previousPt.x, previousPt.y, touchPt.x, touchPt.y, pressure);
    }

    // Update the coordinates of this touch.
    this.touches.set(touchId, touchPt);
  }

  /**
   * Handle when a 'touchend' event is fired from Touch API, or an existing
   * touch ends from evdev.
   * @param touchId The identifier of a touch.
   * @param touchPt The coordinates of a touch point.
   */
  onDrawEnd(touchId: number, touchPt: Point): void {
    this.drawingProvider.drawTrailMark(touchPt.x, touchPt.y);
    // This touch has ended. Remove it from the touches object.
    this.touches.delete(touchId);
  }

  /**
   * Implements TabletModeObserver.OnTabletModeChanged.
   * @param isTabletMode Is current display on tablet mode.
   */
  onTabletModeChanged(isTabletMode: boolean): void {
    this.isTabletMode = isTabletMode;
    // TODO(wenyu): Show exit instruction toaster.
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'touchscreen-tester': TouchscreenTesterElement;
  }
}

customElements.define(TouchscreenTesterElement.is, TouchscreenTesterElement);