chromium/ui/file_manager/file_manager/foreground/js/directory_contents.ts

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

import {dispatchSimpleEvent} from 'chrome://resources/ash/common/cr_deprecated.js';
import {NativeEventTarget as EventTarget} from 'chrome://resources/ash/common/event_target.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import {mountGuest} from '../../common/js/api.js';
import {AsyncQueue, ConcurrentQueue} from '../../common/js/async_util.js';
import {createDOMError} from '../../common/js/dom_utils.js';
import {isDriveRootType, isFakeEntry, isTrashEntry, readEntriesRecursively} from '../../common/js/entry_utils.js';
import {isType} from '../../common/js/file_type.js';
import type {EntryList, UniversalDirectory, UniversalEntry} from '../../common/js/files_app_entry_types.js';
import {type CustomEventMap, FilesEventTarget} from '../../common/js/files_event_target.js';
import {recordInterval, recordMediumCount, startInterval} from '../../common/js/metrics.js';
import {getEarliestTimestamp} from '../../common/js/recent_date_bucket.js';
import {createTrashReaders, TRASH_CONFIG} from '../../common/js/trash.js';
import {FileErrorToDomError} from '../../common/js/util.js';
import {RootType, VolumeType} from '../../common/js/volume_manager_types.js';
import {directoryContentSelector, fetchDirectoryContents} from '../../state/ducks/current_directory.js';
import {getDefaultSearchOptions} from '../../state/ducks/search.js';
import {type DirectoryContent, type FileKey, PropStatus, SearchLocation, type SearchOptions} from '../../state/state.js';
import {getFileData, getStore, type Store} from '../../state/store.js';

import {ACTIONS_MODEL_METADATA_PREFETCH_PROPERTY_NAMES, CROSTINI_CONNECT_ERR, DLP_METADATA_PREFETCH_PROPERTY_NAMES, FILE_SELECTION_METADATA_PREFETCH_PROPERTY_NAMES, LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES} from './constants.js';
import {FileListModel} from './file_list_model.js';
import type {MetadataItem} from './metadata/metadata_item.js';
import {type MetadataKey} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';

// Common callback types used by content scanners.
type ScanResultCallback = (entries: UniversalEntry[]) => void;
type ScanErrorCallback = (error: DOMError) => void;

/**
 * Scanner of the entries.
 */
export abstract class ContentScanner {
  protected canceled_: boolean = false;

  /**
   * Starts to scan the entries. Any entries discovered during the scanning
   * operation should be delivered on the `entriesCallback`. This callback can
   * be called multiple times. Once the scanning completed successfully, it must
   * call the `successCallback`. If the scanning encountered an error it must
   * call the `errorCallback`. The last parameter tells the scanner if it must
   * invalidate any cached results before scanning.
   */
  abstract scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback,
      invalidateCache?: boolean): Promise<void>;

  /**
   * Request cancelling of the running scan. When the cancelling is done,
   * an error will be reported from errorCallback passed to scan().
   */
  cancel() {
    this.canceled_ = true;
  }

  /**
   * Whether the scanner pushes the entry directly to the store.
   */
  isStoreBased(): boolean {
    return false;
  }
}

/**
 * No-op class to be used for fake entries and such.
 */
export class EmptyContentScanner extends ContentScanner {
  /**
   * A dummy implementation of the scan method. It delivers an empty list of
   * entries on the `entriesCallback` and immediately calls the
   * `successCallback`.
   */
  override scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      _errorCallback: ScanErrorCallback,
      _invalidateCache?: boolean): Promise<void> {
    entriesCallback([]);
    successCallback();
    return Promise.resolve();
  }
}

/**
 * Scanner of the entries in a directory.
 */
export class DirectoryContentScanner extends ContentScanner {
  constructor(private readonly entry_: UniversalDirectory|undefined) {
    super();
  }

  /**
   * Starts to read the entries in the directory.
   */
  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    if (!this.entry_ || !this.entry_.createReader) {
      // If entry is not specified or if entry doesn't implement createReader,
      // we cannot read it.
      errorCallback(
          createDOMError(FileErrorToDomError.INVALID_MODIFICATION_ERR));
      return;
    }

    startInterval('DirectoryScan');
    const reader = this.entry_.createReader();
    const readEntries = () => {
      reader.readEntries(entries => {
        if (this.canceled_) {
          errorCallback(createDOMError(FileErrorToDomError.ABORT_ERR));
          return;
        }

        if (entries.length === 0) {
          // All entries are read.
          recordInterval('DirectoryScan');
          successCallback();
          return;
        }

        entriesCallback(entries);
        readEntries();
      }, errorCallback);
    };
    readEntries();
  }
}

/**
 * Latency metric variant names supported by the search content scanner.
 */
enum LatencyVariant {
  /** Local volume; typically local SSD */
  LOCAL = 'Local',
  /** Removable storage, typically USB */
  REMOVABLE = 'Removable',
  /** Provided volume, such as OneDrive */
  PROVIDED = 'Provided',
  /** Android based volume exposed via DocumentsProvider type service. */
  DOCUMENTS_PROVIDER = 'DocumentsProvider',
}

/**
 * A content scanner capable of scanning both the local file system and Google
 * Drive. When created you need to specify the root type, current entry
 * in the directory tree, the search query and options. The `rootType` together
 * with `options` is then used to determine if the search is conducted on the
 * local folder, root folder, or on the local file system and Google Drive.
 *
 * NOTE: This class is a stop-gap solution when transitioning to a content
 * scanner that talks to a browser level service. The service ultimately should
 * be the one that determines what is being searched, and aggregates the results
 * for the frontend client.
 */
export class SearchV2ContentScanner extends ContentScanner {
  private readonly query_: string;
  private readonly options_: SearchOptions;
  private readonly rootType_: RootType|null;
  private readonly driveSearchTypeMap_:
      Map<RootType, chrome.fileManagerPrivate.SearchType>;

