chromium/ash/webui/camera_app_ui/resources/js/mojo/chrome_helper.ts

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

import {assert, assertInstanceof, assertNotReached} from '../assert.js';
import {reportError} from '../error.js';
import {Point} from '../geometry.js';
import * as localDev from '../local_dev.js';
import {getCanUseBigBuffer} from '../models/load_time_data.js';
import {
  ErrorLevel,
  ErrorType,
  MimeType,
} from '../type.js';
import {windowController} from '../window_controller.js';

import {
  BigBuffer,
  CameraAppHelper,
  CameraAppHelperRemote,
  CameraIntentAction,
  EventsSenderRemote,
  ExternalScreenMonitorCallbackRouter,
  FileMonitorResult,
  LidState,
  LidStateMonitorCallbackRouter,
  OcrResult,
  PdfBuilderRemote,
  Rotation,
  ScreenLockedMonitorCallbackRouter,
  ScreenState,
  ScreenStateMonitorCallbackRouter,
  StorageMonitorCallbackRouter,
  StorageMonitorStatus,
  SWPrivacySwitchMonitorCallbackRouter,
  TabletModeMonitorCallbackRouter,
  WifiConfig,
} from './type.js';
import {wrapEndpoint} from './util.js';

/**
 * The singleton instance of ChromeHelper. Initialized by the first
 * invocation of getInstance().
 */
let instance: ChromeHelper|null = null;

/**
 * Forces casting type from Uint8Array to number[].
 */
function castToNumberArray(data: Uint8Array): number[] {
  // This cast is to workaround that the generated mojo binding only accepts
  // number[], but actually can be passed Uint8Array (which also supports
  // indexing via [] and length).
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
  return data as unknown as number[];
}

/**
 * Casts from rotation degrees to mojo rotation.
 */
function castToMojoRotation(rotation: number): Rotation {
  switch (rotation) {
    case 0:
      return Rotation.ROTATION_0;
    case 90:
      return Rotation.ROTATION_90;
    case 180:
      return Rotation.ROTATION_180;
    case 270:
      return Rotation.ROTATION_270;
    default:
      assertNotReached(`Invalid rotation ${rotation}`);
  }
}

/**
 * Creates a BigBuffer from `blob`.
 */
export async function createBigBufferFromBlob(blob: Blob): Promise<BigBuffer> {
  const bytes = new Uint8Array(await blob.arrayBuffer());
  const size = bytes.byteLength;

  const sharedBuffer = Mojo.createSharedBuffer(size);
  assert(
      sharedBuffer.result === Mojo.RESULT_OK,
      'Failed to create shared buffer.');

  const mapBuffer = sharedBuffer.handle.mapBuffer(0, size);
  assert(mapBuffer.result === Mojo.RESULT_OK, 'Failed to map buffer.');

  const uint8View = new Uint8Array(mapBuffer.buffer);
  uint8View.set(bytes);

  // BigBuffer type wants all properties but Mojo expects only one of them.
  const bigBuffer: BigBuffer = {
    sharedMemory: {
      bufferHandle: sharedBuffer.handle,
      size,
    },
    invalidBuffer: undefined,
    bytes: undefined,
  };
  delete bigBuffer.invalidBuffer;
  delete bigBuffer.bytes;

  return bigBuffer;
}

/**
 * Creates a number array from `blob` for Mojo's `array<uint8>`.
 */
export async function createNumArrayFromBlob(blob: Blob): Promise<number[]> {
  const buffer = await blob.arrayBuffer();
  return castToNumberArray(new Uint8Array(buffer));
}

/**
 * When BigBuffer fails, set this flag to `true` and fallback to inline buffer
 * for further calls.
 */
let bigBufferFailed = false;

/**
 * Returns if BigBuffer should be used.
 */
export function shouldUseBigBuffer(): boolean {
  return getCanUseBigBuffer() && !bigBufferFailed;
}

/**
 * Sets the `bigBufferFailed` flag and reports the error.
 */
