chromium/ash/webui/camera_app_ui/resources/js/device/camera_manager.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 {
  assert,
  assertExists,
} from '../assert.js';
import * as expert from '../expert.js';
import {Point} from '../geometry.js';
import * as metrics from '../metrics.js';
import * as loadTimeData from '../models/load_time_data.js';
import {ChromeHelper} from '../mojo/chrome_helper.js';
import {LidState, ScreenState} from '../mojo/type.js';
import * as nav from '../nav.js';
import {PerfLogger} from '../perf.js';
import * as state from '../state.js';
import {
  AspectRatioSet,
  CameraSuspendError,
  Facing,
  Mode,
  PerfEvent,
  PhotoResolutionLevel,
  PreviewVideo,
  Resolution,
  VideoResolutionLevel,
  ViewName,
} from '../type.js';
import * as util from '../util.js';
import {WarningType} from '../views/warning.js';
import {WaitableEvent} from '../waitable_event.js';
import {windowController} from '../window_controller.js';

import {EventListener, OperationScheduler} from './camera_operation.js';
import {VideoCaptureCandidate} from './capture_candidate.js';
import {Preview} from './preview.js';
import {PTZController} from './ptz_controller.js';
import {
  CameraConfig,
  CameraInfo,
  CameraUI,
  CameraViewUI,
  ModeConstraints,
  PhotoAspectRatioOptionListener,
  PhotoResolutionOptionListener,
  VideoResolutionOptionListener,
} from './type.js';

class ResumeStateWatchdog {
  // This is definitely assigned in this.start() in the first statement of the
  // while loop.
  private trialDone!: WaitableEvent<boolean>;

  private succeed = false;

  constructor(private readonly doReconfigure: () => Promise<boolean>) {
    // This is for watchdog running in the background.
    // TODO(pihsun): Move this out of constructor.
    void this.start();
  }

  private async start() {
    while (!this.succeed) {
      this.trialDone = new WaitableEvent<boolean>();
      await util.sleep(100);
      this.succeed = await this.doReconfigure();
      this.trialDone.signal(this.succeed);
    }
  }

  /**
   * Waits for the next unfinished reconfigure result.
   *
   * @return The reconfigure is succeed or failed.
   */
  async waitNextReconfigure(): Promise<boolean> {
    return this.trialDone.wait();
  }
}

/**
 * Manages usages of all camera operations.
 * TODO(b/209726472): Move more camera logic in camera view to here.
 */
export class CameraManager implements EventListener {
  private hasExternalScreen = false;

  private screenOffAuto = false;

  private cameraAvailable = false;

  /**
   * Whether the device is in locked state.
   */
  private locked = false;

  private suspendRequested = false;

  private readonly scheduler: OperationScheduler;

  private watchdog: ResumeStateWatchdog|null = null;

  private readonly cameraUIs: CameraUI[] = [];

  private readonly preview: Preview;

  constructor(
      defaultFacing: Facing|null,
      modeConstraints: ModeConstraints,
  ) {
    this.preview = new Preview(async () => {
      await this.reconfigure();
    }, () => this.useSquareResolution());

    this.scheduler = new OperationScheduler(
        this,
        this.preview,
        defaultFacing,
        modeConstraints,
    );

    document.addEventListener('visibilitychange', async () => {
      const recording = state.get(state.State.TAKING) && state.get(Mode.VIDEO);
      if (!recording) {
        await this.maybeSuspendResumeCamera();
      }
    });

    expert.addObserver(
        expert.ExpertOption.SHOW_ALL_RESOLUTIONS,
        async () => {
          // Rebuilds the options to adapt to the new state. Then, reconfigure
          // the stream so that it will apply the new resolution order. At last,
          // update the checked status of the resolution options.
          this.scheduler.reconfigurer.capturePreferrer.buildOptions();
          await this.tryReconfigure(() => {/* Do nothing */});
        },
    );
  }

  getCameraInfo(): CameraInfo {
    return assertExists(this.scheduler.cameraInfo);
  }

