chromium/ash/webui/camera_app_ui/resources/js/views/camera.ts

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

import * as animate from '../animation.js';
import {
  assert,
  assertEnumVariant,
  assertExists,
  assertInstanceof,
  assertNotReached,
} from '../assert.js';
import {AsyncJobQueue, queuedAsyncCallback} from '../async_job_queue.js';
import {
  CameraConfig,
  CameraManager,
  CameraViewUI,
  getDefaultScanCorners,
  GifResult,
  PhotoResult,
  setAvc1Parameters,
  VideoResult,
} from '../device/index.js';
import {TimeLapseResult} from '../device/mode/video.js';
import * as dom from '../dom.js';
import * as error from '../error.js';
import * as expert from '../expert.js';
import {I18nString} from '../i18n_string.js';
import {ModeSelector} from '../lit/components/mode-selector.js';
import * as metrics from '../metrics.js';
import {Filenamer} from '../models/file_namer.js';
import {getI18nMessage} from '../models/load_time_data.js';
import {ResultSaver} from '../models/result_saver.js';
import {
  TimeLapseEncoderArgs,
  TimeLapseSaver,
  VideoSaver,
} from '../models/video_saver.js';
import {ChromeHelper} from '../mojo/chrome_helper.js';
import {DeviceOperator} from '../mojo/device_operator.js';
import * as nav from '../nav.js';
import {PerfLogger} from '../perf.js';
import * as sound from '../sound.js';
import {speak} from '../spoken_msg.js';
import * as state from '../state.js';
import * as toast from '../toast.js';
import {
  CameraSuspendError,
  CanceledError,
  ErrorLevel,
  ErrorType,
  Facing,
  LowStorageDialogType,
  LowStorageError,
  MimeType,
  Mode,
  PerfEvent,
  PortraitErrorNoFaceDetected,
  Resolution,
  Rotation,
  ViewName,
} from '../type.js';
import * as util from '../util.js';
import {WaitableEvent} from '../waitable_event.js';

import {Layout} from './camera/layout.js';
import {Options} from './camera/options.js';
import {ScanOptions} from './camera/scan_options.js';
import * as timertick from './camera/timertick.js';
import {VideoEncoderOptions} from './camera/video_encoder_options.js';
import {Dialog} from './dialog.js';
import {
  DocumentReview,
  initializeInstance as initializeDocumentReview,
} from './document_review.js';
import {Flash} from './flash.js';
import {OptionPanel} from './option_panel.js';
import {PTZPanel} from './ptz_panel.js';
import * as review from './review.js';
import {PrimarySettings} from './settings/primary.js';
import {View} from './view.js';
import {WarningType} from './warning.js';

/**
 * Camera-view controller.
 */
export class Camera extends View implements CameraViewUI {
  private readonly documentReview: DocumentReview;

  private currentLowStorageType: LowStorageDialogType|null = null;

  private readonly lowStorageDialogView: Dialog;

  private readonly subViews: View[];

  /**
   * Layout handler for the camera view.
   */
  private readonly layoutHandler: Layout;

  private readonly videoEncoderOptions =
      new VideoEncoderOptions((parameters) => setAvc1Parameters(parameters));

  /**
   * Clock-wise rotation that needs to be applied to the recorded video in
   * order for the video to be replayed in upright orientation.
   */
  protected outputVideoRotation = 0;

  /**
   * Device id of video device of active preview stream. Sets to null when
   * preview become inactive.
   */
  private activeDeviceId: string|null = null;

  protected readonly review = new review.Review();

  protected facing: Facing|null = null;

  protected shutterType = metrics.ShutterType.UNKNOWN;

  /**
   * Event for tracking camera availability state.
   */
  private cameraReady = new WaitableEvent();

  /**
   * Current take of photo or recording queue.
   */
  private readonly takeQueue = new AsyncJobQueue('drop');

  private readonly modeSelector = dom.get('mode-selector', ModeSelector);

  private readonly defaultFocus = queuedAsyncCallback('drop', async () => {
    await this.cameraReady.wait();

    // Check the view is still on the top after await.
    if (!nav.isTopMostView(ViewName.CAMERA)) {
      return;
    }

    this.focusShutterButton();
  });