export function handleBigBufferError(e: unknown): void {
  bigBufferFailed = true;
  reportError(
      ErrorType.BIG_BUFFER_FAILURE,
      ErrorLevel.WARNING,
      assertInstanceof(e, Error),
  );
}

export abstract class ChromeHelper {
  /**
   * Starts monitoring tablet mode state of device.
   *
   * @param onChange Callback called each time when tablet mode state of device
   *     changes with boolean parameter indicating whether device is entering
   *     tablet mode.
   * @return Resolved to initial tablet mode state of device.
   */
  abstract initTabletModeMonitor(onChange: (isTablet: boolean) => void):
      Promise<boolean>;

  /**
   * Starts monitoring screen state of device.
   *
   * @param onChange Callback called each time when screen state of device
   *     changes with parameter of newly changed value.
   * @return Resolved to initial screen state of device.
   */
  abstract initScreenStateMonitor(onChange: (state: ScreenState) => void):
      Promise<ScreenState>;

  /**
   * Starts monitoring the existence of external screens.
   *
   * @param onChange Callback called each time when the existence of external
   *     screens changes.
   * @return Resolved to the initial state of external screens existence.
   */
  abstract initExternalScreenMonitor(
      onChange: (hasExternalScreen: boolean) => void): Promise<boolean>;

  abstract isTabletMode(): Promise<boolean>;

  /**
   * Initializes the camera window controller and bootstraps the mojo
   * communication to get window states.
   */
  abstract initCameraWindowController(): Promise<void>;

  /**
   * Starts event tracing of `event` in Chrome.
   */
  abstract startTracing(event: string): void;

  /**
   * Stops event tracing of `event` in Chrome.
   */
  abstract stopTracing(event: string): void;

  /**
   * Opens the file in Downloads folder by its `name` in gallery.
   */
  abstract openFileInGallery(name: string): void;

  /**
   * Opens the chrome feedback dialog.
   *
   * @param placeholder The text of the placeholder in the description
   *     field.
   */
  abstract openFeedbackDialog(placeholder: string): void;

  /**
   * Opens the given `url` in the browser.
   */
  abstract openUrlInBrowser(url: string): void;

  /**
   * Notifies ARC++ to finish the intent of given `intendId`.
   */
  abstract finish(intentId: number): Promise<void>;

  /**
   * Notifies ARC++ to append `data` to intent of the given `intentId`.
   *
   * @param intentId Intent id of the intent which `data` appends to.
   * @param data The data to be appended to intent result.
   */
  abstract appendData(intentId: number, data: Uint8Array): Promise<void>;

  /**
   * Notifies ARC++ to clear appended intent result data.
   *
   * @param intentId Intent id of the intent whose results to be cleared.
   */
  abstract clearData(intentId: number): Promise<void>;

  /**
   * Returns if the logging consent option is enabled.
   */
  abstract isMetricsAndCrashReportingEnabled(): Promise<boolean>;

  /**
   * Sends the broadcast to ARC to notify the new photo/video is captured.
   */
  abstract sendNewCaptureBroadcast(args: {isVideo: boolean, name: string}):
      void;

  /**
   * Monitors for the file deletion of the file given by its `name` and
   * triggers `callback` when the file is deleted. Note that a previous
   * monitor request will be canceled once another monitor request is sent.
   *
   * @return Resolved when the file is deleted or the current monitor is
   *     canceled by future monitor call.
   * @throws When error occurs during monitor.
   */
  abstract monitorFileDeletion(name: string, callback: () => void):
      Promise<void>;

  abstract isDocumentScannerSupported(): Promise<boolean>;

  /**
   * Checks the document mode readiness. Returns false if it fails to load.
   */
  abstract checkDocumentModeReadiness(): Promise<boolean>;

  /**
   * Scans the `blob` data and returns the detected document corners.
   *
   * @return Promise resolve to positions of document corner. Null for failing
   *     to detected corner positions.
   */
  abstract scanDocumentCorners(blob: Blob): Promise<Point[]|null>;

