blob: feeab9ca72dd04318b6385f02105424e0eeb81a2 [file] [log] [blame]
// 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.
import {NativeEventTarget as EventTarget} from 'chrome://resources/ash/common/event_target.js';
import {getUniqueParents} from '../../common/js/api.js';
import {AsyncQueue, RateLimiter} from '../../common/js/async_util.js';
import {VolumeEntry} from '../../common/js/files_app_entry_types.js';
import {notifications} from '../../common/js/notifications.js';
import {ProgressCenterItem, ProgressItemState, ProgressItemType} from '../../common/js/progress_center_common.js';
import {getFilesAppIconURL, toFilesAppURL} from '../../common/js/url_constants.js';
import {str, strf, util} from '../../common/js/util.js';
import {DriveSyncHandler} from '../../externs/background/drive_sync_handler.js';
import {ProgressCenter} from '../../externs/background/progress_center.js';
import {DriveDialogControllerInterface} from '../../externs/drive_dialog_controller.js';
import {MetadataModelInterface} from '../../externs/metadata_model.js';
import {getStore} from '../../state/store.js';
import {fileOperationUtil} from './file_operation_util.js';
/**
* Keys in the metadata store related to individual sync status.
* @const {!Array<!chrome.fileManagerPrivate.EntryPropertyName>}
*/
const METADATA_KEYS = [
chrome.fileManagerPrivate.EntryPropertyName.SYNC_STATUS,
chrome.fileManagerPrivate.EntryPropertyName.PROGRESS,
];
/**
* @const {!chrome.fileManagerPrivate.EntryPropertyName}
*/
const SYNC_STATUS = chrome.fileManagerPrivate.EntryPropertyName.SYNC_STATUS;
/**
* @const {!chrome.fileManagerPrivate.SyncStatus}
*/
const COMPLETED = chrome.fileManagerPrivate.SyncStatus.COMPLETED;
/**
* Handler of the background page for the Drive sync events.
* @implements {DriveSyncHandler}
*/
export class DriveSyncHandlerImpl extends EventTarget {
/** @param {ProgressCenter} progressCenter */
constructor(progressCenter) {
super();
/**
* Progress center to submit the progressing item.
* @type {ProgressCenter}
* @const
* @private
*/
this.progressCenter_ = progressCenter;
/**
* The metadata model to notify entries have changed.
* @type {?MetadataModelInterface}
* @private
*/
this.metadataModel_ = null;
/**
* Predefined error ID for out of quota messages.
* @type {number}
* @const
* @private
*/
this.driveErrorIdOutOfQuota_ = 1;
/**
* Predefined error ID for shared drive out of storage messages.
* @type {number}
* @const
* @private
*/
this.driveErrorIdSharedDriveNoStorage_ = 2;
/**
* Maximum reserved ID for predefined errors.
* @type {number}
* @const
* @private
*/
this.driveErrorIdMax_ = this.driveErrorIdSharedDriveNoStorage_;
/**
* Counter for error ID.
* @type {number}
* @private
*/
this.errorIdCounter_ = this.driveErrorIdMax_ + 1;
/**
* Progress center item for sync status.
* @type {ProgressCenterItem}
* @const
* @private
*/
this.syncItem_ = new ProgressCenterItem();
this.syncItem_.id = 'drive-sync';
// Set to canceled so that it starts out hidden when sent to ProgressCenter.
this.syncItem_.state = ProgressItemState.CANCELED;
/**
* Progress center item for pinning status.
* @type {ProgressCenterItem}
* @const
* @private
*/
this.pinItem_ = new ProgressCenterItem();
this.pinItem_.id = 'drive-pin';
// Set to canceled so that it starts out hidden when sent to ProgressCenter.
this.pinItem_.state = ProgressItemState.CANCELED;
/**
* If the property is true, this item is syncing.
* @type {boolean}
* @private
*/
this.syncing_ = false;
/**
* Whether the sync is disabled on cellular network or not.
* @type {boolean}
* @private
*/
this.cellularDisabled_ = false;
/**
* Async queue.
* @type {AsyncQueue}
* @const
* @private
*/
this.queue_ = new AsyncQueue();
/**
* The length average window in calculating moving average speed of task.
* @private {number}
*/
this.SPEED_BUFFER_WINDOW_ = 30;
/**
* Speedometers to track speed and remaining time of sync.
* @const {Object<string, fileOperationUtil.Speedometer>}
* @private
*/
this.speedometers_ = {
[this.syncItem_.id]:
new fileOperationUtil.Speedometer(this.SPEED_BUFFER_WINDOW_),
[this.pinItem_.id]:
new fileOperationUtil.Speedometer(this.SPEED_BUFFER_WINDOW_),
};
Object.freeze(this.speedometers_);
/**
* Drive sync messages for each id.
* @const {Object<string, {single: string, plural: string}>}
* @private
*/
this.statusMessages_ = {
[this.syncItem_.id]:
{single: 'SYNC_FILE_NAME', plural: 'SYNC_FILE_NUMBER'},
[this.pinItem_.id]: {
single: 'OFFLINE_PROGRESS_MESSAGE',
plural: 'OFFLINE_PROGRESS_MESSAGE_PLURAL',
},
};
Object.freeze(this.statusMessages_);
/**
* Rate limiter which is used to avoid sending update request for progress
* bar too frequently.
* @private {RateLimiter}
*/
this.progressRateLimiter_ = new RateLimiter(() => {
this.progressCenter_.updateItem(this.syncItem_);
this.progressCenter_.updateItem(this.pinItem_);
}, 2000);
/**
* Saved dialog event to be sent to the next launched Files App UI window.
* @private {?chrome.fileManagerPrivate.DriveConfirmDialogEvent}
*/
this.savedDialogEvent_ = null;
/**
* A mapping of App IDs to dialogs.
* @private {Map<string, DriveDialogControllerInterface>}
*/
this.dialogs_ = new Map();
// Register events.
if (util.isInlineSyncStatusEnabled()) {
chrome.fileManagerPrivate.onIndividualFileTransfersUpdated.addListener(
this.updateSyncStateMetadata_.bind(this));
} else {
chrome.fileManagerPrivate.onFileTransfersUpdated.addListener(
this.onFileTransfersStatusReceived_.bind(this, this.syncItem_));
chrome.fileManagerPrivate.onPinTransfersUpdated.addListener(
this.onFileTransfersStatusReceived_.bind(this, this.pinItem_));
}
chrome.fileManagerPrivate.onDriveSyncError.addListener(
this.onDriveSyncError_.bind(this));
notifications.onButtonClicked.addListener(
this.onNotificationButtonClicked_.bind(this));
notifications.onClosed.addListener(this.onNotificationClosed_.bind(this));
chrome.fileManagerPrivate.onPreferencesChanged.addListener(
this.onPreferencesChanged_.bind(this));
chrome.fileManagerPrivate.onDriveConnectionStatusChanged.addListener(
this.onDriveConnectionStatusChanged_.bind(this));
chrome.fileManagerPrivate.onMountCompleted.addListener(
this.onMountCompleted_.bind(this));
// Set initial values.
this.onPreferencesChanged_();
}
/**
* @return {boolean} Whether the handler is syncing items or not.
*/
get syncing() {
return this.syncing_;
}
/**
* @param {!MetadataModelInterface} model
*/
set metadataModel(model) {
this.metadataModel_ = model;
}
/**
* Returns the completed event name.
* @return {string}
*/
getCompletedEventName() {
return DriveSyncHandlerImpl.DRIVE_SYNC_COMPLETED_EVENT;
}
/**
* Returns whether the Drive sync is currently suppressed or not.
* @return {boolean}
*/
isSyncSuppressed() {
return navigator.connection.type === 'cellular' && this.cellularDisabled_;
}
/**
* Shows a notification that Drive sync is disabled on cellular networks.
*/
showDisabledMobileSyncNotification() {
notifications.create(
DriveSyncHandlerImpl.DISABLED_MOBILE_SYNC_NOTIFICATION_ID_, {
type: 'basic',
title: str('FILEMANAGER_APP_NAME'),
message: str('DISABLED_MOBILE_SYNC_NOTIFICATION_MESSAGE'),
iconUrl: getFilesAppIconURL().toString(),
buttons:
[{title: str('DISABLED_MOBILE_SYNC_NOTIFICATION_ENABLE_BUTTON')}],
},
() => {});
}
/**
* Handles file transfer status updates and updates the given item
* accordingly.
* @param {ProgressCenterItem} item Item to update.
* @param {chrome.fileManagerPrivate.FileTransferStatus} status Transfer
* status.
* @private
*/
async onFileTransfersStatusReceived_(item, status) {
if (!this.isProcessableEvent(status)) {
return;
}
if (!status.showNotification) {
// Hide the notification by settings its state to Canceled.
item.state = ProgressItemState.CANCELED;
this.progressCenter_.updateItem(item);
return;
}
switch (status.transferState) {
case 'in_progress':
await this.updateItem_(item, status);
break;
case 'queued':
case 'completed':
case 'failed':
if ((status.hideWhenZeroJobs && status.numTotalJobs === 0) ||
(!status.hideWhenZeroJobs && status.numTotalJobs === 1)) {
await this.removeItem_(item, status);
}
break;
default:
throw new Error(
'Invalid transfer state: ' + status.transferState + '.');
}
}
/**
* Handles file transfer status updates for individual files, updating their
* sync status metadata.
* @param {!Array<!chrome.fileManagerPrivate.SyncState>}
* syncStates Updated file transfer statuses.
* @private
*/
async updateSyncStateMetadata_(syncStates) {
if (!this.metadataModel_) {
// Files app is still loading. This should have no user visible impact
// since sync status update events are constantly emitted.
return;
}
const completedUrls = [];
const completedValues = [];
const urlsToUpdate = [];
const valuesToUpdate = [];
for (const {fileUrl, syncStatus, progress} of syncStates) {
if (syncStatus === COMPLETED) {
completedUrls.push(fileUrl);
completedValues.push([syncStatus, progress, Date.now()]);
} else {
urlsToUpdate.push(fileUrl);
valuesToUpdate.push([syncStatus, progress]);
}
}
this.metadataModel_.update(urlsToUpdate, METADATA_KEYS, valuesToUpdate);
if (!completedUrls.length) {
return;
}
this.metadataModel_.update(
completedUrls,
[
...METADATA_KEYS,
chrome.fileManagerPrivate.EntryPropertyName.SYNC_COMPLETED_TIME,
],
completedValues);
// The UX spec determines completed sync status should be displayed for
// 300ms before transitioning to other statuses. Let's pull filtered states
// that have completed in 300ms from now.
await new Promise(r => setTimeout(r, 300));
const {allEntries} = getStore().getState();
// Note: Volume Entries have to be filtered out because they are not
// accepted by the `MetadataModel.get()` method.
const completedEntries = completedUrls.map(url => allEntries[url]?.entry)
.filter(Boolean)
.map(util.unwrapEntry);
if (!completedEntries.length) {
return;
}
this.metadataModel_.notifyEntriesChanged(completedEntries);
this.metadataModel_.get(completedEntries, [
...METADATA_KEYS,
chrome.fileManagerPrivate.EntryPropertyName.AVAILABLE_OFFLINE,
chrome.fileManagerPrivate.EntryPropertyName.PINNED,
]);
}
/**
* Updates the given progress status item using a transfer status update.
* @param {ProgressCenterItem} item Item to update.
* @param {chrome.fileManagerPrivate.FileTransferStatus} status Transfer
* status.
* @private
*/
async updateItem_(item, status) {
const unlock = await this.queue_.lock();
try {
item.state = ProgressItemState.PROGRESSING;
item.type = ProgressItemType.SYNC;
item.quiet = true;
this.syncing_ = true;
if (status.numTotalJobs > 1) {
item.message =
strf(this.statusMessages_[item.id].plural, status.numTotalJobs);
} else {
try {
const entry = await util.urlToEntry(status.fileUrl);
item.message = strf(this.statusMessages_[item.id].single, entry.name);
} catch (error) {
console.warn('Resolving URL ' + status.fileUrl + ' failed: ', error);
return;
}
}
item.progressValue = status.processed || 0;
item.progressMax = status.total || 0;
const speedometer = this.speedometers_[item.id];
speedometer.setTotalBytes(item.progressMax);
speedometer.update(item.progressValue);
item.remainingTime = speedometer.getRemainingTime();
this.progressRateLimiter_.run();
} finally {
unlock();
}
}
/**
* Removes an item due to the given transfer status update.
* @param {ProgressCenterItem} item Item to remove.
* @param {chrome.fileManagerPrivate.FileTransferStatus} status Transfer
* status.
* @private
*/
async removeItem_(item, status) {
const unlock = await this.queue_.lock();
try {
item.state = status.transferState === 'completed' ?
ProgressItemState.COMPLETED :
ProgressItemState.CANCELED;
this.speedometers_[item.id].reset();
this.progressCenter_.updateItem(item);
this.syncing_ = false;
this.dispatchEvent(new Event(this.getCompletedEventName()));
} finally {
unlock();
}
}
/**
* 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.
* @param {!Object} event
* @return {boolean} Whether or not the event should be processed.
*/
isProcessableEvent(event) {
const fileUrl = event.fileUrl;
if (fileUrl) {
return fileUrl.startsWith(`filesystem:${toFilesAppURL()}`);
}
return true;
}
/**
* Handles drive's sync errors.
* @param {chrome.fileManagerPrivate.DriveSyncErrorEvent} event Drive sync
* error event.
* @private
*/
async onDriveSyncError_(event) {
if (!this.isProcessableEvent(event)) {
return;
}
const postError = name => {
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 = strf('SYNC_NO_SERVER_SPACE');
item.setExtraButton(
ProgressItemState.ERROR, str('LEARN_MORE_LABEL'),
() => util.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 = DriveSyncHandlerImpl.DRIVE_SYNC_ERROR_PREFIX +
this.driveErrorIdOutOfQuota_;
break;
case 'no_server_space_organization':
item.message = strf('SYNC_NO_SERVER_SPACE_ORGANIZATION');
item.setExtraButton(
ProgressItemState.ERROR, str('LEARN_MORE_LABEL'),
() => util.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 = DriveSyncHandlerImpl.DRIVE_SYNC_ERROR_ORGANIZATION_PREFIX +
this.driveErrorIdOutOfQuota_;
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'),
() => util.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 = `${DriveSyncHandlerImpl.DRIVE_SYNC_ERROR_PREFIX}${
this.driveErrorIdSharedDriveNoStorage_}${event.sharedDrive}`;
break;
case 'misc':
item.message = strf('SYNC_MISC_ERROR', name);
break;
}
if (!item.id) {
item.id = DriveSyncHandlerImpl.DRIVE_SYNC_ERROR_PREFIX +
(this.errorIdCounter_++);
}
this.progressCenter_.updateItem(item);
};
if (!event.fileUrl) {
postError('');
return;
}
try {
const entry = await util.urlToEntry(event.fileUrl);
if (util.isInlineSyncStatusEnabled()) {
const ancestors = await getUniqueParents([entry]);
this.updateSyncStateMetadata_([entry, ...ancestors].map(
e => ({
fileUrl: e.toURL(),
// Flip files that failed to sync back to QUEUED.
syncStatus: chrome.fileManagerPrivate.SyncStatus.QUEUED,
progress: 0,
})));
}
postError(entry.name);
} catch (error) {
postError('');
}
}
/**
* Adds a dialog to be controlled by DriveSyncHandler.
* @param {string} appId App ID of window containing the dialog.
* @param {DriveDialogControllerInterface} dialog Dialog to be controlled.
*/
addDialog(appId, dialog) {
this.dialogs_.set(appId, dialog);
if (this.savedDialogEvent_) {
notifications.clear(
DriveSyncHandlerImpl.ENABLE_DOCS_OFFLINE_NOTIFICATION_ID_, () => {});
dialog.showDialog(this.savedDialogEvent_);
this.savedDialogEvent_ = null;
}
}
/**
* Removes a dialog from being controlled by DriveSyncHandler.
* @param {string} appId App ID of window containing the dialog.
*/
removeDialog(appId) {
if (this.dialogs_.has(appId) && this.dialogs_.get(appId).open) {
chrome.fileManagerPrivate.notifyDriveDialogResult(
chrome.fileManagerPrivate.DriveDialogResult.DISMISS);
this.dialogs_.delete(appId);
}
}
/**
* Handles notification's button click.
* @param {string} notificationId Notification ID.
* @param {number} buttonIndex Index of the button.
* @private
*/
onNotificationButtonClicked_(notificationId, buttonIndex) {
switch (notificationId) {
case DriveSyncHandlerImpl.DISABLED_MOBILE_SYNC_NOTIFICATION_ID_:
notifications.clear(notificationId, () => {});
chrome.fileManagerPrivate.setPreferences({cellularDisabled: false});
break;
case DriveSyncHandlerImpl.ENABLE_DOCS_OFFLINE_NOTIFICATION_ID_:
notifications.clear(notificationId, () => {});
this.savedDialogEvent_ = null;
chrome.fileManagerPrivate.notifyDriveDialogResult(
buttonIndex == 1 ?
chrome.fileManagerPrivate.DriveDialogResult.ACCEPT :
chrome.fileManagerPrivate.DriveDialogResult.REJECT);
break;
}
}
/**
* Handles notifications being closed by user or system action.
* @param {string} notificationId Notification ID.
* @param {boolean} byUser True if the notification was closed by user action.
*/
onNotificationClosed_(notificationId, byUser) {
switch (notificationId) {
case DriveSyncHandlerImpl.ENABLE_DOCS_OFFLINE_NOTIFICATION_ID_:
this.savedDialogEvent_ = null;
if (byUser) {
chrome.fileManagerPrivate.notifyDriveDialogResult(
chrome.fileManagerPrivate.DriveDialogResult.DISMISS);
}
break;
}
}
/**
* Handles preferences change.
* @private
*/
onPreferencesChanged_() {
chrome.fileManagerPrivate.getPreferences(pref => {
this.cellularDisabled_ = pref.cellularDisabled;
});
}
/**
* 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 == 'offline' && state.reason == 'no_network' &&
this.syncing_) {
this.syncing_ = false;
this.syncItem_.state = ProgressItemState.CANCELED;
this.pinItem_.state = ProgressItemState.CANCELED;
this.progressCenter_.updateItem(this.syncItem_);
this.progressCenter_.updateItem(this.pinItem_);
this.dispatchEvent(new Event(this.getCompletedEventName()));
}
});
}
/**
* Handles mount events to handle Drive mounting and unmounting.
* @param {chrome.fileManagerPrivate.MountCompletedEvent} event Mount
* completed event.
* @private
*/
onMountCompleted_(event) {
if (event.eventType ===
chrome.fileManagerPrivate.MountCompletedEventType.UNMOUNT &&
event.volumeMetadata.volumeType ===
chrome.fileManagerPrivate.VolumeType.DRIVE) {
notifications.clear(
DriveSyncHandlerImpl.ENABLE_DOCS_OFFLINE_NOTIFICATION_ID_, () => {});
}
}
}
/**
* Completed event name.
* @type {string}
* @private
* @const
*/
DriveSyncHandlerImpl.DRIVE_SYNC_COMPLETED_EVENT = 'completed';
/**
* Notification ID of the disabled mobile sync notification.
* @type {string}
* @private
* @const
*/
DriveSyncHandlerImpl.DISABLED_MOBILE_SYNC_NOTIFICATION_ID_ =
'disabled-mobile-sync';
/**
* Notification ID of the enable Docs Offline notification.
* @type {string}
* @private
* @const
*/
DriveSyncHandlerImpl.ENABLE_DOCS_OFFLINE_NOTIFICATION_ID_ =
'enable-docs-offline';
/**
* Drive sync error prefix for expired individual quotas.
* @type {string}
* @private
* @const
*/
DriveSyncHandlerImpl.DRIVE_SYNC_ERROR_PREFIX = 'drive-sync-error-';
/**
* Drive sync error prefix for expired organization quotas.
* @type {string}
* @private
* @const
*/
DriveSyncHandlerImpl.DRIVE_SYNC_ERROR_ORGANIZATION_PREFIX =
'drive-sync-error-organization';