  /**
   * Ends the current take (or clears scheduled further takes if any).
   *
   * @return Promise for the operation.
   */
  private readonly endTake = queuedAsyncCallback('drop', async () => {
    timertick.cancel();
    await this.cameraManager.stopCapture();
    await this.takeQueue.flush();
  });

  constructor(
      protected readonly resultSaver: ResultSaver,
      protected readonly cameraManager: CameraManager,
  ) {
    super(ViewName.CAMERA);
    this.documentReview = initializeDocumentReview(resultSaver);
    this.lowStorageDialogView = new Dialog(ViewName.LOW_STORAGE_DIALOG, {
      onNegativeButtonClicked: () => this.openStorageManagement(),
    });
    this.subViews = [
      new PrimarySettings(this.cameraManager),
      new OptionPanel(),
      new PTZPanel(),
      this.review,
      this.documentReview,
      this.lowStorageDialogView,
      new Flash(),
    ];

    this.layoutHandler = new Layout(this.cameraManager);


    // These constructions are left here without any references pointing to them
    // to prevent TypeScript from complaining about the unused reference.
    // Sub mode options for the scan mode.
    new ScanOptions(this.cameraManager);
    // Options that controls the camera UI.
    new Options(this.cameraManager);

    /**
     * Gets type of ways to trigger shutter from click event.
     */
    function getShutterType(e: MouseEvent) {
      if (e.clientX === 0 && e.clientY === 0) {
        return metrics.ShutterType.KEYBOARD;
      }
      return (e.sourceCapabilities?.firesTouchEvents ?? false) ?
          metrics.ShutterType.TOUCH :
          metrics.ShutterType.MOUSE;
    }
    const photoShutter = dom.get('#start-takephoto', HTMLButtonElement);
    photoShutter.addEventListener('click', (e) => {
      this.beginTake(getShutterType(e));
    });
    function checkPhotoShutter() {
      const disabled = state.get(state.State.CAMERA_CONFIGURING) ||
          state.get(state.State.TAKING);
      photoShutter.disabled = disabled;
    }
    state.addObserver(state.State.CAMERA_CONFIGURING, checkPhotoShutter);
    state.addObserver(state.State.TAKING, checkPhotoShutter);

    dom.get('#stop-takephoto', HTMLButtonElement)
        .addEventListener('click', () => this.endTake());

    const videoShutter = dom.get('#recordvideo', HTMLButtonElement);
    videoShutter.addEventListener('click', (e) => {
      if (!state.get(state.State.TAKING)) {
        this.beginTake(getShutterType(e));
      } else {
        this.endTake();
      }
    });
    function checkVideoShutter() {
      const disabled = state.get(state.State.CAMERA_CONFIGURING) &&
          !state.get(state.State.TAKING);
      videoShutter.disabled = disabled;
    }
    state.addObserver(state.State.CAMERA_CONFIGURING, checkVideoShutter);
    state.addObserver(state.State.TAKING, checkVideoShutter);

    const videoSnapshotButton = dom.get('#video-snapshot', HTMLButtonElement);
    videoSnapshotButton.addEventListener('click', () => {
      this.cameraManager.takeVideoSnapshot();
    });
    function checkVideoSnapshotButton() {
      const disabled = state.get(state.State.SNAPSHOTTING);
      videoSnapshotButton.disabled = disabled;
    }
    state.addObserver(state.State.SNAPSHOTTING, checkVideoSnapshotButton);

    const pauseShutter = dom.get('#pause-recordvideo', HTMLButtonElement);
    pauseShutter.addEventListener('click', () => {
      this.cameraManager.toggleVideoRecordingPause();
    });

    // TODO(shik): Tune the timing for playing video shutter button animation.
    // Currently the |TAKING| state is ended when the file is saved.
    util.bindElementAriaLabelWithState({
      element: videoShutter,
      state: state.State.TAKING,
      onLabel: I18nString.RECORD_VIDEO_STOP_BUTTON,
      offLabel: I18nString.RECORD_VIDEO_START_BUTTON,
    });
    util.bindElementAriaLabelWithState({
      element: pauseShutter,
      state: state.State.RECORDING_PAUSED,
      onLabel: I18nString.RECORD_VIDEO_RESUME_BUTTON,
      offLabel: I18nString.RECORD_VIDEO_PAUSE_BUTTON,
    });

    this.cameraManager.registerCameraUI({
      onTryingNewConfig: (config: CameraConfig) => {
        this.updateMode(config.mode);
      },
      onUpdateConfig: async (config: CameraConfig) => {
        nav.close(ViewName.WARNING, WarningType.NO_CAMERA);
        this.facing = config.facing;
        this.updateActiveCamera(config.deviceId);

        // Update current mode.
        this.modeSelector.supportedModes =
            await this.cameraManager.getSupportedModes(config.deviceId);
      },
      onCameraUnavailable: () => {
        this.cameraReady = new WaitableEvent();
        updateModeSelectorDisabled();
      },
      onCameraAvailable: () => {
        this.cameraReady.signal();
        updateModeSelectorDisabled();
      },
    });

    const updateModeSelectorDisabled = () => {
      const disabled = !this.cameraReady.isSignaled() ||
          !state.get(state.State.STREAMING) || state.get(state.State.TAKING);
      this.modeSelector.disabled = disabled;
    };

    state.addObserver(state.State.STREAMING, updateModeSelectorDisabled);
    state.addObserver(state.State.TAKING, updateModeSelectorDisabled);
    updateModeSelectorDisabled();

    this.modeSelector.addEventListener('mode-change', async (e) => {
      // TODO(pihsun): Check if there's a cleaner way to have typed custom
      // events. Current options are:
      // * Setting HTMLElementEventMap, which are global and would make all
      //   HTMLElement have that event on type level and is not ideal.
      // * Override addEventListener/removeEventListener in the custom
      //   component (e.g.
      //   https://gist.github.com/difosfor/ceeb01d03a8db7dc68d5cd4167d60637).
      //   This requires lots of boilerplate code.
      const mode =
          assertEnumVariant(Mode, assertInstanceof(e, CustomEvent).detail);
      this.updateMode(mode);
      const perfLogger = PerfLogger.getInstance();
      perfLogger.start(PerfEvent.MODE_SWITCHING);
      const isSuccess = await this.cameraManager.switchMode(mode) ?? false;
      perfLogger.stop(PerfEvent.MODE_SWITCHING, {hasError: !isSuccess});
    });

    dom.get('#back-to-review-document', HTMLButtonElement)
        .addEventListener(
            'click',
            async () => {
              await this.reviewDocument();
            },
        );
  }