  getDeviceId(): string|null {
    return this.scheduler.reconfigurer.config?.deviceId ?? null;
  }

  getPreviewVideo(): PreviewVideo {
    return this.preview.getVideo();
  }

  getAudioTrack(): MediaStreamTrack|null {
    return this.getPreviewVideo().getStream().getAudioTracks()[0] ?? null;
  }

  /**
   * USB camera vid:pid identifier of the opened stream.
   *
   * @return Identifier formatted as "vid:pid" or null for non-USB camera.
   */
  getVidPid(): string|null {
    return this.preview.getVidPid();
  }

  getPreviewResolution(): Resolution {
    const {video} = this.getPreviewVideo();
    const {videoWidth, videoHeight} = video;
    if (this.useSquareResolution()) {
      const size = Math.min(videoWidth, videoHeight);
      return new Resolution(size, size);
    }
    return new Resolution(videoWidth, videoHeight);
  }

  getCaptureResolution(): Resolution|null {
    assert(this.scheduler.reconfigurer.config !== null);
    return this.scheduler.reconfigurer.config.captureCandidate.resolution;
  }

  getConstFps(): number|null {
    assert(this.scheduler.reconfigurer.config !== null);
    const c = this.scheduler.reconfigurer.config.captureCandidate;
    if (!(c instanceof VideoCaptureCandidate)) {
      return null;
    }
    return c.constFps;
  }

  async getSupportedModes(deviceId: string|null): Promise<Mode[]> {
    const modes: Mode[] = [];
    for (const mode of Object.values(Mode)) {
      if (await this.scheduler.modes.isSupported(mode, deviceId)) {
        modes.push(mode);
      }
    }
    return modes;
  }

  async onUpdateConfig(config: CameraConfig): Promise<void> {
    for (const ui of this.cameraUIs) {
      await ui.onUpdateConfig?.(config);
    }
  }

  onTryingNewConfig(config: CameraConfig): void {
    for (const ui of this.cameraUIs) {
      ui.onTryingNewConfig?.(config);
    }
  }

  onUpdateCapability(cameraInfo: CameraInfo): void {
    for (const ui of this.cameraUIs) {
      ui.onUpdateCapability?.(cameraInfo);
    }
  }

  registerCameraUI(ui: CameraUI): void {
    this.cameraUIs.push(ui);
  }

  /**
   * @return Whether window is put to background in tablet mode.
   */
  private isTabletBackground(): boolean {
    return state.get(state.State.TABLET) &&
        document.visibilityState === 'hidden';
  }

  /**
   * @return If the App window is invisible to user with respect to screen off
   *     state.
   */
  private get screenOff(): boolean {
    return this.screenOffAuto && !this.hasExternalScreen;
  }

  async initialize(cameraViewUI: CameraViewUI): Promise<void> {
    const helper = ChromeHelper.getInstance();

    function setTablet(isTablet: boolean) {
      state.set(state.State.TABLET, isTablet);
    }
    const isTablet = await helper.initTabletModeMonitor(setTablet);
    setTablet(isTablet);

    function setLidClosed(lidState: LidState) {
      state.set(state.State.LID_CLOSED, lidState === LidState.kClosed);
    }
    const lidState = await helper.initLidStateMonitor(setLidClosed);
    setLidClosed(lidState);

    function setSWPirvacySwitchOn(isSWPrivacySwitchOn: boolean) {
      state.set(state.State.SW_PRIVACY_SWITCH_ON, isSWPrivacySwitchOn);
    }
    const isSWPrivacySwitchOn =
        await helper.initSWPrivacySwitchMonitor(setSWPirvacySwitchOn);
    setSWPirvacySwitchOn(isSWPrivacySwitchOn);

    const handleScreenLockedChange = async (isScreenLocked: boolean) => {
      this.locked = isScreenLocked;
      await this.maybeSuspendResumeCamera();
    };

    this.locked =
        await helper.initScreenLockedMonitor(handleScreenLockedChange);

    const handleScreenStateChange = async () => {
      await this.maybeSuspendResumeCamera();
    };

    const updateScreenOffAuto = async (screenState: ScreenState) => {
      const isOffAuto = screenState === ScreenState.kOffAuto;
      if (this.screenOffAuto !== isOffAuto) {
        this.screenOffAuto = isOffAuto;
        await handleScreenStateChange();
      }
    };
    const screenState =
        await helper.initScreenStateMonitor(updateScreenOffAuto);

    const updateExternalScreen = async (hasExternalScreen: boolean) => {
      if (this.hasExternalScreen !== hasExternalScreen) {
        this.hasExternalScreen = hasExternalScreen;
        await handleScreenStateChange();
      }
    };
    const hasExternalScreen =
        await helper.initExternalScreenMonitor(updateExternalScreen);

    this.screenOffAuto = screenState === ScreenState.kOffAuto;
    this.hasExternalScreen = hasExternalScreen;

    await this.scheduler.initialize(cameraViewUI);
  }