  constructor(
      private readonly volumeManager_: VolumeManager,
      private readonly entry_: UniversalDirectory, query: string,
      options?: SearchOptions) {
    super();
    const locationInfo = this.volumeManager_.getLocationInfo(this.entry_);
    this.rootType_ = locationInfo ? locationInfo.rootType : null;
    this.query_ = query.toLowerCase();
    this.options_ = options || getDefaultSearchOptions();
    this.driveSearchTypeMap_ = new Map([
      [
        RootType.DRIVE_OFFLINE,
        chrome.fileManagerPrivate.SearchType.OFFLINE,
      ],
      [
        RootType.DRIVE_SHARED_WITH_ME,
        chrome.fileManagerPrivate.SearchType.EXCLUDE_DIRECTORIES,
      ],
      [
        RootType.DRIVE_RECENT,
        chrome.fileManagerPrivate.SearchType.EXCLUDE_DIRECTORIES,
      ],
    ]);
  }

  /**
   * For the given `dirEntry` it returns a list of searchable roots. This
   * method exists as we have special volumes that aggregate other volumes.
   * Examples include Crostini, Playfiles, aggregated in My files or
   * USB partitions aggregated by USB root. For those cases we return multiple
   * search roots. For plain directories we just return the directory itself.
   */
  private getSearchRoots_(dirEntry: UniversalDirectory): DirectoryEntry[] {
    const typeName: string|null =
        'typeName' in dirEntry ? dirEntry.typeName : null;
    if (typeName !== 'EntryList' && typeName !== 'VolumeEntry') {
      return [dirEntry as DirectoryEntry];
    }
    const children = (dirEntry as EntryList).getUiChildren();
    const allRoots = [dirEntry, ...children];
    return allRoots.filter(entry => !isFakeEntry(entry))
        .map(entry => entry.filesystem!.root);
  }

  /**
   * For the given entry attempts to return the top most volume that contains
   * this entry. The reason for this method is that for some entries, getting
   * the root volume is not sufficient. For example, for a Linux folder the root
   * volume would be the Linux volume. However, in the UI Linux is nested inside
   * My files, so we need to get My files as the top-most volume of a Linux
   * directory.
   */
  private getTopMostVolume_(entry: UniversalDirectory): UniversalDirectory {
    const volumeInfo = this.volumeManager_.getVolumeInfo(entry);
    if (!volumeInfo) {
      // It's a placeholder or a fake entry.
      return entry;
    }
    const topEntry = volumeInfo.prefixEntry ?
        // TODO(b/289003444): Fix this cast.
        volumeInfo.prefixEntry as UniversalDirectory :
        volumeInfo.displayRoot;
    // Here entry should never be null, but due to Closure annotations, Closure
    // thinks it may be (both prefixEntry and displayRoot above are not
    // guaranteed to be non-null).
    return topEntry ? this.getWrappedVolumeEntry_(topEntry) : entry;
  }

  private getWrappedVolumeEntry_(entry: UniversalDirectory):
      UniversalDirectory {
    const state = getStore().getState();
    // Fetch the wrapped VolumeEntry from the store.
    const fileData = state.allEntries[entry.toURL()];
    if (!fileData || !fileData.entry) {
      console.warn(`Missing FileData for ${entry.toURL()}`);
      // TODO(b/289003444): Fix this cast.
      return entry as UniversalDirectory;
    }
    return fileData.entry as UniversalDirectory;
  }

  /**
   * For the given colume type returns root directories for all volumes with the
   * given `volumeType`.
   */
  private getRootFoldersByVolumeType_(volumeType: string):
      UniversalDirectory[] {
    const rootDirs: UniversalDirectory[] = [];
    const volumeInfoList = this.volumeManager_.volumeInfoList;
    for (let index = 0; index < volumeInfoList.length; ++index) {
      const volumeInfo = volumeInfoList.item(index);
      if (volumeInfo.volumeType === volumeType) {
        const displayRoot = volumeInfo.displayRoot;
        if (displayRoot) {
          rootDirs.push(...this.getSearchRoots_(displayRoot));
        }
      }
    }
    return rootDirs;
  }

  /**
   * Creates a single promise that, when fulfilled, returns a non-null array of
   * file entries. The array may be empty. The metricVariant must be a valid
   * name of the UMA search metric variant.
   */
  private makeFileSearchPromise_(
      params: chrome.fileManagerPrivate.SearchMetadataParams,
      metricVariant: LatencyVariant): Promise<UniversalEntry[]> {
    return new Promise<UniversalEntry[]>((resolve, reject) => {
      startInterval(`Search.${metricVariant}.Latency`);
      chrome.fileManagerPrivate.searchFiles(
          params, (entries: UniversalEntry[]) => {
            if (this.canceled_) {
              reject(createDOMError(FileErrorToDomError.ABORT_ERR));
            } else if (chrome.runtime.lastError) {
              reject(createDOMError(
                  FileErrorToDomError.NOT_READABLE_ERR,
                  chrome.runtime.lastError.message));
            } else {
              recordInterval(`Search.${metricVariant}.Latency`);
              resolve(entries);
            }
          });
    });
  }

  /**
   * Creates a promise that, when fulfilled, returns a non-null array of
   * file entries. This promise uses a client side recursive entry reader.
   */
  private makeReadEntriesRecursivelyPromise_(
      folder: UniversalDirectory, modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory, maxResults: number,
      metricVariant: LatencyVariant): Promise<UniversalEntry[]> {
    // A promise that resolves to an entry if it is modified after cutoffDate or
    // null, otherwise. Used to filter entries by modified time. If we fail to
    // get metadata for an entry we return it without comparison, to be on the
    // safe side.
    const newDateFilterPromise = (entry: UniversalEntry, cutoffDate: Date) =>
        new Promise<UniversalEntry|null>(resolve => {
          entry.getMetadata(
              // TODO(b:289003444): Check if metadata is available in the store.
              (metadata) => {
                resolve(metadata.modificationTime > cutoffDate ? entry : null);
              },
              () => {
                resolve(entry);
              });
        });
    return new Promise<UniversalEntry[]>((resolve, reject) => {
      startInterval(`Search.${metricVariant}.Latency`);
      const collectedEntries: UniversalEntry[] = [];
      let workLeft = 1;
      readEntriesRecursively(
          folder,
          // More entries found callback.
          (entries: UniversalEntry[]) => {
            const filtered = entries.filter((entry: UniversalEntry) => {
              if (entry.name.toLowerCase().indexOf(this.query_) < 0) {
                return false;
              }
              if (category !== chrome.fileManagerPrivate.FileCategory.ALL) {
                if (!isType([category], entry)) {
                  return false;
                }
              }
              return true;
            });
            if (modifiedTimestamp === 0) {
              collectedEntries.push(...filtered);
            } else {
              workLeft += filtered.length;
              const cutoff = new Date(modifiedTimestamp);
              Promise
                  .all(filtered.map(
                      (entry: UniversalEntry) =>
                          newDateFilterPromise(entry, cutoff)))
                  .then((modified: Array<UniversalEntry|null>) => {
                    const nullEntryFilter =
                        (e: UniversalEntry|null): e is UniversalEntry => {
                          return e !== null;
                        };
                    collectedEntries.push(...modified.filter(nullEntryFilter));
                    workLeft -= modified.length;
                    if (workLeft <= 0) {
                      recordInterval(`Search.${metricVariant}.Latency`);
                      resolve(collectedEntries);
                    }
                  });
            }
          },
          // All entries read callback.
          () => {
            if (--workLeft <= 0) {
              recordInterval(`Search.${metricVariant}.Latency`);
              resolve(collectedEntries);
            }
          },
          // Error callback.
          () => {
            if (!this.canceled_ && collectedEntries.length >= maxResults) {
              recordInterval(`Search.${metricVariant}.Latency`);
              resolve(collectedEntries);
            } else {
              reject();
            }
          },
          // Should stop callback.
          () => {
            return collectedEntries.length >= maxResults || this.canceled_;
          });
    });
  }