  /**
   * Converts the blob to document given by its `blob` data, `rotation` and
   * target `corners` to crop. The output will be converted according to given
   * `mimeType`.
   */
  abstract convertToDocument(blob: Blob, corners: Point[], rotation: number):
      Promise<Blob>;

  /**
   * Tries to trigger HaTS survey for CCA.
   */
  abstract maybeTriggerSurvey(): void;

  abstract startMonitorStorage(
      onChange: (status: StorageMonitorStatus) => void):
      Promise<StorageMonitorStatus>;

  abstract stopMonitorStorage(): void;

  abstract openStorageManagement(): void;

  abstract openWifiDialog(config: WifiConfig): void;

  abstract initLidStateMonitor(onChange: (lidStatus: LidState) => void):
      Promise<LidState>;

  abstract initSWPrivacySwitchMonitor(
      onChange: (is_sw_privacy_switch_on: boolean) => void): Promise<boolean>;

  abstract getEventsSender(): Promise<EventsSenderRemote>;

  abstract initScreenLockedMonitor(onChange: (isScreenLocked: boolean) => void):
      Promise<boolean>;

  abstract renderPdfAsImage(pdf: Blob): Promise<Blob>;

  abstract performOcr(jpeg: Blob): Promise<OcrResult>;

  abstract createPdfBuilder(): PdfBuilderRemote;

  /**
   * Creates a new instance of ChromeHelper if it is not set. Returns the
   *     existing instance.
   *
   * @return The singleton instance.
   */
  static getInstance(): ChromeHelper {
    if (instance === null) {
      instance = getInstanceImpl();
    }
    return instance;
  }
}

export const getInstanceImpl =
    localDev.overridableFunction((): ChromeHelper => new ChromeHelperImpl());

/**
 * Communicates with Chrome.
 */
class ChromeHelperImpl extends ChromeHelper {
  /**
   * An interface remote that is used to communicate with Chrome.
   */
  private readonly remote: CameraAppHelperRemote =
      wrapEndpoint(CameraAppHelper.getRemote());

  override async initTabletModeMonitor(onChange: (isTablet: boolean) => void):
      Promise<boolean> {
    const monitorCallbackRouter =
        wrapEndpoint(new TabletModeMonitorCallbackRouter());
    monitorCallbackRouter.update.addListener(onChange);

    const {isTabletMode} = await this.remote.setTabletMonitor(
        monitorCallbackRouter.$.bindNewPipeAndPassRemote());
    return isTabletMode;
  }

  override async initScreenStateMonitor(onChange: (state: ScreenState) => void):
      Promise<ScreenState> {
    const monitorCallbackRouter =
        wrapEndpoint(new ScreenStateMonitorCallbackRouter());
    monitorCallbackRouter.update.addListener(onChange);

    const {initialState} = await this.remote.setScreenStateMonitor(
        monitorCallbackRouter.$.bindNewPipeAndPassRemote());
    return initialState;
  }

  override async initExternalScreenMonitor(
      onChange: (hasExternalScreen: boolean) => void): Promise<boolean> {
    const monitorCallbackRouter =
        wrapEndpoint(new ExternalScreenMonitorCallbackRouter());
    monitorCallbackRouter.update.addListener(onChange);

    const {hasExternalScreen} = await this.remote.setExternalScreenMonitor(
        monitorCallbackRouter.$.bindNewPipeAndPassRemote());
    return hasExternalScreen;
  }

  override async isTabletMode(): Promise<boolean> {
    const {isTabletMode} = await this.remote.isTabletMode();
    return isTabletMode;
  }

  override async initCameraWindowController(): Promise<void> {
    let {controller} = await this.remote.getWindowStateController();
    controller = wrapEndpoint(controller);
    await windowController.bind(controller);
  }

  override startTracing(event: string): void {
    this.remote.startPerfEventTrace(event);
  }