  /**
   * Initializes camera view.
   */
  initialize(): void {
    expert.addObserver(
        expert.ExpertOption.ENABLE_FULL_SIZED_VIDEO_SNAPSHOT,
        () => this.cameraManager.reconfigure());
    expert.addObserver(
        expert.ExpertOption.ENABLE_PTZ_FOR_BUILTIN,
        () => this.cameraManager.reconfigure());

    this.initVideoEncoderOptions();
  }

  /**
   * Gets current facing after |initialize()|.
   */
  protected getFacing(): Facing {
    return assertEnumVariant(Facing, this.facing);
  }

  private updateMode(mode: Mode) {
    for (const m of Object.values(Mode)) {
      state.set(m, m === mode);
    }
    this.modeSelector.selectedMode = mode;
    this.updateShutterLabel(mode);
  }

  private updateShutterLabel(mode: Mode) {
    const element = dom.get('#start-takephoto', HTMLButtonElement);
    const label =
        mode === 'scan' ? I18nString.SCAN_BUTTON : I18nString.TAKE_PHOTO_BUTTON;
    element.setAttribute('i18n-label', label);
    element.setAttribute('aria-label', getI18nMessage(label));
  }

  private initVideoEncoderOptions() {
    const options = this.videoEncoderOptions;
    this.cameraManager.registerCameraUI({
      onUpdateConfig: () => {
        if (state.get(Mode.VIDEO)) {
          const {width, height, frameRate} =
              this.cameraManager.getPreviewVideo().getVideoSettings();
          assert(width !== undefined);
          assert(height !== undefined);
          assert(frameRate !== undefined);
          options.updateValues(new Resolution(width, height), frameRate);
        }
      },
    });
    options.initialize();
  }

