chromium/ui/file_manager/file_manager/foreground/js/file_tasks.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} from 'chrome://resources/ash/common/assert.js';

import type {Crostini} from '../../background/js/crostini.js';
import type {ProgressCenter} from '../../background/js/progress_center.js';
import type {VolumeInfo} from '../../background/js/volume_info.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {executeTask, getDirectory, getFileTasks} from '../../common/js/api.js';
import {AsyncQueue} from '../../common/js/async_util.js';
import {entriesToURLs, isFakeEntry} from '../../common/js/entry_utils.js';
import {type AnnotatedTask, annotateTasks, getDefaultTask, INSTALL_LINUX_PACKAGE_TASK_DESCRIPTOR, isFilesAppId, parseActionId} from '../../common/js/file_tasks.js';
import {getExtension} from '../../common/js/file_type.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {recordEnum, recordTime} from '../../common/js/metrics.js';
import {ProgressCenterItem, ProgressItemState, ProgressItemType} from '../../common/js/progress_center_common.js';
import {bytesToString, str, strf} from '../../common/js/translations.js';
import {recordViewingNavigationSurfaceUma, recordViewingVolumeTypeUma} from '../../common/js/uma.js';
import {LEGACY_FILES_EXTENSION_ID} from '../../common/js/url_constants.js';
import {descriptorEqual, extractFilePath, isTeleported, makeTaskID, splitExtension} from '../../common/js/util.js';
import {RootType, RootTypesForUMA, VolumeError, VolumeType} from '../../common/js/volume_manager_types.js';
import {type FileTasks as StoreFileTasks} from '../../state/state.js';
import {getStore} from '../../state/store.js';
import type {XfPasswordDialog} from '../../widgets/xf_password_dialog.js';
import {USER_CANCELLED} from '../../widgets/xf_password_dialog.js';

import {DEFAULT_CROSTINI_VM} from './constants.js';
import type {DirectoryModel} from './directory_model.js';
import {type DirectoryChangeTracker} from './directory_model.js';
import type {FileTransferController} from './file_transfer_controller.js';
import {PastePlan} from './file_transfer_controller.js';
import type {MetadataItem} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import type {TaskController} from './task_controller.js';
import {type DropdownItem} from './task_controller.js';
import type {TaskHistory} from './task_history.js';
import type {DefaultTaskDialog} from './ui/default_task_dialog.js';
import type {FileManagerUI} from './ui/file_manager_ui.js';
import {FilesConfirmDialog} from './ui/files_confirm_dialog.js';
import {UMA_INDEX_KNOWN_EXTENSIONS} from './uma_enums.gen.js';

/**
 * Office file handlers UMA values (must be consistent with OfficeFileHandler in
 * tools/metrics/histograms/enums.xml).
 */
const OfficeFileHandlersHistogramValues = {
  OTHER: 0,
  WEB_DRIVE_OFFICE: 1,
  QUICK_OFFICE: 2,
};

/**
 * Represents a collection of available tasks to execute for a specific list
 * of entries.
 */
export class FileTasks {
  /** Mutex used to serialize password dialogs. */
  private mutex_: AsyncQueue;

  constructor(
      private volumeManager_: VolumeManager,
      private metadataModel_: MetadataModel,
      private directoryModel_: DirectoryModel, private ui_: FileManagerUI,
      private fileTransferController_: FileTransferController,
      private entries_: Array<Entry|FilesAppEntry>,
      private resultingTasks_: chrome.fileManagerPrivate.ResultingTasks,
      private defaultTask_: chrome.fileManagerPrivate.FileTask|null,
      private taskHistory_: TaskHistory,
      private progressCenter_: ProgressCenter,
      private taskController_: TaskController) {
    this.mutex_ = new AsyncQueue();
  }