  override stopTracing(event: string): void {
    this.remote.stopPerfEventTrace(event);
  }

  override openFileInGallery(name: string): void {
    this.remote.openFileInGallery(name);
  }

  override openFeedbackDialog(placeholder: string): void {
    this.remote.openFeedbackDialog(placeholder);
  }

  override openUrlInBrowser(url: string): void {
    this.remote.openUrlInBrowser({url: url});
  }

  private async checkReturn(
      caller: string, value: Promise<{isSuccess: boolean}>): Promise<void> {
    const {isSuccess} = await value;
    if (!isSuccess) {
      reportError(
          ErrorType.HANDLE_CAMERA_RESULT_FAILURE, ErrorLevel.ERROR,
          new Error(`Return not isSuccess from calling intent ${caller}.`));
    }
  }

  override async finish(intentId: number): Promise<void> {
    const ret =
        this.remote.handleCameraResult(intentId, CameraIntentAction.FINISH, []);
    await this.checkReturn('finish()', ret);
  }

  override async appendData(intentId: number, data: Uint8Array): Promise<void> {
    const ret = this.remote.handleCameraResult(
        intentId, CameraIntentAction.APPEND_DATA, castToNumberArray(data));
    await this.checkReturn('appendData()', ret);
  }

  override async clearData(intentId: number): Promise<void> {
    const ret = this.remote.handleCameraResult(
        intentId, CameraIntentAction.CLEAR_DATA, []);
    await this.checkReturn('clearData()', ret);
  }

  override async isMetricsAndCrashReportingEnabled(): Promise<boolean> {
    const {isEnabled} = await this.remote.isMetricsAndCrashReportingEnabled();
    return isEnabled;
  }

  override sendNewCaptureBroadcast({isVideo, name}:
                                       {isVideo: boolean, name: string}): void {
    this.remote.sendNewCaptureBroadcast(isVideo, name);
  }

  override async monitorFileDeletion(name: string, callback: () => void):
      Promise<void> {
    const {result} = await this.remote.monitorFileDeletion(name);
    switch (result) {
      case FileMonitorResult.kDeleted:
        callback();
        return;
      case FileMonitorResult.kCanceled:
        // Do nothing if it is canceled by another monitor call.
        return;
      case FileMonitorResult.kError:
        throw new Error('Error happens when monitoring file deletion');
      default:
        assertNotReached();
    }
  }

  override async isDocumentScannerSupported(): Promise<boolean> {
    const {isSupported} = await this.remote.isDocumentScannerSupported();
    return isSupported;
  }

  override async checkDocumentModeReadiness(): Promise<boolean> {
    const {isLoaded} = await this.remote.checkDocumentModeReadiness();
    return isLoaded;
  }


  override async scanDocumentCorners(blob: Blob): Promise<Point[]|null> {
    const buffer = new Uint8Array(await blob.arrayBuffer());

    const {corners} =
        await this.remote.scanDocumentCorners(castToNumberArray(buffer));
    if (corners.length === 0) {
      return null;
    }
    return corners.map(({x, y}) => new Point(x, y));
  }

  override async convertToDocument(
      blob: Blob, corners: Point[], rotation: number): Promise<Blob> {
    assert(corners.length === 4, 'Unexpected amount of corners');
    const buffer = new Uint8Array(await blob.arrayBuffer());
    const {docData} = await this.remote.convertToDocument(
        castToNumberArray(buffer), corners, castToMojoRotation(rotation));
    return new Blob([new Uint8Array(docData)], {type: MimeType.JPEG});
  }

  override maybeTriggerSurvey(): void {
    this.remote.maybeTriggerSurvey();
  }