  override getSubViews(): View[] {
    return this.subViews;
  }

  private focusShutterButton(): void {
    if (!nav.isTopMostView(this.name)) {
      return;
    }
    // Avoid focusing invisible shutters.
    for (const btn of dom.getAll('button.shutter', HTMLButtonElement)) {
      if (btn.offsetParent !== null) {
        btn.focus();
      }
    }
  }

  override onShownAsTop(): void {
    this.defaultFocus();
  }

  override onUncoveredAsTop(viewName: ViewName): void {
    if ([ViewName.SETTINGS, ViewName.OPTION_PANEL].includes(viewName)) {
      // Don't refocus on shutter button when coming back from setting menu.
      super.onUncoveredAsTop(viewName);
    } else {
      this.setFocusable();
      this.defaultFocus();
    }
  }

  /**
   * Begins to take photo or recording with the current options, e.g. timer.
   *
   * @param shutterType The shutter is triggered by which shutter type.
   */
  beginTake(shutterType: metrics.ShutterType): void {
    this.takeQueue.push(async () => {
      if (state.get(state.State.CAMERA_CONFIGURING) ||
          state.get(state.State.TAKING)) {
        return;
      }

      this.shutterType = shutterType;
      // Refocus the visible shutter button for ChromeVox.
      this.focusShutterButton();
      let hasError = false;
      try {
        // Record and keep the rotation only at the instance the user starts the
        // capture. Users may change the device orientation while taking video.
        const cameraFrameRotation = await (async () => {
          const deviceOperator = DeviceOperator.getInstance();
          if (deviceOperator === null) {
            return 0;
          }
          assert(this.activeDeviceId !== null);
          return deviceOperator.getCameraFrameRotation(this.activeDeviceId);
        })();
        // Translate the camera frame rotation back to the UI rotation, which is
        // what we need to rotate the captured video with.
        this.outputVideoRotation = (360 - cameraFrameRotation) % 360;
        state.set(state.State.TAKING, true);
        await timertick.start();
        const [captureDone] = await this.cameraManager.startCapture();
        await captureDone;
      } catch (e) {
        if (e instanceof LowStorageError) {
          this.showLowStorageDialog(LowStorageDialogType.CANNOT_START);
          // Don't send capture error.
          return;
        }
        hasError = true;
        if (e instanceof CanceledError || e instanceof CameraSuspendError) {
          return;
        }
        error.reportError(
            ErrorType.START_CAPTURE_FAILURE, ErrorLevel.ERROR,
            assertInstanceof(e, Error));
      } finally {
        state.set(state.State.TAKING, false, {
          hasError,
          facing: this.getFacing(),
        });
        // Refocus the visible shutter button for ChromeVox.
        this.focusShutterButton();
      }
    });
  }

  private async checkPhotoResult<T>(pendingPhotoResult: Promise<T>):
      Promise<T> {
    try {
      return await pendingPhotoResult;
    } catch (e) {
      this.onPhotoError();
      throw e;
    }
  }

  async handleVideoSnapshot({resolution, blob, timestamp, metadata}:
                                PhotoResult): Promise<void> {
    metrics.sendCaptureEvent({
      facing: this.getFacing(),
      resolution,
      shutterType: this.shutterType,
      isVideoSnapshot: true,
      resolutionLevel: this.cameraManager.getVideoResolutionLevel(resolution),
      aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
      zoomRatio: this.cameraManager.getZoomRatio(),
    });
    try {
      const name = (new Filenamer(timestamp)).newImageName();
      await this.resultSaver.savePhoto(blob, name, metadata);
    } catch (e) {
      toast.show(I18nString.ERROR_MSG_SAVE_FILE_FAILED);
      throw e;
    }
  }

  onPhotoError(): void {
    toast.show(I18nString.ERROR_MSG_TAKE_PHOTO_FAILED);
  }

  shouldUsePreviewAsPhoto(): boolean {
    return this.cameraManager.shouldUsePreviewAsPhoto();
  }