  /**
   * Creates an instance of FileTasks for the specified list of entries with
   * mime types.
   */
  static async create(
      volumeManager: VolumeManager, metadataModel: MetadataModel,
      directoryModel: DirectoryModel, ui: FileManagerUI,
      fileTransferController: FileTransferController,
      entries: Array<Entry|FilesAppEntry>, taskHistory: TaskHistory,
      crostini: Crostini, progressCenter: ProgressCenter,
      taskController: TaskController): Promise<FileTasks> {
    let resultingTasks: chrome.fileManagerPrivate.ResultingTasks = {
      tasks: [],
      policyDefaultHandlerStatus: undefined,
    };

    // Cannot use fake entries with getFileTasks.
    entries = entries.filter(e => !isFakeEntry(e));
    const dlpSourceUrls = metadataModel.getCache(entries, ['sourceUrl'])
                              .map(m => m.sourceUrl || '');
    if (entries.length !== 0) {
      resultingTasks = await getFileTasks(entries, dlpSourceUrls);
      if (!resultingTasks || !resultingTasks.tasks) {
        throw new Error('Cannot get file tasks.');
      }
    }

    // Linux package installation is currently only supported for a single
    // file which is inside the Linux container, or in a shareable volume.
    // TODO(timloh): Instead of filtering these out, we probably should show a
    // dialog with an error message, similar to when attempting to run
    // Crostini tasks with non-Crostini entries.
    if (entries.length !== 1 ||
        !(isCrostiniEntry(entries[0]!, volumeManager) ||
          crostini.canSharePath(
              DEFAULT_CROSTINI_VM, entries[0]!, false /* persist */))) {
      resultingTasks.tasks = resultingTasks.tasks.filter(
          (task: chrome.fileManagerPrivate.FileTask) => !descriptorEqual(
              task.descriptor, INSTALL_LINUX_PACKAGE_TASK_DESCRIPTOR));
    }

    const tasks = annotateTasks(resultingTasks.tasks, entries);
    resultingTasks.tasks = tasks;

    const defaultTask = getDefaultTask(
        tasks, resultingTasks.policyDefaultHandlerStatus, taskHistory);


    return new FileTasks(
        volumeManager, metadataModel, directoryModel, ui,
        fileTransferController, entries, resultingTasks, defaultTask,
        taskHistory, progressCenter, taskController);
  }

  /** Creates FileTasks instance based on the data from the Store. */
  static fromStoreTasks(
      tasks: StoreFileTasks, volumeManager: VolumeManager,
      metadataModel: MetadataModel, directoryModel: DirectoryModel,
      ui: FileManagerUI, fileTransferController: FileTransferController,
      entries: Entry[], taskHistory: TaskHistory,
      progressCenter: ProgressCenter,
      taskController: TaskController): FileTasks {
    return new FileTasks(
        volumeManager, metadataModel, directoryModel, ui,
        fileTransferController, entries, tasks, tasks.defaultTask ?? null,
        taskHistory, progressCenter, taskController);
  }

  get entries(): Array<Entry|FilesAppEntry> {
    return this.entries_;
  }

  get defaultTask(): chrome.fileManagerPrivate.FileTask|null {
    return this.defaultTask_;
  }

  getAnnotatedTasks(): AnnotatedTask[] {
    // resultingTasks_.tasks is annotated at create().
    return this.resultingTasks_.tasks as AnnotatedTask[];
  }

  /** Gets the policy default handler status.  */
  getPolicyDefaultHandlerStatus():
      chrome.fileManagerPrivate.PolicyDefaultHandlerStatus|undefined {
    return this.resultingTasks_.policyDefaultHandlerStatus;
  }

  /** Returns whether the system is currently offline. */
  private static isOffline_(volumeManager: VolumeManager): boolean {
    const connection = volumeManager.getDriveConnectionState();
    return connection.type ===
        chrome.fileManagerPrivate.DriveConnectionStateType.OFFLINE &&
        connection.reason ===
        chrome.fileManagerPrivate.DriveOfflineReason.NO_NETWORK;
  }

  /**
   * Records a metric, as well as recording online and offline versions of it.
   *
   * @param name Metric name.
   * @param value Enum value.
   * @param values Array of valid values.
   */
  private static recordEnumWithOnlineAndOffline_(
      volumeManager: VolumeManager, name: string, value: any, values: any[]) {
    recordEnum(name, value, values);
    if (FileTasks.isOffline_(volumeManager)) {
      recordEnum(name + '.Offline', value, values);
    } else {
      recordEnum(name + '.Online', value, values);
    }
  }

