chromium/ui/file_manager/file_manager/state/ducks/volumes.ts

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

import type {VolumeInfo} from '../../background/js/volume_info.js';
import {isOneDriveId, isSameEntry} from '../../common/js/entry_utils.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {EntryList, VolumeEntry} from '../../common/js/files_app_entry_types.js';
import {isGuestOsEnabled, isSinglePartitionFormatEnabled} from '../../common/js/flags.js';
import {str} from '../../common/js/translations.js';
import type {GetActionFactoryPayload} from '../../common/js/util.js';
import {RootType, Source, VolumeType} from '../../common/js/volume_manager_types.js';
import {ICON_TYPES, ODFS_EXTENSION_ID} from '../../foreground/js/constants.js';
import type {ActionsProducerGen} from '../../lib/actions_producer.js';
import {Slice} from '../../lib/base_store.js';
import {PropStatus, type State, type Volume, type VolumeId} from '../../state/state.js';
import type {FileKey} from '../file_key.js';
import {getEntry, getFileData, getStore} from '../store.js';

import {cacheEntries, getMyFiles, readSubDirectories, updateFileDataInPlace} from './all_entries.js';
import {updateDeviceConnectionState} from './device.js';
import {removeUiEntry} from './ui_entries.js';

/**
 * @fileoverview Volumes slice of the store.
 */

const slice = new Slice<State, State['volumes']>('volumes');
export {slice as volumesSlice};

export const myFilesEntryListKey = `entry-list://${RootType.MY_FILES}`;
export const crostiniPlaceHolderKey = `fake-entry://${RootType.CROSTINI}`;
export const drivePlaceHolderKey = `fake-entry://${RootType.DRIVE_FAKE_ROOT}`;
export const recentRootKey = `fake-entry://${RootType.RECENT}/all`;
export const trashRootKey = `fake-entry://${RootType.TRASH}`;
export const driveRootEntryListKey = `entry-list://${RootType.DRIVE_FAKE_ROOT}`;
export const oneDriveFakeRootKey =
    `fake-entry://${RootType.PROVIDED}/${ODFS_EXTENSION_ID}`;
export const makeRemovableParentKey =
    (volume: Volume|chrome.fileManagerPrivate.VolumeMetadata) => {
      // Should be consistent with EntryList's toURL() method.
      if (volume.devicePath) {
        return `entry-list://${RootType.REMOVABLE}/${volume.devicePath}`;
      }
      return `entry-list://${RootType.REMOVABLE}`;
    };
export const removableGroupKey =
    (volume: Volume|chrome.fileManagerPrivate.VolumeMetadata) =>
        `${volume.devicePath}/${volume.driveLabel}`;

export function getVolumeTypesNestedInMyFiles() {
  const myFilesNestedVolumeTypes = new Set<VolumeType>([
    VolumeType.ANDROID_FILES,
    VolumeType.CROSTINI,
  ]);
  if (isGuestOsEnabled()) {
    myFilesNestedVolumeTypes.add(VolumeType.GUEST_OS);
  }
  return myFilesNestedVolumeTypes;
}

/**
 * Convert VolumeInfo and VolumeMetadata to its store representation: Volume.
 */
export function convertVolumeInfoAndMetadataToVolume(
    volumeInfo: VolumeInfo,
    volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata): Volume {
  /**
   * FileKey for the volume root's Entry. Or how do we find the Entry for this
   * volume in the allEntries.
   */
  const volumeRootKey = volumeInfo.displayRoot.toURL();
  return {
    volumeId: volumeMetadata.volumeId,
    volumeType: volumeMetadata.volumeType as VolumeType,
    rootKey: volumeRootKey,
    status: PropStatus.SUCCESS,
    label: volumeInfo.label,
    error: volumeMetadata.mountCondition,
    deviceType: volumeMetadata.deviceType,
    devicePath: volumeMetadata.devicePath,
    isReadOnly: volumeMetadata.isReadOnly,
    isReadOnlyRemovableDevice: volumeMetadata.isReadOnlyRemovableDevice,
    providerId: volumeMetadata.providerId,
    configurable: volumeMetadata.configurable,
    watchable: volumeMetadata.watchable,
    source: volumeMetadata.source,
    diskFileSystemType: volumeMetadata.diskFileSystemType,
    iconSet: volumeMetadata.iconSet,
    driveLabel: volumeMetadata.driveLabel,
    vmType: volumeMetadata.vmType,

    isDisabled: false,
    // FileKey to volume's parent in the Tree.
    prefixKey: undefined,
    // A volume is by default interactive unless explicitly made
    // non-interactive.
    isInteractive: true,
  };
}

