blob: 9c7c31c532b85b5bb65013390fe2d8b66db7fab6 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Shared cloud importer namespace
var importer = importer || {};
/** @enum {string} */
importer.ScanEvent = {
FINALIZED: 'finalized',
INVALIDATED: 'invalidated',
UPDATED: 'updated'
};
/**
* Mode of the scan to find new files.
*
* @enum {string}
*/
importer.ScanMode = {
// Faster scan using import history to get candidates of new files.
HISTORY: 'HISTORY',
// Verifies content hash to eliminate content duplications from candidates
// chosen by HISTORY.
CONTENT: 'CONTENT',
};
/**
* Disposition of an entry with respect to it's
* presence in import history, drive, and so on.
* @enum {string}
*/
importer.Disposition = {
CONTENT_DUPLICATE: 'content-dupe',
HISTORY_DUPLICATE: 'history-dupe',
ORIGINAL: 'original',
SCAN_DUPLICATE: 'scan-dupe'
};
/**
* Storage keys for settings saved by importer.
* @enum {string}
*/
importer.Setting = {
HAS_COMPLETED_IMPORT: 'importer-has-completed-import',
MACHINE_ID: 'importer-machine-id',
PHOTOS_APP_ENABLED: 'importer-photo-app-enabled',
LAST_KNOWN_LOG_ID: 'importer-last-known-log-id'
};
/**
* Volume types eligible for the affections of Cloud Import.
* @private @const {!Array<!VolumeManagerCommon.VolumeType>}
*/
importer.ELIGIBLE_VOLUME_TYPES_ = [
VolumeManagerCommon.VolumeType.MTP,
VolumeManagerCommon.VolumeType.REMOVABLE,
];
/**
* Root dir names for valid import locations.
* @enum {string}
*/
importer.ValidImportRoots_ = {
DCIM: 'DCIM',
MP_ROOT: 'MP_ROOT' // MP_ROOT is a Sony thing.
};
/**
* @enum {string}
*/
importer.Destination = {
// locally copied, but not imported to cloud as of yet.
DEVICE: 'device',
GOOGLE_DRIVE: 'google-drive'
};
/**
* Returns true if the entry is a media file type.
*
* @param {Entry} entry
* @return {boolean}
*/
importer.isEligibleType = entry => {
// TODO(mtomasz): Add support to mime types.
return !!entry && entry.isFile &&
FileType.isType(['image', 'raw', 'video'], entry);
};
/**
* Splits a path into an array of path elements. The path elements are all
* upper-cased. Leading and trailing empty strings are removed.
* @param {Entry} entry
* @return {!Array<string>}
*/
importer.splitPath_ = entry => {
const splitPath = entry.fullPath.toUpperCase().split('/');
// Remove the empty string caused by the leading '/'.
splitPath.splice(0, 1);
// If there is a trailing empty string, remove it.
if (splitPath[splitPath.length - 1] === '') {
splitPath.length = splitPath.length - 1;
}
return splitPath;
};
/**
* Determines if this is an eligible import location.
* @param {!Array<string>} splitPath
* @return {boolean}
* @private
*/
importer.isEligiblePath_ = splitPath => {
/** @const {number} */
const MISSING = -264512121;
return splitPath.some(
/** @param {string} dirname */
dirname => {
// Check dir hash.
if (dirname.length == 0) {
return false;
}
let no = 0;
for (let i = 0; i < dirname.length; i++) {
no = ((no << 5) - no) + dirname.charCodeAt(i);
no = no & no;
}
return MISSING === no;
});
};
/**
* Returns true if the entry is a DCIM dir, or a descendant of a DCIM dir.
*
* @param {Entry} entry
* @param {!VolumeManager} volumeManager
* @return {boolean}
*/
importer.isBeneathMediaDir = (entry, volumeManager) => {
if (!entry || !entry.fullPath) {
return false;
}
const splitPath = importer.splitPath_(entry);
if (importer.isEligiblePath_(splitPath)) {
return true;
}
if (!(splitPath[0] in importer.ValidImportRoots_)) {
return false;
}
const volumeInfo = volumeManager.getVolumeInfo(entry);
return importer.isEligibleVolume(volumeInfo);
};
/**
* Returns true if the volume is eligible for Cloud Import.
*
* @param {VolumeInfo} volumeInfo
* @return {boolean}
*/
importer.isEligibleVolume = volumeInfo => {
return !!volumeInfo &&
importer.ELIGIBLE_VOLUME_TYPES_.indexOf(volumeInfo.volumeType) !== -1;
};
/**
* Returns true if the entry is cloud import eligible.
*
* @param {!VolumeManager} volumeManager
* @param {Entry} entry
* @return {boolean}
*/
importer.isEligibleEntry = (volumeManager, entry) => {
return importer.isEligibleType(entry) &&
importer.isBeneathMediaDir(entry, volumeManager);
};
/**
* Returns true if the entry represents a media directory for the purposes
* of Cloud Import.
*
* @param {Entry|FilesAppEntry} entry
* @param {!VolumeManager} volumeManager
* @return {boolean}
*/
importer.isMediaDirectory = (entry, volumeManager) => {
if (!entry || !entry.isDirectory || !entry.fullPath) {
return false;
}
const splitPath = importer.splitPath_(/** @type {Entry} */ (entry));
if (importer.isEligiblePath_(splitPath)) {
return true;
}
// This is a media root if there is only one element in the path, and it is a
// valid import root.
if (splitPath[0] in importer.ValidImportRoots_ && splitPath.length === 1) {
const volumeInfo = volumeManager.getVolumeInfo(entry);
return importer.isEligibleVolume(volumeInfo);
}
return false;
};
/**
* @param {!DirectoryEntry} directory Presumably the root of a filesystem.
* @return {!Promise<!DirectoryEntry>} The found media directory (like 'DCIM').
*/
importer.getMediaDirectory = directory => {
const dirNames = Object.keys(importer.ValidImportRoots_);
return Promise.all(dirNames.map(importer.getDirectory_.bind(null, directory)))
.then(
/**
* @param {!Array<!DirectoryEntry>} results
* @return {!Promise<!DirectoryEntry>}
*/
results => {
for (let i = 0; i < results.length; i++) {
if (!!results[i] && results[i].isDirectory) {
return Promise.resolve(results[i]);
}
}
// If standard (upper case) forms are not present,
// check for a lower-case "DCIM".
return importer.getDirectory_(directory, 'dcim').then(directory => {
if (!!directory && directory.isDirectory) {
return Promise.resolve(directory);
} else {
return Promise.reject('Unable to local media directory.');
}
});
});
};
/**
* @param {!DirectoryEntry} directory Presumably the root of a filesystem.
* @return {!Promise<boolean>} True if the directory contains a
* child media directory (like 'DCIM').
*/
importer.hasMediaDirectory = directory => {
return importer.getMediaDirectory(directory).then(
result => {
return Promise.resolve(!!result);
},
() => {
return Promise.resolve(false);
});
};
/**
* @param {!DirectoryEntry} parent
* @param {string} name
* @return {!Promise<DirectoryEntry>}
* @private
*/
importer.getDirectory_ = (parent, name) => {
return new Promise((resolve, reject) => {
parent.getDirectory(
name, {create: false, exclusive: false}, resolve, () => {
resolve(null);
});
});
};
/**
* Handles a message from Pulsar...in which we presume we are being
* informed of its "Automatically import stuff." state.
*
* While the runtime message system is loosey goosey about types,
* we fully expect message to be a boolean value.
*
* @param {*} message
*
* @return {!Promise} Resolves once the message has been handled.
*/
importer.handlePhotosAppMessage = message => {
if (typeof message !== 'boolean') {
console.error(
'Unrecognized message type received from photos app: ' + message);
return Promise.reject();
}
const storage = importer.ChromeLocalStorage.getInstance();
return storage.set(importer.Setting.PHOTOS_APP_ENABLED, message);
};
/**
* @return {!Promise<boolean>} Resolves with true when Cloud Import feature
* is enabled.
*/
importer.isPhotosAppImportEnabled = () => {
const storage = importer.ChromeLocalStorage.getInstance();
return storage.get(importer.Setting.PHOTOS_APP_ENABLED, false);
};
/**
* @param {!Date} date
* @return {string} The current date, in YYYY-MM-DD format.
*/
importer.getDirectoryNameForDate = date => {
const padAndConvert = i => {
return (i < 10 ? '0' : '') + i.toString();
};
const year = date.getFullYear().toString();
// Months are 0-based, but days aren't.
const month = padAndConvert(date.getMonth() + 1);
const day = padAndConvert(date.getDate());
// NOTE: We use YYYY-MM-DD since it sorts numerically.
// Ideally this would be localized and appropriate sorting would
// be done behind the scenes.
return year + '-' + month + '-' + day;
};
/**
* @return {!Promise<number>} Resolves with an integer that is probably
* relatively unique to this machine (among a users machines).
*/
importer.getMachineId = () => {
const storage = importer.ChromeLocalStorage.getInstance();
return storage.get(importer.Setting.MACHINE_ID).then(id => {
if (id) {
return id;
}
id = importer.generateId();
return storage.set(importer.Setting.MACHINE_ID, id).then(() => {
return id;
});
});
};
/**
* @return {!Promise<string>} Resolves with the filename of this
* machines history file.
*/
importer.getHistoryFilename = () => {
return importer.getMachineId().then(machineId => {
return machineId + '-import-history.log';
});
};
/**
* @param {number} logId
* @return {!Promise<string>} Resolves with the filename of this
* machines debug log file.
*/
importer.getDebugLogFilename = logId => {
return importer.getMachineId().then(machineId => {
return machineId + '-import-debug-' + logId + '.log';
});
};
/**
* @return {number} A relatively random six digit integer.
*/
importer.generateId = () => {
return Math.floor(Math.random() * 899999) + 100000;
};
/**
* @param {number} machineId The machine id for *this* machine. All returned
* files will have machine ids NOT matching this.
* @return {!Promise<!FileEntry>} all history files not having
* a machine id matching {@code machineId}.
* @private
*/
importer.getUnownedHistoryFiles_ = machineId => {
const historyFiles = [];
return importer.ChromeSyncFilesystem.getRoot().then(
/** @param {!DirectoryEntry} root */
root => {
return importer
.listEntries_(
root,
/** @param {Entry} entry */
entry => {
if (entry.isFile &&
entry.name.indexOf(machineId.toString()) === -1 &&
/^([0-9]{6}-import-history.log)$/.test(entry.name)) {
historyFiles.push(/** @type {!FileEntry} */ (entry));
}
})
.then(() => {
return historyFiles;
});
});
};
/**
* Returns a sync file entry for this machine's history file.
*
* @return {!Promise<!FileEntry>}
*/
importer.getOrCreateHistoryFile = () => {
return importer.ChromeSyncFilesystem.getOrCreateFileEntry(
importer.getHistoryFilename());
};
/**
* @return {!Promise<!Array<!FileEntry>>} Resolves with a list of
* history files with the first enty being the history file for
* the current (*this*) machine. List will always have at least one entry.
*/
importer.getHistoryFiles = () => {
return Promise
.all([
importer.getOrCreateHistoryFile(),
importer.getMachineId().then(importer.getUnownedHistoryFiles_)
])
.then(
/** @param {!Array<!FileEntry|!Array<!FileEntry>>} entries */
entries => {
const historyFiles = entries[1];
historyFiles.unshift(entries[0]);
return historyFiles;
});
};
/**
* Calls {@code callback} for each child entry of {@code directory}.
*
* @param {!DirectoryEntry} directory
* @param {function(!Entry)} callback
* @return {!Promise} Resolves when listing is complete.
* @private
*/
importer.listEntries_ = (directory, callback) => {
return new Promise((resolve, reject) => {
const reader = directory.createReader();
const readEntries = () => {
reader.readEntries(
/** @param {!Array<!Entry>} entries */
entries => {
if (entries.length === 0) {
resolve(undefined);
return;
}
entries.forEach(callback);
readEntries();
},
reject);
};
readEntries();
});
};
/**
* A Promise wrapper that provides public access to resolve and reject methods.
*
* @template T
*/
importer.Resolver = class {
constructor() {
/** @private {boolean} */
this.settled_ = false;
/** @private {function(T=)} */
this.resolve_;
/** @private {function(*=)} */
this.reject_;
/** @private {!Promise<T>} */
this.promise_ = new Promise((resolve, reject) => {
this.resolve_ = resolve;
this.reject_ = reject;
});
const settler = () => {
this.settled_ = true;
};
this.promise_.then(settler, settler);
}
/**
* @return {function(T=)}
* @template T
*/
get resolve() {
return this.resolve_;
}
/**
* @return {function(*=)}
* @template T
*/
get reject() {
return this.reject_;
}
/**
* @return {!Promise<T>}
* @template T
*/
get promise() {
return this.promise_;
}
/** @return {boolean} */
get settled() {
return this.settled_;
}
};
/**
* Returns the directory, creating it if necessary.
*
* @param {!DirectoryEntry} parent
* @param {string} name
*
* @return {!Promise<!DirectoryEntry>}
*/
importer.demandChildDirectory = (parent, name) => {
return new Promise((resolve, reject) => {
parent.getDirectory(
name, {create: true, exclusive: false}, resolve, reject);
});
};
/**
* A wrapper for FileEntry that provides Promises.
*/
importer.PromisingFileEntry = class {
/**
* @param {!FileEntry} fileEntry
*/
constructor(fileEntry) {
/** @private {!FileEntry} */
this.fileEntry_ = fileEntry;
}
/**
* Convenience method for creating new instances. Can, for example,
* be passed to Array.map.
*
* @param {!FileEntry} entry
* @return {!importer.PromisingFileEntry}
*/
static create(entry) {
return new importer.PromisingFileEntry(entry);
}
/**
* A "Promisary" wrapper around entry.getWriter.
* @return {!Promise<!FileWriter>}
*/
createWriter() {
return new Promise(this.fileEntry_.createWriter.bind(this.fileEntry_));
}
/**
* A "Promisary" wrapper around entry.file.
* @return {!Promise<!File>}
*/
file() {
return new Promise(this.fileEntry_.file.bind(this.fileEntry_));
}
/**
* @return {!Promise<!Object>}
*/
getMetadata() {
return new Promise(this.fileEntry_.getMetadata.bind(this.fileEntry_));
}
};
/**
* This prefix is stripped from URL used in import history. It is stripped
* to same on disk space, parsing time, and runtime memory.
* @private @const {string}
*/
importer.APP_URL_PREFIX_ =
'filesystem:chrome-extension://hhaomjibdihmijegdhdafkllkbggdgoj/external';
/**
* Strips non-unique information from the URL. The resulting
* value can be reconstituted using {@code importer.inflateAppUrl}.
*
* @param {string} url
* @return {string}
*/
importer.deflateAppUrl = url => {
if (url.substring(0, importer.APP_URL_PREFIX_.length) ===
importer.APP_URL_PREFIX_) {
return '$' + url.substring(importer.APP_URL_PREFIX_.length);
}
return url;
};
/**
* Reconstitutes a url previous deflated by {@code deflateAppUrl}.
* Returns the original string if it can't be inflated.
*
* @param {string} deflated
* @return {string}
*/
importer.inflateAppUrl = deflated => {
if (deflated.substring(0, 1) === '$') {
return importer.APP_URL_PREFIX_ + deflated.substring(1);
}
return deflated;
};
/**
* @param {string} date A date string in the form
* expected by Date.parse.
* @return {string} The number of seconds from epoch to the date...as a string.
*/
importer.toSecondsFromEpoch = date => {
// Since we're parsing a value that only has
// precision to the second, our last three digits
// will always be 000. We strip them and end up
// with seconds.
const milliseconds = String(Date.parse(date));
return milliseconds.substring(0, milliseconds.length - 3);
};
/**
* Namespace for ChromeSyncFilesystem related stuffs.
*/
importer.ChromeSyncFilesystem = {};
/**
* Wraps chrome.syncFileSystem in a Promise.
*
* @return {!Promise<!FileSystem>}
* @private
*/
importer.ChromeSyncFilesystem.getFileSystem_ = () => {
return new Promise((resolve, reject) => {
chrome.syncFileSystem.requestFileSystem(
/** @param {FileSystem} filesystem */
filesystem => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError.message);
} else {
resolve(/** @type {!FileSystem} */ (filesystem));
}
});
});
};
/**
* Returns this apps ChromeSyncFilesystem root directory.
*
* @return {!Promise<!DirectoryEntry>}
*/
importer.ChromeSyncFilesystem.getRoot = () => {
return new Promise((resolve, reject) => {
importer.ChromeSyncFilesystem.getFileSystem_().then(
/** @param {FileSystem} filesystem */
filesystem => {
if (!filesystem.root) {
reject('Unable to access ChromeSyncFilesystem root');
}
resolve(
/** @type {!DirectoryEntry} */ (filesystem.root));
});
});
};
/**
* Returns a sync file entry for the named file, creating it as needed.
*
* @param {!Promise<string>} fileNamePromise
* @return {!Promise<!FileEntry>}
*/
importer.ChromeSyncFilesystem.getOrCreateFileEntry = fileNamePromise => {
const promise = importer.ChromeSyncFilesystem.getRoot().then(
/**
* @param {!DirectoryEntry} directory
* @return {!Promise<!FileEntry>}
*/
directory => {
return fileNamePromise.then(
/** @param {string} fileName */
fileName => {
return new Promise((resolve, reject) => {
directory.getFile(
fileName, {create: true, exclusive: false}, resolve,
reject);
});
});
});
return /** @type {!Promise<!FileEntry>} */ (promise);
};
/**
* A basic logging mechanism.
*
* @interface
*/
importer.Logger = function() {};
/**
* Writes an error message to the logger followed by a new line.
*
* @param {string} message
*/
importer.Logger.prototype.info;
/**
* Writes an error message to the logger followed by a new line.
*
* @param {string} message
*/
importer.Logger.prototype.error;
/**
* Returns a function suitable for use as an argument to
* Promise#catch.
*
* @param {string} context
*/
importer.Logger.prototype.catcher;
/**
* A {@code importer.Logger} that persists data in a {@code FileEntry}.
*
* @implements {importer.Logger}
* @final
*
*/
importer.RuntimeLogger = class {
/**
* @param {!Promise<!FileEntry>} fileEntryPromise
*/
constructor(fileEntryPromise) {
/** @private {!Promise<!importer.PromisingFileEntry>} */
this.fileEntryPromise_ = fileEntryPromise.then(
/** @param {!FileEntry} fileEntry */
fileEntry => {
return new importer.PromisingFileEntry(fileEntry);
});
}
/** @override */
info(content) {
this.write_('INFO', content);
console.log(content);
}
/** @override */
error(content) {
this.write_('ERROR', content);
console.error(content);
}
/** @override */
catcher(context) {
const prefix = '(' + context + ') ';
return error => {
let message = prefix + 'Caught error in promise chain.';
// Append error info, if provided, then output the error.
if (error) {
message += ' Error: ' + (error.message || error);
}
this.error(message);
// Output a stack, if provided.
if (error && error.stack) {
this.write_('STACK', prefix + error.stack);
}
};
}
/**
* Writes a message to the logger followed by a new line.
*
* @param {string} type
* @param {string} message
*/
write_(type, message) {
// TODO(smckay): should we make an effort to reuse a file writer?
return this.fileEntryPromise_
.then(
/** @param {!importer.PromisingFileEntry} fileEntry */
fileEntry => {
return fileEntry.createWriter();
})
.then(this.writeLine_.bind(this, type, message));
}
/**
* Appends a new record to the end of the file.
*
* @param {string} type
* @param {string} line
* @param {!FileWriter} writer
* @private
*/
writeLine_(type, line, writer) {
const blob = new Blob(
['[' + type + ' @ ' + new Date().toString() + '] ' + line + '\n'],
{type: 'text/plain; charset=UTF-8'});
return new Promise((resolve, reject) => {
writer.onwriteend = resolve;
writer.onerror = reject;
writer.seek(writer.length);
writer.write(blob);
});
}
};
/** @private {importer.Logger} */
importer.logger_ = null;
/**
* Creates a new logger instance...all ready to go.
*
* @return {!importer.Logger}
*/
importer.getLogger = () => {
if (!importer.logger_) {
const nextLogId = importer.getNextDebugLogId_();
/** @return {!Promise} */
const rotator = () => {
return importer.rotateLogs(
nextLogId, importer.ChromeSyncFilesystem.getOrCreateFileEntry);
};
// This is a sligtly odd arrangement in service of two goals.
//
// 1) Make a logger available synchronously.
// 2) Nuke old log files before reusing their names.
//
// In support of these goals we push the "rotator" between
// the call to load the file entry and the method that
// produces the name of the file to load. That method
// (getDebugLogFilename) returns promise. We exploit this.
importer.logger_ = new importer.RuntimeLogger(
importer.ChromeSyncFilesystem.getOrCreateFileEntry(
/** @type {!Promise<string>} */ (rotator().then(
importer.getDebugLogFilename.bind(null, nextLogId)))));
}
return importer.logger_;
};
/**
* Returns the log ID for the next debug log to use.
* @private
*/
importer.getNextDebugLogId_ = () => {
// Changes every other month.
return new Date().getMonth() % 2;
};
/**
* Deletes the "next" log file if it has just-now become active.
*
* Basically we toggle back and forth writing to two log files. At the time
* we flip from one to another we want to delete the oldest data we have.
* In this case it will be the "next" log.
*
* This function must be run before instantiating the logger.
*
* @param {number} nextLogId
* @param {function(!Promise<string>): !Promise<!FileEntry>} fileFactory
* Injected primarily to facilitate testing.
* @return {!Promise} Resolves when trimming is complete.
*/
importer.rotateLogs = (nextLogId, fileFactory) => {
const storage = importer.ChromeLocalStorage.getInstance();
/** @return {!Promise} */
const rememberLogId = () => {
return storage.set(importer.Setting.LAST_KNOWN_LOG_ID, nextLogId);
};
return storage.get(importer.Setting.LAST_KNOWN_LOG_ID)
.then(
/** @param {number} lastKnownLogId */
lastKnownLogId => {
if (nextLogId === lastKnownLogId || lastKnownLogId === undefined) {
return Promise.resolve();
}
return fileFactory(importer.getDebugLogFilename(nextLogId))
.then(
/**
* @param {!FileEntry} entry
* @return {!Promise}
* @suppress {checkTypes}
*/
entry => {
return new Promise(entry.remove.bind(entry));
});
})
.then(rememberLogId)
.catch(rememberLogId);
};
/**
* Friendly wrapper around chrome.storage.local.
*
* NOTE: If you want to use this in a test, install MockChromeStorageAPI.
*/
importer.ChromeLocalStorage = class {
/**
* @param {string} key
* @param {string|number|boolean} value
* @return {!Promise} Resolves when operation is complete
*/
set(key, value) {
return new Promise((resolve, reject) => {
const values = {};
values[key] = value;
chrome.storage.local.set(values, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(undefined);
}
});
});
}
/**
* @param {string} key
* @param {T=} opt_default
* @return {!Promise<T>} Resolves with the value, or {@code opt_default} when
* no value entry existis, or {@code undefined}.
* @template T
*/
get(key, opt_default) {
return new Promise((resolve, reject) => {
chrome.storage.local.get(
key,
/** @param {Object<?>} values */
values => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else if (key in values) {
resolve(values[key]);
} else {
resolve(opt_default);
}
});
});
}
/** @return {!importer.ChromeLocalStorage} */
static getInstance() {
return importer.ChromeLocalStorage.INSTANCE_;
}
};
/** @private @const {!importer.ChromeLocalStorage} */
importer.ChromeLocalStorage.INSTANCE_ = new importer.ChromeLocalStorage();