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