  /**
   * For the given set of `folders` holding directory entries, creates an array
   * of promises that, when fulfilled, return an array of entries in those
   * directories.
   */
  private makeFileSearchPromiseList_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory, maxResults: number,
      metricVariant: LatencyVariant,
      folders: UniversalDirectory[]): Array<Promise<UniversalEntry[]>> {
    const baseParams: chrome.fileManagerPrivate.SearchMetadataParams = {
      rootDir: undefined,  // Provided in the loop below.
      query: this.query_,
      types: chrome.fileManagerPrivate.SearchType.ALL,
      maxResults: maxResults,
      modifiedTimestamp: modifiedTimestamp,
      category: category,
    };
    return folders.map(
        (searchDir: UniversalDirectory): Promise<UniversalEntry[]> =>
            this.makeFileSearchPromise_(
                {
                  ...baseParams,
                  rootDir: searchDir as DirectoryEntry,
                },
                metricVariant));
  }

  /**
   * Returns an array of promises that, when fulfilled, return an array of
   * entries matching the current query, modified timestamp, and category for
   * folders located under My files.
   */
  private createMyFilesSearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Array<Promise<UniversalEntry[]>> {
    const myFilesVolume =
        this.volumeManager_.getCurrentProfileVolumeInfo(VolumeType.DOWNLOADS);
    if (!myFilesVolume || !myFilesVolume.displayRoot) {
      return [];
    }
    const myFilesEntry = this.getWrappedVolumeEntry_(myFilesVolume.displayRoot);
    return this.makeFileSearchPromiseList_(
        modifiedTimestamp, category, maxResults, LatencyVariant.LOCAL,
        this.getSearchRoots_(myFilesEntry));
  }

  /**
   * Returns an array of promises that, when fulfilled, return an array of
   * entries matching the current query, modified timestamp, and category for
   * all known removable drives.
   */
  private createRemovablesSearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Array<Promise<UniversalEntry[]>> {
    return this.makeFileSearchPromiseList_(
        modifiedTimestamp, category, maxResults, LatencyVariant.REMOVABLE,
        this.getRootFoldersByVolumeType_(VolumeType.REMOVABLE));
  }

  /**
   * Returns an array of promises that, when fulfilled, return an array of
   * entries matching the current query, modified timestamp, and category for
   * all known document providers.
   */
  private createDocumentsProviderSearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Array<Promise<UniversalEntry[]>> {
    const rootFolderList =
        this.getRootFoldersByVolumeType_(VolumeType.DOCUMENTS_PROVIDER);
    return rootFolderList.map(
        rootFolder => this.makeReadEntriesRecursivelyPromise_(
            rootFolder, modifiedTimestamp, category, maxResults,
            LatencyVariant.DOCUMENTS_PROVIDER));
  }

  /**
   * Returns an array of promises that, when fulfilled, return an array of
   * entries matching the current query, modified timestamp, and category for
   * all known file system provider volumes.
   */
  private createFileSystemProviderSearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Array<Promise<UniversalEntry[]>> {
    const rootFolderList =
        this.getRootFoldersByVolumeType_(VolumeType.PROVIDED);
    return rootFolderList.map(
        rootFolder => this.makeReadEntriesRecursivelyPromise_(
            rootFolder, modifiedTimestamp, category, maxResults,
            LatencyVariant.PROVIDED));
  }

  /**
   * Returns a promise that, when fulfilled, returns an array of file entries
   * matching the current query, modified timestamp and category for files
   * located on Drive.
   */
  private createDriveSearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Promise<UniversalEntry[]> {
    let searchType = this.rootType_ !== null ?
        this.driveSearchTypeMap_.get(this.rootType_) :
        null;
    if (!searchType) {
      searchType = chrome.fileManagerPrivate.SearchType.ALL;
    }
    return new Promise<UniversalEntry[]>((resolve, reject) => {
      startInterval('Search.Drive.Latency');
      chrome.fileManagerPrivate.searchDriveMetadata(
          {
            query: this.query_,
            category: category,
            types: searchType!,
            maxResults: maxResults,
            modifiedTimestamp: modifiedTimestamp,
            rootDir: undefined,
          },
          (results) => {
            if (chrome.runtime.lastError) {
              reject(createDOMError(
                  FileErrorToDomError.NOT_READABLE_ERR,
                  chrome.runtime.lastError.message));
            } else if (this.canceled_) {
              reject(createDOMError(FileErrorToDomError.ABORT_ERR));
            } else if (!results) {
              reject(
                  createDOMError(FileErrorToDomError.INVALID_MODIFICATION_ERR));
            } else {
              recordInterval('Search.Drive.Latency');
              resolve(results.map(r => r.entry));
            }
          });
    });
  }

  private createDirectorySearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Array<Promise<UniversalEntry[]>> {
    if (isDriveRootType(this.rootType_)) {
      return [
        this.createDriveSearch_(modifiedTimestamp, category, maxResults),
      ];
    }
    const searchFolder = this.options_.location === SearchLocation.THIS_FOLDER ?
        this.entry_ :
        this.getTopMostVolume_(this.entry_);
    if (this.rootType_ === RootType.DOCUMENTS_PROVIDER) {
      return [this.makeReadEntriesRecursivelyPromise_(
          searchFolder, modifiedTimestamp, category, maxResults,
          LatencyVariant.DOCUMENTS_PROVIDER)];
    }
    if (this.rootType_ === RootType.PROVIDED) {
      return [this.makeReadEntriesRecursivelyPromise_(
          searchFolder, modifiedTimestamp, category, maxResults,
          LatencyVariant.PROVIDED)];
    }
    const metricVariant = this.rootType_ === RootType.REMOVABLE ?
        LatencyVariant.REMOVABLE :
        LatencyVariant.LOCAL;
    // My Files or a folder nested in it.
    return this.makeFileSearchPromiseList_(
        modifiedTimestamp, category, maxResults, metricVariant,
        this.getSearchRoots_(searchFolder));
  }

  private createEverywhereSearch_(
      modifiedTimestamp: number,
      category: chrome.fileManagerPrivate.FileCategory,
      maxResults: number): Array<Promise<UniversalEntry[]>> {
    return [
      ...this.createMyFilesSearch_(modifiedTimestamp, category, maxResults),
      ...this.createRemovablesSearch_(modifiedTimestamp, category, maxResults),
      this.createDriveSearch_(modifiedTimestamp, category, maxResults),
      ...this.createDocumentsProviderSearch_(
          modifiedTimestamp, category, maxResults),
      ...this.createFileSystemProviderSearch_(
          modifiedTimestamp, category, maxResults),
    ];
  }

  /**
   * Starts the file name search.
   */
  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    const category = this.options_.fileCategory;
    const modifiedTimestamp =
        getEarliestTimestamp(this.options_.recency, new Date());
    const maxResults = 100;

    const searchPromises =
        this.options_.location === SearchLocation.EVERYWHERE ?
        this.createEverywhereSearch_(modifiedTimestamp, category, maxResults) :
        this.createDirectorySearch_(modifiedTimestamp, category, maxResults);

    if (!searchPromises) {
      console.warn(
          `No search promises for options ${JSON.stringify(this.options_)}`);
      successCallback();
    }
    // The job of entriesCallbackCaller is to call entriesCallback as soon as
    // entries are available. We call successCallback only once all of them are
    // settled, but we do not wish to wait for all of promises to be settled
    // before showing the entries.
    const entriesCallbackCaller = (entries: UniversalEntry[]): number => {
      if (entries && entries.length > 0) {
        entriesCallback(entries);
      }
      return entries ? entries.length : 0;
    };
    Promise.allSettled(searchPromises.map(p => p.then(entriesCallbackCaller)))
        .then((results) => {
          let resultCount = 0;
          for (const result of results) {
            if (result.status === 'rejected') {
              errorCallback(result.reason);
            } else if (result.status === 'fulfilled') {
              resultCount += result.value;
            }
          }
          successCallback();
          recordMediumCount('Search.ResultCount', resultCount);
        });
  }
}