  /**
   * Returns ViewFileType enum or 'other' for the given entry.
   * @return A ViewFileType enum or 'other'.
   */
  static getViewFileType(entry: Entry|FilesAppEntry): string {
    let extension = getExtension(entry).toLowerCase();
    if (UMA_INDEX_KNOWN_EXTENSIONS.indexOf(extension) < 0) {
      extension = 'other';
    }
    return extension;
  }

  /** Records trial of opening file grouped by extensions.  */
  private static recordViewingFileTypeUma_(
      volumeManager: VolumeManager, entries: Array<Entry|FilesAppEntry>) {
    const state = getStore().getState();
    for (const entry of entries) {
      FileTasks.recordEnumWithOnlineAndOffline_(
          volumeManager, 'ViewingFileType', FileTasks.getViewFileType(entry),
          UMA_INDEX_KNOWN_EXTENSIONS as string[]);
      recordViewingVolumeTypeUma(state, entry.toURL());
      // Recorded per file.
      recordViewingNavigationSurfaceUma(state);
    }
  }

  /**
   * Records trial of opening file grouped by root types.
   * @param rootType The type of the root where entries are being opened.
   */
  private static recordViewingRootTypeUma_(
      volumeManager: VolumeManager, rootType: RootType|null) {
    if (rootType !== null) {
      FileTasks.recordEnumWithOnlineAndOffline_(
          volumeManager, 'ViewingRootType', rootType, RootTypesForUMA);
    }
  }

  /**
   * Records the elapsed time for mounting a ZIP file as a ZipMountTime
   * histogram value.
   * @param rootType The type of the root where the ZIP file has been mounted
   *     from.
   * @param time Time to be recorded in milliseconds.
   */
  private static recordZipMountTimeUma_(rootType: RootType|null, time: number) {
    let root;
    switch (rootType) {
      case RootType.MY_FILES:
      case RootType.DOWNLOADS:
        root = 'MyFiles';
        break;
      case RootType.DRIVE:
        root = 'Drive';
        break;
      default:
        root = 'Other';
    }
    recordTime(`ZipMountTime.${root}`, time);
  }

  /**
   * Records trial of opening Office file grouped by file handlers.
   * @param entries The entries to be opened.
   * @param rootType The type of the root where entries are being opened.
   */
  private static recordOfficeFileHandlerUma_(
      volumeManager: VolumeManager, entries: Array<Entry|FilesAppEntry>,
      rootType: RootType|null, task: chrome.fileManagerPrivate.FileTask|null) {
    if (!task) {
      return;
    }

    // This UMA is only applicable to Office files.
    if (!entries.every(entry => hasOfficeExtension(entry))) {
      return;
    }

    let histogramName = 'OfficeFiles.FileHandler';
    switch (rootType) {
      case RootType.DRIVE:
        histogramName += '.Drive';
        break;
      default:
        histogramName += '.NotDrive';
    }

    if (FileTasks.isOffline_(volumeManager)) {
      histogramName += '.Offline';
    } else {
      histogramName += '.Online';
    }

    let fileHandler = OfficeFileHandlersHistogramValues.OTHER;
    switch (parseActionId(task.descriptor.actionId)) {
      case 'open-web-drive-office-word':
      case 'open-web-drive-office-excel':
      case 'open-web-drive-office-powerpoint':
        fileHandler = OfficeFileHandlersHistogramValues.WEB_DRIVE_OFFICE;
        break;
      case 'qo_documents':
        fileHandler = OfficeFileHandlersHistogramValues.QUICK_OFFICE;
        break;
    }

    recordEnum(
        histogramName, fileHandler,
        Object.values(OfficeFileHandlersHistogramValues));
  }