  async cropIfUsingSquareResolution(result: Promise<PhotoResult>):
      Promise<PhotoResult> {
    if (!this.cameraManager.useSquareResolution()) {
      return result;
    }
    const photoResult = await result;
    const croppedBlob = await util.cropSquare(photoResult.blob);
    return {
      ...photoResult,
      blob: croppedBlob,
    };
  }

  async onPhotoCaptureDone(pendingPhotoResult: Promise<PhotoResult>):
      Promise<void> {
    const perfLogger = PerfLogger.getInstance();
    perfLogger.start(PerfEvent.PHOTO_CAPTURE_POST_PROCESSING_SAVING);

    pendingPhotoResult = this.cropIfUsingSquareResolution(pendingPhotoResult);

    try {
      const {resolution, blob, timestamp, metadata} =
          await this.checkPhotoResult(pendingPhotoResult);

      metrics.sendCaptureEvent({
        facing: this.getFacing(),
        resolution,
        shutterType: this.shutterType,
        isVideoSnapshot: false,
        resolutionLevel: this.cameraManager.getPhotoResolutionLevel(resolution),
        aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
        zoomRatio: this.cameraManager.getZoomRatio(),
      });

      try {
        const name = (new Filenamer(timestamp)).newImageName();
        await this.resultSaver.savePhoto(blob, name, metadata);
      } catch (e) {
        toast.show(I18nString.ERROR_MSG_SAVE_FILE_FAILED);
        throw e;
      }
      perfLogger.stop(
          PerfEvent.PHOTO_CAPTURE_POST_PROCESSING_SAVING,
          {resolution, facing: this.getFacing()});
    } catch (e) {
      perfLogger.stop(
          PerfEvent.PHOTO_CAPTURE_POST_PROCESSING_SAVING, {hasError: true});
      throw e;
    }
    ChromeHelper.getInstance().maybeTriggerSurvey();
  }

  async onPortraitCaptureDone(
      pendingReference: Promise<PhotoResult>,
      pendingPortrait: Promise<PhotoResult>): Promise<void> {
    const perfLogger = PerfLogger.getInstance();
    perfLogger.start(PerfEvent.PORTRAIT_MODE_CAPTURE_POST_PROCESSING_SAVING);

    let filenamer: Filenamer;

    const saveReference = async () => {
      const pendingCroppedReference =
          this.cropIfUsingSquareResolution(pendingReference);
      try {
        const {timestamp, resolution, blob, metadata} =
            await this.checkPhotoResult(pendingCroppedReference);

        metrics.sendCaptureEvent({
          facing: this.getFacing(),
          resolution,
          shutterType: this.shutterType,
          isVideoSnapshot: false,
          resolutionLevel:
              this.cameraManager.getPhotoResolutionLevel(resolution),
          aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
          zoomRatio: this.cameraManager.getZoomRatio(),
        });

        filenamer = filenamer ?? new Filenamer(timestamp);
        const name = filenamer.newBurstName(false);
        await this.resultSaver.savePhoto(blob, name, metadata);
      } catch (e) {
        toast.show(I18nString.ERROR_MSG_SAVE_FILE_FAILED);
        throw e;
      }
    };

    const savePortrait = async () => {
      const pendingCroppedPortrait =
          this.cropIfUsingSquareResolution(pendingPortrait);
      try {
        const {
          timestamp: portraitTimestamp,
          blob: portraitBlob,
          metadata: portraitMetadata,
        } = await pendingCroppedPortrait;

        filenamer = filenamer ?? new Filenamer(portraitTimestamp);
        const name = filenamer.newBurstName(true);
        await this.resultSaver.savePhoto(portraitBlob, name, portraitMetadata);
      } catch (e) {
        // We tolerate the error when no face is detected for the scene.
        toast.show(I18nString.ERROR_MSG_TAKE_PORTRAIT_BOKEH_PHOTO_FAILED);
        if (!(e instanceof PortraitErrorNoFaceDetected)) {
          throw e;
        }
      }
    };

    let error = null;
    const results = await Promise.allSettled([saveReference(), savePortrait()]);
    for (const result of results) {
      if (result.status === 'rejected') {
        error = result.reason;
        break;
      }
    }
    const hasError = error !== null;
    perfLogger.stop(
        PerfEvent.PORTRAIT_MODE_CAPTURE_POST_PROCESSING_SAVING,
        {hasError, facing: this.getFacing()});
    if (hasError) {
      throw error;
    }
    ChromeHelper.getInstance().maybeTriggerSurvey();
  }