/**
 * Updates a volume from the store.
 */
export function updateVolume(
    state: State, volumeId: VolumeId, changes: Partial<Volume>): Volume|
    undefined {
  const volume = state.volumes[volumeId];
  if (!volume) {
    console.warn(`Volume not found in the store: ${volumeId}`);
    return;
  }
  return {
    ...volume,
    ...changes,
  };
}

function appendChildIfNotExisted(
    parentEntry: VolumeEntry|EntryList,
    childEntry: Entry|FilesAppEntry): boolean {
  if (!parentEntry.getUiChildren().find(
          (entry) => isSameEntry(entry, childEntry))) {
    parentEntry.addEntry(childEntry);
    return true;
  }

  return false;
}

/**
 * Given a volume info, check if we need to group it into a wrapper.
 *
 * When the "SinglePartitionFormat" flag is on, we always group removable volume
 * even there's only 1 partition, otherwise the group only happens when there
 * are more than 1 partition in the same device.
 */
function shouldGroupRemovable(
    volumes: State['volumes'], volumeInfo: VolumeInfo,
    volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata): boolean {
  if (isSinglePartitionFormatEnabled()) {
    return true;
  }
  const groupingKey = removableGroupKey(volumeMetadata);
  return Object.values<Volume>(volumes).some(v => {
    return (
        v.volumeType === VolumeType.REMOVABLE &&
        removableGroupKey(v) === groupingKey &&
        v.volumeId !== volumeInfo.volumeId);
  });
}

/** Create action to add a volume. */
const addVolumeInternal = slice.addReducer('add', addVolumeReducer);

