chromium/ash/webui/camera_app_ui/resources/js/models/result_saver.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} from '../assert.js';
import {CoverPhoto} from '../cover_photo.js';
import * as dom from '../dom.js';
import {reportError} from '../error.js';
import {GalleryButton} from '../lit/components/gallery-button.js';
import {ChromeHelper} from '../mojo/chrome_helper.js';
import {
  Awaitable,
  ErrorLevel,
  ErrorType,
  Metadata,
  MimeType,
  VideoType,
} from '../type.js';
import {sleep} from '../util.js';

import {Filenamer} from './file_namer.js';
import * as filesystem from './file_system.js';
import {
  DirectoryAccessEntry,
  FileAccessEntry,
} from './file_system_access_entry.js';

/**
 * Handles captured result photos and video.
 */
export interface ResultSaver {
  /**
   * Saves photo capture result.
   *
   * @param blob Data of the photo to be added.
   * @param name Name of the photo to be saved.
   * @param metadata Data of the photo to be added.
   */
  savePhoto(blob: Blob, name: string, metadata: Metadata|null): Promise<void>;

  /**
   * Saves gif capture result.
   *
   * @param blob Data of the gif to be added.
   * @param name Name of the gif to be saved.
   */
  saveGif(blob: Blob, name: string): Promise<void>;

  /**
   * Saves captured video result.
   *
   * @param file Contains the video file to be saved.
   */
  saveVideo(video: FileAccessEntry): Awaitable<void>;
}

/**
 * Default handler for captured result photos and video.
 */
export class DefaultResultSaver implements ResultSaver {
  /**
   * Cover photo from latest saved picture.
   */
  private cover: CoverPhoto|null = null;

  /**
   * Directory holding saved pictures showing in gallery.
   */
  private directory: DirectoryAccessEntry|null = null;

  private readonly galleryButton = dom.get('gallery-button', GalleryButton);

  private retryingCheckCover = false;

  /**
   * Initializes the result saver.
   *
   * @param dir Directory holding saved pictures showing in gallery.
   */
  async initialize(dir: DirectoryAccessEntry): Promise<void> {
    this.directory = dir;
    await this.checkCover();
    this.galleryButton.addEventListener('click', () => {
      if (this.cover !== null) {
        ChromeHelper.getInstance().openFileInGallery(this.cover.name);
      }
    });
  }

  /**
   * @param file File to be set as cover photo.
   */
  private async updateCover(file: FileAccessEntry|null): Promise<void> {
    const cover = file === null ? null : await CoverPhoto.create(file);
    if (this.cover === cover) {
      return;
    }
    if (this.cover !== null) {
      this.cover.release();
    }
    this.cover = cover;
    this.galleryButton.cover = cover;

    if (file !== null) {
      // The promise is only resolved after the file is deleted.
      void ChromeHelper.getInstance().monitorFileDeletion(
          file.name, async () => {
            try {
              await this.checkCover();
            } catch (e) {
              reportError(ErrorType.CHECK_COVER_FAILURE, ErrorLevel.ERROR, e);
            }
          });
    }
  }

  /**
   * Checks validity of cover photo from camera directory.
   */
  private async checkCover(): Promise<void> {
    if (this.directory === null) {
      return;
    }
    const dir = this.directory;

    // Checks existence of cached cover photo.
    if (this.cover !== null) {
      if (await dir.exists(this.cover.name)) {
        return;
      }
    }

    // Rescan file system. Only select files following CCA naming styles.
    const files = (await filesystem.getEntries())
                      .filter((file) => Filenamer.isCCAFileFormat(file.name));
    if (files.length === 0) {
      await this.updateCover(null);
      return;
    }

    try {
      const filesWithTime = await Promise.all(
          files.map(async (file) => ({
                      file,
                      time: (await file.getLastModificationTime()),
                    })));
      const lastFile =
          filesWithTime.reduce((last, cur) => last.time > cur.time ? last : cur)
              .file;
      await this.updateCover(lastFile);
    } catch (e) {
      // The file might be deleted at any time and cause the operation
      // interrupted. Since it might take a while when doing bulk deletion, only
      // try check cover again if the amount of files become stable.
      if (e instanceof DOMException && !this.retryingCheckCover) {
        this.retryingCheckCover = true;
        try {
          await this.waitUntilCameraFolderStable();
          await this.checkCover();
        } finally {
          this.retryingCheckCover = false;
        }
      } else {
        throw e;
      }
    }
  }

  private async waitUntilCameraFolderStable(): Promise<void> {
    let prevFileCount = (await filesystem.getEntries()).length;
    while (true) {
      await sleep(500);
      const newFileCount = (await filesystem.getEntries()).length;
      if (prevFileCount === newFileCount) {
        return;
      }
      prevFileCount = newFileCount;
    }
  }

  async savePhoto(blob: Blob, name: string, metadata: Metadata|null):
      Promise<void> {
    const file = await filesystem.saveBlob(blob, name);
    if (metadata !== null) {
      const metadataBlob =
          new Blob([JSON.stringify(metadata, null, 2)], {type: MimeType.JSON});
      await filesystem.saveBlob(metadataBlob, Filenamer.getMetadataName(name));
    }

    ChromeHelper.getInstance().sendNewCaptureBroadcast(
        {isVideo: false, name: file.name});
    await this.updateCover(file);
  }

  async saveGif(blob: Blob, name: string): Promise<void> {
    const file = await filesystem.saveBlob(blob, name);
    await this.updateCover(file);
  }

  async saveVideo(file: FileAccessEntry): Promise<void> {
    const videoName = (new Filenamer()).newVideoName(VideoType.MP4);
    assert(this.directory !== null);
    await file.moveTo(this.directory, videoName);
    ChromeHelper.getInstance().sendNewCaptureBroadcast(
        {isVideo: true, name: file.name});
    await this.updateCover(file);
  }
}