1// Copyright 2013 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5'use strict'; 6 7/** 8 * Client used to connect to the remote ImageLoader extension. Client class runs 9 * in the extension, where the client.js is included (eg. Files.app). 10 * It sends remote requests using IPC to the ImageLoader class and forwards 11 * its responses. 12 * 13 * Implements cache, which is stored in the calling extension. 14 * 15 * @constructor 16 */ 17function ImageLoaderClient() { 18 /** 19 * Hash array with active tasks. 20 * @type {Object} 21 * @private 22 */ 23 this.tasks_ = {}; 24 25 /** 26 * @type {number} 27 * @private 28 */ 29 this.lastTaskId_ = 0; 30 31 /** 32 * LRU cache for images. 33 * @type {ImageLoaderClient.Cache} 34 * @private 35 */ 36 this.cache_ = new ImageLoaderClient.Cache(); 37} 38 39/** 40 * Image loader's extension id. 41 * @const 42 * @type {string} 43 */ 44ImageLoaderClient.EXTENSION_ID = 'pmfjbimdmchhbnneeidfognadeopoehp'; 45 46/** 47 * Returns a singleton instance. 48 * @return {Client} Client instance. 49 */ 50ImageLoaderClient.getInstance = function() { 51 if (!ImageLoaderClient.instance_) 52 ImageLoaderClient.instance_ = new ImageLoaderClient(); 53 return ImageLoaderClient.instance_; 54}; 55 56/** 57 * Records binary metrics. Counts for true and false are stored as a histogram. 58 * @param {string} name Histogram's name. 59 * @param {boolean} value True or false. 60 */ 61ImageLoaderClient.recordBinary = function(name, value) { 62 chrome.metricsPrivate.recordValue( 63 { metricName: 'ImageLoader.Client.' + name, 64 type: 'histogram-linear', 65 min: 1, // According to histogram.h, this should be 1 for enums. 66 max: 2, // Maximum should be exclusive. 67 buckets: 3 }, // Number of buckets: 0, 1 and overflowing 2. 68 value ? 1 : 0); 69}; 70 71/** 72 * Records percent metrics, stored as a histogram. 73 * @param {string} name Histogram's name. 74 * @param {number} value Value (0..100). 75 */ 76ImageLoaderClient.recordPercentage = function(name, value) { 77 chrome.metricsPrivate.recordPercentage('ImageLoader.Client.' + name, 78 Math.round(value)); 79}; 80 81/** 82 * Sends a message to the Image Loader extension. 83 * @param {Object} request Hash array with request data. 84 * @param {function(Object)=} opt_callback Response handling callback. 85 * The response is passed as a hash array. 86 * @private 87 */ 88ImageLoaderClient.sendMessage_ = function(request, opt_callback) { 89 opt_callback = opt_callback || function(response) {}; 90 var sendMessage = chrome.runtime ? chrome.runtime.sendMessage : 91 chrome.extension.sendMessage; 92 sendMessage(ImageLoaderClient.EXTENSION_ID, request, opt_callback); 93}; 94 95/** 96 * Handles a message from the remote image loader and calls the registered 97 * callback to pass the response back to the requester. 98 * 99 * @param {Object} message Response message as a hash array. 100 * @private 101 */ 102ImageLoaderClient.prototype.handleMessage_ = function(message) { 103 if (!(message.taskId in this.tasks_)) { 104 // This task has been canceled, but was already fetched, so it's result 105 // should be discarded anyway. 106 return; 107 } 108 109 var task = this.tasks_[message.taskId]; 110 111 // Check if the task is still valid. 112 if (task.isValid()) 113 task.accept(message); 114 115 delete this.tasks_[message.taskId]; 116}; 117 118/** 119 * Loads and resizes and image. Use opt_isValid to easily cancel requests 120 * which are not valid anymore, which will reduce cpu consumption. 121 * 122 * @param {string} url Url of the requested image. 123 * @param {function} callback Callback used to return response. 124 * @param {Object=} opt_options Loader options, such as: scale, maxHeight, 125 * width, height and/or cache. 126 * @param {function=} opt_isValid Function returning false in case 127 * a request is not valid anymore, eg. parent node has been detached. 128 * @return {?number} Remote task id or null if loaded from cache. 129 */ 130ImageLoaderClient.prototype.load = function( 131 url, callback, opt_options, opt_isValid) { 132 opt_options = opt_options || {}; 133 opt_isValid = opt_isValid || function() { return true; }; 134 135 // Record cache usage. 136 ImageLoaderClient.recordPercentage('Cache.Usage', this.cache_.getUsage()); 137 138 // Cancel old, invalid tasks. 139 var taskKeys = Object.keys(this.tasks_); 140 for (var index = 0; index < taskKeys.length; index++) { 141 var taskKey = taskKeys[index]; 142 var task = this.tasks_[taskKey]; 143 if (!task.isValid()) { 144 // Cancel this task since it is not valid anymore. 145 this.cancel(taskKey); 146 delete this.tasks_[taskKey]; 147 } 148 } 149 150 // Replace the extension id. 151 var sourceId = chrome.i18n.getMessage('@@extension_id'); 152 var targetId = ImageLoaderClient.EXTENSION_ID; 153 154 url = url.replace('filesystem:chrome-extension://' + sourceId, 155 'filesystem:chrome-extension://' + targetId); 156 157 // Try to load from cache, if available. 158 var cacheKey = ImageLoaderClient.Cache.createKey(url, opt_options); 159 if (opt_options.cache) { 160 // Load from cache. 161 ImageLoaderClient.recordBinary('Cached', 1); 162 var cachedData = this.cache_.loadImage(cacheKey, opt_options.timestamp); 163 if (cachedData) { 164 ImageLoaderClient.recordBinary('Cache.HitMiss', 1); 165 callback({status: 'success', data: cachedData}); 166 return null; 167 } else { 168 ImageLoaderClient.recordBinary('Cache.HitMiss', 0); 169 } 170 } else { 171 // Remove from cache. 172 ImageLoaderClient.recordBinary('Cached', 0); 173 this.cache_.removeImage(cacheKey); 174 } 175 176 // Not available in cache, performing a request to a remote extension. 177 var request = opt_options; 178 this.lastTaskId_++; 179 var task = {isValid: opt_isValid}; 180 this.tasks_[this.lastTaskId_] = task; 181 182 request.url = url; 183 request.taskId = this.lastTaskId_; 184 request.timestamp = opt_options.timestamp; 185 186 ImageLoaderClient.sendMessage_( 187 request, 188 function(result) { 189 // Save to cache. 190 if (result.status == 'success' && opt_options.cache) 191 this.cache_.saveImage(cacheKey, result.data, opt_options.timestamp); 192 callback(result); 193 }.bind(this)); 194 return request.taskId; 195}; 196 197/** 198 * Cancels the request. 199 * @param {number} taskId Task id returned by ImageLoaderClient.load(). 200 */ 201ImageLoaderClient.prototype.cancel = function(taskId) { 202 ImageLoaderClient.sendMessage_({taskId: taskId, cancel: true}); 203}; 204 205/** 206 * Least Recently Used (LRU) cache implementation to be used by 207 * Client class. It has memory constraints, so it will never 208 * exceed specified memory limit defined in MEMORY_LIMIT. 209 * 210 * @constructor 211 */ 212ImageLoaderClient.Cache = function() { 213 this.images_ = []; 214 this.size_ = 0; 215}; 216 217/** 218 * Memory limit for images data in bytes. 219 * 220 * @const 221 * @type {number} 222 */ 223ImageLoaderClient.Cache.MEMORY_LIMIT = 20 * 1024 * 1024; // 20 MB. 224 225/** 226 * Creates a cache key. 227 * 228 * @param {string} url Image url. 229 * @param {Object=} opt_options Loader options as a hash array. 230 * @return {string} Cache key. 231 */ 232ImageLoaderClient.Cache.createKey = function(url, opt_options) { 233 opt_options = opt_options || {}; 234 return JSON.stringify({url: url, 235 orientation: opt_options.orientation, 236 scale: opt_options.scale, 237 width: opt_options.width, 238 height: opt_options.height, 239 maxWidth: opt_options.maxWidth, 240 maxHeight: opt_options.maxHeight}); 241}; 242 243/** 244 * Evicts the least used elements in cache to make space for a new image. 245 * 246 * @param {number} size Requested size. 247 * @private 248 */ 249ImageLoaderClient.Cache.prototype.evictCache_ = function(size) { 250 // Sort from the most recent to the oldest. 251 this.images_.sort(function(a, b) { 252 return b.lastLoadTimestamp - a.lastLoadTimestamp; 253 }); 254 255 while (this.images_.length > 0 && 256 (ImageLoaderClient.Cache.MEMORY_LIMIT - this.size_ < size)) { 257 var entry = this.images_.pop(); 258 this.size_ -= entry.data.length; 259 } 260}; 261 262/** 263 * Saves an image in the cache. 264 * 265 * @param {string} key Cache key. 266 * @param {string} data Image data. 267 * @param {number=} opt_timestamp Last modification timestamp. Used to detect 268 * if the cache entry becomes out of date. 269 */ 270ImageLoaderClient.Cache.prototype.saveImage = function( 271 key, data, opt_timestamp) { 272 // If the image is currently in cache, then remove it. 273 if (this.images_[key]) 274 this.removeImage(key); 275 276 if (ImageLoaderClient.Cache.MEMORY_LIMIT - this.size_ < data.length) { 277 ImageLoaderClient.recordBinary('Evicted', 1); 278 this.evictCache_(data.length); 279 } else { 280 ImageLoaderClient.recordBinary('Evicted', 0); 281 } 282 283 if (ImageLoaderClient.Cache.MEMORY_LIMIT - this.size_ >= data.length) { 284 this.images_[key] = {lastLoadTimestamp: Date.now(), 285 timestamp: opt_timestamp ? opt_timestamp : null, 286 data: data}; 287 this.size_ += data.length; 288 } 289}; 290 291/** 292 * Loads an image from the cache (if available) or returns null. 293 * 294 * @param {string} key Cache key. 295 * @param {number=} opt_timestamp Last modification timestamp. If different 296 * that the one in cache, then the entry will be invalidated. 297 * @return {?string} Data of the loaded image or null. 298 */ 299ImageLoaderClient.Cache.prototype.loadImage = function(key, opt_timestamp) { 300 if (!(key in this.images_)) 301 return null; 302 303 var entry = this.images_[key]; 304 entry.lastLoadTimestamp = Date.now(); 305 306 // Check if the image in cache is up to date. If not, then remove it and 307 // return null. 308 if (entry.timestamp != opt_timestamp) { 309 this.removeImage(key); 310 return null; 311 } 312 313 return entry.data; 314}; 315 316/** 317 * Returns cache usage. 318 * @return {number} Value in percent points (0..100). 319 */ 320ImageLoaderClient.Cache.prototype.getUsage = function() { 321 return this.size_ / ImageLoaderClient.Cache.MEMORY_LIMIT * 100.0; 322}; 323 324/** 325 * Removes the image from the cache. 326 * @param {string} key Cache key. 327 */ 328ImageLoaderClient.Cache.prototype.removeImage = function(key) { 329 if (!(key in this.images_)) 330 return; 331 332 var entry = this.images_[key]; 333 this.size_ -= entry.data.length; 334 delete this.images_[key]; 335}; 336 337// Helper functions. 338 339/** 340 * Loads and resizes and image. Use opt_isValid to easily cancel requests 341 * which are not valid anymore, which will reduce cpu consumption. 342 * 343 * @param {string} url Url of the requested image. 344 * @param {Image} image Image node to load the requested picture into. 345 * @param {Object} options Loader options, such as: orientation, scale, 346 * maxHeight, width, height and/or cache. 347 * @param {function=} onSuccess Callback for success. 348 * @param {function=} onError Callback for failure. 349 * @param {function=} opt_isValid Function returning false in case 350 * a request is not valid anymore, eg. parent node has been detached. 351 * @return {?number} Remote task id or null if loaded from cache. 352 */ 353ImageLoaderClient.loadToImage = function( 354 url, image, options, onSuccess, onError, opt_isValid) { 355 var callback = function(result) { 356 if (result.status == 'error') { 357 onError(); 358 return; 359 } 360 image.src = result.data; 361 onSuccess(); 362 }; 363 364 return ImageLoaderClient.getInstance().load( 365 url, callback, options, opt_isValid); 366}; 367