  /** Returns true if the descriptor is for an internal task.  */
  private static isInternalTask_(
      descriptor: chrome.fileManagerPrivate.FileTaskDescriptor): boolean {
    const {appId, taskType, actionId} = descriptor;
    if (!isFilesAppId(appId)) {
      return false;
    }

    // Legacy Files app task type is 'app', Files SWA is 'web'.
    if (!(taskType === 'app' || taskType === 'web')) {
      return false;
    }
    const parsedActionId = parseActionId(actionId);

    switch (parsedActionId) {
      case 'mount-archive':
      case 'install-linux-package':
      case 'import-crostini-image':
        return true;
      default:
        return false;
    }
  }

  /**
   * Show dialog when user opens or drags a file with PluginVM and the file
   * is not in PvmSharedDir or shared with PluginVM. The dialog tells the
   * user to move or copy the file to PvmSharedDir and offers an action to do
   * that.
   *
   * @param entries Selected entries to be moved or copied.
   * @param ui FileManager UI to show dialog.
   * @param moveMessage Message if files are local and can be moved.
   * @param copyMessage Message if files should be copied.
   */
  static showPluginVmNotSharedDialog(
      entries: Array<Entry|FilesAppEntry>, volumeManager: VolumeManager,
      metadataModel: MetadataModel, ui: FileManagerUI, moveMessage: string,
      copyMessage: string, fileTransferController: FileTransferController|null,
      directoryModel: DirectoryModel) {
    assert(entries.length > 0);
    const isMyFiles = isMyFilesEntry(entries[0]!, volumeManager);
    const dialog = new FilesConfirmDialog(ui.element);
    dialog.setOkLabel(str(
        isMyFiles ? 'CONFIRM_MOVE_BUTTON_LABEL' : 'CONFIRM_COPY_BUTTON_LABEL'));
    dialog.show(isMyFiles ? moveMessage : copyMessage, async () => {
      if (!fileTransferController) {
        console.warn('FileTransferController not set');
        return;
      }

      const pvmDir = await FileTasks.getPvmSharedDir_(volumeManager);

      assert(volumeManager.getLocationInfo(pvmDir));

      fileTransferController.executePaste(new PastePlan(
          entries.map(e => e.toURL()), [], pvmDir, metadataModel,
          /*isMove=*/ isMyFiles));
      directoryModel.changeDirectoryEntry(pvmDir);
    });
  }

  /** Executes default task.  */
  async executeDefault(): Promise<void> {
    FileTasks.recordViewingFileTypeUma_(this.volumeManager_, this.entries_);
    FileTasks.recordViewingRootTypeUma_(
        this.volumeManager_, this.directoryModel_.getCurrentRootType());
    FileTasks.recordOfficeFileHandlerUma_(
        this.volumeManager_, this.entries_,
        this.directoryModel_.getCurrentRootType(), this.defaultTask_);
    return this.executeDefaultInternal_();
  }

