1/**
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.volley.toolbox;
17
18import android.graphics.Bitmap;
19import android.graphics.Bitmap.Config;
20import android.os.Handler;
21import android.os.Looper;
22import android.widget.ImageView;
23import android.widget.ImageView.ScaleType;
24import com.android.volley.Request;
25import com.android.volley.RequestQueue;
26import com.android.volley.Response.ErrorListener;
27import com.android.volley.Response.Listener;
28import com.android.volley.VolleyError;
29
30import java.util.HashMap;
31import java.util.LinkedList;
32
33/**
34 * Helper that handles loading and caching images from remote URLs.
35 *
36 * The simple way to use this class is to call {@link ImageLoader#get(String, ImageListener)}
37 * and to pass in the default image listener provided by
38 * {@link ImageLoader#getImageListener(ImageView, int, int)}. Note that all function calls to
39 * this class must be made from the main thead, and all responses will be delivered to the main
40 * thread as well.
41 */
42public class ImageLoader {
43    /** RequestQueue for dispatching ImageRequests onto. */
44    private final RequestQueue mRequestQueue;
45
46    /** Amount of time to wait after first response arrives before delivering all responses. */
47    private int mBatchResponseDelayMs = 100;
48
49    /** The cache implementation to be used as an L1 cache before calling into volley. */
50    private final ImageCache mCache;
51
52    /**
53     * HashMap of Cache keys -> BatchedImageRequest used to track in-flight requests so
54     * that we can coalesce multiple requests to the same URL into a single network request.
55     */
56    private final HashMap<String, BatchedImageRequest> mInFlightRequests =
57            new HashMap<String, BatchedImageRequest>();
58
59    /** HashMap of the currently pending responses (waiting to be delivered). */
60    private final HashMap<String, BatchedImageRequest> mBatchedResponses =
61            new HashMap<String, BatchedImageRequest>();
62
63    /** Handler to the main thread. */
64    private final Handler mHandler = new Handler(Looper.getMainLooper());
65
66    /** Runnable for in-flight response delivery. */
67    private Runnable mRunnable;
68
69    /**
70     * Simple cache adapter interface. If provided to the ImageLoader, it
71     * will be used as an L1 cache before dispatch to Volley. Implementations
72     * must not block. Implementation with an LruCache is recommended.
73     */
74    public interface ImageCache {
75        public Bitmap getBitmap(String url);
76        public void putBitmap(String url, Bitmap bitmap);
77    }
78
79    /**
80     * Constructs a new ImageLoader.
81     * @param queue The RequestQueue to use for making image requests.
82     * @param imageCache The cache to use as an L1 cache.
83     */
84    public ImageLoader(RequestQueue queue, ImageCache imageCache) {
85        mRequestQueue = queue;
86        mCache = imageCache;
87    }
88
89    /**
90     * The default implementation of ImageListener which handles basic functionality
91     * of showing a default image until the network response is received, at which point
92     * it will switch to either the actual image or the error image.
93     * @param view The imageView that the listener is associated with.
94     * @param defaultImageResId Default image resource ID to use, or 0 if it doesn't exist.
95     * @param errorImageResId Error image resource ID to use, or 0 if it doesn't exist.
96     */
97    public static ImageListener getImageListener(final ImageView view,
98            final int defaultImageResId, final int errorImageResId) {
99        return new ImageListener() {
100            @Override
101            public void onErrorResponse(VolleyError error) {
102                if (errorImageResId != 0) {
103                    view.setImageResource(errorImageResId);
104                }
105            }
106
107            @Override
108            public void onResponse(ImageContainer response, boolean isImmediate) {
109                if (response.getBitmap() != null) {
110                    view.setImageBitmap(response.getBitmap());
111                } else if (defaultImageResId != 0) {
112                    view.setImageResource(defaultImageResId);
113                }
114            }
115        };
116    }
117
118    /**
119     * Interface for the response handlers on image requests.
120     *
121     * The call flow is this:
122     * 1. Upon being  attached to a request, onResponse(response, true) will
123     * be invoked to reflect any cached data that was already available. If the
124     * data was available, response.getBitmap() will be non-null.
125     *
126     * 2. After a network response returns, only one of the following cases will happen:
127     *   - onResponse(response, false) will be called if the image was loaded.
128     *   or
129     *   - onErrorResponse will be called if there was an error loading the image.
130     */
131    public interface ImageListener extends ErrorListener {
132        /**
133         * Listens for non-error changes to the loading of the image request.
134         *
135         * @param response Holds all information pertaining to the request, as well
136         * as the bitmap (if it is loaded).
137         * @param isImmediate True if this was called during ImageLoader.get() variants.
138         * This can be used to differentiate between a cached image loading and a network
139         * image loading in order to, for example, run an animation to fade in network loaded
140         * images.
141         */
142        public void onResponse(ImageContainer response, boolean isImmediate);
143    }
144
145    /**
146     * Checks if the item is available in the cache.
147     * @param requestUrl The url of the remote image
148     * @param maxWidth The maximum width of the returned image.
149     * @param maxHeight The maximum height of the returned image.
150     * @return True if the item exists in cache, false otherwise.
151     */
152    public boolean isCached(String requestUrl, int maxWidth, int maxHeight) {
153        return isCached(requestUrl, maxWidth, maxHeight, ScaleType.CENTER_INSIDE);
154    }
155
156    /**
157     * Checks if the item is available in the cache.
158     *
159     * @param requestUrl The url of the remote image
160     * @param maxWidth   The maximum width of the returned image.
161     * @param maxHeight  The maximum height of the returned image.
162     * @param scaleType  The scaleType of the imageView.
163     * @return True if the item exists in cache, false otherwise.
164     */
165    public boolean isCached(String requestUrl, int maxWidth, int maxHeight, ScaleType scaleType) {
166        throwIfNotOnMainThread();
167
168        String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight, scaleType);
169        return mCache.getBitmap(cacheKey) != null;
170    }
171
172    /**
173     * Returns an ImageContainer for the requested URL.
174     *
175     * The ImageContainer will contain either the specified default bitmap or the loaded bitmap.
176     * If the default was returned, the {@link ImageLoader} will be invoked when the
177     * request is fulfilled.
178     *
179     * @param requestUrl The URL of the image to be loaded.
180     */
181    public ImageContainer get(String requestUrl, final ImageListener listener) {
182        return get(requestUrl, listener, 0, 0);
183    }
184
185    /**
186     * Equivalent to calling {@link #get(String, ImageListener, int, int, ScaleType)} with
187     * {@code Scaletype == ScaleType.CENTER_INSIDE}.
188     */
189    public ImageContainer get(String requestUrl, ImageListener imageListener,
190            int maxWidth, int maxHeight) {
191        return get(requestUrl, imageListener, maxWidth, maxHeight, ScaleType.CENTER_INSIDE);
192    }
193
194    /**
195     * Issues a bitmap request with the given URL if that image is not available
196     * in the cache, and returns a bitmap container that contains all of the data
197     * relating to the request (as well as the default image if the requested
198     * image is not available).
199     * @param requestUrl The url of the remote image
200     * @param imageListener The listener to call when the remote image is loaded
201     * @param maxWidth The maximum width of the returned image.
202     * @param maxHeight The maximum height of the returned image.
203     * @param scaleType The ImageViews ScaleType used to calculate the needed image size.
204     * @return A container object that contains all of the properties of the request, as well as
205     *     the currently available image (default if remote is not loaded).
206     */
207    public ImageContainer get(String requestUrl, ImageListener imageListener,
208            int maxWidth, int maxHeight, ScaleType scaleType) {
209
210        // only fulfill requests that were initiated from the main thread.
211        throwIfNotOnMainThread();
212
213        final String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight, scaleType);
214
215        // Try to look up the request in the cache of remote images.
216        Bitmap cachedBitmap = mCache.getBitmap(cacheKey);
217        if (cachedBitmap != null) {
218            // Return the cached bitmap.
219            ImageContainer container = new ImageContainer(cachedBitmap, requestUrl, null, null);
220            imageListener.onResponse(container, true);
221            return container;
222        }
223
224        // The bitmap did not exist in the cache, fetch it!
225        ImageContainer imageContainer =
226                new ImageContainer(null, requestUrl, cacheKey, imageListener);
227
228        // Update the caller to let them know that they should use the default bitmap.
229        imageListener.onResponse(imageContainer, true);
230
231        // Check to see if a request is already in-flight.
232        BatchedImageRequest request = mInFlightRequests.get(cacheKey);
233        if (request != null) {
234            // If it is, add this request to the list of listeners.
235            request.addContainer(imageContainer);
236            return imageContainer;
237        }
238
239        // The request is not already in flight. Send the new request to the network and
240        // track it.
241        Request<Bitmap> newRequest = makeImageRequest(requestUrl, maxWidth, maxHeight, scaleType,
242                cacheKey);
243
244        mRequestQueue.add(newRequest);
245        mInFlightRequests.put(cacheKey,
246                new BatchedImageRequest(newRequest, imageContainer));
247        return imageContainer;
248    }
249
250    protected Request<Bitmap> makeImageRequest(String requestUrl, int maxWidth, int maxHeight,
251            ScaleType scaleType, final String cacheKey) {
252        return new ImageRequest(requestUrl, new Listener<Bitmap>() {
253            @Override
254            public void onResponse(Bitmap response) {
255                onGetImageSuccess(cacheKey, response);
256            }
257        }, maxWidth, maxHeight, scaleType, Config.RGB_565, new ErrorListener() {
258            @Override
259            public void onErrorResponse(VolleyError error) {
260                onGetImageError(cacheKey, error);
261            }
262        });
263    }
264
265    /**
266     * Sets the amount of time to wait after the first response arrives before delivering all
267     * responses. Batching can be disabled entirely by passing in 0.
268     * @param newBatchedResponseDelayMs The time in milliseconds to wait.
269     */
270    public void setBatchedResponseDelay(int newBatchedResponseDelayMs) {
271        mBatchResponseDelayMs = newBatchedResponseDelayMs;
272    }
273
274    /**
275     * Handler for when an image was successfully loaded.
276     * @param cacheKey The cache key that is associated with the image request.
277     * @param response The bitmap that was returned from the network.
278     */
279    protected void onGetImageSuccess(String cacheKey, Bitmap response) {
280        // cache the image that was fetched.
281        mCache.putBitmap(cacheKey, response);
282
283        // remove the request from the list of in-flight requests.
284        BatchedImageRequest request = mInFlightRequests.remove(cacheKey);
285
286        if (request != null) {
287            // Update the response bitmap.
288            request.mResponseBitmap = response;
289
290            // Send the batched response
291            batchResponse(cacheKey, request);
292        }
293    }
294
295    /**
296     * Handler for when an image failed to load.
297     * @param cacheKey The cache key that is associated with the image request.
298     */
299    protected void onGetImageError(String cacheKey, VolleyError error) {
300        // Notify the requesters that something failed via a null result.
301        // Remove this request from the list of in-flight requests.
302        BatchedImageRequest request = mInFlightRequests.remove(cacheKey);
303
304        if (request != null) {
305            // Set the error for this request
306            request.setError(error);
307
308            // Send the batched response
309            batchResponse(cacheKey, request);
310        }
311    }
312
313    /**
314     * Container object for all of the data surrounding an image request.
315     */
316    public class ImageContainer {
317        /**
318         * The most relevant bitmap for the container. If the image was in cache, the
319         * Holder to use for the final bitmap (the one that pairs to the requested URL).
320         */
321        private Bitmap mBitmap;
322
323        private final ImageListener mListener;
324
325        /** The cache key that was associated with the request */
326        private final String mCacheKey;
327
328        /** The request URL that was specified */
329        private final String mRequestUrl;
330
331        /**
332         * Constructs a BitmapContainer object.
333         * @param bitmap The final bitmap (if it exists).
334         * @param requestUrl The requested URL for this container.
335         * @param cacheKey The cache key that identifies the requested URL for this container.
336         */
337        public ImageContainer(Bitmap bitmap, String requestUrl,
338                String cacheKey, ImageListener listener) {
339            mBitmap = bitmap;
340            mRequestUrl = requestUrl;
341            mCacheKey = cacheKey;
342            mListener = listener;
343        }
344
345        /**
346         * Releases interest in the in-flight request (and cancels it if no one else is listening).
347         */
348        public void cancelRequest() {
349            if (mListener == null) {
350                return;
351            }
352
353            BatchedImageRequest request = mInFlightRequests.get(mCacheKey);
354            if (request != null) {
355                boolean canceled = request.removeContainerAndCancelIfNecessary(this);
356                if (canceled) {
357                    mInFlightRequests.remove(mCacheKey);
358                }
359            } else {
360                // check to see if it is already batched for delivery.
361                request = mBatchedResponses.get(mCacheKey);
362                if (request != null) {
363                    request.removeContainerAndCancelIfNecessary(this);
364                    if (request.mContainers.size() == 0) {
365                        mBatchedResponses.remove(mCacheKey);
366                    }
367                }
368            }
369        }
370
371        /**
372         * Returns the bitmap associated with the request URL if it has been loaded, null otherwise.
373         */
374        public Bitmap getBitmap() {
375            return mBitmap;
376        }
377
378        /**
379         * Returns the requested URL for this container.
380         */
381        public String getRequestUrl() {
382            return mRequestUrl;
383        }
384    }
385
386    /**
387     * Wrapper class used to map a Request to the set of active ImageContainer objects that are
388     * interested in its results.
389     */
390    private class BatchedImageRequest {
391        /** The request being tracked */
392        private final Request<?> mRequest;
393
394        /** The result of the request being tracked by this item */
395        private Bitmap mResponseBitmap;
396
397        /** Error if one occurred for this response */
398        private VolleyError mError;
399
400        /** List of all of the active ImageContainers that are interested in the request */
401        private final LinkedList<ImageContainer> mContainers = new LinkedList<ImageContainer>();
402
403        /**
404         * Constructs a new BatchedImageRequest object
405         * @param request The request being tracked
406         * @param container The ImageContainer of the person who initiated the request.
407         */
408        public BatchedImageRequest(Request<?> request, ImageContainer container) {
409            mRequest = request;
410            mContainers.add(container);
411        }
412
413        /**
414         * Set the error for this response
415         */
416        public void setError(VolleyError error) {
417            mError = error;
418        }
419
420        /**
421         * Get the error for this response
422         */
423        public VolleyError getError() {
424            return mError;
425        }
426
427        /**
428         * Adds another ImageContainer to the list of those interested in the results of
429         * the request.
430         */
431        public void addContainer(ImageContainer container) {
432            mContainers.add(container);
433        }
434
435        /**
436         * Detatches the bitmap container from the request and cancels the request if no one is
437         * left listening.
438         * @param container The container to remove from the list
439         * @return True if the request was canceled, false otherwise.
440         */
441        public boolean removeContainerAndCancelIfNecessary(ImageContainer container) {
442            mContainers.remove(container);
443            if (mContainers.size() == 0) {
444                mRequest.cancel();
445                return true;
446            }
447            return false;
448        }
449    }
450
451    /**
452     * Starts the runnable for batched delivery of responses if it is not already started.
453     * @param cacheKey The cacheKey of the response being delivered.
454     * @param request The BatchedImageRequest to be delivered.
455     */
456    private void batchResponse(String cacheKey, BatchedImageRequest request) {
457        mBatchedResponses.put(cacheKey, request);
458        // If we don't already have a batch delivery runnable in flight, make a new one.
459        // Note that this will be used to deliver responses to all callers in mBatchedResponses.
460        if (mRunnable == null) {
461            mRunnable = new Runnable() {
462                @Override
463                public void run() {
464                    for (BatchedImageRequest bir : mBatchedResponses.values()) {
465                        for (ImageContainer container : bir.mContainers) {
466                            // If one of the callers in the batched request canceled the request
467                            // after the response was received but before it was delivered,
468                            // skip them.
469                            if (container.mListener == null) {
470                                continue;
471                            }
472                            if (bir.getError() == null) {
473                                container.mBitmap = bir.mResponseBitmap;
474                                container.mListener.onResponse(container, false);
475                            } else {
476                                container.mListener.onErrorResponse(bir.getError());
477                            }
478                        }
479                    }
480                    mBatchedResponses.clear();
481                    mRunnable = null;
482                }
483
484            };
485            // Post the runnable.
486            mHandler.postDelayed(mRunnable, mBatchResponseDelayMs);
487        }
488    }
489
490    private void throwIfNotOnMainThread() {
491        if (Looper.myLooper() != Looper.getMainLooper()) {
492            throw new IllegalStateException("ImageLoader must be invoked from the main thread.");
493        }
494    }
495    /**
496     * Creates a cache key for use with the L1 cache.
497     * @param url The URL of the request.
498     * @param maxWidth The max-width of the output.
499     * @param maxHeight The max-height of the output.
500     * @param scaleType The scaleType of the imageView.
501     */
502    private static String getCacheKey(String url, int maxWidth, int maxHeight, ScaleType scaleType) {
503        return new StringBuilder(url.length() + 12).append("#W").append(maxWidth)
504                .append("#H").append(maxHeight).append("#S").append(scaleType.ordinal()).append(url)
505                .toString();
506    }
507}
508