/**
 * Scanner of the entries for the metadata search on Drive File System.
 */
export class DriveMetadataSearchContentScanner extends ContentScanner {
  constructor(private readonly searchType_:
                  chrome.fileManagerPrivate.SearchType) {
    super();
  }

  /**
   * Starts to metadata-search on Drive File System.
   */
  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    chrome.fileManagerPrivate.searchDriveMetadata(
        {
          query: '',
          types: this.searchType_,
          maxResults: 100,
          rootDir: undefined,
          modifiedTimestamp: undefined,
          category: undefined,
        },
        (results: chrome.fileManagerPrivate.DriveMetadataSearchResult[]) => {
          if (chrome.runtime.lastError) {
            console.error(chrome.runtime.lastError.message);
          }
          if (this.canceled_) {
            errorCallback(createDOMError(FileErrorToDomError.ABORT_ERR));
            return;
          }

          if (!results) {
            console.warn('Drive search encountered an error.');
            errorCallback(
                createDOMError(FileErrorToDomError.INVALID_MODIFICATION_ERR));
            return;
          }

          const entries = results.map(result => {
            return result.entry;
          });
          if (entries.length > 0) {
            entriesCallback(entries);
          }
          successCallback();
        });
  }
}

export class RecentContentScanner extends ContentScanner {
  private readonly query_: string;
  private readonly sourceRestriction_:
      chrome.fileManagerPrivate.SourceRestriction;
  private readonly fileCategory_: chrome.fileManagerPrivate.FileCategory;

  constructor(
      query: string, private readonly cutoffDays_: number,
      private readonly volumeManager_: VolumeManager,
      sourceRestriction?: chrome.fileManagerPrivate.SourceRestriction,
      fileCategory?: chrome.fileManagerPrivate.FileCategory) {
    super();

    this.query_ = query.toLowerCase();
    this.sourceRestriction_ = sourceRestriction ||
        chrome.fileManagerPrivate.SourceRestriction.ANY_SOURCE;
    this.fileCategory_ =
        fileCategory || chrome.fileManagerPrivate.FileCategory.ALL;
  }

  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, invalidateCache: boolean = false) {
    /**
     * Files app launched with "volumeFilter" launch parameter will filter
     * out some volumes. Before returning the recent entries, we need to
     * check if the entry's volume location is valid or not
     * (crbug.com/1333385/#c17).
     */
    const isAllowedVolume = (entry: Entry) =>
        this.volumeManager_.getVolumeInfo(entry) !== null;
    chrome.fileManagerPrivate.getRecentFiles(
        this.sourceRestriction_, this.query_, this.cutoffDays_,
        this.fileCategory_, invalidateCache, entries => {
          if (chrome.runtime.lastError) {
            console.error(chrome.runtime.lastError.message);
            errorCallback(
                createDOMError(FileErrorToDomError.INVALID_MODIFICATION_ERR));
            return;
          }
          if (entries.length > 0) {
            entriesCallback(entries.filter(entry => isAllowedVolume(entry)));
          }
          successCallback();
        });
  }
}