  private async executeDefaultInternal_(): Promise<void> {
    if (this.defaultTask_) {
      this.executeInternal_(this.defaultTask_);
      return;
    }

    // If there's policy involved and |defaultTask_| is null, means that policy
    // assignment was incorrect. We should not execute anything in this case.
    if (this.getPolicyDefaultHandlerStatus()) {
      console.assert(
          this.getPolicyDefaultHandlerStatus() ===
              chrome.fileManagerPrivate.PolicyDefaultHandlerStatus
                  .INCORRECT_ASSIGNMENT,
          'policyDefaultHandlerStatus expected to be INCORRECT, thus not executing the task');
      return;
    }

    const nonGenericTasks =
        this.resultingTasks_.tasks.filter(t => !t.isGenericFileHandler);
    // If there is only one task that is not a generic file handler, it should
    // be executed as a default task. If there are multiple tasks that are not
    // generic file handlers, and none of them are considered as default, we
    // show a task picker to ask the user to choose one.
    if (nonGenericTasks.length >= 2) {
      this.showTaskPicker(
          this.ui_.defaultTaskPicker, str('OPEN_WITH_BUTTON_LABEL'),
          '', task => {
            this.execute(task);
          }, TaskPickerType.OpenWith);
      return;
    }

    // We don't have tasks, so try to show a file in a browser tab.
    // We only do that for single selection to avoid confusion.
    if (this.entries_.length !== 1) {
      return;
    }

    const filename = this.entries_[0]!.name;
    const extension = splitExtension(filename)[1] || null;

    try {
      await this.checkAvailability_();
    } catch (error) {
      console.warn('Rejected after checking availability due to', error);
      return;
    }

    try {
      const descriptor = {
        appId: LEGACY_FILES_EXTENSION_ID,
        taskType: 'file',
        actionId: 'view-in-browser',
      };
      const result = await executeTask(descriptor, this.entries_);
      switch (result) {
        case 'opened':
          break;
        case 'message_sent':
          isTeleported().then(teleported => {
            if (teleported) {
              this.ui_.showOpenInOtherDesktopAlert(this.entries_);
            }
          });
          break;
        case 'empty':
          break;
        case 'failed':
          throw new Error();
      }
    } catch {
      let textMessageId;
      let titleMessageId;
      switch (extension) {
        case '.exe':
        case '.msi':
          textMessageId = 'NO_TASK_FOR_EXECUTABLE';
          break;
        case '.dmg':
          textMessageId = 'NO_TASK_FOR_DMG';
          break;
        case '.crx':
          textMessageId = 'NO_TASK_FOR_CRX';
          titleMessageId = 'NO_TASK_FOR_CRX_TITLE';
          break;
        default:
          textMessageId = 'NO_TASK_FOR_FILE';
      }

      const text = strf(textMessageId, str('NO_TASK_FOR_FILE_URL'));
      const title = titleMessageId ? str(titleMessageId) : filename;
      this.ui_.alertDialog.showHtml(title, text);
    }
  }

  /** Executes a single task.  */
  execute(task: chrome.fileManagerPrivate.FileTask) {
    FileTasks.recordViewingFileTypeUma_(this.volumeManager_, this.entries_);
    FileTasks.recordViewingRootTypeUma_(
        this.volumeManager_, this.directoryModel_.getCurrentRootType());
    FileTasks.recordOfficeFileHandlerUma_(
        this.volumeManager_, this.entries_,
        this.directoryModel_.getCurrentRootType(), task);
    this.executeInternal_(task);
  }

  /** The core implementation to execute a single task. */
  private async executeInternal_(task: chrome.fileManagerPrivate.FileTask):
      Promise<void> {
    const entries = this.entries_;
    try {
      await this.checkAvailability_();
    } catch (error) {
      console.warn('Rejected after checking availability due to', error);
      return;
    }
    this.taskHistory_.recordTaskExecuted(task.descriptor);
    const msg = (entries.length === 1) ?
        strf('OPEN_A11Y', entries[0]!.name) :
        strf('OPEN_A11Y_PLURAL', entries.length);
    this.ui_.speakA11yMessage(msg);
    if (FileTasks.isInternalTask_(task.descriptor)) {
      this.executeInternalTask_(task.descriptor);
      return;
    }

    try {
      const result = await executeTask(task.descriptor, entries);
      const TaskResult = chrome.fileManagerPrivate.TaskResult;
      switch (result) {
        case TaskResult.MESSAGE_SENT:
          isTeleported().then((teleported) => {
            if (teleported) {
              this.ui_.showOpenInOtherDesktopAlert(entries);
            }
          });
          break;
        case TaskResult.FAILED_PLUGIN_VM_DIRECTORY_NOT_SHARED:
          const moveMessage = strf(
              'UNABLE_TO_OPEN_WITH_PLUGIN_VM_DIRECTORY_NOT_SHARED_MESSAGE',
              task.title);
          const copyMessage = strf(
              'UNABLE_TO_OPEN_WITH_PLUGIN_VM_EXTERNAL_DRIVE_MESSAGE',
              task.title);
          FileTasks.showPluginVmNotSharedDialog(
              entries, this.volumeManager_, this.metadataModel_, this.ui_,
              moveMessage, copyMessage, this.fileTransferController_,
              this.directoryModel_);
          break;
      }
    } catch (error) {
      console.warn(`Failed to execute task ${
          JSON.stringify(task.descriptor)}: ${error}`);
    }
  }

