// Copyright 2013 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. 'use strict'; /** * Client used to connect to the remote ImageLoader extension. Client class runs * in the extension, where the client.js is included (eg. Files.app). * It sends remote requests using IPC to the ImageLoader class and forwards * its responses. * * Implements cache, which is stored in the calling extension. * * @constructor */ function ImageLoaderClient() { /** * Hash array with active tasks. * @type {Object} * @private */ this.tasks_ = {}; /** * @type {number} * @private */ this.lastTaskId_ = 0; /** * LRU cache for images. * @type {ImageLoaderClient.Cache} * @private */ this.cache_ = new ImageLoaderClient.Cache(); } /** * Image loader's extension id. * @const * @type {string} */ ImageLoaderClient.EXTENSION_ID = 'pmfjbimdmchhbnneeidfognadeopoehp'; /** * Returns a singleton instance. * @return {Client} Client instance. */ ImageLoaderClient.getInstance = function() { if (!ImageLoaderClient.instance_) ImageLoaderClient.instance_ = new ImageLoaderClient(); return ImageLoaderClient.instance_; }; /** * Records binary metrics. Counts for true and false are stored as a histogram. * @param {string} name Histogram's name. * @param {boolean} value True or false. */ ImageLoaderClient.recordBinary = function(name, value) { chrome.metricsPrivate.recordValue( { metricName: 'ImageLoader.Client.' + name, type: 'histogram-linear', min: 1, // According to histogram.h, this should be 1 for enums. max: 2, // Maximum should be exclusive. buckets: 3 }, // Number of buckets: 0, 1 and overflowing 2. value ? 1 : 0); }; /** * Records percent metrics, stored as a histogram. * @param {string} name Histogram's name. * @param {number} value Value (0..100). */ ImageLoaderClient.recordPercentage = function(name, value) { chrome.metricsPrivate.recordPercentage('ImageLoader.Client.' + name, Math.round(value)); }; /** * Sends a message to the Image Loader extension. * @param {Object} request Hash array with request data. * @param {function(Object)=} opt_callback Response handling callback. * The response is passed as a hash array. * @private */ ImageLoaderClient.sendMessage_ = function(request, opt_callback) { opt_callback = opt_callback || function(response) {}; var sendMessage = chrome.runtime ? chrome.runtime.sendMessage : chrome.extension.sendMessage; sendMessage(ImageLoaderClient.EXTENSION_ID, request, opt_callback); }; /** * Handles a message from the remote image loader and calls the registered * callback to pass the response back to the requester. * * @param {Object} message Response message as a hash array. * @private */ ImageLoaderClient.prototype.handleMessage_ = function(message) { if (!(message.taskId in this.tasks_)) { // This task has been canceled, but was already fetched, so it's result // should be discarded anyway. return; } var task = this.tasks_[message.taskId]; // Check if the task is still valid. if (task.isValid()) task.accept(message); delete this.tasks_[message.taskId]; }; /** * Loads and resizes and image. Use opt_isValid to easily cancel requests * which are not valid anymore, which will reduce cpu consumption. * * @param {string} url Url of the requested image. * @param {function} callback Callback used to return response. * @param {Object=} opt_options Loader options, such as: scale, maxHeight, * width, height and/or cache. * @param {function=} opt_isValid Function returning false in case * a request is not valid anymore, eg. parent node has been detached. * @return {?number} Remote task id or null if loaded from cache. */ ImageLoaderClient.prototype.load = function( url, callback, opt_options, opt_isValid) { opt_options = opt_options || {}; opt_isValid = opt_isValid || function() { return true; }; // Record cache usage. ImageLoaderClient.recordPercentage('Cache.Usage', this.cache_.getUsage()); // Cancel old, invalid tasks. var taskKeys = Object.keys(this.tasks_); for (var index = 0; index < taskKeys.length; index++) { var taskKey = taskKeys[index]; var task = this.tasks_[taskKey]; if (!task.isValid()) { // Cancel this task since it is not valid anymore. this.cancel(taskKey); delete this.tasks_[taskKey]; } } // Replace the extension id. var sourceId = chrome.i18n.getMessage('@@extension_id'); var targetId = ImageLoaderClient.EXTENSION_ID; url = url.replace('filesystem:chrome-extension://' + sourceId, 'filesystem:chrome-extension://' + targetId); // Try to load from cache, if available. var cacheKey = ImageLoaderClient.Cache.createKey(url, opt_options); if (opt_options.cache) { // Load from cache. ImageLoaderClient.recordBinary('Cached', 1); var cachedData = this.cache_.loadImage(cacheKey, opt_options.timestamp); if (cachedData) { ImageLoaderClient.recordBinary('Cache.HitMiss', 1); callback({status: 'success', data: cachedData}); return null; } else { ImageLoaderClient.recordBinary('Cache.HitMiss', 0); } } else { // Remove from cache. ImageLoaderClient.recordBinary('Cached', 0); this.cache_.removeImage(cacheKey); } // Not available in cache, performing a request to a remote extension. var request = opt_options; this.lastTaskId_++; var task = {isValid: opt_isValid}; this.tasks_[this.lastTaskId_] = task; request.url = url; request.taskId = this.lastTaskId_; request.timestamp = opt_options.timestamp; ImageLoaderClient.sendMessage_( request, function(result) { // Save to cache. if (result.status == 'success' && opt_options.cache) this.cache_.saveImage(cacheKey, result.data, opt_options.timestamp); callback(result); }.bind(this)); return request.taskId; }; /** * Cancels the request. * @param {number} taskId Task id returned by ImageLoaderClient.load(). */ ImageLoaderClient.prototype.cancel = function(taskId) { ImageLoaderClient.sendMessage_({taskId: taskId, cancel: true}); }; /** * Least Recently Used (LRU) cache implementation to be used by * Client class. It has memory constraints, so it will never * exceed specified memory limit defined in MEMORY_LIMIT. * * @constructor */ ImageLoaderClient.Cache = function() { this.images_ = []; this.size_ = 0; }; /** * Memory limit for images data in bytes. * * @const * @type {number} */ ImageLoaderClient.Cache.MEMORY_LIMIT = 20 * 1024 * 1024; // 20 MB. /** * Creates a cache key. * * @param {string} url Image url. * @param {Object=} opt_options Loader options as a hash array. * @return {string} Cache key. */ ImageLoaderClient.Cache.createKey = function(url, opt_options) { opt_options = opt_options || {}; return JSON.stringify({url: url, orientation: opt_options.orientation, scale: opt_options.scale, width: opt_options.width, height: opt_options.height, maxWidth: opt_options.maxWidth, maxHeight: opt_options.maxHeight}); }; /** * Evicts the least used elements in cache to make space for a new image. * * @param {number} size Requested size. * @private */ ImageLoaderClient.Cache.prototype.evictCache_ = function(size) { // Sort from the most recent to the oldest. this.images_.sort(function(a, b) { return b.lastLoadTimestamp - a.lastLoadTimestamp; }); while (this.images_.length > 0 && (ImageLoaderClient.Cache.MEMORY_LIMIT - this.size_ < size)) { var entry = this.images_.pop(); this.size_ -= entry.data.length; } }; /** * Saves an image in the cache. * * @param {string} key Cache key. * @param {string} data Image data. * @param {number=} opt_timestamp Last modification timestamp. Used to detect * if the cache entry becomes out of date. */ ImageLoaderClient.Cache.prototype.saveImage = function( key, data, opt_timestamp) { // If the image is currently in cache, then remove it. if (this.images_[key]) this.removeImage(key); if (ImageLoaderClient.Cache.MEMORY_LIMIT - this.size_ < data.length) { ImageLoaderClient.recordBinary('Evicted', 1); this.evictCache_(data.length); } else { ImageLoaderClient.recordBinary('Evicted', 0); } if (ImageLoaderClient.Cache.MEMORY_LIMIT - this.size_ >= data.length) { this.images_[key] = {lastLoadTimestamp: Date.now(), timestamp: opt_timestamp ? opt_timestamp : null, data: data}; this.size_ += data.length; } }; /** * Loads an image from the cache (if available) or returns null. * * @param {string} key Cache key. * @param {number=} opt_timestamp Last modification timestamp. If different * that the one in cache, then the entry will be invalidated. * @return {?string} Data of the loaded image or null. */ ImageLoaderClient.Cache.prototype.loadImage = function(key, opt_timestamp) { if (!(key in this.images_)) return null; var entry = this.images_[key]; entry.lastLoadTimestamp = Date.now(); // Check if the image in cache is up to date. If not, then remove it and // return null. if (entry.timestamp != opt_timestamp) { this.removeImage(key); return null; } return entry.data; }; /** * Returns cache usage. * @return {number} Value in percent points (0..100). */ ImageLoaderClient.Cache.prototype.getUsage = function() { return this.size_ / ImageLoaderClient.Cache.MEMORY_LIMIT * 100.0; }; /** * Removes the image from the cache. * @param {string} key Cache key. */ ImageLoaderClient.Cache.prototype.removeImage = function(key) { if (!(key in this.images_)) return; var entry = this.images_[key]; this.size_ -= entry.data.length; delete this.images_[key]; }; // Helper functions. /** * Loads and resizes and image. Use opt_isValid to easily cancel requests * which are not valid anymore, which will reduce cpu consumption. * * @param {string} url Url of the requested image. * @param {Image} image Image node to load the requested picture into. * @param {Object} options Loader options, such as: orientation, scale, * maxHeight, width, height and/or cache. * @param {function=} onSuccess Callback for success. * @param {function=} onError Callback for failure. * @param {function=} opt_isValid Function returning false in case * a request is not valid anymore, eg. parent node has been detached. * @return {?number} Remote task id or null if loaded from cache. */ ImageLoaderClient.loadToImage = function( url, image, options, onSuccess, onError, opt_isValid) { var callback = function(result) { if (result.status == 'error') { onError(); return; } image.src = result.data; onSuccess(); }; return ImageLoaderClient.getInstance().load( url, callback, options, opt_isValid); };