function addVolumeReducer(currentState: State, payload: {
  volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata,
  volumeInfo: VolumeInfo,
}): State {
  const {volumeMetadata, volumeInfo} = payload;

  // Cache entries, so the reducers can use any entry from `allEntries`.
  const newVolumeEntry = new VolumeEntry(payload.volumeInfo);
  cacheEntries(currentState, [newVolumeEntry]);
  const volumeRootKey = newVolumeEntry.toURL();

  // Update isEjectable fields in the FileData.
  currentState.allEntries[volumeRootKey] = {
    ...currentState.allEntries[volumeRootKey]!,
    isEjectable: (volumeInfo.source === Source.DEVICE &&
                  volumeInfo.volumeType !== VolumeType.MTP) ||
        volumeInfo.source === Source.FILE,
  };

  const volume =
      convertVolumeInfoAndMetadataToVolume(volumeInfo, volumeMetadata);
  // Use volume entry's disabled property because that one is derived from
  // volume manager.
  volume.isDisabled = !!newVolumeEntry.disabled;

  // Handles volumes nested inside MyFiles, if local user files are allowed.
  // It creates a placeholder for MyFiles if MyFiles volume isn't mounted yet.
  const myFilesNestedVolumeTypes = getVolumeTypesNestedInMyFiles();
  const {myFilesEntry} = getMyFiles(currentState);
  // For volumes which are supposed to be nested inside MyFiles (e.g. Android,
  // Crostini, GuestOS), we need to nest them into MyFiles and remove the
  // placeholder fake entry if existed.
  if (myFilesEntry && myFilesNestedVolumeTypes.has(volume.volumeType)) {
    volume.prefixKey = myFilesEntry.toURL();

    // Nest the entry for the new volume info in MyFiles.
    const uiEntryPlaceholder = myFilesEntry.getUiChildren().find(
        childEntry => childEntry.name === newVolumeEntry.name);
    // Remove a placeholder for the currently mounting volume.
    if (uiEntryPlaceholder) {
      myFilesEntry.removeChildEntry(uiEntryPlaceholder);
      // Do not remove the placeholder ui entry from the store. Removing it from
      // the MyFiles is sufficient to prevent it from showing in the directory
      // tree. We keep it in the store (`currentState["uiEntries"]`) because
      // when the corresponding volume unmounts, we need to use its existence to
      // decide if we need to re-add the placeholder back to MyFiles.
    }
    appendChildIfNotExisted(myFilesEntry, newVolumeEntry);
  }

  // Handles MyFiles volume.
  // It nests the Android, Crostini & GuestOSes inside MyFiles.
  if (volume.volumeType === VolumeType.DOWNLOADS) {
    for (const v of Object.values<Volume>(currentState.volumes)) {
      if (myFilesNestedVolumeTypes.has(v.volumeType)) {
        v.prefixKey = volumeRootKey;
      }
    }

    // Do not use myFilesEntry above, because at this moment both fake MyFiles
    // and real MyFiles are in the store.
    const myFilesEntryList =
        getEntry(currentState, myFilesEntryListKey) as EntryList;
    if (myFilesEntryList) {
      // We need to copy the children of the entry list to the real volume
      // entry.
      const uiChildren = [...myFilesEntryList.getUiChildren()];
      for (const childEntry of uiChildren) {
        appendChildIfNotExisted(newVolumeEntry, childEntry);
        myFilesEntryList.removeChildEntry(childEntry);
      }
      // Remove MyFiles entry list from the uiEntries.
      currentState.uiEntries = currentState.uiEntries.filter(
          uiEntryKey => uiEntryKey !== myFilesEntryListKey);
    }
  }

  // Handles Drive volume.
  // It nests the Drive root (aka MyDrive) inside a EntryList for "Google
  // Drive", and also the fake entries for "Offline" and "Shared with me".
  if (volume.volumeType === VolumeType.DRIVE) {
    let driveFakeRoot: EntryList|null =
        getEntry(currentState, driveRootEntryListKey) as EntryList;
    if (!driveFakeRoot) {
      driveFakeRoot =
          new EntryList(str('DRIVE_DIRECTORY_LABEL'), RootType.DRIVE_FAKE_ROOT);
      cacheEntries(currentState, [driveFakeRoot]);
    }
    // When Drive is disabled via pref change, the root key in `uiEntries` will
    // be removed immediately but the corresponding entry in `allEntries` is
    // removed asynchronously. When Drive is enabled again, it's possible the
    // entry is still in `allEntries` but we don't have root key in `uiEntries`.
    if (!currentState.uiEntries.includes(driveFakeRoot.toURL())) {
      currentState.uiEntries =
          [...currentState.uiEntries, driveFakeRoot.toURL()];
    }
    // We want the order to be
    // - My Drive
    // - Shared Drives (if the user has any)
    // - Computers (if the user has any)
    // - Shared with me
    // - Offline
    //
    // Clear all existing UI children to make sure we can maintain the append
    // order. For example: when Drive is disconnected and then reconnected, if
    // we don't clear current children, all other children are still there and
    // only "My Drive" will be re-added at the end.
    driveFakeRoot.removeAllChildren();
    driveFakeRoot.addEntry(newVolumeEntry);

    const {sharedDriveDisplayRoot, computersDisplayRoot, fakeEntries} =
        volumeInfo;
    // Add "Shared drives" (team drives) grand root into Drive. It's guaranteed
    // to be resolved at this moment because ADD_VOLUME action will only be
    // triggered after resolving all roots.
    if (sharedDriveDisplayRoot) {
      cacheEntries(currentState, [sharedDriveDisplayRoot]);
      driveFakeRoot.addEntry(sharedDriveDisplayRoot);
    }

    // Add "Computer" grand root into Drive. It's guaranteed to be resolved at
    // this moment because ADD_VOLUME action will only be triggered after
    // resolving all roots.
    if (computersDisplayRoot) {
      cacheEntries(currentState, [computersDisplayRoot]);
      driveFakeRoot.addEntry(computersDisplayRoot);
    }

    // Add "Shared with me" into Drive.
    const fakeSharedWithMe = fakeEntries[RootType.DRIVE_SHARED_WITH_ME];
    if (fakeSharedWithMe) {
      cacheEntries(currentState, [fakeSharedWithMe]);
      currentState.uiEntries =
          [...currentState.uiEntries, fakeSharedWithMe.toURL()];
      driveFakeRoot.addEntry(fakeSharedWithMe);
    }

    // Add "Offline" into Drive.
    const fakeOffline = fakeEntries[RootType.DRIVE_OFFLINE];
    if (fakeOffline) {
      cacheEntries(currentState, [fakeOffline]);
      currentState.uiEntries = [...currentState.uiEntries, fakeOffline.toURL()];
      driveFakeRoot.addEntry(fakeOffline);
    }

    volume.prefixKey = driveFakeRoot.toURL();
  }

  // Handles Removable volume.
  // It may nest in a EntryList if one device has multiple partitions.
  if (volume.volumeType === VolumeType.REMOVABLE) {
    const groupingKey = removableGroupKey(volumeMetadata);
    const shouldGroup =
        shouldGroupRemovable(currentState.volumes, volumeInfo, volumeMetadata);

    if (shouldGroup) {
      const parentKey = makeRemovableParentKey(volumeMetadata);
      let parentEntry = getEntry(currentState, parentKey) as EntryList | null;
      if (!parentEntry) {
        parentEntry = new EntryList(
            volumeMetadata.driveLabel || '', RootType.REMOVABLE,
            volumeMetadata.devicePath);
        cacheEntries(currentState, [parentEntry]);
        currentState.uiEntries =
            [...currentState.uiEntries, parentEntry.toURL()];
      }
      // Update the siblings too.
      Object.values<Volume>(currentState.volumes)
          .filter(
              v => v.volumeType === VolumeType.REMOVABLE &&
                  removableGroupKey(v) === groupingKey,
              )
          .forEach(v => {
            const fileData = getFileData(currentState, v.rootKey!);
            if (!fileData || !fileData?.entry) {
              return;
            }
            if (!v.prefixKey) {
              v.prefixKey = parentEntry!.toURL();
              appendChildIfNotExisted(parentEntry!, fileData.entry);
              // For sub-partition from a removable volume, its children icon
              // should be UNKNOWN_REMOVABLE, and it shouldn't be ejectable.
              currentState.allEntries[v.rootKey!] = {
                ...fileData,
                icon: ICON_TYPES.UNKNOWN_REMOVABLE,
                isEjectable: false,
              };
            }
          });
      // At this point the current `newVolumeEntry` is not in `parentEntry`, we
      // need to add that to that group.
      appendChildIfNotExisted(parentEntry, newVolumeEntry);
      volume.prefixKey = parentEntry.toURL();
      // For sub-partition from a removable volume, its children icon should be
      // UNKNOWN_REMOVABLE, and it shouldn't be ejectable.
      const fileData = getFileData(currentState, volumeRootKey)!;
      currentState.allEntries[volumeRootKey] = {
        ...fileData,
        icon: ICON_TYPES.UNKNOWN_REMOVABLE,
        isEjectable: false,
      };
      currentState.allEntries[parentKey] = {
        ...getFileData(currentState, parentKey)!,
        // Removable devices with group, its parent should always be ejectable.
        isEjectable: true,
      };
    }
  }

  return {
    ...currentState,
    volumes: {
      ...currentState.volumes,
      [volume.volumeId]: volume,
    },
  };
}

