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

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

/**
 * @fileoverview Handles notifications supplied by drivefs.
 */

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

import {RateLimiter} from '../../common/js/async_util.js';
import {unwrapEntry, urlToEntry} from '../../common/js/entry_utils.js';
import {ProgressCenterItem, ProgressItemState, ProgressItemType} from '../../common/js/progress_center_common.js';
import {str, strf} from '../../common/js/translations.js';
import {toFilesAppURL} from '../../common/js/url_constants.js';
import {visitURL} from '../../common/js/util.js';
import type {MetadataKey} from '../../foreground/js/metadata/metadata_item.js';
import type {MetadataModel} from '../../foreground/js/metadata/metadata_model.js';
import {getStore} from '../../state/store.js';

import type {ProgressCenter} from './progress_center.js';

/**
 * Shorthand for metadata keys.
 */
const SYNC_STATUS = 'syncStatus';
const PROGRESS = 'progress';
const SYNC_COMPLETED_TIME = 'syncCompletedTime';
const AVAILABLE_OFFLINE = 'availableOffline';
const PINNED = 'pinned';
const CAN_PIN = 'canPin';

/**
 * Shorthand for sync statuses.
 */
const {COMPLETED} = chrome.fileManagerPrivate.SyncStatus;

/**
 * Prefix for Out of Quota sync messages to ensure they reuse existing
 * notification messages instead of starting new ones.
 */
const enum DriveErrorId {
  OUT_OF_QUOTA = 1,
  SHARED_DRIVE_NO_STORAGE = 2,
  MAX_VALUE = SHARED_DRIVE_NO_STORAGE,
}

/**
 * The completed event name.
 */
const DRIVE_SYNC_COMPLETED_EVENT = 'completed';

/**
 * A list of prefixes used to disambiguate errors that come from the same source
 * to ensure separate notifications are generated.
 */
enum ErrorPrefix {
  NORMAL = 'drive-sync-error-',
  ORGANIZATION = 'drive-sync-error-organization',
}

type FileTransferStatus = chrome.fileManagerPrivate.FileTransferStatus;
type DriveSyncErrorEvent = chrome.fileManagerPrivate.DriveSyncErrorEvent;

export class DriveSyncHandlerImpl extends EventTarget {
  private metadataModel_?: MetadataModel;

  private errorIdCounter_ = DriveErrorId.MAX_VALUE + 1;

  /**
   * Recently completed URLs whose metadata should be updated after 300ms.
   */
  private completedUrls_: string[] = [];

  /**
   * With a rate limit of 200ms, update entries that have completed 300ms ago or
   * longer.
   */
  private updateCompletedRateLimiter_ = new RateLimiter(async () => {
    if (this.completedUrls_.length === 0) {
      return;
    }

    const entriesToUpdate: Entry[] = [];
    this.completedUrls_ = this.completedUrls_.filter(url => {
      const [entry, syncCompletedTime] =
          this.getEntryAndSyncCompletedTimeForUrl_(url);
      // Stop tracking URLs that are no longer in the store.
      if (!entry) {
        return false;
      }
      // Update URLs that have completed over 300ms and stop tracking them.
      if (Date.now() - syncCompletedTime > 300) {
        entriesToUpdate.push(entry);
        return false;
      }
      // Keep tracking URLs that are in the store and have completed <300ms ago.
      return true;
    });

    if (entriesToUpdate.length) {
      this.metadataModel_?.notifyEntriesChanged(entriesToUpdate);
      // TODO(austinct): Check if we can remove the `as MetadataKey[]` assertion
      // once we only have typescript bindings for fileManagerPrivate.
      this.metadataModel_?.get(entriesToUpdate, [
        SYNC_STATUS,
        PROGRESS,
        AVAILABLE_OFFLINE,
        PINNED,
        CAN_PIN,
      ] as MetadataKey[]);
    }

    this.updateCompletedRateLimiter_.run();
  }, 200);

  constructor(private progressCenter_: ProgressCenter) {
    super();

    // Register events.
    chrome.fileManagerPrivate.onIndividualFileTransfersUpdated.addListener(
        this.updateSyncStateMetadata_.bind(this));
    chrome.fileManagerPrivate.onDriveSyncError.addListener(
        this.onDriveSyncError_.bind(this));
    chrome.fileManagerPrivate.onDriveConnectionStatusChanged.addListener(
        this.onDriveConnectionStatusChanged_.bind(this));
  }

  /**
   * Sets the MetadataModel on the DriveSyncHandler.
   */
  set metadataModel(model: MetadataModel) {
    this.metadataModel_ = model;
  }

  /**
   * Returns the completed event name.
   */
  getCompletedEventName() {
    return DRIVE_SYNC_COMPLETED_EVENT;
  }

  private getEntryAndSyncCompletedTimeForUrl_(url: string):
      [Entry|null, number] {
    const entry = getStore().getState().allEntries[url]?.entry;

    if (!entry) {
      return [null, 0];
    }

    // TODO(austinct): Check if we can remove the `as MetadataKey` assertion
    // once we only have typescript bindings for fileManagerPrivate.
    const metadata = this.metadataModel_?.getCache(
        [entry], [SYNC_COMPLETED_TIME as MetadataKey])[0];

    return [
      unwrapEntry(entry) as Entry,
      metadata?.syncCompletedTime || 0,
    ];
  }