/**
 * Scanner of media-view volumes.
 */
export class MediaViewContentScanner extends ContentScanner {
  /**
   * Creates a scanner at the given root entry of the media-view volume.
   */
  constructor(private rootEntry_: UniversalDirectory) {
    super();
  }

  /**
   * This scanner provides flattened view of media providers.
   *
   * In FileSystem API level, each media-view root directory has directory
   * hierarchy. We need to list files under the root directory to provide
   * flatten view. A file will not be shown in multiple directories in
   * media-view hierarchy since no folders will be added in media documents
   * provider. We can list all files without duplication by just retrieving
   * files in directories recursively.
   */
  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    // To provide flatten view of files, this media-view scanner retrieves
    // files in directories inside the media's root entry recursively.
    readEntriesRecursively(
        this.rootEntry_,
        entries => entriesCallback(entries.filter(entry => !entry.isDirectory)),
        successCallback, errorCallback, () => false);
  }
}

/**
 * Shows an empty list and spinner whilst starting and mounting the
 * crostini container.
 *
 * This function is only called once to start and mount the crostini
 * container.  When FilesApp starts, the related fake root entry for
 * crostini is shown which uses this CrostiniMounter as its ContentScanner.
 *
 * When the sshfs mount completes, it will show up as a disk volume.
 * `refreshNavigationRootsReducer` will detect that crostini
 * is mounted as a disk volume and hide the fake root item while the
 * disk volume exists.
 */
export class CrostiniMounter extends ContentScanner {
  override async scan(
      _entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    chrome.fileManagerPrivate.mountCrostini(() => {
      if (chrome.runtime.lastError) {
        console.warn(`Cannot mount Crostini volume: ${
            chrome.runtime.lastError.message}`);
        errorCallback(createDOMError(
            CROSTINI_CONNECT_ERR, chrome.runtime.lastError.message));
        return;
      }
      successCallback();
    });
  }
}

/**
 * Shows an empty list and spinner whilst starting and mounting a Guest OS's
 * shared files.
 *
 * When FilesApp starts, the related placeholder root entry is shown which uses
 * this GuestOsMounter as its ContentScanner. When the mount succeeds it will
 * show up as a disk volume. `refreshNavigationRootsReducer` will
 * detect thew new volume and hide the placeholder root item while the disk
 * volume exists.
 */
export class GuestOsMounter extends ContentScanner {
  /**
   * Creates a new GuestOSMounter. The `guest_id` is the id for the
   * GuestOsMountProvider to use
   */
  constructor(private readonly guest_id_: number) {
    super();
  }

  override async scan(
      _entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    try {
      await mountGuest(this.guest_id_);
      successCallback();
    } catch (error) {
      errorCallback(createDOMError(
          // TODO(crbug/1293229): Strings
          CROSTINI_CONNECT_ERR, JSON.stringify(error)));
    }
  }
}

/**
 * Read all the Trash directories for content.
 */
export class TrashContentScanner extends ContentScanner {
  private readonly readers_: FileSystemDirectoryReader[];

  /**
   * volumeManager Identifies the underlying filesystem.
   */
  constructor(volumeManager: VolumeManager) {
    super();
    this.readers_ = createTrashReaders(volumeManager);
  }

  /**
   * Scan all the trash directories for content.
   */
  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    const readEntries = (idx: number) => {
      if (idx >= this.readers_.length) {
        // All Trash directories have been read.
        successCallback();
        return;
      }
      this.readers_[idx]!.readEntries(entries => {
        if (this.canceled_) {
          errorCallback(createDOMError(FileErrorToDomError.ABORT_ERR));
          return;
        }

        entriesCallback(entries);
        readEntries(idx + 1);
      }, errorCallback);
    };
    readEntries(0);
    return;
  }
}

type EntryFilter = (e: UniversalEntry) => boolean;

/**
 * Top-level Android folders which are visible by default.
 */
const DEFAULT_ANDROID_FOLDERS = ['Documents', 'Movies', 'Music', 'Pictures'];

/**
 * Windows files or folders to hide by default.
 */
const WINDOWS_HIDDEN = ['$RECYCLE.BIN'];


/**
 * This class manages filters and determines a file should be shown or not.
 * When filters are changed, a 'changed' event is fired.
 */
export class FileFilter extends EventTarget {
  private filters_ = new Map<string, EntryFilter>();

  constructor(private readonly volumeManager_: VolumeManager) {
    super();
    /**
     * Setup initial filters.
     */
    this.setupInitialFilters_();
  }

  private setupInitialFilters_() {
    this.setHiddenFilesVisible(false);
    this.setAllAndroidFoldersVisible(false);
    this.hideAndroidDownload();
  }

  /**
   * Registers the given filter with the given name.
   */
  addFilter(name: string, filterFn: EntryFilter) {
    this.filters_.set(name, filterFn);
    dispatchSimpleEvent(this, 'changed');
  }

  /**
   * @param name Filter identifier.
   */
  removeFilter(name: string) {
    this.filters_.delete(name);
    dispatchSimpleEvent(this, 'changed');
  }

  /**
   * Show/Hide hidden files (i.e. files starting with '.', or other system files
   * for Windows files). Passing `true` as the `visible` parameters means the
   * hidden files should be visible to the user.
   */
  setHiddenFilesVisible(visible: boolean) {
    if (!visible) {
      this.addFilter('hidden', (entry: UniversalEntry): boolean => {
        if (entry.name.startsWith('.')) {
          return false;
        }
        // Hide folders under .Trash, but we don't want to hide anything showing
        // in "Trash", hence the `!isTrashEntry` check because all entries
        // showing under "Trash" will be TrashEntry.
        const insideTrash = TRASH_CONFIG.map(t => t.trashDir)
                                .some(dir => entry.fullPath.startsWith(dir));
        if (insideTrash && !isTrashEntry(entry)) {
          return false;
        }
        // Only hide WINDOWS_HIDDEN in downloads:/PvmDefault.
        if (entry.fullPath.startsWith('/PvmDefault/') &&
            WINDOWS_HIDDEN.includes(entry.name)) {
          const info = this.volumeManager_.getLocationInfo(entry);
          if (info && info.rootType === RootType.DOWNLOADS) {
            return false;
          }
        }
        return true;
      });
    } else {
      this.removeFilter('hidden');
    }
  }