export async function*
    addVolume(
        volumeInfo: VolumeInfo,
        volumeMetadata: chrome.fileManagerPrivate.VolumeMetadata):
        ActionsProducerGen {
  if (!volumeInfo.fileSystem) {
    console.error(
        'Only add to the store volumes that have successfully resolved.');
    return;
  }

  yield addVolumeInternal({volumeInfo, volumeMetadata});

  // For volume changes which involves UI children change, we need to trigger a
  // re-scan for the parent entry to populate the FileData.children with its UI
  // children.
  let fileKeyToScan: FileKey|null = null;

  const store = getStore();
  const state = store.getState();
  const myFilesNestedVolumeTypes = getVolumeTypesNestedInMyFiles();
  const {myFilesEntry} = getMyFiles(state);

  if (myFilesNestedVolumeTypes.has(volumeInfo.volumeType) ||
      volumeInfo.volumeType === VolumeType.DOWNLOADS) {
    // Adding volumes which are supposed to be nested inside MyFiles (e.g.
    // Android, Crostini, GuestOS) will modify MyFiles's UI children, re-scan
    // required.
    // Adding MyFiles volume might inherit UI children from its placeholder,
    // which modifies MyFiles's UI children, re-scan required.
    if (myFilesEntry) {
      fileKeyToScan = myFilesEntry.toURL();
    }
  }

  if (volumeInfo.volumeType === VolumeType.DRIVE) {
    // Adding Drive volume updates UI children for Drive root entry list,
    // re-scan required.
    fileKeyToScan = driveRootEntryListKey;
  }

  if (volumeInfo.volumeType === VolumeType.REMOVABLE) {
    // Adding Removable volume which requires grouping updates UI children for
    // the wrapper entry list, re-scan required.
    const shouldGroup =
        shouldGroupRemovable(state.volumes, volumeInfo, volumeMetadata);
    if (shouldGroup) {
      fileKeyToScan = makeRemovableParentKey(volumeMetadata);
    }
  }

  if (!fileKeyToScan) {
    return;
  }
  store.dispatch(readSubDirectories(fileKeyToScan));
}

/** Create action to remove a volume. */
const removeVolumeInternal = slice.addReducer('remove', removeVolumeReducer);