  requestSuspend(): Promise<void> {
    this.suspendRequested = true;
    return this.maybeSuspendResumeCamera();
  }

  requestResume(): Promise<void> {
    this.suspendRequested = false;
    return this.maybeSuspendResumeCamera();
  }

  // Checks the state of CCA and suspends or resumes the camera accordingly.
  async maybeSuspendResumeCamera(): Promise<void> {
    const shouldSuspend = this.shouldSuspend();
    if (state.get(state.State.SUSPEND) === shouldSuspend) {
      return;
    }
    await this.reconfigure();
  }

  /**
   * Switches to the next available camera device.
   */
  switchCamera(): Promise<void>|null {
    const perfLogger = PerfLogger.getInstance();
    const promise = this.tryReconfigure(() => {
      perfLogger.start(PerfEvent.CAMERA_SWITCHING);
      const deviceIds =
          this.scheduler.reconfigurer.getDeviceIdsSortedbyPreferredFacing(
              this.getCameraInfo());
      if (deviceIds.length === 0) {
        return;
      }
      let index =
          deviceIds.findIndex((deviceId) => deviceId === this.getDeviceId());
      // findIndex() may return -1, which means the device is not in the list.
      // In this case, we will try to switch to the preferred facing device.
      index = (index + 1) % deviceIds.length;
      assertExists(this.scheduler.reconfigurer.config).deviceId =
          deviceIds[index];
    });
    if (promise === null) {
      return null;
    }
    return promise.then((succeed) => {
      perfLogger.stop(PerfEvent.CAMERA_SWITCHING, {hasError: !succeed});
      metrics.sendOpenCameraEvent(this.getVidPid());
    });
  }

  switchMode(mode: Mode): Promise<boolean>|null {
    return this.tryReconfigure(() => {
      assert(this.scheduler.reconfigurer.config !== null);
      this.scheduler.reconfigurer.config.mode = mode;
    });
  }

  private async setCapturePref(deviceId: string, setPref: () => void):
      Promise<boolean> {
    if (!this.cameraAvailable) {
      return false;
    }
    if (deviceId !== this.getDeviceId()) {
      // Changing the configure of the camera not currently opened, thus no
      // reconfiguration are required.
      setPref();
      return true;
    }
    return this.tryReconfigure(setPref) ?? false;
  }

  addPhotoResolutionOptionListener(listener: PhotoResolutionOptionListener):
      void {
    this.scheduler.reconfigurer.capturePreferrer
        .addPhotoResolutionOptionListener(listener);
  }

  addPhotoAspectRatioOptionListener(listener: PhotoAspectRatioOptionListener):
      void {
    this.scheduler.reconfigurer.capturePreferrer
        .addPhotoAspectRatioOptionListener(listener);
  }

  addVideoResolutionOptionListener(listener: VideoResolutionOptionListener):
      void {
    this.scheduler.reconfigurer.capturePreferrer
        .addVideoResolutionOptionListener(listener);
  }