  /**
   * Ensures that the all files are available right now.
   * Must not call before initialization.
   * Resolved when checking is completed and all files are available
   * Rejected/throws if the user cancels the confirmation dialog for downloading
   * in cellular/metered network dialog.
   */
  private async checkAvailability_(): Promise<void> {
    const areAll =
        (entries: Array<Entry|FilesAppEntry>, props: MetadataItem[],
         name: keyof MetadataItem) => {
          let okEntriesNum = 0;
          for (let i = 0; i < entries.length; i++) {
            // If got no properties, we safely assume that item is available.
            if (props[i] && (props[i]![name] || entries[i]?.isDirectory)) {
              okEntriesNum++;
            }
          }
          return okEntriesNum === props.length;
        };

    const containsDriveEntries = this.entries_.some(entry => {
      const volumeInfo = this.volumeManager_.getVolumeInfo(entry);
      return volumeInfo && volumeInfo.volumeType === VolumeType.DRIVE;
    });

    // Availability is not checked for non-Drive files, as availableOffline, nor
    // availableWhenMetered are not exposed for other types of volumes at this
    // moment.
    if (!containsDriveEntries) {
      return;
    }

    const isDriveOffline =
        this.volumeManager_.getDriveConnectionState().type ===
        chrome.fileManagerPrivate.DriveConnectionStateType.OFFLINE;

    if (isDriveOffline) {
      const props = await this.metadataModel_.get(
          this.entries_, ['availableOffline', 'hosted']);
      if (areAll(this.entries_, props, 'availableOffline')) {
        return;
      }
      const msg = props[0]!.hosted ?
          str(this.entries_.length === 1 ? 'HOSTED_OFFLINE_MESSAGE' :
                                           'HOSTED_OFFLINE_MESSAGE_PLURAL') :
          strf(
              this.entries_.length === 1 ? 'OFFLINE_MESSAGE' :
                                           'OFFLINE_MESSAGE_PLURAL',
              str('OFFLINE_COLUMN_LABEL'));

      this.ui_.alertDialog.showHtml(str('OFFLINE_HEADER'), msg);
      const isBulkPinningEnabled =
          !!getStore().getState()?.preferences?.driveFsBulkPinningEnabled;
      for (const entry of this.entries_) {
        recordEnum(
            'DriveOfflineOpen.Unavailable', FileTasks.getViewFileType(entry),
            UMA_INDEX_KNOWN_EXTENSIONS);
        if (isBulkPinningEnabled) {
          recordEnum(
              'GoogleDrive.BulkPinning.OfflineOpen',
              FileTasks.getViewFileType(entry), UMA_INDEX_KNOWN_EXTENSIONS);
        }
      }
      return Promise.reject('drive is offline');
    }

    const isOnMetered = this.volumeManager_.getDriveConnectionState().type ===
        chrome.fileManagerPrivate.DriveConnectionStateType.METERED;

    if (!isOnMetered) {
      return;
    }
    const props = await this.metadataModel_.get(
        this.entries_, ['availableWhenMetered', 'size']);

    if (areAll(this.entries_, props, 'availableWhenMetered')) {
      return;
    }

    let sizeToDownload = 0;
    for (let i = 0; i !== this.entries_.length; i++) {
      if (!props[i]!.availableWhenMetered) {
        sizeToDownload += (props[i]!.size || 0);
      }
    }
    const msg = strf(
        this.entries_.length === 1 ? 'CONFIRM_MOBILE_DATA_USE' :
                                     'CONFIRM_MOBILE_DATA_USE_PLURAL',
        bytesToString(sizeToDownload));
    return new Promise(
        (resolve, reject) => this.ui_.confirmDialog.show(msg, resolve, reject));
  }

