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