  async onDocumentCaptureDone(pendingPhotoResult: Promise<PhotoResult>):
      Promise<void> {
    nav.open(ViewName.FLASH);
    const perfLogger = PerfLogger.getInstance();
    perfLogger.start(PerfEvent.DOCUMENT_CAPTURE_POST_PROCESSING);
    let enterInFixMode = false;
    let hasError = false;
    let resolution: Resolution|undefined;
    try {
      const photoResult = await this.checkPhotoResult(pendingPhotoResult);
      const blob = photoResult.blob;
      resolution = photoResult.resolution;
      const helper = ChromeHelper.getInstance();
      let corners = await helper.scanDocumentCorners(blob);
      if (corners === null) {
        corners = getDefaultScanCorners(resolution);
        enterInFixMode = true;
      }
      await this.documentReview.addPage({
        blob,
        corners,
        rotation: Rotation.ANGLE_0,
      });
      metrics.sendCaptureEvent({
        facing: this.getFacing(),
        resolution,
        shutterType: this.shutterType,
        resolutionLevel: this.cameraManager.getPhotoResolutionLevel(resolution),
        aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
        zoomRatio: this.cameraManager.getZoomRatio(),
      });
    } catch (e) {
      hasError = true;
      throw e;
    } finally {
      perfLogger.stop(PerfEvent.DOCUMENT_CAPTURE_POST_PROCESSING, {
        hasError,
        facing: this.getFacing(),
        resolution,
      });
      nav.close(ViewName.FLASH);
    }
    await this.reviewDocument(enterInFixMode);
    if (!state.get(state.State.DOC_MODE_REVIEWING)) {
      ChromeHelper.getInstance().maybeTriggerSurvey();
    }
  }

  /**
   * Opens review view to review input blob.
   */
  protected async prepareReview(doReview: () => Promise<void>): Promise<void> {
    // Because the review view will cover the whole camera view, prepare for
    // temporarily turn off camera by stopping preview.
    await this.cameraManager.requestSuspend();
    try {
      await doReview();
    } finally {
      await this.cameraManager.requestResume();
    }
  }

  private async reviewDocument(enterInFixMode = false): Promise<void> {
    await this.prepareReview(async () => {
      const pageCount = await this.documentReview.open({fix: enterInFixMode});
      dom.get('#document-page-count', HTMLDivElement).textContent =
          getI18nMessage(I18nString.NEXT_PAGE_COUNT, pageCount + 1);
      state.set(state.State.DOC_MODE_REVIEWING, pageCount > 0);
    });
  }

  createVideoSaver(): Promise<VideoSaver> {
    return VideoSaver.create(this.outputVideoRotation);
  }

  createTimeLapseSaver(encoderArgs: TimeLapseEncoderArgs, speed: number):
      Promise<TimeLapseSaver> {
    encoderArgs.videoRotation = this.outputVideoRotation;
    return TimeLapseSaver.create(encoderArgs, speed);
  }

  playShutterEffect(): void {
    sound.play('shutter');
    animate.play(this.cameraManager.getPreviewVideo().video);
  }

  private getLowStorageDialogKeys(dialogType: LowStorageDialogType) {
    switch (dialogType) {
      case LowStorageDialogType.AUTO_STOP:
        return {
          title: I18nString.LOW_STORAGE_DIALOG_AUTO_STOP_TITLE,
          description: I18nString.LOW_STORAGE_DIALOG_AUTO_STOP_DESC,
          dialogAction: metrics.LowStorageActionType.SHOW_AUTO_STOP_DIALOG,
          manageAction: metrics.LowStorageActionType.MANAGE_STORAGE_AUTO_STOP,
        };
      case LowStorageDialogType.CANNOT_START:
        return {
          title: I18nString.LOW_STORAGE_DIALOG_CANNOT_START_TITLE,
          description: I18nString.LOW_STORAGE_DIALOG_CANNOT_START_DESC,
          dialogAction: metrics.LowStorageActionType.SHOW_CANNOT_START_DIALOG,
          manageAction:
              metrics.LowStorageActionType.MANAGE_STORAGE_CANNOT_START,
        };
      default:
        assertNotReached();
    }
  }

