chromium/ui/file_manager/image_loader/image_request_task.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 {getFileTypeForName} from 'chrome://file-manager/common/js/file_types_base.js';
import {assert} from 'chrome://resources/js/assert.js';

import type {ImageCache} from './cache.js';
import {resizeAndCrop, shouldProcess} from './image_loader_util.js';
import {ImageOrientation} from './image_orientation.js';
import {cacheKey, type LoadImageRequest, LoadImageResponse, LoadImageResponseStatus} from './load_image_request.js';
import {PiexLoader} from './piex_loader.js';
import type {PrivateApi} from './sw_od_messages.js';

const ExtensionContentTypeMap = new Map<string, string>([
  ['gif', 'image/gif'],
  ['png', 'image/png'],
  ['svg', 'image/svg'],
  ['bmp', 'image/bmp'],
  ['jpg', 'image/jpeg'],
  ['jpeg', 'image/jpeg'],
]);

const adpRegExp = RegExp(
    '^filesystem:chrome-extension://[a-z]+/external/arc-documents-provider/');

/**
 * Calls the imageLoaderPrivate API with the given message.
 *
 * @param msg The imageLoaderPrivate call arguments.
 * @return A promise for the thumbnailDataUrl.
 */
function callImageLoaderPrivate(msg: PrivateApi): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    const callback = (thumbnailDataUrl: string) => {
      if (chrome.runtime.lastError) {
        console.warn(chrome.runtime.lastError.message);
        reject(chrome.runtime.lastError);
      } else if (thumbnailDataUrl) {
        resolve(thumbnailDataUrl);
      } else {
        reject();
      }
    };

    if (msg.apiMethod === 'getDriveThumbnail') {
      chrome.imageLoaderPrivate.getDriveThumbnail(
          msg.params.url,
          msg.params.cropToSquare,
          callback,
      );
    } else if (msg.apiMethod === 'getPdfThumbnail') {
      chrome.imageLoaderPrivate.getPdfThumbnail(
          msg.params.url,
          msg.params.width,
          msg.params.height,
          callback,
      );
    } else if (msg.apiMethod === 'getArcDocumentsProviderThumbnail') {
      chrome.imageLoaderPrivate.getArcDocumentsProviderThumbnail(
          msg.params.url,
          msg.params.widthHint,
          msg.params.heightHint,
          callback,
      );
    }
  });
}

/**
 * Creates and starts downloading and then resizing of the image. Finally,
 * returns the image using the callback.
 */
export class ImageRequestTask {
  /**
   * The maximum milliseconds to load video. If loading video exceeds the limit,
   * we give up generating video thumbnail and free the consumed memory.
   */
  static readonly MAX_MILLISECONDS_TO_LOAD_VIDEO: number = 10000;

  /**
   * The default width of a non-square thumbnail. The value is set to match the
   * behavior of drivefs thumbnail generation.
   * See chromeos/ash/components/drivefs/mojom/drivefs.mojom
   */
  static readonly DEFAULT_THUMBNAIL_SQUARE_SIZE: number = 360;

  /**
   * The default width of a non-square thumbnail. The value is set to match the
   * behavior of drivefs thumbnail generation.
   * See chromeos/ash/components/drivefs/mojom/drivefs.mojom
   */
  static readonly DEFAULT_THUMBNAIL_WIDTH: number = 500;

  /**
   * The default height of a non-square thumbnail. The value is set to match the
   * behavior of drivefs thumbnail generation.
   * See chromeos/ash/components/drivefs/mojom/drivefs.mojom
   */
  static readonly DEFAULT_THUMBNAIL_HEIGHT: number = 500;

  /**
   * Temporary image used to download images.
   */
  private image_: HTMLImageElement = new Image();

  /**
   * MIME type of the fetched image.
   */
  private contentType_?: string;

  /**
   * IFD data of the fetched image. Only RAW images provide a non-null
   * ifd at this time. Drive images might provide an ifd in future.
   */
  private ifd_?: string;

  /**
   * Used to download remote images using http:// or https:// protocols.
   */
  private xhr_: XMLHttpRequest|null = null;

  /**
   * Temporary canvas used to resize and compress the image.
   */
  private canvas_: HTMLCanvasElement;