  /**
   * Returns whether or not hidden files are visible to the user now.
   */
  isHiddenFilesVisible(): boolean {
    return !this.filters_.has('hidden');
  }

  /**
   * Show/Hide uncommon Android folders.
   * @param visible True if uncommon folders should be visible to the
   *     user.
   */
  setAllAndroidFoldersVisible(visible: boolean) {
    if (!visible) {
      this.addFilter('android_hidden', (entry: UniversalEntry): boolean => {
        if (entry.filesystem && entry.filesystem.name !== 'android_files') {
          return true;
        }
        // Hide top-level folder or sub-folders that should be hidden.
        if (entry.fullPath) {
          const components = entry.fullPath.split('/');
          if (components[1] &&
              DEFAULT_ANDROID_FOLDERS.indexOf(components[1]) === -1) {
            return false;
          }
        }
        return true;
      });
    } else {
      this.removeFilter('android_hidden');
    }
  }

  /**
   * @return True if uncommon folders is visible to the user now.
   */
  isAllAndroidFoldersVisible(): boolean {
    return !this.filters_.has('android_hidden');
  }

  /**
   * Sets up a filter to hide /Download directory in 'Play files' volume.
   *
   * "Play files/Download" is an alias to Chrome OS's Downloads volume. It is
   * convenient in Android file picker, but can be confusing in Chrome OS Files
   * app. This function adds a filter to hide the Android's /Download.
   */
  hideAndroidDownload() {
    this.addFilter('android_download', (entry: UniversalEntry): boolean => {
      if (entry.filesystem && entry.filesystem.name === 'android_files' &&
          entry.fullPath === '/Download') {
        return false;
      }
      return true;
    });
  }

  /**
   * @param entry File entry.
   * @return True if the file should be shown, false otherwise.
   */
  filter(entry: UniversalEntry): boolean {
    for (const p of this.filters_.values()) {
      if (!p(entry)) {
        return false;
      }
    }
    return true;
  }
}

/**
 * A context of DirectoryContents.
 * TODO(yoshiki): remove this. crbug.com/224869.
 */
export class FileListContext {
  readonly fileList: FileListModel;
  readonly prefetchPropertyNames: MetadataKey[];

  constructor(
      readonly fileFilter: FileFilter, readonly metadataModel: MetadataModel,
      readonly volumeManager: VolumeManager) {
    this.fileList = new FileListModel(metadataModel);
    this.prefetchPropertyNames = Array.from(new Set([
      ...LIST_CONTAINER_METADATA_PREFETCH_PROPERTY_NAMES,
      ...ACTIONS_MODEL_METADATA_PREFETCH_PROPERTY_NAMES,
      ...FILE_SELECTION_METADATA_PREFETCH_PROPERTY_NAMES,
      ...DLP_METADATA_PREFETCH_PROPERTY_NAMES,
    ]));
  }
}

export type DirContentsScanUpdatedEvent = CustomEvent<{
  /** Whether the content scanner was based in the store. */
  isStoreBased: boolean,
}>;
export type DirContentsScanFailedEvent = CustomEvent<{error: DOMError}>;
export type DirContentsScanCanceled = CustomEvent<undefined>;
export type DirContentsScanCompleted = CustomEvent<undefined>;

interface DirectoryContentsEventMap extends CustomEventMap {
  'dir-contents-scan-updated': DirContentsScanUpdatedEvent;
  'dir-contents-scan-failed': DirContentsScanFailedEvent;
  'dir-contents-scan-canceled': DirContentsScanCanceled;
  'dir-contents-scan-completed': DirContentsScanCompleted;
}

/**
 * This class is responsible for scanning directory (or search results), and
 * filling the fileList. Different descendants handle various types of directory
 * contents shown: basic directory, drive search results, local search results.
 */