  async setPrefPhotoResolutionLevel(
      deviceId: string, level: PhotoResolutionLevel): Promise<void> {
    await this.setCapturePref(deviceId, () => {
      this.scheduler.reconfigurer.capturePreferrer.setPrefPhotoResolutionLevel(
          deviceId, level);
    });
  }

  async setPrefPhotoAspectRatioSet(
      deviceId: string, aspectRatioSet: AspectRatioSet): Promise<void> {
    await this.setCapturePref(deviceId, () => {
      this.scheduler.reconfigurer.capturePreferrer.setPrefPhotoAspectRatioSet(
          deviceId, aspectRatioSet);
    });
  }

  async setPrefVideoResolutionLevel(
      deviceId: string, level: VideoResolutionLevel): Promise<void> {
    await this.setCapturePref(deviceId, () => {
      this.scheduler.reconfigurer.capturePreferrer.setPrefVideoResolutionLevel(
          deviceId, level);
    });
  }

  /**
   * Used when showing all resolutions.
   */
  async setPrefPhotoResolution(deviceId: string, resolution: Resolution):
      Promise<void> {
    await this.setCapturePref(deviceId, () => {
      this.scheduler.reconfigurer.capturePreferrer.setPrefPhotoResolution(
          deviceId, resolution);
    });
  }

  /**
   * Used when showing all resolutions.
   */
  async setPrefVideoResolution(deviceId: string, resolution: Resolution):
      Promise<void> {
    await this.setCapturePref(deviceId, () => {
      this.scheduler.reconfigurer.capturePreferrer.setPrefVideoResolution(
          deviceId, resolution);
    });
  }

  /**
   * Sets fps of constant video recording on currently opened camera and
   * resolution.
   */
  setPrefVideoConstFps(
      deviceId: string, level: VideoResolutionLevel, fps: number,
      shouldReconfigure: boolean): Promise<boolean>|null {
    // We only need to reconfigure the stream if the FPS preference has been
    // changed for selected resolution level.
    if (shouldReconfigure) {
      return this.setCapturePref(deviceId, () => {
        this.scheduler.reconfigurer.capturePreferrer.setPrefVideoConstFps(
            deviceId, level, fps, shouldReconfigure);
      });
    } else {
      this.scheduler.reconfigurer.capturePreferrer.setPrefVideoConstFps(
          deviceId, level, fps, shouldReconfigure);
      return null;
    }
  }

  getPhotoResolutionLevel(resolution: Resolution): PhotoResolutionLevel {
    return this.scheduler.reconfigurer.capturePreferrer.getPhotoResolutionLevel(
        resolution);
  }

  getVideoResolutionLevel(resolution: Resolution): VideoResolutionLevel {
    return this.scheduler.reconfigurer.capturePreferrer.getVideoResolutionLevel(
        resolution);
  }

  getAspectRatioSet(resolution: Resolution): AspectRatioSet {
    if (this.useSquareResolution()) {
      return AspectRatioSet.RATIO_SQUARE;
    }
    return util.toAspectRatioSet(resolution);
  }

  getZoomRatio(): number {
    return this.preview.getZoomRatio();
  }

  /**
   * Applies point of interest to the stream.
   *
   * @param point The point in normalize coordidate system, which means both
   *     |x| and |y| are in range [0, 1).
   */
  setPointOfInterest(point: Point): Promise<void> {
    return this.preview.setPointOfInterest(point);
  }

  getPTZController(): PTZController {
    return this.preview.getPTZController();
  }

  resetPTZ(): Promise<void> {
    return this.preview.resetPTZ();
  }

  /**
   * Whether the photo taking should be done by using preview frame as photo.
   * This is the workaround for b/184089334 to avoid mismatch between preview
   * and photo results in some PTZ cameras.
   */
  shouldUsePreviewAsPhoto(): boolean {
    const deviceId = this.getDeviceId();
    if (deviceId === null) {
      return false;
    }
    return state.get(state.State.ENABLE_PTZ) &&
        this.getCameraInfo().hasBuiltinPTZSupport(deviceId);
  }