  private renderOrientation_: ImageOrientation|null = null;

  /**
   * Callback to be called once downloading is finished.
   */
  private downloadCallback_: null|VoidCallback = null;

  private aborted_: boolean = false;

  /**
   * @param id Request ID.
   * @param cache Cache object.
   * @param request Request message as a hash array.
   * @param callback Response handler.
   */
  constructor(
      private id_: string,
      private cache_: ImageCache,
      private request_: LoadImageRequest,
      private sendResponse_: (a: LoadImageResponse) => void,
  ) {
    this.canvas_ = document.createElement('canvas');
  }

  /**
   * Extracts MIME type of a data URL.
   * @param dataUrl Data URL.
   * @return MIME type string, or null if the URL is invalid.
   */
  static getDataUrlMimeType(dataUrl?: string): string|undefined {
    const dataUrlMatches = (dataUrl || '').match(/^data:([^,;]*)[,;]/);
    return dataUrlMatches ? dataUrlMatches[1] : undefined;
  }

  /**
   * Returns ID of the request.
   * @return Request ID.
   */
  getId(): string {
    return this.id_;
  }

  getClientTaskId(): number {
    // Every incoming request should have been given a taskId.
    assert(this.request_.taskId);
    return this.request_.taskId;
  }

  /**
   * Returns priority of the request. The higher priority, the faster it will
   * be handled. The highest priority is 0. The default one is 2.
   *
   * @return Priority.
   */
  getPriority(): number {
    return this.request_.priority !== undefined ? this.request_.priority : 2;
  }

  /**
   * Tries to load the image from cache, if it exists in the cache, and sends
   * the response. Fails if the image is not found in the cache.
   *
   * @param onSuccess Success callback.
   * @param onFailure Failure callback.
   */
  loadFromCacheAndProcess(onSuccess: VoidCallback, onFailure: VoidCallback) {
    this.loadFromCache_(
        (width: number, height: number, ifd?: string, data?: string) => {
          // Found in cache.
          this.ifd_ = ifd;
          this.sendImageData_(width, height, data!);
          onSuccess();
        },
        onFailure,
    );  // Not found in cache.
  }

  /**
   * Tries to download the image, resizes and sends the response.
   *
   * @param callback Completion callback.
   */
  downloadAndProcess(callback: VoidCallback) {
    if (this.downloadCallback_) {
      throw new Error('Downloading already started.');
    }

    this.downloadCallback_ = callback;
    this.downloadThumbnail_(
        this.onImageLoad_.bind(this),
        this.onImageError_.bind(this),
    );
  }

  /**
   * Fetches the image from the persistent cache.
   *
   * @param onSuccess callback with the image width, height, ?ifd, and data.
   * @param onFailure Failure callback.
   */
  private loadFromCache_(
      onSuccess: (
          width: number,
          height: number,
          ifd?: string,
          data?: string,
          ) => void,
      onFailure: VoidCallback,
  ) {
    const key = cacheKey(this.request_);

    if (!key) {
      // Cache key is not provided for the request.
      onFailure();
      return;
    }

    if (!this.request_.cache) {
      // Cache is disabled for this request; therefore, remove it from cache
      // if existed.
      this.cache_.removeImage(key);
      onFailure();
      return;
    }

    const timestamp = this.request_.timestamp;
    if (!timestamp) {
      // Persistent cache is available only when a timestamp is provided.
      onFailure();
      return;
    }

    this.cache_.loadImage(key, timestamp, onSuccess, onFailure);
  }

  /**
   * Saves the image to the persistent cache.
   *
   * @param width Image width.
   * @param height Image height.
   * @param data Image data.
   */
  private saveToCache_(width: number, height: number, data: string) {
    const timestamp = this.request_.timestamp;

    if (!this.request_.cache || !timestamp) {
      // Persistent cache is available only when a timestamp is provided.
      return;
    }

    const key = cacheKey(this.request_);
    if (!key) {
      // Cache key is not provided for the request.
      return;
    }

    this.cache_.saveImage(key, timestamp, width, height, this.ifd_, data);
  }