export class DirectoryContents extends
    FilesEventTarget<DirectoryContentsEventMap> {
  private fileList_: FileListModel;
  private scanner_: ContentScanner|null = null;
  private processNewEntriesQueue_: AsyncQueue = new AsyncQueue();
  private scanCanceled_: boolean = false;
  /**
   * Metadata snapshot which is used to know which file is actually changed.
   */
  private metadataSnapshot_: Map<string, MetadataItem>|null = null;

  /**
   * @param context The file list context.
   * @param isSearch True for search directory contents, otherwise false.
   * @param directoryEntry The entry of the current directory.
   * @param scannerFactory The factory to create ContentScanner instance.
   */
  constructor(
      private readonly context_: FileListContext,
      private readonly isSearch_: boolean,
      private readonly directoryEntry_: UniversalDirectory|undefined,
      private readonly fileKey_: FileKey|undefined,
      private readonly scannerFactory_: () => ContentScanner) {
    super();

    this.fileList_ = this.context_.fileList;
    this.fileList_.initNewDirContents(this.context_.volumeManager);
  }

  /**
   * Create the copy of the object, but without scan started.
   * @return Object copy.
   */
  clone(): DirectoryContents {
    return new DirectoryContents(
        this.context_, this.isSearch_, this.directoryEntry_, this.fileKey_,
        this.scannerFactory_);
  }

  /**
   * Returns the file list length.
   */
  getFileListLength(): number {
    return this.fileList_.length;
  }

  /**
   * Use a given fileList instead of the fileList from the context.
   * @param fileList The new file list.
   */
  setFileList(fileList: FileListModel) {
    this.fileList_ = fileList;
  }

  /**
   * Creates snapshot of metadata in the directory. Returns Metadata snapshot
   * of current directory contents.
   */
  createMetadataSnapshot(): Map<string, MetadataItem> {
    const snapshot = new Map<string, MetadataItem>();
    const entries: UniversalEntry[] = this.fileList_.slice();
    const metadata =
        this.context_.metadataModel.getCache(entries, ['modificationTime']);
    for (const [i, entry] of entries.entries()) {
      snapshot.set(entry.toURL(), metadata[i]!);
    }
    return snapshot;
  }

  /**
   * Sets metadata snapshot which is used to check changed files.
   * @param metadataSnapshot A metadata snapshot.
   */
  setMetadataSnapshot(metadataSnapshot: Map<string, MetadataItem>) {
    this.metadataSnapshot_ = metadataSnapshot;
  }

  /**
   * Use the filelist from the context and replace its contents with the entries
   * from the current fileList. If metadata snapshot is set, this method checks
   * actually updated files and dispatch change events by calling updateIndexes.
   */
  replaceContextFileList() {
    if (this.context_.fileList === this.fileList_) {
      return;
    }
    // TODO(yawano): While we should update the list with adding or deleting
    // what actually added and deleted instead of deleting and adding all
    // items, splice of array data model is expensive since it always runs
    // sort and we replace the list in this way to reduce the number of splice
    // calls.
    const spliceArgs = this.fileList_.slice();
    const fileList = this.context_.fileList;
    fileList.splice(0, fileList.length, ...spliceArgs);
    this.fileList_ = fileList;

    // Check updated files and dispatch change events.
    if (!this.metadataSnapshot_) {
      return;
    }
    const updatedIndexes = [];
    const entries: UniversalEntry[] = this.fileList_.slice();
    const freshMetadata =
        this.context_.metadataModel.getCache(entries, ['modificationTime']);

    for (let i = 0; i < entries.length; i++) {
      const url = entries[i]!.toURL();
      const entryMetadata = freshMetadata[i];
      // If the Files app fails to obtain both old and new modificationTime,
      // regard the entry as not updated.
      const storedMetadata = this.metadataSnapshot_.get(url);
      if (entryMetadata?.modificationTime?.getTime() !==
          storedMetadata?.modificationTime?.getTime()) {
        updatedIndexes.push(i);
      }
    }

    if (updatedIndexes.length > 0) {
      this.fileList_.updateIndexes(updatedIndexes);
    }
  }

  /**
   * @return If the scan is active.
   */
  isScanning(): boolean {
    return this.scanner_ !== null || this.processNewEntriesQueue_.isRunning();
  }

  /**
   * @return True if search results (drive or local).
   */
  isSearch(): boolean {
    return this.isSearch_;
  }

  /**
   * @return A DirectoryEntry for
   *     current directory. In case of search -- the top directory from which
   *     search is run.
   */
  getDirectoryEntry(): UniversalDirectory|undefined {
    return this.directoryEntry_;
  }

  getFileKey(): FileKey|undefined {
    return this.fileKey_;
  }

  /**
   * Start directory scan/search operation. Either 'dir-contents-scan-completed'
   * or 'dir-contents-scan-failed' event will be fired upon completion.
   *
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param invalidateCache True to invalidate the backend scanning result
   *     cache. This param only works if the corresponding backend scanning
   *     supports cache.
   */
  scan(refresh: boolean, invalidateCache: boolean) {
    /**
     * Invoked when the scanning is completed successfully.
     */
    const completionCallback = () => {
      this.onScanFinished_();
      this.onScanCompleted_();
    };

    /**
     * Invoked when the scanning is finished but is not completed due to error.
     */
    const errorCallback = (error: DOMError) => {
      this.onScanFinished_();
      this.onScanError_(error);
    };

    // TODO(hidehiko,mtomasz): this scan method must be
    // called at most once. Remove such a limitation.
    this.scanner_ = this.scannerFactory_();
    this.scanner_.scan(
        this.onNewEntries_.bind(this, refresh, this.scanner_.isStoreBased()),
        completionCallback, errorCallback, invalidateCache);
  }

  /**
   * Adds/removes/updates items of file list.
   * @param updatedEntries Entries of updated/added files.
   * @param removedUrls URLs of removed files.
   */
  update(updatedEntries: Entry[], removedUrls: string[]) {
    const removedSet = new Set<string>();
    for (const url of removedUrls) {
      removedSet.add(url);
    }

    const updatedMap = new Map<string, Entry>();
    for (const entry of updatedEntries) {
      updatedMap.set(entry.toURL(), entry);
    }

    const updatedList: Entry[] = [];
    const updatedIndexes = [];
    for (let i = 0; i < this.fileList_.length; i++) {
      const url = this.fileList_.item(i)!.toURL();

      if (removedSet.has(url)) {
        // Find the maximum range in which all items need to be removed.
        const begin = i;
        let end = i + 1;
        while (end < this.fileList_.length &&
               removedSet.has(this.fileList_.item(end)?.toURL() || '')) {
          end++;
        }
        // Remove the range [begin, end) at once to avoid multiple sorting.
        this.fileList_.splice(begin, end - begin);
        i--;
        continue;
      }

      const updatedEntry = updatedMap.get(url);
      if (updatedEntry) {
        updatedList.push(updatedEntry);
        updatedIndexes.push(i);
        updatedMap.delete(url);
      }
    }

    if (updatedIndexes.length > 0) {
      this.fileList_.updateIndexes(updatedIndexes);
    }

    const addedList: Entry[] = [];
    for (const updatedEntry of updatedMap.values()) {
      addedList.push(updatedEntry);
    }

    if (removedUrls.length > 0) {
      this.context_.metadataModel.notifyEntriesRemoved(removedUrls);
    }

    this.prefetchMetadata(updatedList, true, () => {
      this.onNewEntries_(true, false, addedList);
      this.onScanFinished_();
      this.onScanCompleted_();
    });
  }

  /**
   * Cancels the running scan.
   */
  cancelScan() {
    if (this.scanCanceled_) {
      return;
    }
    this.scanCanceled_ = true;
    if (this.scanner_) {
      this.scanner_.cancel();
    }

    this.onScanFinished_();

    this.processNewEntriesQueue_.cancel();
    this.dispatchEvent(new CustomEvent('dir-contents-scan-canceled'));
  }

  /**
   * Called when the scanning by scanner_ is done, even when the scanning is
   * succeeded or failed. This is called before completion (or error) callback.
   *
   */
  private onScanFinished_() {
    this.scanner_ = null;
  }

  /**
   * Called when the scanning by scanner_ is succeeded.
   */
  private onScanCompleted_() {
    if (this.scanCanceled_) {
      return;
    }

    this.processNewEntriesQueue_.run(callback => {
      // Call callback first, so isScanning() returns false in the event
      // handlers.
      callback();
      this.dispatchEvent(new CustomEvent('dir-contents-scan-completed'));
    });
  }

  /**
   * Called in case scan has failed. Should send the event.
   * @param error error.
   */
  private onScanError_(error: DOMError) {
    if (this.scanCanceled_) {
      return;
    }

    this.processNewEntriesQueue_.run(callback => {
      // Call callback first, so isScanning() returns false in the event
      // handlers.
      callback();
      this.dispatchEvent(
          new CustomEvent('dir-contents-scan-failed', {detail: {error}}));
    });
  }

  /**
   * Called when some chunk of entries are read by scanner.
   *
   * @param refresh True to refresh metadata, or false to use cached one.
   * @param storeBased Whether the scan for `entries` was done in the store.
   * @param entries The list of the scanned entries.
   */
  private onNewEntries_(
      refresh: boolean, storeBased: boolean, entries: UniversalEntry[]) {
    if (this.scanCanceled_) {
      return;
    }

    if (entries.length === 0) {
      return;
    }

    this.processNewEntriesQueue_.run(callbackOuter => {
      const finish = () => {
        if (!this.scanCanceled_) {
          // From new entries remove all entries that are rejected by the
          // filters or are already present in the current file list.
          const currentURLs = new Set<string>();
          for (let i = 0; i < this.fileList_.length; ++i) {
            currentURLs.add(this.fileList_.item(i)!.toURL());
          }
          const entriesFiltered = entries.filter(
              (e) => this.context_.fileFilter.filter(e) &&
                  !(currentURLs.has(e.toURL())));

          // Update the filelist without waiting the metadata.
          this.fileList_.push.apply(this.fileList_, entriesFiltered);
          const event = new CustomEvent('dir-contents-scan-updated', {
            detail: {
              isStoreBased: storeBased,
            },
          });
          this.dispatchEvent(event);
        }
        callbackOuter();
      };

      // Because the prefetchMetadata can be slow, throttling by splitting
      // entries into smaller chunks to reduce UI latency.
      // TODO(hidehiko,mtomasz): This should be handled in MetadataCache.
      const MAX_CHUNK_SIZE = 25;
      const prefetchMetadataQueue = new ConcurrentQueue(4);
      for (let i = 0; i < entries.length; i += MAX_CHUNK_SIZE) {
        if (prefetchMetadataQueue.isCanceled()) {
          break;
        }

        const chunk = entries.slice(i, i + MAX_CHUNK_SIZE);
        prefetchMetadataQueue.run(
            ((chunk: UniversalEntry[], callbackInner: VoidCallback) => {
              this.prefetchMetadata(chunk, refresh, () => {
                if (!prefetchMetadataQueue.isCanceled()) {
                  if (this.scanCanceled_) {
                    prefetchMetadataQueue.cancel();
                  }
                }

                // Checks if this is the last task.
                if (prefetchMetadataQueue.getWaitingTasksCount() === 0 &&
                    prefetchMetadataQueue.getRunningTasksCount() === 1) {
                  // |callbackOuter| in |finish| must be called before
                  // |callbackInner|, to prevent double-calling.
                  finish();
                }

                callbackInner();
              });
            }).bind(null, chunk));
      }
    });
  }

  prefetchMetadata(
      entries: UniversalEntry[], refresh: boolean,
      callback: (items: MetadataItem[]) => void) {
    if (refresh) {
      this.context_.metadataModel.notifyEntriesChanged(entries);
    }
    this.context_.metadataModel
        .get(entries, this.context_.prefetchPropertyNames)
        .then(callback);
  }
}