  /**
   * Handles file transfer status updates for individual files, updating their
   * sync status metadata.
   */
  private async updateSyncStateMetadata_(
      syncStates: chrome.fileManagerPrivate.SyncState[]) {
    const urlsToUpdate = [];
    const valuesToUpdate = [];

    for (const {fileUrl, syncStatus, progress} of syncStates) {
      if (syncStatus !== COMPLETED) {
        urlsToUpdate.push(fileUrl);
        valuesToUpdate.push([syncStatus, progress, 0]);
        continue;
      }

      // Only update status to completed if the previous status was different.
      // Note: syncCompletedTime is 0 if the last event wasn't completed.
      if (!this.getEntryAndSyncCompletedTimeForUrl_(fileUrl)[1]) {
        urlsToUpdate.push(fileUrl);
        valuesToUpdate.push([syncStatus, progress, Date.now()]);
        this.completedUrls_.push(fileUrl);
      }
    }

    this.metadataModel_?.update(
        urlsToUpdate, [SYNC_STATUS, PROGRESS, SYNC_COMPLETED_TIME],
        valuesToUpdate);
    this.updateCompletedRateLimiter_.run();
  }

  /**
   * Attempts to infer of the given event is processable by the drive sync
   * handler. It uses fileUrl to make a decision. It
   * errs on the side of 'yes', when passing the judgement.
   */
  isProcessableEvent(event: (FileTransferStatus|DriveSyncErrorEvent)) {
    const fileUrl = event.fileUrl;
    if (fileUrl) {
      return fileUrl.startsWith(`filesystem:${toFilesAppURL()}`);
    }
    return true;
  }

  /**
   * Handles drive's sync errors.
   */
  private async onDriveSyncError_(event: DriveSyncErrorEvent) {
    if (!this.isProcessableEvent(event)) {
      return;
    }
    const postError = (name: string) => {
      const item = new ProgressCenterItem();
      item.type = ProgressItemType.SYNC;
      item.quiet = true;
      item.state = ProgressItemState.ERROR;
      switch (event.type) {
        case 'delete_without_permission':
          item.message = strf('SYNC_DELETE_WITHOUT_PERMISSION_ERROR', name);
          break;
        case 'service_unavailable':
          item.message = str('SYNC_SERVICE_UNAVAILABLE_ERROR');
          break;
        case 'no_server_space':
          item.message = str('SYNC_NO_SERVER_SPACE');
          item.setExtraButton(
              ProgressItemState.ERROR, str('LEARN_MORE_LABEL'),
              () => visitURL(str('GOOGLE_DRIVE_MANAGE_STORAGE_URL')));

          // This error will reappear every time sync is retried, so we use
          // a fixed ID to avoid spamming the user.
          item.id = ErrorPrefix.NORMAL + DriveErrorId.OUT_OF_QUOTA;
          break;
        case 'no_server_space_organization':
          item.message = str('SYNC_NO_SERVER_SPACE_ORGANIZATION');
          item.setExtraButton(
              ProgressItemState.ERROR, str('LEARN_MORE_LABEL'),
              () => visitURL(str('GOOGLE_DRIVE_MANAGE_STORAGE_URL')));

          // This error will reappear every time sync is retried, so we use
          // a fixed ID to avoid spamming the user.
          item.id = ErrorPrefix.ORGANIZATION + DriveErrorId.OUT_OF_QUOTA;
          break;
        case 'no_local_space':
          item.message = strf('DRIVE_OUT_OF_SPACE_HEADER', name);
          break;
        case 'no_shared_drive_space':
          item.message =
              strf('SYNC_ERROR_SHARED_DRIVE_OUT_OF_SPACE', event.sharedDrive);
          item.setExtraButton(
              ProgressItemState.ERROR, str('LEARN_MORE_LABEL'),
              () =>
                  visitURL(str('GOOGLE_DRIVE_ENTERPRISE_MANAGE_STORAGE_URL')));

          // Shared drives will keep trying to sync the file until it is either
          // removed or available storage is increased. This ensures each
          // subsequent error message only ever shows once for each individual
          // shared drive.
          item.id = `${ErrorPrefix.NORMAL}${
              DriveErrorId.SHARED_DRIVE_NO_STORAGE}${event.sharedDrive}`;
          break;
        case 'misc':
          item.message = strf('SYNC_MISC_ERROR', name);
          break;
      }
      if (!item.id) {
        item.id = ErrorPrefix.NORMAL + (this.errorIdCounter_++);
      }
      this.progressCenter_.updateItem(item);
    };

    if (!event.fileUrl) {
      postError('');
      return;
    }

    try {
      this.updateSyncStateMetadata_([
        {
          fileUrl: event.fileUrl,
          syncStatus: chrome.fileManagerPrivate.SyncStatus.QUEUED,
          progress: 0,
        },
      ]);
      const entry = await urlToEntry(event.fileUrl);
      postError(entry.name);
    } catch (error) {
      postError('');
    }
  }

  /**
   * Handles connection state change.
   */
  private onDriveConnectionStatusChanged_() {
    chrome.fileManagerPrivate.getDriveConnectionState((state) => {
      // If offline, hide any sync progress notifications. When online again,
      // the Drive sync client may retry syncing and trigger
      // onFileTransfersUpdated events, causing it to be shown again.
      if (state.type ===
              chrome.fileManagerPrivate.DriveConnectionStateType.OFFLINE &&
          state.reason ===
              chrome.fileManagerPrivate.DriveOfflineReason.NO_NETWORK) {
        this.dispatchEvent(new Event(this.getCompletedEventName()));
      }
    });
  }
}