  private openStorageManagement(): void {
    assert(this.currentLowStorageType !== null);
    const {manageAction} =
        this.getLowStorageDialogKeys(this.currentLowStorageType);
    metrics.sendLowStorageEvent(manageAction);
    ChromeHelper.getInstance().openStorageManagement();
  }

  private showLowStorageDialog(dialogType: LowStorageDialogType): void {
    const {description, dialogAction, title} =
        this.getLowStorageDialogKeys(dialogType);
    this.currentLowStorageType = dialogType;
    metrics.sendLowStorageEvent(dialogAction);
    nav.open(ViewName.LOW_STORAGE_DIALOG, {title, description});
  }

  async onGifCaptureDone({name, gifSaver, resolution, duration}: GifResult):
      Promise<void> {
    nav.open(ViewName.FLASH);

    // Measure the latency of gif encoder finishing rest of the encoding
    // works.
    const perfLogger = PerfLogger.getInstance();
    perfLogger.start(PerfEvent.GIF_CAPTURE_POST_PROCESSING);
    const blob = await gifSaver.endWrite();
    perfLogger.stop(
        PerfEvent.GIF_CAPTURE_POST_PROCESSING,
        {resolution, facing: this.getFacing()});

    const sendEvent = (gifResult: metrics.GifResultType) => {
      metrics.sendCaptureEvent({
        recordType: metrics.RecordType.GIF,
        facing: this.getFacing(),
        resolution,
        duration,
        shutterType: this.shutterType,
        gifResult,
        resolutionLevel: this.cameraManager.getVideoResolutionLevel(resolution),
        aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
        zoomRatio: this.cameraManager.getZoomRatio(),
      });
    };

    let result: boolean|null = false;
    await this.prepareReview(async () => {
      await this.review.setReviewPhoto(blob);
      const negative = new review.OptionGroup({
        template: review.ButtonGroupTemplate.NEGATIVE,
        options: [new review.Option(
            {text: I18nString.LABEL_RETAKE}, {exitValue: null})],
      });
      const positive = new review.OptionGroup<boolean>({
        template: review.ButtonGroupTemplate.POSITIVE,
        options: [
          new review.Option(
              {text: I18nString.LABEL_SHARE, icon: 'review_share.svg'}, {
                callback: async () => {
                  sendEvent(metrics.GifResultType.SHARE);
                  await util.share(
                      new File([blob], name, {type: MimeType.GIF}));
                },
              }),
          new review.Option(
              {text: I18nString.LABEL_SAVE, primary: true}, {exitValue: true}),
        ],
      });
      nav.close(ViewName.FLASH);
      result = await this.review.startReview(negative, positive);
    });
    if (result) {
      sendEvent(metrics.GifResultType.SAVE);
      const perfLogger = PerfLogger.getInstance();
      perfLogger.start(PerfEvent.GIF_CAPTURE_SAVING);
      await this.resultSaver.saveGif(blob, name);
      perfLogger.stop(
          PerfEvent.GIF_CAPTURE_SAVING, {resolution, facing: this.getFacing()});
    } else {
      sendEvent(metrics.GifResultType.RETAKE);
    }
    ChromeHelper.getInstance().maybeTriggerSurvey();
  }

