blob: a7490f357e62f7ac15fdfdd2ee2b93380d87689c [file] [log] [blame]
// Copyright 2017 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.
/**
* @fileoverview API for classes to save data and cleanup before the event page
* is shut down.
*
* Temporary data is removed once the extension version is changed, and thus
* objects should never write anything to temporary data that needs to survive
* an extension update.
*
* Persistent data is retained across extension versions and browser restarts;
* it can be removed when the user clears local storage from their profile. Do
* not store any sensitive or per-route data persistently.
*/
goog.provide('mr.PersistentData');
goog.provide('mr.PersistentDataManager');
goog.require('mr.Logger');
/**
* @interface
*/
mr.PersistentData = class {};
/**
* Alias for localStorage to deal with broken Storage interface.
* @const @private {!Object}
*/
mr.PersistentData.storageObj_ = /** @type {!Object} */ (window.localStorage);
/**
* Get the unique name of the object instance that has data to be saved.
* The name is used to isolate data from different objects.
* @return {string}
*/
mr.PersistentData.prototype.getStorageKey;
/**
* Invoked by persistent data manager when an object registers itself with the
* manager. The object should load its saved data in this method.
*/
mr.PersistentData.prototype.loadSavedData;
/**
* Implements this method to cleanup and return data that needs to be saved
* before the event page is shut down. The method should normally return a one-
* or two-element array of temporary and optional persistent data to save.
*
* Temporary data is cleared on browser restart or extension version change.
* Persistent data is retained until local storage is cleared by the browser
* profile.
*
* Any Objects must be serializable with JSON.stringify.
*
* The implementation of getData should not make any asynchronous
* calls; otherwise, there is no guarantee that asynchronous calls can finish.
*
* @return {!Array<!Object>} A one- or two-element array of data that needs to
* be saved. The first element is temporary data and the second element is
* persistent data. Return an empty array if there is no data to save. If
* only persistent data needs to be persisted, pass in undefined for the
* first element in the two-element array.
*/
mr.PersistentData.prototype.getData;
/**
* The total number of characters that can be stored in localStorage,
* approximately.
* @const {number}
*/
mr.PersistentDataManager.QUOTA_CHARS = 5200000;
/**
* The total number of characters used by persistent data. Note that writes that
* access localStorage directly may not be counted here.
* @private {number}
*/
mr.PersistentDataManager.charsUsed_ = 0;
/**
* @param {!mr.PersistentData} obj The object that may have temporary data.
* @return {T} The data saved before. Null if no data is saved.
* @template T
*/
mr.PersistentDataManager.getTemporaryData = function(obj) {
const data = window.localStorage.getItem(
mr.PersistentDataManager.getStorageKey_(obj, false));
return data ? JSON.parse(data) : null;
};
/**
* @param {!mr.PersistentData} obj The object that may have peristent data.
* @return {T} The data saved before. Null if no data is saved.
* @template T
*/
mr.PersistentDataManager.getPersistentData = function(obj) {
const data = window.localStorage.getItem(
mr.PersistentDataManager.getStorageKey_(obj, true));
return data ? JSON.parse(data) : null;
};
/**
* Registers an object so that it gets informed about onSuspend events.
*
* @param {!mr.PersistentData} obj An object that has data to save.
*/
mr.PersistentDataManager.register = function(obj) {
if (mr.PersistentDataManager.dataInstances_.has(obj.getStorageKey())) {
throw Error('Duplicate instance name ' + obj.getStorageKey());
}
mr.PersistentDataManager.dataInstances_.set(obj.getStorageKey(), obj);
obj.loadSavedData();
};
/**
* Un-Registers an object from being informed of onSuspend/Resume events.
*
* @param {!mr.PersistentData} obj An object to remove from tracking.
*/
mr.PersistentDataManager.unregister = function(obj) {
mr.PersistentDataManager.dataInstances_.delete(obj.getStorageKey());
};
/**
* @param {string} mrInstanceId The media router instance ID, which stays the
* same till Chrome restarts.
*/
mr.PersistentDataManager.initialize = function(mrInstanceId) {
let otherChars = 0;
for (let key of Object.keys(mr.PersistentData.storageObj_)) {
const itemSize = key.length + window.localStorage.getItem(key).length;
if (key.startsWith(mr.PersistentDataManager.KEY_PREFIX_)) {
mr.PersistentDataManager.charsUsed_ += itemSize;
} else {
otherChars += itemSize;
}
}
mr.PersistentDataManager.mrInstanceId_ = mrInstanceId;
if (mr.PersistentDataManager.isVersionChanged_() ||
mr.PersistentDataManager.isChromeReloaded(mrInstanceId)) {
mr.PersistentDataManager.removeTemporary_();
}
mr.PersistentDataManager.logger_.info(
'initialize: ' + mr.PersistentDataManager.charsUsed_ + ' chars used, ' +
otherChars + ' other chars');
chrome.runtime.onSuspend.addListener(mr.PersistentDataManager.onSuspend_);
};
/**
* @private {?string}
*/
mr.PersistentDataManager.mrInstanceId_ = null;
/**
* @const {mr.Logger}
* @private
*/
mr.PersistentDataManager.logger_ =
mr.Logger.getInstance('mr.PersistentDataManager');
/**
* A map from object's instance name to the object.
* @private @const {!Map<string, !mr.PersistentData>}
*/
mr.PersistentDataManager.dataInstances_ = new Map();
/** @private @const {string} */
mr.PersistentDataManager.KEY_PREFIX_ = 'mr.';
/**
* @param {!mr.PersistentData} obj
* @param {boolean} persistent
* @return {string}
* @private
*/
mr.PersistentDataManager.getStorageKey_ = function(obj, persistent) {
return mr.PersistentDataManager.KEY_PREFIX_ +
(persistent ? 'persistent.' : 'temp.') + obj.getStorageKey();
};
/**
* Checks if the extension version has changed.
* @return {boolean} True if current extension has a different version as
* the saved version.
* @private
*/
mr.PersistentDataManager.isVersionChanged_ = function() {
return !!window.localStorage.getItem('version') &&
window.localStorage.getItem('version') !==
chrome.runtime.getManifest().version;
};
/**
* Checks if the chrome has been reloaded since the last time the extension is
* loaded.
* @param {string} mrInstanceId The media router instance ID, which stays the
* same till Chrome restarts.
* @return {boolean} True if Chrome was reloaded.
*/
mr.PersistentDataManager.isChromeReloaded = function(mrInstanceId) {
return !!window.localStorage.getItem('mrInstanceId') &&
window.localStorage.getItem('mrInstanceId') !== mrInstanceId;
};
/**
* Handles onSuspend event.
* @private
*/
mr.PersistentDataManager.onSuspend_ = function() {
mr.PersistentDataManager.logger_.info('onSuspend');
mr.PersistentDataManager.write(
'version', chrome.runtime.getManifest().version);
if (mr.PersistentDataManager.mrInstanceId_) {
mr.PersistentDataManager.write(
'mrInstanceId', mr.PersistentDataManager.mrInstanceId_);
}
const logManager = mr.PersistentDataManager.dataInstances_.get('LogManager');
for (const [key, obj] of mr.PersistentDataManager.dataInstances_) {
if (obj != logManager) {
mr.PersistentDataManager.saveData(
/** @type {!mr.PersistentData} */ (obj));
}
}
// Save the data for LogManager last, so that we save the logs generated
// during saveData() calls.
if (logManager) {
mr.PersistentDataManager.saveData(logManager);
}
};
/**
* Save PersistentData object to local storage.
* @param {!mr.PersistentData} obj
*/
mr.PersistentDataManager.saveData = function(obj) {
try {
const data = obj.getData();
if (data && data[0] != undefined) {
mr.PersistentDataManager.write(
mr.PersistentDataManager.getStorageKey_(obj, false),
JSON.stringify(data[0]));
}
if (data && data[1] != undefined) {
mr.PersistentDataManager.write(
mr.PersistentDataManager.getStorageKey_(obj, true),
JSON.stringify(data[1]));
}
} catch (e) {
mr.PersistentDataManager.logger_.error(
`Error while saving data for ${obj.getStorageKey()}: ${e.message}`);
}
};
/**
* Writes value to localStorage under key. If the value is too large to fit
* into the remaining localStorage quota, temporary data is first removed. If
* the value still won't fit, an exception is thrown.
* @param {string} key The localStorage key.
* @param {string} value The value to write.
*/
mr.PersistentDataManager.write = function(key, value) {
const dm = mr.PersistentDataManager;
let sizeDelta = 0;
const currentValue = window.localStorage.getItem(key);
if (currentValue != null) {
sizeDelta = value.length - currentValue.length;
} else {
sizeDelta = key.length + value.length;
}
if (dm.charsUsed_ + sizeDelta > dm.QUOTA_CHARS) {
mr.PersistentDataManager.logger_.warning(
'Unable to write ' + sizeDelta + ' chars');
dm.removeTemporary_();
}
if (dm.charsUsed_ + sizeDelta > dm.QUOTA_CHARS) {
dm.logger_.error(
'Unable to write ' + sizeDelta + ' chars after clearing temporary');
throw Error(
`Setting the value of '${key}' would exceed the quota, ` +
'according to accounting.');
}
try {
window.localStorage.setItem(key, value);
} catch (error) {
throw Error(
`Setting the value of '${key}' would exceed the quota, ` +
'according to the browser.');
}
// Adjusting dm.charsUsed_ only after the call to setItem() has succeeded.
dm.charsUsed_ += sizeDelta;
};
/**
* Writes a Blob to localStorage under the given key, making space-efficient use
* of localStorage by encoding two of the Blob's bytes into each DOMString
* character. Use readBlob() to read the value back.
* @param {string} key The localStorage key.
* @param {!Blob} value The value to write.
* @return {!Promise<void>} Resolves once the Blob has been written; or rejects
* if it won't fit.
*/
mr.PersistentDataManager.writeBlob = function(key, value) {
// The byte size needs to be a multiple of two, since each string character
// code is a 16-bit unsigned integer. Thus, append padding byte(s) to the end
// of the Blob. These will also be used to indicate whether the original Blob
// was of even or odd length when reading back later.
if (value.size % 2 == 0) {
value = new Blob([value, new Uint8Array([0, 0])]);
} else {
value = new Blob([value, new Uint8Array([1])]);
}
return new Promise((resolve, reject) => {
// Use FileReader to gain access to the Blob content via an ArrayBuffer.
const reader = new FileReader();
reader.onloadend = () => {
if (reader.error) {
reject(reader.error);
return;
}
try {
const buffer = /** @type {!ArrayBuffer} */ (reader.result);
// Convert the buffer bytes into a string, storing two bytes in each of
// the string's characters for space efficiency. This is done in batches
// to avoid smashing the call stack when calling String.fromCharCode().
const batchSize = 8192;
const pieces = [];
for (let pos = 0, end = buffer.byteLength; pos < end;
pos += batchSize) {
// Note: The byteLength will always be an even number since all input
// values to the following expression must be even numbers:
const byteLengthOfChunk = Math.min(end - pos, batchSize);
pieces.push(String.fromCharCode.apply(
null, new Uint16Array(buffer, pos, byteLengthOfChunk / 2)));
}
// Finally, join the pieces into a single string and attempt to store
// the string using the quota management heuristics in write().
mr.PersistentDataManager.write(key, pieces.join(''));
resolve();
} catch (error) {
reject(error);
}
};
reader.readAsArrayBuffer(value);
});
};
/**
* Reads a Blob from localStorage under the given key. Returns null if it does
* not exist.
* @param {string} key The localStorage key.
* @param {Object=} blobOptions The options for the reconstituted Blob (e.g.,
* {'type': 'application/gzip'}).
* @return {?Blob}
*/
mr.PersistentDataManager.readBlob = function(key, blobOptions) {
const asString = window.localStorage.getItem(key);
if (asString == null || asString.length < 1) {
return null;
}
const charCodes = new Uint16Array(asString.length);
for (let i = 0; i < asString.length; ++i) {
charCodes[i] = asString.charCodeAt(i);
}
// Determine, from the last byte value, whether the original Blob was of even
// or odd length (see writeBlob()). Create a Blob from a view of all but the
// padding byte(s) at the end of the buffer.
const buffer = charCodes.buffer;
const flagByte = (new Uint8Array(buffer, buffer.byteLength - 1, 1))[0];
const viewOfPayload =
new Uint8Array(buffer, 0, buffer.byteLength - ((flagByte == 0) ? 2 : 1));
return new Blob([viewOfPayload], blobOptions);
};
/**
* Removes temporary data.
* @private
*/
mr.PersistentDataManager.removeTemporary_ = function() {
for (let key of Object.keys(mr.PersistentData.storageObj_)) {
if (key.startsWith(mr.PersistentDataManager.KEY_PREFIX_ + 'temp.')) {
mr.PersistentDataManager.charsUsed_ -=
(key.length + window.localStorage.getItem(key).length);
delete window.localStorage[key];
}
}
mr.PersistentDataManager.logger_.info(
'removeTemporary_: ' + mr.PersistentDataManager.charsUsed_ +
' chars used');
};
/**
* Removes all data.
* @private
*/
mr.PersistentDataManager.removeAll_ = function() {
for (let key of Object.keys(mr.PersistentData.storageObj_)) {
if (key.startsWith(mr.PersistentDataManager.KEY_PREFIX_))
window.localStorage.removeItem(key);
}
mr.PersistentDataManager.charsUsed_ = 0;
};
/**
* Clears internal state and remove all saved data.
* Utility method for unit test.
*/
mr.PersistentDataManager.clear = function() {
mr.PersistentDataManager.removeAll_();
mr.PersistentDataManager.dataInstances_.clear();
};
/**
* Simulates suspend.
* Utility method for unit test.
*/
mr.PersistentDataManager.suspendForTest = function() {
mr.PersistentDataManager.onSuspend_();
mr.PersistentDataManager.dataInstances_.clear();
};