  /**
   * Executes an internal task, which is a task Files app handles internally
   * without calling into fileManagerPrivate to execute it.
   */
  private executeInternalTask_(
      descriptor: chrome.fileManagerPrivate.FileTaskDescriptor) {
    const parsedActionId = parseActionId(descriptor.actionId);
    if (parsedActionId === 'mount-archive') {
      this.mountArchives_();
      return;
    }
    if (parsedActionId === 'install-linux-package') {
      this.installLinuxPackageInternal_();
      return;
    }
    if (parsedActionId === 'import-crostini-image') {
      this.importCrostiniImageInternal_();
      return;
    }

    console.error(
        'The specified task is not a valid internal task: ' +
        makeTaskID(descriptor));
  }

  /** Install a Linux Package in the Linux container.  */
  private installLinuxPackageInternal_() {
    assert(this.entries_.length === 1);
    this.ui_.installLinuxPackageDialog.showInstallLinuxPackageDialog(
        this.entries_[0]! as Entry);
  }

  /**
   * Imports a Crostini Image File (.tini). This overrides the existing Linux
   * apps and files.
   */
  private importCrostiniImageInternal_() {
    assert(this.entries_.length === 1);
    this.ui_.importCrostiniImageDialog.showImportCrostiniImageDialog(
        this.entries_[0]! as Entry);
  }

  /**
   * Mounts an archive file. Asks for password and retries if necessary.
   * @param url URL of the archive file to mount.
   */
  private async mountArchive_(url: string): Promise<VolumeInfo> {
    const filename = extractFilePath(url)?.split('/').pop() || '';

    const item = new ProgressCenterItem();
    item.id = 'Mounting: ' + url;
    item.type = ProgressItemType.MOUNT_ARCHIVE;
    item.message = strf('ARCHIVE_MOUNT_MESSAGE', filename);

    item.cancelCallback = async () => {
      // Remove progress panel.
      item.state = ProgressItemState.CANCELED;
      this.progressCenter_.updateItem(item);

      // Cancel archive mounting.
      try {
        await this.volumeManager_.cancelMounting(url);
      } catch (error) {
        console.warn('Cannot cancel archive (redacted):', error);
        console.log(`Cannot cancel archive '${url}':`, error);
      }
    };

    // Display progress panel.
    item.state = ProgressItemState.PROGRESSING;
    this.progressCenter_.updateItem(item);

    // First time, try without providing a password.
    try {
      return await this.volumeManager_.mountArchive(url);
    } catch (error) {
      // If error is not about needing a password, propagate it.
      if (error !== VolumeError.NEED_PASSWORD) {
        throw error;
      }
    } finally {
      // Remove progress panel.
      item.state = ProgressItemState.COMPLETED;
      this.progressCenter_.updateItem(item);
    }

    // We need a password.
    const unlock = await this.mutex_.lock();
    try {
      let password: string|null = null;
      while (true) {
        // Ask for password.
        do {
          const dialog = this.ui_.passwordDialog as XfPasswordDialog;
          password = await dialog.askForPassword(filename, password);
        } while (!password);

        // Display progress panel.
        item.state = ProgressItemState.PROGRESSING;
        this.progressCenter_.updateItem(item);

        // Mount archive with password.
        try {
          return await this.volumeManager_.mountArchive(url, password);
        } catch (error) {
          // If error is not about needing a password, propagate it.
          if (error !== VolumeError.NEED_PASSWORD) {
            throw error;
          }
        } finally {
          // Remove progress panel.
          item.state = ProgressItemState.COMPLETED;
          this.progressCenter_.updateItem(item);
        }
      }
    } finally {
      unlock();
    }
  }