  async onVideoCaptureDone(
      {resolution, videoSaver, duration, everPaused, autoStopped}: VideoResult):
      Promise<void> {
    if (autoStopped) {
      this.showLowStorageDialog(LowStorageDialogType.AUTO_STOP);
    }
    const perfLogger = PerfLogger.getInstance();
    perfLogger.start(PerfEvent.VIDEO_CAPTURE_POST_PROCESSING_SAVING);
    try {
      metrics.sendCaptureEvent({
        recordType: metrics.RecordType.NORMAL_VIDEO,
        facing: this.getFacing(),
        duration,
        resolution,
        shutterType: this.shutterType,
        everPaused,
        resolutionLevel: this.cameraManager.getVideoResolutionLevel(resolution),
        aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
        zoomRatio: this.cameraManager.getZoomRatio(),
      });
      const file = assertExists(await videoSaver.endWrite());
      await this.resultSaver.saveVideo(file);
      perfLogger.stop(
          PerfEvent.VIDEO_CAPTURE_POST_PROCESSING_SAVING,
          {resolution, facing: this.getFacing()});
    } catch (e) {
      perfLogger.stop(
          PerfEvent.VIDEO_CAPTURE_POST_PROCESSING_SAVING, {hasError: true});
      throw e;
    }
    ChromeHelper.getInstance().maybeTriggerSurvey();
  }

  async onTimeLapseCaptureDone(
      {autoStopped, duration, everPaused, resolution, speed, timeLapseSaver}:
          TimeLapseResult): Promise<void> {
    if (autoStopped) {
      this.showLowStorageDialog(LowStorageDialogType.AUTO_STOP);
    }
    nav.open(ViewName.FLASH, I18nString.MSG_PROCESSING_VIDEO);
    const perfLogger = PerfLogger.getInstance();
    perfLogger.start(PerfEvent.TIME_LAPSE_CAPTURE_POST_PROCESSING_SAVING);
    try {
      metrics.sendCaptureEvent({
        recordType: metrics.RecordType.TIME_LAPSE,
        facing: this.getFacing(),
        duration,
        everPaused,
        resolution,
        shutterType: this.shutterType,
        resolutionLevel: this.cameraManager.getVideoResolutionLevel(resolution),
        aspectRatioSet: this.cameraManager.getAspectRatioSet(resolution),
        timeLapseSpeed: speed,
        zoomRatio: this.cameraManager.getZoomRatio(),
      });
      const file = assertExists(await timeLapseSaver.endWrite());
      await this.resultSaver.saveVideo(file);
      perfLogger.stop(
          PerfEvent.TIME_LAPSE_CAPTURE_POST_PROCESSING_SAVING,
          {resolution, facing: this.getFacing()});
    } catch (e) {
      perfLogger.stop(
          PerfEvent.TIME_LAPSE_CAPTURE_POST_PROCESSING_SAVING,
          {hasError: true});
      throw e;
    } finally {
      nav.close(ViewName.FLASH);
    }
    ChromeHelper.getInstance().maybeTriggerSurvey();
  }

  override layout(): void {
    this.layoutHandler.update();
  }

  override handlingKey(key: util.KeyboardShortcut): boolean {
    if (key === 'Ctrl-Alt-R') {
      toast.showDebugMessage(
          this.cameraManager.getPreviewResolution().toString());
      return true;
    }

    if (state.get(state.State.STREAMING) &&
        !state.get(state.State.ENABLE_SCAN_BARCODE)) {
      if ((key === 'AudioVolumeUp' || key === 'AudioVolumeDown') &&
          state.get(state.State.TABLET)) {
        if (state.get(state.State.TAKING)) {
          this.endTake();
        } else {
          this.beginTake(metrics.ShutterType.VOLUME_KEY);
        }
        return true;
      }

      if (key === ' ') {
        this.focusShutterButton();
        if (state.get(state.State.TAKING)) {
          this.endTake();
        } else {
          this.beginTake(metrics.ShutterType.KEYBOARD);
        }
        return true;
      }
    }

    return false;
  }

  /**
   * Updates |this.activeDeviceId|.
   */
  private updateActiveCamera(newDeviceId: string|null) {
    // Make the different active camera announced by screen reader.
    if (newDeviceId === this.activeDeviceId) {
      return;
    }
    this.activeDeviceId = newDeviceId;
    if (newDeviceId !== null) {
      const info =
          this.cameraManager.getCameraInfo().getDeviceInfo(newDeviceId);
      speak(I18nString.STATUS_MSG_CAMERA_SWITCHED, info.label);
    }
  }
}