  /**
   * Gets the target image size for external thumbnails, where supported.
   * The defaults replicate drivefs thumbnailer behavior.
   */
  private targetThumbnailSize_(): {width: number, height: number} {
    const crop = !!this.request_.crop;
    const defaultWidth = crop ? ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE :
                                ImageRequestTask.DEFAULT_THUMBNAIL_WIDTH;
    const defaultHeight = crop ?
        ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE :
        ImageRequestTask.DEFAULT_THUMBNAIL_HEIGHT;
    return {
      width: this.request_.width || defaultWidth,
      height: this.request_.height || defaultHeight,
    };
  }

  /**
   * Loads |this.image_| with the |this.request_.url| source or the thumbnail
   * image of the source.
   *
   * @param onSuccess Success callback.
   * @param onFailure Failure callback.
   */
  private async downloadThumbnail_(
      onSuccess: VoidCallback,
      onFailure: VoidCallback,
  ) {
    // Load methods below set |this.image_.src|. Call revokeObjectURL(src) to
    // release resources if the image src was created with createObjectURL().
    this.image_.onload = () => {
      URL.revokeObjectURL(this.image_.src);
      onSuccess();
    };
    this.image_.onerror = () => {
      URL.revokeObjectURL(this.image_.src);
      onFailure();
    };

    // Load dataURL sources directly.
    const dataUrlMimeType = ImageRequestTask.getDataUrlMimeType(
        this.request_.url,
    );
    const requestUrl = this.request_.url ?? '';
    if (dataUrlMimeType) {
      this.image_.src = requestUrl;
      this.contentType_ = dataUrlMimeType;
      return;
    }

    const onExternalThumbnail = (dataUrl: string) => {
      this.image_.src = dataUrl;
      this.contentType_ = ImageRequestTask.getDataUrlMimeType(dataUrl);
    };

    // Load Drive source thumbnail.
    const drivefsUrlMatches = requestUrl.match(/^drivefs:(.*)/);
    if (drivefsUrlMatches) {
      callImageLoaderPrivate({
        apiMethod: 'getDriveThumbnail',
        params: {
          url: drivefsUrlMatches[1] || '',
          cropToSquare: !!this.request_.crop,
        },
      })
          .then(onExternalThumbnail)
          .catch(onFailure);
      return;
    }

    // Load PDF source thumbnail.
    if (requestUrl.endsWith('.pdf')) {
      const {width, height} = this.targetThumbnailSize_();
      callImageLoaderPrivate({
        apiMethod: 'getPdfThumbnail',
        params: {
          url: requestUrl,
          width,
          height,
        },
      })
          .then(onExternalThumbnail)
          .catch(onFailure);
      return;
    }

    // Load ARC DocumentsProvider thumbnail, if supported.
    if (requestUrl.match(adpRegExp)) {
      const {width, height} = this.targetThumbnailSize_();
      callImageLoaderPrivate({
        apiMethod: 'getArcDocumentsProviderThumbnail',
        params: {
          url: requestUrl,
          widthHint: width,
          heightHint: height,
        },
      })
          .then(onExternalThumbnail)
          .catch(onFailure);
      return;
    }

    const fileType = getFileTypeForName(requestUrl);

    // Load video source thumbnail.
    if (fileType.type === 'video') {
      this.createVideoThumbnailUrl_(requestUrl)
          .then((url) => {
            this.image_.src = url;
          })
          .catch((error) => {
            console.warn('Video thumbnail error: ', error);
            onFailure();
          });
      return;
    }

    // Load the source directly.
    this.load(
        requestUrl,
        (contentType, blob) => {
          // Load RAW image source thumbnail.
          if (fileType.type === 'raw') {
            blob.arrayBuffer()
                .then(
                    (buffer) => PiexLoader.load(buffer, chrome.runtime.reload))
                .then((data) => {
                  this.renderOrientation_ =
                      ImageOrientation.fromExifOrientation(
                          data.orientation,
                      );
                  this.ifd_ = data.ifd ?? undefined;
                  this.contentType_ = data.mimeType;
                  const blob =
                      new Blob([data.thumbnail], {type: data.mimeType});
                  this.image_.src = URL.createObjectURL(blob);
                })
                .catch(onFailure);
            return;
          }

          this.image_.src = blob ? URL.createObjectURL(blob) : '!';
          this.contentType_ = contentType || undefined;
          if (this.contentType_ === 'image/jpeg') {
            this.renderOrientation_ = ImageOrientation.fromExifOrientation(1);
          }
        },
        onFailure,
    );
  }