function removeVolumeReducer(currentState: State, payload: {
  volumeId: VolumeId,
}): State {
  delete currentState.volumes[payload.volumeId];
  currentState.volumes = {
    ...currentState.volumes,
  };

  return {...currentState};
}

export async function* removeVolume(volumeId: VolumeId): ActionsProducerGen {
  const store = getStore();
  const state = store.getState();
  const volumeToRemove: Volume|undefined = state.volumes[volumeId];
  if (!volumeToRemove) {
    // Somehow the volume is already removed from the store, do nothing.
    return;
  }

  yield removeVolumeInternal({volumeId});

  const volumeEntry = getEntry(state, volumeToRemove.rootKey!);
  if (!volumeEntry) {
    return;
  }
  if (!volumeToRemove.prefixKey) {
    return;
  }

  // We also need to remove it from its prefix entry if there is one.
  const prefixEntryFileData = getFileData(state, volumeToRemove.prefixKey);
  if (!prefixEntryFileData) {
    return;
  }
  const prefixEntry = prefixEntryFileData.entry as EntryList | VolumeEntry;
  // Remove it from the prefix entry's UI children.
  prefixEntry.removeChildEntry(volumeEntry);

  // If the prefix entry is an entry list for removable partitions, and this
  // is the last child, remove the prefix entry.
  if (prefixEntry.rootType === RootType.REMOVABLE &&
      prefixEntry.getUiChildren().length === 0) {
    store.dispatch(removeUiEntry(volumeToRemove.prefixKey));
    // No scan is required because the prefix entry is removed.
    return;
  }
  // If the volume entry is under MyFiles, we need to add the placeholder
  // entry back after the corresponding volume is removed (e.g.
  // Crostini/Play files).
  const volumeTypesNestedInMyFiles = getVolumeTypesNestedInMyFiles();
  const uiEntryKey = state.uiEntries.find(entryKey => {
    const uiEntry = getEntry(state, entryKey)!;
    return uiEntry.name === volumeEntry.name;
  });
  if (volumeTypesNestedInMyFiles.has(volumeToRemove.volumeType) && uiEntryKey) {
    // Re-add the corresponding placeholder ui entry to the UI children.
    const uiEntry = getEntry(state, uiEntryKey)!;
    prefixEntry.addEntry(uiEntry);
  }
  // The UI children for the prefix entry has been changed, re-scan required.
  store.dispatch(readSubDirectories(volumeToRemove.prefixKey));
}

/** Create action to update isInteractive for a volume. */
export const updateIsInteractiveVolume =
    slice.addReducer('set-is-interactive', updateIsInteractiveVolumeReducer);

function updateIsInteractiveVolumeReducer(currentState: State, payload: {
  volumeId: VolumeId,
  isInteractive: boolean,
}): State {
  const volumes = {
    ...currentState.volumes,
  };

  const updatedVolume = {
    ...volumes[payload.volumeId],
    isInteractive: payload.isInteractive,
  } as Volume;

  return {
    ...currentState,
    volumes: {
      ...volumes,
      [payload.volumeId]: updatedVolume,
    },
  };
}

slice.addReducer(
    updateDeviceConnectionState.type, updateDeviceConnectionStateReducer);

function updateDeviceConnectionStateReducer(
    currentState: State,
    payload: GetActionFactoryPayload<typeof updateDeviceConnectionState>):
    State {
  let volumes: State['volumes']|undefined;

  // Find ODFS volume(s) and disable it (or them) if offline.
  const disableODFS = payload.connection ===
      chrome.fileManagerPrivate.DeviceConnectionState.OFFLINE;
  for (const volume of Object.values<Volume>(currentState.volumes)) {
    if (!isOneDriveId(volume.providerId) || volume.isDisabled === disableODFS) {
      continue;
    }
    const updatedVolume =
        updateVolume(currentState, volume.volumeId, {isDisabled: disableODFS});
    if (updatedVolume) {
      if (!volumes) {
        volumes = {
          ...currentState.volumes,
          [volume.volumeId]: updatedVolume,
        };
      } else {
        volumes[volume.volumeId] = updatedVolume;
      }
    }
    // Make the ODFS FileData/VolumeEntry consistent with its volume in the
    // store.
    updateFileDataInPlace(
        currentState, volume.rootKey!, {disabled: disableODFS});
    const odfsVolumeEntry =
        getEntry(currentState, volume.rootKey!) as VolumeEntry;
    if (odfsVolumeEntry) {
      odfsVolumeEntry.disabled = disableODFS;
    }
  }

  return volumes ? {...currentState, volumes} : currentState;
}