/**
 * Scan entries using the Store and ActionsProducer to talk to the backend and
 * propagate the state.
 *
 * This adapts the Store to the existing ContentScanner architecture.
 */
export class StoreScanner extends ContentScanner {
  private store_: Store;
  private entriesCallback_?: ScanResultCallback;
  private successCallbcak_?: VoidCallback;
  private errorCallback_?: ScanErrorCallback;
  private unsubscribe_?: VoidCallback;

  constructor(private fileKey_: FileKey) {
    super();
    this.store_ = getStore();
  }

  private onDirectoryContentUpdated_(dirContent?: DirectoryContent) {
    if (!dirContent) {
      return;
    }
    if (!(this.entriesCallback_ && this.errorCallback_ &&
          this.successCallbcak_)) {
      return;
    }

    if (dirContent.status === PropStatus.ERROR) {
      // TODO(lucmult): Figure out the DOMError here.
      this.errorCallback_({} as DOMError);
      this.finalize_();
      return;
    }

    if (dirContent.status === PropStatus.STARTED &&
        dirContent.keys.length > 0) {
      const entries = this.getEntries_(dirContent.keys);
      this.entriesCallback_(entries);
      return;
    }

    if (dirContent.status === PropStatus.SUCCESS) {
      const entries = this.getEntries_(dirContent.keys);
      this.entriesCallback_(entries);
      this.successCallbcak_();
      this.finalize_();
      return;
    }
  }

  private getEntries_(keys: FileKey[]): UniversalEntry[] {
    const state = this.store_.getState();
    const entries: UniversalEntry[] = [];
    for (const k of keys) {
      const entry = getFileData(state, k)?.entry;
      if (!entry) {
        console.debug(`Failed to find entry for ${k}`);
        continue;
      }
      entries.push(entry);
    }
    return entries;
  }

  override async scan(
      entriesCallback: ScanResultCallback, successCallback: VoidCallback,
      errorCallback: ScanErrorCallback, _invalidateCache: boolean = false) {
    this.entriesCallback_ = entriesCallback;
    this.errorCallback_ = errorCallback;
    this.successCallbcak_ = successCallback;
    // Start listening to the store.
    this.unsubscribe_ = directoryContentSelector.subscribe(
        this.onDirectoryContentUpdated_.bind(this));

    // Dispatch action to scan in the store.
    this.store_.dispatch(fetchDirectoryContents(this.fileKey_));
  }

  override cancel() {
    super.cancel();
    this.finalize_();
  }

  private finalize_() {
    // Usubscribe from the store.
    if (this.unsubscribe_) {
      this.unsubscribe_();
    }
    this.unsubscribe_ = undefined;
    this.successCallbcak_ = undefined;
    this.errorCallback_ = undefined;
    this.entriesCallback_ = undefined;
  }

  override isStoreBased(): boolean {
    return true;
  }
}