  /**
   * Creates a video thumbnail data url from video file.
   *
   * @param url Video URL.
   * @return Promise that resolves with the data url of video
   *    thumbnail.
   */
  private createVideoThumbnailUrl_(url: string): Promise<string> {
    const video: HTMLVideoElement = document.createElement('video');
    return Promise
        .race([
          new Promise<void>((resolve, reject) => {
            video.addEventListener('loadedmetadata', () => {
              video.addEventListener('seeked', () => {
                if (video.readyState >= video.HAVE_CURRENT_DATA) {
                  resolve();
                } else {
                  video.addEventListener('loadeddata', () => resolve());
                }
              });
              // For videos with longer duration (>= 6 seconds), consider the
              // frame at 3rd second, or use the frame at midpoint otherwise.
              // This ensures the target position is always close to the
              // beginning of the video. Seek operations may be costly if the
              // video doesn't contain keyframes for referencing.
              const thumbnailPosition = Math.min(video.duration / 2, 3);
              video.currentTime = thumbnailPosition;
            });
            video.addEventListener('error', reject);
            video.preload = 'metadata';
            video.src = url;
            video.load();
          }),
          new Promise((resolve) => {
            setTimeout(
                resolve, ImageRequestTask.MAX_MILLISECONDS_TO_LOAD_VIDEO);
          }).then(() => {
            // If we can't get the frame at the midpoint of the video after 3
            // seconds have passed for some reason (e.g. unseekable video), we
            // give up generating thumbnail.
            // Make sure to stop loading remaining part of the video.
            video.src = '';
            throw new Error('Seeking video failed.');
          }),
        ])
        .then(() => {
          const canvas: HTMLCanvasElement = document.createElement('canvas');
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          canvas.getContext('2d')!.drawImage(video, 0, 0);
          // Clearing the `src` helps the decoder to dispose its memory earlier.
          video.src = '';
          return canvas.toDataURL();
        });
  }

  /**
   * Loads an image.
   *
   * @param url URL to the resource to be fetched.
   * @param onSuccess Success callback with the content type and the fetched
   *     data.
   * @param onFailure Failure callback.
   */
  load(
      url: string,
      onSuccess: (contentType: string|undefined, reponse: Blob) => void,
      onFailure: VoidCallback,
  ) {
    this.aborted_ = false;

    // Do not call any callbacks when aborting.
    const onMaybeSuccess = (
        contentType: string|undefined,
        response: Blob,
        ) => {
      // When content type is not available, try to estimate it from url.
      if (!contentType) {
        contentType = ExtensionContentTypeMap.get(this.extractExtension_(url));
      }

      if (!this.aborted_) {
        onSuccess(contentType, response);
      }
    };

    const onMaybeFailure = () => {
      if (!this.aborted_) {
        onFailure();
      }
    };

    // The query parameter is workaround for crbug.com/379678, which forces the
    // browser to obtain the latest contents of the image.
    const noCacheUrl = url + '?nocache=' + Date.now();
    this.xhr_ = ImageRequestTask.load_(
        noCacheUrl,
        onMaybeSuccess,
        onMaybeFailure,
    );
  }

  /**
   * Extracts extension from url.
   * @param url Url.
   * @return Extracted extension, e.g. png.
   */
  private extractExtension_(url: string): string {
    const result = /\.([a-zA-Z]+)$/i.exec(url);
    return result ? result[1] ?? '' : '';
  }

  /**
   * Fetches data using XmlHttpRequest.
   *
   * @param url URL to the resource to be fetched.
   * @param onSuccess Success callback with the content type and the fetched
   *     data.
   * @param onFailure Failure callback with the error code if available.
   * @return XHR instance.
   */
  private static load_(
      url: string,
      onSuccess: (a: string, b: Blob) => void,
      onFailure: (a?: number) => void,
      ): XMLHttpRequest {
    const xhr = new XMLHttpRequest();
    xhr.responseType = 'blob';

    xhr.onreadystatechange = () => {
      if (xhr.readyState !== 4) {
        return;
      }
      if (xhr.status !== 200) {
        onFailure(xhr.status);
        return;
      }
      const response: Blob = xhr.response;
      const contentType =
          xhr.getResponseHeader('Content-Type') || response.type;
      onSuccess(contentType, response);
    };

    // Perform a xhr request.
    try {
      xhr.open('GET', url, true);
      xhr.send();
    } catch (e) {
      onFailure();
    }

    return xhr;
  }

