chromium/ui/file_manager/file_manager/background/js/volume_manager.ts

// Copyright 2016 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/js/assert.js';

import {getRootType, isComputersRoot, isFakeEntry, isOneDrivePlaceholder, isSameEntry, isSameFileSystem, isTeamDriveRoot} from '../../common/js/entry_utils.js';
import type {FilesAppDirEntry, FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {type CustomEventMap, FilesEventTarget} from '../../common/js/files_event_target.js';
import {str} from '../../common/js/translations.js';
import {promisify, timeoutPromise} from '../../common/js/util.js';
import type {FileSystemType, Source} from '../../common/js/volume_manager_types.js';
import {COMPUTERS_DIRECTORY_PATH, getMediaViewRootTypeFromVolumeId, getRootTypeFromVolumeType, MediaViewRootType, RootType, SHARED_DRIVES_DIRECTORY_PATH, VolumeError, VolumeType} from '../../common/js/volume_manager_types.js';
import {addVolume, removeVolume} from '../../state/ducks/volumes.js';
import {getStore} from '../../state/store.js';

import {EntryLocation} from './entry_location_impl.js';
import {VolumeInfo} from './volume_info.js';
import {VolumeInfoList} from './volume_info_list.js';

/**
 * Time in milliseconds that we wait a response for general volume operations
 * such as mount, unmount, and requestFileSystem. If no response on
 * mount/unmount received the request supposed failed.
 */
const TIMEOUT = 15 * 60 * 1000;

const TIMEOUT_STR_REQUEST_FILE_SYSTEM = 'timeout(requestFileSystem)';

/**
 * A list of RequestType
 */
enum RequestType {
  MOUNT = 'mount',
  UNMOUNT = 'unmount',
}

/**
 * Logs a warning message if the given error is not in
 * VolumeError.
 *
 * @param error Status string usually received from APIs.
 */
function validateError(error: string) {
  const found = Object.values(VolumeError).find(value => value === error);
  if (found) {
    return;
  }

  console.warn(`Invalid mount error: ${error}`);
}

/**
 * Builds the VolumeInfo data from chrome.fileManagerPrivate.VolumeMetadata.
 * @param volumeMetadata Metadata instance for the volume.
 * @return Promise settled with the VolumeInfo instance.
 */
export async function createVolumeInfo(
    volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata):
    Promise<VolumeInfo> {
  let localizedLabel: string;
  switch (volumeMetadata.volumeType) {
    case VolumeType.DOWNLOADS:
      localizedLabel = str('MY_FILES_ROOT_LABEL');
      break;
    case VolumeType.DRIVE:
      localizedLabel = str('DRIVE_DIRECTORY_LABEL');
      break;
    case VolumeType.MEDIA_VIEW:
      switch (getMediaViewRootTypeFromVolumeId(volumeMetadata.volumeId)) {
        case MediaViewRootType.IMAGES:
          localizedLabel = str('MEDIA_VIEW_IMAGES_ROOT_LABEL');
          break;
        case MediaViewRootType.VIDEOS:
          localizedLabel = str('MEDIA_VIEW_VIDEOS_ROOT_LABEL');
          break;
        case MediaViewRootType.AUDIO:
          localizedLabel = str('MEDIA_VIEW_AUDIO_ROOT_LABEL');
          break;
      }
      break;
    case VolumeType.CROSTINI:
      localizedLabel = str('LINUX_FILES_ROOT_LABEL');
      break;
    case VolumeType.ANDROID_FILES:
      localizedLabel = str('ANDROID_FILES_ROOT_LABEL');
      break;
    default:
      // TODO(mtomasz): Calculate volumeLabel for all types of volumes in the
      // C++ layer.
      localizedLabel = volumeMetadata.volumeLabel ||
          volumeMetadata.volumeId.split(':', 2)[1]!;
      break;
  }

  console.debug(`Getting file system '${volumeMetadata.volumeId}'`);
  return timeoutPromise(
             new Promise<DirectoryEntry>((resolve, reject) => {
               chrome.fileManagerPrivate.getVolumeRoot(
                   {
                     volumeId: volumeMetadata.volumeId,
                     writable: !volumeMetadata.isReadOnly,
                   },
                   (rootDirectoryEntry: DirectoryEntry) => {
                     if (chrome.runtime.lastError) {
                       reject(chrome.runtime.lastError.message);
                     } else {
                       resolve(rootDirectoryEntry);
                     }
                   });
             }),
             TIMEOUT,
             TIMEOUT_STR_REQUEST_FILE_SYSTEM + ': ' + volumeMetadata.volumeId)
      .then(rootDirectoryEntry => {
        console.debug(`Got file system '${volumeMetadata.volumeId}'`);
        return new VolumeInfo(
            volumeMetadata.volumeType as VolumeType, volumeMetadata.volumeId,
            rootDirectoryEntry.filesystem, volumeMetadata.mountCondition,
            volumeMetadata.deviceType, volumeMetadata.devicePath,
            volumeMetadata.isReadOnly, volumeMetadata.isReadOnlyRemovableDevice,
            volumeMetadata.profile, localizedLabel, volumeMetadata.providerId,
            volumeMetadata.configurable, volumeMetadata.watchable,
            volumeMetadata.source as Source,
            volumeMetadata.diskFileSystemType as FileSystemType,
            volumeMetadata.iconSet, volumeMetadata.driveLabel,
            volumeMetadata.remoteMountPath, volumeMetadata.vmType);
      })
      .then(async (volumeInfo) => {
        // resolveDisplayRoot() is a promise, but instead of using await here,
        // we just pass a onSuccess function to it, because we don't want to it
        // to interfere the startup time.
        volumeInfo.resolveDisplayRoot(() => {
          getStore().dispatch(addVolume(volumeInfo, volumeMetadata));
        });
        return volumeInfo;
      })
      .catch(error => {
        console.warn(`Cannot mount file system '${volumeMetadata.volumeId}': ${
            error.stack || error}`);

        // TODO(crbug.com/41391739): Report a mount error via UMA.

        throw error;
      });
}


type RequestSuccessCallback = (volumeInfo?: VolumeInfo) => void;
type RequestErrorCallback = (error: VolumeError) => void;
interface Request {
  successCallbacks: RequestSuccessCallback[];
  errorCallbacks: RequestErrorCallback[];
  timeout: number;
}

export type DeviceConnectionChangedEvent = CustomEvent<undefined>&{
  type: 'drive-connection-changed',
};

/**
 * An event triggered when a user tries to mount the volume which is
 * already mounted. The event object must have a volumeId property.
 */
export type VolumeAlreadyMountedEvent = CustomEvent<{
  volumeId: string,
}>&{
  type: 'volume_already_mounted',
};

/**
 * An event triggered when an archive file is newly mounted, or when opened a
 * one already mounted.
 */
export type ArchiveOpenEvent = CustomEvent<{
  mountPoint: DirectoryEntry,
}>&{
  type: 'archive_opened',
};

/**
 * Event object which is dispatched with 'externally-unmounted' event.
 */
export type ExternallyUnmountedEvent = CustomEvent<VolumeInfo>&{
  type: 'externally-unmounted',
};

export interface VolumeManagerEventMap extends CustomEventMap {
  'drive-connection-changed': DeviceConnectionChangedEvent;
  'volume_already_mounted': VolumeAlreadyMountedEvent;
  'archive_opened': ArchiveOpenEvent;
  'externally-unmounted': ExternallyUnmountedEvent;
}

/**
 * VolumeManager is responsible for tracking list of mounted volumes.
 */
export class VolumeManager extends FilesEventTarget<VolumeManagerEventMap> {
  /**
   * The list of VolumeInfo instances for each mounted volume.
   */
  volumeInfoList = new VolumeInfoList();

  /**
   * The list of archives requested to mount. We will show contents once
   * archive is mounted, but only for mounts from within this filebrowser tab.
   */
  private requests_: Record<string, Request> = {};

  // The status should be merged into VolumeManager.
  // TODO(hidehiko): Remove them after the migration.
  /**
   * Connection state of the Drive.
   */
  private driveConnectionState_:
      chrome.fileManagerPrivate.DriveConnectionState = {
    type: chrome.fileManagerPrivate.DriveConnectionStateType.OFFLINE,
    reason: chrome.fileManagerPrivate.DriveOfflineReason.NO_SERVICE,
  };

  /**
   * Holds the resolver for the `waitForInitialization_` promise.
   */
  private finishInitialization_: (() => void)|null = null;

  /**
   * Promise used to wait for the initialize() method to finish.
   */
  private waitForInitialization_: Promise<void> =
      new Promise(resolve => this.finishInitialization_ = resolve);

  constructor(
      private createVolumeInfo_: typeof createVolumeInfo = createVolumeInfo) {
    super();

    chrome.fileManagerPrivate.onDriveConnectionStatusChanged.addListener(
        this.onDriveConnectionStatusChanged_.bind(this));
    this.onDriveConnectionStatusChanged_();

    // Subscribe to mount event as early as possible, but after the
    // waitForInitialization_ above.
    chrome.fileManagerPrivate.onMountCompleted.addListener(
        this.onMountCompleted_.bind(this));
  }

  /**
   * Gets the 'fusebox-only' filter state: true if enabled, false if disabled.
   * The filter is only enabled by the SelectFileAsh (Lacros) file picker, and
   * implemented by {FilteredVolumeManager} override.
   */
  getFuseBoxOnlyFilterEnabled(): boolean {
    return false;
  }

  /**
   * Gets the 'media-store-files-only' filter state: true if enabled, false if
   * disabled. The filter is only enabled by the Android (ARC) file picker, and
   * implemented by {FilteredVolumeManager} override.
   */
  getMediaStoreFilesOnlyFilterEnabled(): boolean {
    return false;
  }

  /**
   * Disposes the instance. After the invocation of this method, any other
   * method should not be called.
   */
  dispose(): void {}

  /**
   * Invoked when the drive connection status is changed.
   */
  private onDriveConnectionStatusChanged_() {
    chrome.fileManagerPrivate.getDriveConnectionState(state => {
      this.driveConnectionState_ = state;
      this.dispatchEvent(new CustomEvent('drive-connection-changed'));
    });
  }

  /**
   * Returns the drive connection state.
   */
  getDriveConnectionState(): chrome.fileManagerPrivate.DriveConnectionState {
    return this.driveConnectionState_;
  }

  /**
   * Adds new volume info from the given volumeMetadata. If the corresponding
   * volume info has already been added, the volumeMetadata is ignored.
   */
  private addVolumeInfo_(volumeInfo: VolumeInfo): VolumeInfo {
    const volumeType = volumeInfo.volumeType as VolumeType;
    if (this.volumeInfoList.findIndex(volumeInfo.volumeId) === -1) {
      this.volumeInfoList.add(volumeInfo);

      // Update the network connection status, because until the drive
      // is initialized, the status is set to not ready.
      // TODO(mtomasz): The connection status should be migrated into
      // chrome.fileManagerPrivate.VolumeMetadata.
      if (volumeType === VolumeType.DRIVE) {
        this.onDriveConnectionStatusChanged_();
      }
    } else if (volumeType === VolumeType.REMOVABLE) {
      // Update for remounted USB external storage, because they were
      // remounted to switch read-only policy.
      this.volumeInfoList.add(volumeInfo);
    }

    return volumeInfo;
  }

  /**
   * Initializes mount points.
   */
  async initialize(): Promise<void> {
    let finished = false;
    /**
     * Resolves the initialization promise to unblock any code awaiting for
     * it.
     */
    const finishInitialization = () => {
      if (finished) {
        return;
      }
      finished = true;
      console.warn('Volumes initialization finished');
      if (this.finishInitialization_) {
        this.finishInitialization_();
      }
    };

    try {
      console.warn('Getting volumes');
      let volumeMetadataList: chrome.fileManagerPrivate.VolumeMetadata[] =
          await promisify(chrome.fileManagerPrivate.getVolumeMetadataList);
      if (!volumeMetadataList) {
        console.warn('Cannot get volumes');
        finishInitialization();
        return;
      }
      volumeMetadataList = volumeMetadataList.filter(volume => !volume.hidden);
      console.debug(`There are ${volumeMetadataList.length} volumes`);

      let counter = 0;

      // Create VolumeInfo for each volume.
      volumeMetadataList.map(async (volumeMetadata, idx) => {
        const volumeId = volumeMetadata.volumeId;
        let volumeInfo = null;
        try {
          console.debug(`Initializing volume #${idx} '${volumeId}'`);
          // createVolumeInfo() requests the filesystem and resolve its root,
          // after that it only creates a VolumeInfo.
          volumeInfo = await this.createVolumeInfo_(volumeMetadata);
          // Add addVolumeInfo_() changes the VolumeInfoList which propagates
          // to the foreground.
          this.addVolumeInfo_(volumeInfo);
          console.debug(`Initialized volume #${idx} ${volumeId}'`);
        } catch (error) {
          console.warn(`Error initializing #${idx} ${volumeId}: ${error}`);
        } finally {
          counter += 1;
          // Finish after all volumes have been processed, or at least Downloads
          // or Drive.
          const isDriveOrDownloads = volumeInfo &&
              (volumeInfo.volumeType === VolumeType.DOWNLOADS ||
               volumeInfo.volumeType === VolumeType.DRIVE);
          if (counter === volumeMetadataList.length || isDriveOrDownloads) {
            finishInitialization();
          }
        }
      });

      // At this point the volumes are still initializing.
      console.warn(
          `Queued the initialization of all ` +
          `${volumeMetadataList.length} volumes`);

      if (volumeMetadataList.length === 0) {
        finishInitialization();
      }
    } catch (error) {
      finishInitialization();
      throw error;
    }
  }

  /**
   * Event handler called when some volume was mounted or unmounted.
   */
  private async onMountCompleted_(
      event: chrome.fileManagerPrivate.MountCompletedEvent) {
    // Wait for the initialization to guarantee that the initialize() runs for
    // some volumes before any mount event, because the mounted volume can be
    // unresponsive, getting stuck when resolving the root in the method
    // createVolumeInfo(). crbug.com/504366
    await this.waitForInitialization_;

    const {eventType, status, volumeMetadata} = event;
    const {sourcePath = '', volumeId} = volumeMetadata;
    const volumeError = status as string as VolumeError;

    switch (eventType) {
      case 'mount': {
        const requestKey = this.makeRequestKey_(RequestType.MOUNT, sourcePath);

        switch (volumeError) {
          case VolumeError.SUCCESS:
          case VolumeError.UNKNOWN_FILESYSTEM:
          case VolumeError.UNSUPPORTED_FILESYSTEM: {
            console.debug(`Mounted '${sourcePath}' as '${volumeId}'`);
            if (volumeMetadata.hidden) {
              console.debug(`Mount discarded for hidden volume: '${volumeId}'`);
              this.finishRequest_(requestKey, volumeError);
              return;
            }

            let volumeInfo;
            try {
              volumeInfo = await this.createVolumeInfo_(volumeMetadata);
            } catch (error: any) {
              console.warn(
                  'Unable to create volumeInfo for ' +
                  `${volumeId} mounted on ${sourcePath}.` +
                  `Mount status: ${volumeError}. Error: ${
                      error.stack || error}.`);
              this.finishRequest_(requestKey, volumeError);
              return;
            }
            this.addVolumeInfo_(volumeInfo);
            this.finishRequest_(requestKey, volumeError, volumeInfo);
            return;
          }

          case VolumeError.PATH_ALREADY_MOUNTED: {
            console.warn(
                `Cannot mount (redacted): Already mounted as '${volumeId}'`);
            console.debug(`Cannot mount '${sourcePath}': Already mounted as '${
                volumeId}'`);
            const navigationEvent =
                new CustomEvent('volume_already_mounted', {detail: {volumeId}});
            this.dispatchEvent(navigationEvent);
            this.finishRequest_(requestKey, volumeError);
            return;
          }

          case VolumeError.NEED_PASSWORD:
          case VolumeError.CANCELLED:
          default:
            console.warn('Cannot mount (redacted):', volumeError);
            console.debug(`Cannot mount '${sourcePath}':`, volumeError);
            this.finishRequest_(requestKey, volumeError);
            return;
        }
      }

      case 'unmount': {
        const requestKey = this.makeRequestKey_(RequestType.UNMOUNT, volumeId);
        const volumeInfoIndex = this.volumeInfoList.findIndex(volumeId);
        const volumeInfo = volumeInfoIndex !== -1 ?
            this.volumeInfoList.item(volumeInfoIndex) :
            null;

        switch (volumeError) {
          case VolumeError.SUCCESS: {
            const requested = requestKey in this.requests_;
            if (!requested && volumeInfo) {
              console.debug(`Unmounted '${volumeId}' without request`);
              this.dispatchEvent(new CustomEvent(
                  'externally-unmounted', {detail: volumeInfo}));
            } else {
              console.debug(`Unmounted '${volumeId}'`);
            }
            getStore().dispatch(removeVolume(volumeId));
            this.volumeInfoList.remove(volumeId);
            this.finishRequest_(requestKey, volumeError);
            return;
          }

          default:
            console.warn('Cannot unmount (redacted):', volumeError);
            console.debug(`Cannot unmount '${volumeId}':`, volumeError);
            this.finishRequest_(requestKey, volumeError);
            return;
        }
      }
    }
  }

  /**
   * Creates string to match mount events with requests.
   * @param requestType 'mount' | 'unmount'.
   * @param argument Argument describing the request, eg. source file
   *     path of the archive to be mounted, or a volumeId for unmounting.
   * @return Key for |this.requests_|.
   */
  private makeRequestKey_(requestType: RequestType, argument: string): string {
    return requestType + ':' + argument;
  }

  /**
   * @param fileUrl File url to the archive file.
   * @param password Password to decrypt archive file.
   * @return Fulfilled on success, otherwise rejected with a VolumeError.
   */
  async mountArchive(fileUrl: string, password?: string): Promise<VolumeInfo> {
    const path: string =
        await promisify(chrome.fileManagerPrivate.addMount, fileUrl, password);
    console.debug(`Mounting '${path}'`);
    const key = this.makeRequestKey_(RequestType.MOUNT, path);
    return this.startRequest_(key);
  }

  /**
   * Cancels mounting an archive.
   * @param fileUrl File url to the archive file.
   * @return Fulfilled on success, otherwise rejected with a VolumeError.
   */
  async cancelMounting(fileUrl: string): Promise<void> {
    console.debug(`Cancelling mounting archive at '${fileUrl}'`);
    return promisify(chrome.fileManagerPrivate.cancelMounting, fileUrl);
  }

  /**
   * Unmounts a volume.
   * @param volumeInfo Volume to be unmounted.
   * @return Fulfilled on success, otherwise rejected with a VolumeError.
   */
  async unmount({volumeId}: VolumeInfo): Promise<void> {
    console.debug(`Unmounting '${volumeId}'`);
    const key = this.makeRequestKey_(RequestType.UNMOUNT, volumeId);
    const request = this.startRequest_(key);
    await promisify(chrome.fileManagerPrivate.removeMount, volumeId);
    await request;
  }


  /**
   * Configures a volume.
   * @param volumeInfo Volume to be configured.
   * @return Fulfilled on success, otherwise rejected with an error message.
   */
  configure(volumeInfo: VolumeInfo): Promise<void> {
    return promisify(
        chrome.fileManagerPrivate.configureVolume, volumeInfo.volumeId);
  }

  /**
   * Obtains a volume info containing the passed entry.
   * @param entry Entry on the volume to be returned. Can be fake.
   */
  getVolumeInfo(entry: Entry|FilesAppEntry): VolumeInfo|null {
    if (!entry) {
      console.warn(`Invalid entry passed to getVolumeInfo: ${entry}`);
      return null;
    }

    for (let i = 0; i < this.volumeInfoList.length; i++) {
      const volumeInfo = this.volumeInfoList.item(i);
      if (volumeInfo.fileSystem &&
          isSameFileSystem(volumeInfo.fileSystem, entry.filesystem)) {
        return volumeInfo;
      }
      // Additionally, check fake entries.
      for (const fakeEntry of Object.values(volumeInfo.fakeEntries)) {
        if (isSameEntry(fakeEntry, entry)) {
          return volumeInfo;
        }
      }
    }

    return null;
  }

  /**
   * Obtains volume information of the current profile.
   */
  getCurrentProfileVolumeInfo(volumeType: VolumeType): VolumeInfo|null {
    for (let i = 0; i < this.volumeInfoList.length; i++) {
      const volumeInfo = this.volumeInfoList.item(i);
      if (volumeInfo.profile.isCurrentProfile &&
          volumeInfo.volumeType === volumeType) {
        return volumeInfo;
      }
    }
    return null;
  }

  /**
   * Obtains location information from an entry.
   * @param entry File or directory entry. It can be a fake entry.
   */
  getLocationInfo(entry: Entry|FilesAppEntry): EntryLocation|null {
    if (!entry) {
      console.warn(`Invalid entry passed to getLocationInfo: ${entry}`);
      return null;
    }

    const volumeInfo = this.getVolumeInfo(entry);

    if (isFakeEntry(entry)) {
      const rootType = getRootType(entry);
      assert(rootType);

      // Aggregated views like RECENTS and TRASH exist as fake entries but may
      // actually defer their logic to some underlying implementation or
      // delegate to the location filesystem.
      let isReadOnly = true;
      if (rootType === RootType.RECENT || rootType === RootType.TRASH ||
          (isOneDrivePlaceholder(entry))) {
        isReadOnly = false;
      }
      return new EntryLocation(
          volumeInfo, rootType, true /* The entry points a root directory. */,
          isReadOnly);
    }

    if (!volumeInfo) {
      return null;
    }

    let rootType;
    let isReadOnly;
    let isRootEntry;
    if (volumeInfo.volumeType === VolumeType.DRIVE) {
      // For Drive, the roots are /root, /team_drives, /Computers and /other,
      // instead of /. Root URLs contain trailing slashes.
      if (entry.fullPath === '/root' ||
          entry.fullPath.indexOf('/root/') === 0) {
        rootType = RootType.DRIVE;
        isReadOnly = volumeInfo.isReadOnly;
        isRootEntry = entry.fullPath === '/root';
      } else if (
          entry.fullPath === SHARED_DRIVES_DIRECTORY_PATH ||
          entry.fullPath.indexOf(SHARED_DRIVES_DIRECTORY_PATH + '/') === 0) {
        if (entry.fullPath === SHARED_DRIVES_DIRECTORY_PATH) {
          rootType = RootType.SHARED_DRIVES_GRAND_ROOT;
          isReadOnly = true;
          isRootEntry = true;
        } else {
          rootType = RootType.SHARED_DRIVE;
          if (isTeamDriveRoot(entry)) {
            isReadOnly = false;
            isRootEntry = true;
          } else {
            // Regular files/directories under Shared Drives.
            isRootEntry = false;
            isReadOnly = volumeInfo.isReadOnly;
          }
        }
      } else if (
          entry.fullPath === COMPUTERS_DIRECTORY_PATH ||
          entry.fullPath.indexOf(COMPUTERS_DIRECTORY_PATH + '/') === 0) {
        if (entry.fullPath === COMPUTERS_DIRECTORY_PATH) {
          rootType = RootType.COMPUTERS_GRAND_ROOT;
          isReadOnly = true;
          isRootEntry = true;
        } else {
          rootType = RootType.COMPUTER;
          if (isComputersRoot(entry)) {
            isReadOnly = true;
            isRootEntry = true;
          } else {
            // Regular files/directories under a Computer entry.
            isRootEntry = false;
            isReadOnly = volumeInfo.isReadOnly;
          }
        }
      } else if (
          entry.fullPath === '/.files-by-id' ||
          entry.fullPath.indexOf('/.files-by-id/') === 0) {
        rootType = RootType.DRIVE_SHARED_WITH_ME;

        // /.files-by-id/<id> is read-only, but /.files-by-id/<id>/foo is
        // read-write.
        isReadOnly = entry.fullPath.split('/').length < 4;
        isRootEntry = entry.fullPath === '/.files-by-id';
      } else if (
          entry.fullPath === '/.shortcut-targets-by-id' ||
          entry.fullPath.indexOf('/.shortcut-targets-by-id/') === 0) {
        rootType = RootType.DRIVE_SHARED_WITH_ME;

        // /.shortcut-targets-by-id/<id> is read-only, but
        // /.shortcut-targets-by-id/<id>/foo is read-write.
        isReadOnly = entry.fullPath.split('/').length < 4;
        isRootEntry = entry.fullPath === '/.shortcut-targets-by-id';
      } else if (
          entry.fullPath === '/.Trash-1000' ||
          entry.fullPath.indexOf('/.Trash-1000/') === 0) {
        // Drive uses "$topdir/.Trash-$uid" as the trash dir as per XDG spec.
        // User chronos is always uid 1000.
        rootType = RootType.TRASH;
        isReadOnly = false;
        isRootEntry = entry.fullPath === '/.Trash-1000';
      } else {
        // Accessing Drive files outside of /drive/root and /drive/other is not
        // allowed, but can happen. Therefore returning null.
        return null;
      }
    } else {
      assert(volumeInfo.volumeType);
      rootType = getRootTypeFromVolumeType(volumeInfo.volumeType);
      isRootEntry = isSameEntry(entry, volumeInfo.fileSystem.root);
      // Although "Play files" root directory is writable in file system level,
      // we prohibit write operations on it in the UI level to avoid confusion.
      // Users can still have write access in sub directories like
      // /Play files/Pictures, /Play files/DCIM, etc...
      if (volumeInfo.volumeType === VolumeType.ANDROID_FILES && isRootEntry) {
        isReadOnly = true;
      } else {
        isReadOnly = volumeInfo.isReadOnly;
      }
    }

    return new EntryLocation(volumeInfo, rootType, isRootEntry, isReadOnly);
  }


  /**
   * Searches the information of the volume that exists on the given device
   * path.
   * @param devicePath Path of the device to search.
   * @return The volume's information, or null if not found.
   */
  findByDevicePath(devicePath: string): VolumeInfo|null {
    for (let i = 0; i < this.volumeInfoList.length; i++) {
      const volumeInfo = this.volumeInfoList.item(i);
      if (volumeInfo.devicePath && volumeInfo.devicePath === devicePath) {
        return volumeInfo;
      }
    }
    return null;
  }

  /**
   * Returns a promise that will be resolved when volume info, identified by
   * `volumeId` is created.
   * @return Resolved with the `VolumeInfo`. It won't resolve if the volume is
   *     never mounted.
   */
  whenVolumeInfoReady(volumeId: string): Promise<VolumeInfo> {
    return new Promise((fulfill) => {
      const handler = () => {
        const index = this.volumeInfoList.findIndex(volumeId);
        if (index !== -1) {
          fulfill(this.volumeInfoList.item(index));
          this.volumeInfoList.removeEventListener('splice', handler);
        }
      };
      this.volumeInfoList.addEventListener('splice', handler);
      handler();
    });
  }

  /**
   * Obtains the default display root entry.
   * @returns Default display root promise, fulfilled when resolved
   *     successfully.
   */
  async getDefaultDisplayRoot(): Promise<DirectoryEntry|FilesAppDirEntry|null> {
    console.warn('Unexpected call to VolumeManager.getDefaultDisplayRoot');
    return null;
  }

  /**
   * @param key Key produced by |makeRequestKey_|.
   * @return Fulfilled on success, otherwise rejected with a
   *     VolumeError.
   */
  private startRequest_(key: string): Promise<VolumeInfo> {
    return new Promise((successCallback, errorCallback) => {
      if (key in this.requests_) {
        const request = this.requests_[key]!;
        request.successCallbacks.push(
            successCallback as RequestSuccessCallback);
        request.errorCallbacks.push(errorCallback);
      } else {
        this.requests_[key] = {
          successCallbacks: [successCallback as RequestSuccessCallback],
          errorCallbacks: [errorCallback],

          timeout: setTimeout(this.onTimeout_.bind(this, key), TIMEOUT),
        };
      }
    });
  }

  /**
   * Called if no response received in |TIMEOUT|.
   * @param key Key produced by |makeRequestKey_|.
   */
  private onTimeout_(key: string) {
    this.invokeRequestCallbacks_(this.requests_[key]!, VolumeError.TIMEOUT);
    delete this.requests_[key];
  }

  /**
   * @param key Key produced by |makeRequestKey_|.
   * @param status Status received from the API.
   * @param volumeInfo Volume info of the mounted volume.
   */
  private finishRequest_(
      key: string, status: VolumeError, volumeInfo?: VolumeInfo) {
    const request = this.requests_[key];
    if (!request) {
      return;
    }

    clearTimeout(request.timeout);
    this.invokeRequestCallbacks_(request, status, volumeInfo);
    delete this.requests_[key];
  }

  /**
   * @param request Structure created in |startRequest_|.
   * @param status If status === 'success' success callbacks are called.
   * @param volumeInfo Volume info of the mounted volume.
   */
  private invokeRequestCallbacks_(
      request: Request, status: VolumeError, volumeInfo?: VolumeInfo) {
    if (status === VolumeError.SUCCESS) {
      request.successCallbacks.map(cb => cb(volumeInfo));

    } else {
      validateError(status);
      request.errorCallbacks.map(cb => cb(status));
    }
  }

  /**
   * Checks if any volumes are disabled for selection.
   * See overridden implementation in `FilteredVolumeManager`.
   */
  hasDisabledVolumes(): boolean {
    return false;
  }

  /**
   * Checks whether the given volume is disabled for selection.
   * See overridden implementation in `FilteredVolumeManager`.
   * @param volume Volume to check.
   */
  isDisabled(_volume: VolumeType): boolean {
    return false;
  }

  /**
   * Checks if a volume is allowed.
   * See overridden implementation in `FilteredVolumeManager`.
   */
  isAllowedVolume(_volumeInfo: VolumeInfo): boolean {
    return true;
  }
}