  /**
   * Whether app window is suspended.
   */
  private shouldSuspend(): boolean {
    return this.locked || windowController.isMinimized() ||
        this.suspendRequested || this.screenOff || this.isTabletBackground();
  }

  async startCapture(): Promise<[Promise<void>]> {
    this.setCameraAvailable(false);
    try {
      const captureDone = await this.scheduler.startCapture();
      return assertExists(captureDone);
    } finally {
      this.setCameraAvailable(true);
    }
  }

  async stopCapture(): Promise<void> {
    await this.scheduler.stopCapture();
  }

  takeVideoSnapshot(): void {
    this.scheduler.takeVideoSnapshot();
  }

  toggleVideoRecordingPause(): void {
    this.scheduler.toggleVideoRecordingPause();
  }

  useSquareResolution(): boolean {
    if (!(state.get(Mode.PHOTO) || state.get(Mode.PORTRAIT))) {
      return false;
    }
    const deviceId = this.getDeviceId();
    if (deviceId === null) {
      return false;
    }
    return this.scheduler.reconfigurer.capturePreferrer.preferSquarePhoto(
        deviceId);
  }

  private setCameraAvailable(available: boolean): void {
    if (available === this.cameraAvailable) {
      return;
    }
    this.cameraAvailable = available;
    for (const ui of this.cameraUIs) {
      if (this.cameraAvailable) {
        ui.onCameraAvailable?.();
      } else {
        ui.onCameraUnavailable?.();
      }
    }
  }

  private tryReconfigure(setNewConfig: () => void): Promise<boolean>|null {
    if (!this.cameraAvailable) {
      return null;
    }
    setNewConfig();
    return this.reconfigure();
  }

  async reconfigure(): Promise<boolean> {
    // TODO(pihsun): This (and tryReconfigure) is being called by many sync
    // callback. Revisit this to push reconfigure jobs on an AsyncJobQueue and
    // returns result in a AsyncJob instead of directly returning a Promise?
    if (this.watchdog !== null) {
      if (!await this.watchdog.waitNextReconfigure()) {
        return false;
      }
      // The watchdog.waitNextReconfigure() only return the most recent
      // reconfigure result which may not reflect the setting before calling it.
      // Thus still fallthrough here to start another reconfigure.
    }
    this.scheduler.reconfigurer.resetConfigurationFailure();
    return this.doReconfigure();
  }

  private async doReconfigure(): Promise<boolean> {
    state.set(state.State.CAMERA_CONFIGURING, true);
    this.setCameraAvailable(false);
    const shouldSuspend = this.shouldSuspend();
    this.scheduler.reconfigurer.setShouldSuspend(shouldSuspend);
    state.set(state.State.SUSPEND, shouldSuspend);
    const perfLogger = PerfLogger.getInstance();
    if (loadTimeData.isCCADisallowed()) {
      nav.open(ViewName.WARNING, WarningType.DISABLED_CAMERA);
      perfLogger.interrupt();
      return false;
    }
    try {
      await this.scheduler.reconfigure();
    } catch (e) {
      if (e instanceof CameraSuspendError) {
        // Bypass this error as it's intended.
      } else {
        // Keep trying reconfiguring until there's an available camera.
        if (this.watchdog === null) {
          // TODO(b/209726472): Move nav out of this module.
          nav.open(ViewName.WARNING, WarningType.NO_CAMERA);
          this.watchdog = new ResumeStateWatchdog(() => this.doReconfigure());
        }
      }
      perfLogger.interrupt();
      return false;
    }

    // TODO(b/209726472): Move nav out of this module.
    nav.close(ViewName.WARNING, WarningType.NO_CAMERA);
    this.watchdog = null;
    state.set(state.State.CAMERA_CONFIGURING, false);
    this.setCameraAvailable(true);
    return true;
  }
}