  /**
   * Mounts an archive file and changes directory. Asks for password if
   * necessary. Displays error message if necessary.
   * @param url URL of the archive file to moumt.
   * @return a promise that is never rejected.
   */
  private async mountArchiveAndChangeDirectory_(
      tracker: DirectoryChangeTracker, url: string): Promise<void> {
    try {
      const startTime = Date.now();
      const volumeInfo = await this.mountArchive_(url);
      // On mountArchive_ success, record mount time UMA.
      FileTasks.recordZipMountTimeUma_(
          this.directoryModel_.getCurrentRootType(), Date.now() - startTime);

      if (tracker.hasChanged) {
        return;
      }

      try {
        const displayRoot = await volumeInfo.resolveDisplayRoot();
        if (tracker.hasChanged) {
          return;
        }

        this.directoryModel_.changeDirectoryEntry(displayRoot);
      } catch (error) {
        console.error('Cannot resolve display root after mounting:', error);
      }
    } catch (error) {
      // No need to display an error message if user canceled mounting or
      // canceled the password prompt.
      if (error === USER_CANCELLED || error === VolumeError.CANCELLED) {
        return;
      }

      const filename = extractFilePath(url)?.split('/').pop() || '';
      const item = new ProgressCenterItem();
      item.id = 'Cannot mount: ' + url;
      item.type = ProgressItemType.MOUNT_ARCHIVE;
      item.message = strf('ARCHIVE_MOUNT_FAILED', filename);
      item.state = ProgressItemState.ERROR;
      this.progressCenter_.updateItem(item);

      console.warn('Cannot mount (redacted):', error);
      console.debug(`Cannot mount '${url}':`, error);
    }
  }

  /** Mounts the selected archive(s). Asks for password if necessary. */
  private async mountArchives_() {
    const tracker = this.directoryModel_.createDirectoryChangeTracker();
    tracker.start();
    try {
      // TODO(mtomasz): Move conversion from entry to url to custom bindings.
      // crbug.com/345527.
      const urls = entriesToURLs(this.entries_);
      const promises =
          urls.map(url => this.mountArchiveAndChangeDirectory_(tracker, url));
      await Promise.all(promises);
    } finally {
      tracker.stop();
    }
  }

  /**
   * Shows modal task picker dialog with currently available list of tasks.
   *
   * @param taskDialog Task dialog to show and update.
   * @param onSuccess Callback to pass selected task.
   * @param pickerType Task picker type.
   */
  showTaskPicker(
      taskDialog: DefaultTaskDialog, title: string, message: string,
      onSuccess: (task: chrome.fileManagerPrivate.FileTask) => void,
      pickerType: TypeTaskPickerType) {
    let items = this.taskController_.createItems(this);
    if (pickerType === TaskPickerType.ChangeDefault) {
      items = items.filter(item => !item.isGenericFileHandler);
    }

    let defaultIdx = 0;
    if (this.defaultTask_) {
      for (let j = 0; j < items.length; j++) {
        if (descriptorEqual(
                items[j]!.task!.descriptor, this.defaultTask_.descriptor)) {
          defaultIdx = j;
        }
      }
    }

    taskDialog.showDefaultTaskDialog(
        title, message, items, defaultIdx, (item: DropdownItem) => {
          onSuccess(item.task!);
        });
  }

  private static async getPvmSharedDir_(volumeManager: VolumeManager):
      Promise<DirectoryEntry> {
    const volumeInfo =
        volumeManager.getCurrentProfileVolumeInfo(VolumeType.DOWNLOADS);
    if (!volumeInfo) {
      throw new Error(`Error getting PvmDefault dir`);
    }
    return await getDirectory(
        volumeInfo.fileSystem.root, 'PvmDefault', {create: false});
  }
}

/** Dialog types to show a task picker. */
export const TaskPickerType = {
  ChangeDefault: 'ChangeDefault',
  OpenWith: 'OpenWith',
} as const;
type TypeTaskPickerType = typeof TaskPickerType[keyof typeof TaskPickerType];

/** Office file extensions. */
const OFFICE_EXTENSIONS =
    new Set(['.doc', '.docx', '.xls', 'xlsm', '.xlsx', '.ppt', '.pptx']);

function hasOfficeExtension(entry: Entry|FilesAppEntry): boolean {
  return OFFICE_EXTENSIONS.has(getExtension(entry));
}

function isCrostiniEntry(
    entry: Entry|FilesAppEntry, volumeManager: VolumeManager): boolean {
  const location = volumeManager.getLocationInfo(entry);
  return !!location && location.rootType === RootType.CROSTINI;
}

function isMyFilesEntry(
    entry: Entry|FilesAppEntry, volumeManager: VolumeManager): boolean {
  const location = volumeManager.getLocationInfo(entry);
  return !!location && location.rootType === RootType.DOWNLOADS;
}