  /**
   * Sends the resized image via the callback. If the image has been changed,
   * then packs the canvas contents, otherwise sends the raw image data.
   *
   * @param imageChanged Whether the image has been changed.
   */
  private sendImage_(imageChanged: boolean) {
    let width: number;
    let height: number;
    let data: string;

    if (!imageChanged) {
      // The image hasn't been processed, so the raw data can be directly
      // forwarded for speed (no need to encode the image again).
      width = this.image_.width;
      height = this.image_.height;
      data = this.image_.src;
    } else {
      // The image has been resized or rotated, therefore the canvas has to be
      // encoded to get the correct compressed image data.
      width = this.canvas_.width;
      height = this.canvas_.height;

      switch (this.contentType_) {
        case 'image/jpeg':
          data = this.canvas_.toDataURL('image/jpeg', 0.9);
          break;
        default:
          data = this.canvas_.toDataURL('image/png');
          break;
      }
    }

    // Send the image data and also save it in the persistent cache.
    this.sendImageData_(width, height, data);
    this.saveToCache_(width, height, data);
  }

  /**
   * Sends the resized image via the callback.
   *
   * @param width Image width.
   * @param height Image height.
   * @param data Image data.
   */
  private sendImageData_(width: number, height: number, data: string) {
    const result = {width, height, ifd: this.ifd_, data};
    this.sendResponse_(
        new LoadImageResponse(
            LoadImageResponseStatus.SUCCESS,
            this.getClientTaskId(),
            result,
            ),
    );
  }

  /**
   * Handler, when contents are loaded into the image element. Performs image
   * processing operations if needed, and finalizes the request process.
   */
  private onImageLoad_() {
    const requestOrientation = this.request_.orientation;

    // Override the request orientation before processing if needed.
    if (this.renderOrientation_) {
      this.request_.orientation = this.renderOrientation_;
    }

    // Perform processing if the url is not a data url, or if there are some
    // operations requested.
    let imageChanged = false;
    const reqUrl = this.request_.url ?? '';
    if (!(reqUrl.match(/^data/) || reqUrl.match(/^drivefs:/)) ||
        shouldProcess(this.image_.width, this.image_.height, this.request_)) {
      resizeAndCrop(this.image_, this.canvas_, this.request_);
      imageChanged = true;  // The image is now on the <canvas>.
    }

    // Restore the request orientation after processing.
    if (this.renderOrientation_) {
      this.request_.orientation = requestOrientation;
    }

    // Finalize the request.
    this.sendImage_(imageChanged);
    this.cleanup_();
    if (this.downloadCallback_) {
      this.downloadCallback_();
    }
  }

  /**
   * Handler, when loading of the image fails. Sends a failure response and
   * finalizes the request process.
   */
  private onImageError_() {
    this.sendResponse_(
        new LoadImageResponse(
            LoadImageResponseStatus.ERROR,
            this.getClientTaskId(),
            ),
    );
    this.cleanup_();
    if (this.downloadCallback_) {
      this.downloadCallback_();
    }
  }

  /**
   * Cancels the request.
   */
  cancel() {
    this.cleanup_();

    // If downloading has started, then call the callback.
    if (this.downloadCallback_) {
      this.downloadCallback_();
    }
  }

  /**
   * Cleans up memory used by this request.
   */
  private cleanup_() {
    this.image_.onerror = () => {};
    this.image_.onload = () => {};

    // Transparent 1x1 pixel gif, to force garbage collecting.
    this.image_.src =
        'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAA' +
        'ABAAEAAAICTAEAOw==';

    this.aborted_ = true;
    if (this.xhr_) {
      this.xhr_.abort();
    }

    // Dispose memory allocated by Canvas.
    this.canvas_.width = 0;
    this.canvas_.height = 0;
  }
}