  override async startMonitorStorage(
      onChange: (status: StorageMonitorStatus) => void):
      Promise<StorageMonitorStatus> {
    const storageCallbackRouter =
        wrapEndpoint(new StorageMonitorCallbackRouter());
    storageCallbackRouter.update.addListener(
        (newStatus: StorageMonitorStatus) => {
          if (newStatus === StorageMonitorStatus.kError) {
            throw new Error('Error occurred while monitoring storage.');
          } else if (newStatus !== StorageMonitorStatus.kCanceled) {
            onChange(newStatus);
          }
        });

    const {initialStatus} = await this.remote.startStorageMonitor(
        storageCallbackRouter.$.bindNewPipeAndPassRemote());
    // Should not get canceled status at initial time.
    if (initialStatus === StorageMonitorStatus.kError ||
        initialStatus === StorageMonitorStatus.kCanceled) {
      throw new Error('Failed to start storage monitoring.');
    }
    return initialStatus;
  }

  override stopMonitorStorage(): void {
    this.remote.stopStorageMonitor();
  }

  override openStorageManagement(): void {
    this.remote.openStorageManagement();
  }

  override openWifiDialog(config: WifiConfig): void {
    this.remote.openWifiDialog(config);
  }

  override async initLidStateMonitor(onChange: (lidStatus: LidState) => void):
      Promise<LidState> {
    const monitorCallbackRouter =
        wrapEndpoint(new LidStateMonitorCallbackRouter());
    monitorCallbackRouter.update.addListener(onChange);

    const {lidStatus} = await this.remote.setLidStateMonitor(
        monitorCallbackRouter.$.bindNewPipeAndPassRemote());
    return lidStatus;
  }

  override async initSWPrivacySwitchMonitor(
      onChange: (is_sw_privacy_switch_on: boolean) => void): Promise<boolean> {
    const monitorCallbackRouter =
        wrapEndpoint(new SWPrivacySwitchMonitorCallbackRouter());
    monitorCallbackRouter.update.addListener(onChange);

    const {isSwPrivacySwitchOn} = await this.remote.setSWPrivacySwitchMonitor(
        monitorCallbackRouter.$.bindNewPipeAndPassRemote());
    return isSwPrivacySwitchOn;
  }

  override async getEventsSender(): Promise<EventsSenderRemote> {
    const {eventsSender} = await this.remote.getEventsSender();
    return wrapEndpoint(eventsSender);
  }

  override async initScreenLockedMonitor(
      onChange: (isScreenLocked: boolean) => void): Promise<boolean> {
    const monitorCallbackRouter =
        wrapEndpoint(new ScreenLockedMonitorCallbackRouter());
    monitorCallbackRouter.update.addListener(onChange);

    const {isScreenLocked} = await this.remote.setScreenLockedMonitor(
        monitorCallbackRouter.$.bindNewPipeAndPassRemote());
    return isScreenLocked;
  }

  override async renderPdfAsImage(pdf: Blob): Promise<Blob> {
    const buffer = new Uint8Array(await pdf.arrayBuffer());
    const numArray = castToNumberArray(buffer);
    const {jpegData} = await this.remote.renderPdfAsJpeg(numArray);
    return new Blob([new Uint8Array(jpegData)], {type: MimeType.JPEG});
  }

  override async performOcr(jpeg: Blob): Promise<OcrResult> {
    try {
      if (shouldUseBigBuffer()) {
        const bigBuffer = await createBigBufferFromBlob(jpeg);
        const {ocrResult} = await this.remote.performOcr(bigBuffer);
        return ocrResult;
      }
    } catch (e) {
      handleBigBufferError(e);
    }
    const numArray = await createNumArrayFromBlob(jpeg);
    const {ocrResult} = await this.remote.performOcrInline(numArray);
    return ocrResult;
  }

  override createPdfBuilder(): PdfBuilderRemote {
    const pdfBuilderRemote = new PdfBuilderRemote();
    const pdfBuilderReceiver = pdfBuilderRemote.$.bindNewPipeAndPassReceiver();
    this.remote.createPdfBuilder(pdfBuilderReceiver);
    return wrapEndpoint(pdfBuilderRemote);
  }
}