1/*
2 * Copyright (C) 2012 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 */
16
17package com.example.android.bitmapfun.util;
18
19import com.example.android.bitmapfun.BuildConfig;
20
21import android.annotation.TargetApi;
22import android.content.Context;
23import android.graphics.Bitmap;
24import android.graphics.Bitmap.CompressFormat;
25import android.graphics.BitmapFactory;
26import android.graphics.drawable.BitmapDrawable;
27import android.os.Bundle;
28import android.os.Environment;
29import android.os.StatFs;
30import android.support.v4.app.Fragment;
31import android.support.v4.app.FragmentManager;
32import android.support.v4.util.LruCache;
33import android.util.Log;
34
35import java.io.File;
36import java.io.FileDescriptor;
37import java.io.FileInputStream;
38import java.io.IOException;
39import java.io.InputStream;
40import java.io.OutputStream;
41import java.lang.ref.SoftReference;
42import java.security.MessageDigest;
43import java.security.NoSuchAlgorithmException;
44import java.util.HashSet;
45import java.util.Iterator;
46
47/**
48 * This class handles disk and memory caching of bitmaps in conjunction with the
49 * {@link ImageWorker} class and its subclasses. Use
50 * {@link ImageCache#getInstance(FragmentManager, ImageCacheParams)} to get an instance of this
51 * class, although usually a cache should be added directly to an {@link ImageWorker} by calling
52 * {@link ImageWorker#addImageCache(FragmentManager, ImageCacheParams)}.
53 */
54public class ImageCache {
55    private static final String TAG = "ImageCache";
56
57    // Default memory cache size in kilobytes
58    private static final int DEFAULT_MEM_CACHE_SIZE = 1024 * 5; // 5MB
59
60    // Default disk cache size in bytes
61    private static final int DEFAULT_DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
62
63    // Compression settings when writing images to disk cache
64    private static final CompressFormat DEFAULT_COMPRESS_FORMAT = CompressFormat.JPEG;
65    private static final int DEFAULT_COMPRESS_QUALITY = 70;
66    private static final int DISK_CACHE_INDEX = 0;
67
68    // Constants to easily toggle various caches
69    private static final boolean DEFAULT_MEM_CACHE_ENABLED = true;
70    private static final boolean DEFAULT_DISK_CACHE_ENABLED = true;
71    private static final boolean DEFAULT_INIT_DISK_CACHE_ON_CREATE = false;
72
73    private DiskLruCache mDiskLruCache;
74    private LruCache<String, BitmapDrawable> mMemoryCache;
75    private ImageCacheParams mCacheParams;
76    private final Object mDiskCacheLock = new Object();
77    private boolean mDiskCacheStarting = true;
78
79    private HashSet<SoftReference<Bitmap>> mReusableBitmaps;
80
81    /**
82     * Create a new ImageCache object using the specified parameters. This should not be
83     * called directly by other classes, instead use
84     * {@link ImageCache#getInstance(FragmentManager, ImageCacheParams)} to fetch an ImageCache
85     * instance.
86     *
87     * @param cacheParams The cache parameters to use to initialize the cache
88     */
89    private ImageCache(ImageCacheParams cacheParams) {
90        init(cacheParams);
91    }
92
93    /**
94     * Return an {@link ImageCache} instance. A {@link RetainFragment} is used to retain the
95     * ImageCache object across configuration changes such as a change in device orientation.
96     *
97     * @param fragmentManager The fragment manager to use when dealing with the retained fragment.
98     * @param cacheParams The cache parameters to use if the ImageCache needs instantiation.
99     * @return An existing retained ImageCache object or a new one if one did not exist
100     */
101    public static ImageCache getInstance(
102            FragmentManager fragmentManager, ImageCacheParams cacheParams) {
103
104        // Search for, or create an instance of the non-UI RetainFragment
105        final RetainFragment mRetainFragment = findOrCreateRetainFragment(fragmentManager);
106
107        // See if we already have an ImageCache stored in RetainFragment
108        ImageCache imageCache = (ImageCache) mRetainFragment.getObject();
109
110        // No existing ImageCache, create one and store it in RetainFragment
111        if (imageCache == null) {
112            imageCache = new ImageCache(cacheParams);
113            mRetainFragment.setObject(imageCache);
114        }
115
116        return imageCache;
117    }
118
119    /**
120     * Initialize the cache, providing all parameters.
121     *
122     * @param cacheParams The cache parameters to initialize the cache
123     */
124    private void init(ImageCacheParams cacheParams) {
125        mCacheParams = cacheParams;
126
127        // Set up memory cache
128        if (mCacheParams.memoryCacheEnabled) {
129            if (BuildConfig.DEBUG) {
130                Log.d(TAG, "Memory cache created (size = " + mCacheParams.memCacheSize + ")");
131            }
132
133            // If we're running on Honeycomb or newer, then
134            if (Utils.hasHoneycomb()) {
135                mReusableBitmaps = new HashSet<SoftReference<Bitmap>>();
136            }
137
138            mMemoryCache = new LruCache<String, BitmapDrawable>(mCacheParams.memCacheSize) {
139
140                /**
141                 * Notify the removed entry that is no longer being cached
142                 */
143                @Override
144                protected void entryRemoved(boolean evicted, String key,
145                        BitmapDrawable oldValue, BitmapDrawable newValue) {
146                    if (RecyclingBitmapDrawable.class.isInstance(oldValue)) {
147                        // The removed entry is a recycling drawable, so notify it
148                        // that it has been removed from the memory cache
149                        ((RecyclingBitmapDrawable) oldValue).setIsCached(false);
150                    } else {
151                        // The removed entry is a standard BitmapDrawable
152
153                        if (Utils.hasHoneycomb()) {
154                            // We're running on Honeycomb or later, so add the bitmap
155                            // to a SoftRefrence set for possible use with inBitmap later
156                            mReusableBitmaps.add(new SoftReference<Bitmap>(oldValue.getBitmap()));
157                        }
158                    }
159                }
160
161                /**
162                 * Measure item size in kilobytes rather than units which is more practical
163                 * for a bitmap cache
164                 */
165                @Override
166                protected int sizeOf(String key, BitmapDrawable value) {
167                    final int bitmapSize = getBitmapSize(value) / 1024;
168                    return bitmapSize == 0 ? 1 : bitmapSize;
169                }
170            };
171        }
172
173        // By default the disk cache is not initialized here as it should be initialized
174        // on a separate thread due to disk access.
175        if (cacheParams.initDiskCacheOnCreate) {
176            // Set up disk cache
177            initDiskCache();
178        }
179    }
180
181    /**
182     * Initializes the disk cache.  Note that this includes disk access so this should not be
183     * executed on the main/UI thread. By default an ImageCache does not initialize the disk
184     * cache when it is created, instead you should call initDiskCache() to initialize it on a
185     * background thread.
186     */
187    public void initDiskCache() {
188        // Set up disk cache
189        synchronized (mDiskCacheLock) {
190            if (mDiskLruCache == null || mDiskLruCache.isClosed()) {
191                File diskCacheDir = mCacheParams.diskCacheDir;
192                if (mCacheParams.diskCacheEnabled && diskCacheDir != null) {
193                    if (!diskCacheDir.exists()) {
194                        diskCacheDir.mkdirs();
195                    }
196                    if (getUsableSpace(diskCacheDir) > mCacheParams.diskCacheSize) {
197                        try {
198                            mDiskLruCache = DiskLruCache.open(
199                                    diskCacheDir, 1, 1, mCacheParams.diskCacheSize);
200                            if (BuildConfig.DEBUG) {
201                                Log.d(TAG, "Disk cache initialized");
202                            }
203                        } catch (final IOException e) {
204                            mCacheParams.diskCacheDir = null;
205                            Log.e(TAG, "initDiskCache - " + e);
206                        }
207                    }
208                }
209            }
210            mDiskCacheStarting = false;
211            mDiskCacheLock.notifyAll();
212        }
213    }
214
215    /**
216     * Adds a bitmap to both memory and disk cache.
217     * @param data Unique identifier for the bitmap to store
218     * @param value The bitmap drawable to store
219     */
220    public void addBitmapToCache(String data, BitmapDrawable value) {
221        if (data == null || value == null) {
222            return;
223        }
224
225        // Add to memory cache
226        if (mMemoryCache != null) {
227            if (RecyclingBitmapDrawable.class.isInstance(value)) {
228                // The removed entry is a recycling drawable, so notify it
229                // that it has been added into the memory cache
230                ((RecyclingBitmapDrawable) value).setIsCached(true);
231            }
232            mMemoryCache.put(data, value);
233        }
234
235        synchronized (mDiskCacheLock) {
236            // Add to disk cache
237            if (mDiskLruCache != null) {
238                final String key = hashKeyForDisk(data);
239                OutputStream out = null;
240                try {
241                    DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
242                    if (snapshot == null) {
243                        final DiskLruCache.Editor editor = mDiskLruCache.edit(key);
244                        if (editor != null) {
245                            out = editor.newOutputStream(DISK_CACHE_INDEX);
246                            value.getBitmap().compress(
247                                    mCacheParams.compressFormat, mCacheParams.compressQuality, out);
248                            editor.commit();
249                            out.close();
250                        }
251                    } else {
252                        snapshot.getInputStream(DISK_CACHE_INDEX).close();
253                    }
254                } catch (final IOException e) {
255                    Log.e(TAG, "addBitmapToCache - " + e);
256                } catch (Exception e) {
257                    Log.e(TAG, "addBitmapToCache - " + e);
258                } finally {
259                    try {
260                        if (out != null) {
261                            out.close();
262                        }
263                    } catch (IOException e) {}
264                }
265            }
266        }
267    }
268
269    /**
270     * Get from memory cache.
271     *
272     * @param data Unique identifier for which item to get
273     * @return The bitmap drawable if found in cache, null otherwise
274     */
275    public BitmapDrawable getBitmapFromMemCache(String data) {
276        BitmapDrawable memValue = null;
277
278        if (mMemoryCache != null) {
279            memValue = mMemoryCache.get(data);
280        }
281
282        if (BuildConfig.DEBUG && memValue != null) {
283            Log.d(TAG, "Memory cache hit");
284        }
285
286        return memValue;
287    }
288
289    /**
290     * Get from disk cache.
291     *
292     * @param data Unique identifier for which item to get
293     * @return The bitmap if found in cache, null otherwise
294     */
295    public Bitmap getBitmapFromDiskCache(String data) {
296        final String key = hashKeyForDisk(data);
297        Bitmap bitmap = null;
298
299        synchronized (mDiskCacheLock) {
300            while (mDiskCacheStarting) {
301                try {
302                    mDiskCacheLock.wait();
303                } catch (InterruptedException e) {}
304            }
305            if (mDiskLruCache != null) {
306                InputStream inputStream = null;
307                try {
308                    final DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
309                    if (snapshot != null) {
310                        if (BuildConfig.DEBUG) {
311                            Log.d(TAG, "Disk cache hit");
312                        }
313                        inputStream = snapshot.getInputStream(DISK_CACHE_INDEX);
314                        if (inputStream != null) {
315                            FileDescriptor fd = ((FileInputStream) inputStream).getFD();
316
317                            // Decode bitmap, but we don't want to sample so give
318                            // MAX_VALUE as the target dimensions
319                            bitmap = ImageResizer.decodeSampledBitmapFromDescriptor(
320                                    fd, Integer.MAX_VALUE, Integer.MAX_VALUE, this);
321                        }
322                    }
323                } catch (final IOException e) {
324                    Log.e(TAG, "getBitmapFromDiskCache - " + e);
325                } finally {
326                    try {
327                        if (inputStream != null) {
328                            inputStream.close();
329                        }
330                    } catch (IOException e) {}
331                }
332            }
333            return bitmap;
334        }
335    }
336
337    /**
338     * @param options - BitmapFactory.Options with out* options populated
339     * @return Bitmap that case be used for inBitmap
340     */
341    protected Bitmap getBitmapFromReusableSet(BitmapFactory.Options options) {
342        Bitmap bitmap = null;
343
344        if (mReusableBitmaps != null && !mReusableBitmaps.isEmpty()) {
345            final Iterator<SoftReference<Bitmap>> iterator = mReusableBitmaps.iterator();
346            Bitmap item;
347
348            while (iterator.hasNext()) {
349                item = iterator.next().get();
350
351                if (null != item && item.isMutable()) {
352                    // Check to see it the item can be used for inBitmap
353                    if (canUseForInBitmap(item, options)) {
354                        bitmap = item;
355
356                        // Remove from reusable set so it can't be used again
357                        iterator.remove();
358                        break;
359                    }
360                } else {
361                    // Remove from the set if the reference has been cleared.
362                    iterator.remove();
363                }
364            }
365        }
366
367        return bitmap;
368    }
369
370    /**
371     * Clears both the memory and disk cache associated with this ImageCache object. Note that
372     * this includes disk access so this should not be executed on the main/UI thread.
373     */
374    public void clearCache() {
375        if (mMemoryCache != null) {
376            mMemoryCache.evictAll();
377            if (BuildConfig.DEBUG) {
378                Log.d(TAG, "Memory cache cleared");
379            }
380        }
381
382        synchronized (mDiskCacheLock) {
383            mDiskCacheStarting = true;
384            if (mDiskLruCache != null && !mDiskLruCache.isClosed()) {
385                try {
386                    mDiskLruCache.delete();
387                    if (BuildConfig.DEBUG) {
388                        Log.d(TAG, "Disk cache cleared");
389                    }
390                } catch (IOException e) {
391                    Log.e(TAG, "clearCache - " + e);
392                }
393                mDiskLruCache = null;
394                initDiskCache();
395            }
396        }
397    }
398
399    /**
400     * Flushes the disk cache associated with this ImageCache object. Note that this includes
401     * disk access so this should not be executed on the main/UI thread.
402     */
403    public void flush() {
404        synchronized (mDiskCacheLock) {
405            if (mDiskLruCache != null) {
406                try {
407                    mDiskLruCache.flush();
408                    if (BuildConfig.DEBUG) {
409                        Log.d(TAG, "Disk cache flushed");
410                    }
411                } catch (IOException e) {
412                    Log.e(TAG, "flush - " + e);
413                }
414            }
415        }
416    }
417
418    /**
419     * Closes the disk cache associated with this ImageCache object. Note that this includes
420     * disk access so this should not be executed on the main/UI thread.
421     */
422    public void close() {
423        synchronized (mDiskCacheLock) {
424            if (mDiskLruCache != null) {
425                try {
426                    if (!mDiskLruCache.isClosed()) {
427                        mDiskLruCache.close();
428                        mDiskLruCache = null;
429                        if (BuildConfig.DEBUG) {
430                            Log.d(TAG, "Disk cache closed");
431                        }
432                    }
433                } catch (IOException e) {
434                    Log.e(TAG, "close - " + e);
435                }
436            }
437        }
438    }
439
440    /**
441     * A holder class that contains cache parameters.
442     */
443    public static class ImageCacheParams {
444        public int memCacheSize = DEFAULT_MEM_CACHE_SIZE;
445        public int diskCacheSize = DEFAULT_DISK_CACHE_SIZE;
446        public File diskCacheDir;
447        public CompressFormat compressFormat = DEFAULT_COMPRESS_FORMAT;
448        public int compressQuality = DEFAULT_COMPRESS_QUALITY;
449        public boolean memoryCacheEnabled = DEFAULT_MEM_CACHE_ENABLED;
450        public boolean diskCacheEnabled = DEFAULT_DISK_CACHE_ENABLED;
451        public boolean initDiskCacheOnCreate = DEFAULT_INIT_DISK_CACHE_ON_CREATE;
452
453        /**
454         * Create a set of image cache parameters that can be provided to
455         * {@link ImageCache#getInstance(FragmentManager, ImageCacheParams)} or
456         * {@link ImageWorker#addImageCache(FragmentManager, ImageCacheParams)}.
457         * @param context A context to use.
458         * @param diskCacheDirectoryName A unique subdirectory name that will be appended to the
459         *                               application cache directory. Usually "cache" or "images"
460         *                               is sufficient.
461         */
462        public ImageCacheParams(Context context, String diskCacheDirectoryName) {
463            diskCacheDir = getDiskCacheDir(context, diskCacheDirectoryName);
464        }
465
466        /**
467         * Sets the memory cache size based on a percentage of the max available VM memory.
468         * Eg. setting percent to 0.2 would set the memory cache to one fifth of the available
469         * memory. Throws {@link IllegalArgumentException} if percent is < 0.05 or > .8.
470         * memCacheSize is stored in kilobytes instead of bytes as this will eventually be passed
471         * to construct a LruCache which takes an int in its constructor.
472         *
473         * This value should be chosen carefully based on a number of factors
474         * Refer to the corresponding Android Training class for more discussion:
475         * http://developer.android.com/training/displaying-bitmaps/
476         *
477         * @param percent Percent of available app memory to use to size memory cache
478         */
479        public void setMemCacheSizePercent(float percent) {
480            if (percent < 0.05f || percent > 0.8f) {
481                throw new IllegalArgumentException("setMemCacheSizePercent - percent must be "
482                        + "between 0.05 and 0.8 (inclusive)");
483            }
484            memCacheSize = Math.round(percent * Runtime.getRuntime().maxMemory() / 1024);
485        }
486    }
487
488    /**
489     * @param candidate - Bitmap to check
490     * @param targetOptions - Options that have the out* value populated
491     * @return true if <code>candidate</code> can be used for inBitmap re-use with
492     *      <code>targetOptions</code>
493     */
494    private static boolean canUseForInBitmap(
495            Bitmap candidate, BitmapFactory.Options targetOptions) {
496        int width = targetOptions.outWidth / targetOptions.inSampleSize;
497        int height = targetOptions.outHeight / targetOptions.inSampleSize;
498
499        return candidate.getWidth() == width && candidate.getHeight() == height;
500    }
501
502    /**
503     * Get a usable cache directory (external if available, internal otherwise).
504     *
505     * @param context The context to use
506     * @param uniqueName A unique directory name to append to the cache dir
507     * @return The cache dir
508     */
509    public static File getDiskCacheDir(Context context, String uniqueName) {
510        // Check if media is mounted or storage is built-in, if so, try and use external cache dir
511        // otherwise use internal cache dir
512        final String cachePath =
513                Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
514                        !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
515                                context.getCacheDir().getPath();
516
517        return new File(cachePath + File.separator + uniqueName);
518    }
519
520    /**
521     * A hashing method that changes a string (like a URL) into a hash suitable for using as a
522     * disk filename.
523     */
524    public static String hashKeyForDisk(String key) {
525        String cacheKey;
526        try {
527            final MessageDigest mDigest = MessageDigest.getInstance("MD5");
528            mDigest.update(key.getBytes());
529            cacheKey = bytesToHexString(mDigest.digest());
530        } catch (NoSuchAlgorithmException e) {
531            cacheKey = String.valueOf(key.hashCode());
532        }
533        return cacheKey;
534    }
535
536    private static String bytesToHexString(byte[] bytes) {
537        // http://stackoverflow.com/questions/332079
538        StringBuilder sb = new StringBuilder();
539        for (int i = 0; i < bytes.length; i++) {
540            String hex = Integer.toHexString(0xFF & bytes[i]);
541            if (hex.length() == 1) {
542                sb.append('0');
543            }
544            sb.append(hex);
545        }
546        return sb.toString();
547    }
548
549    /**
550     * Get the size in bytes of a bitmap in a BitmapDrawable.
551     * @param value
552     * @return size in bytes
553     */
554    @TargetApi(12)
555    public static int getBitmapSize(BitmapDrawable value) {
556        Bitmap bitmap = value.getBitmap();
557
558        if (Utils.hasHoneycombMR1()) {
559            return bitmap.getByteCount();
560        }
561        // Pre HC-MR1
562        return bitmap.getRowBytes() * bitmap.getHeight();
563    }
564
565    /**
566     * Check if external storage is built-in or removable.
567     *
568     * @return True if external storage is removable (like an SD card), false
569     *         otherwise.
570     */
571    @TargetApi(9)
572    public static boolean isExternalStorageRemovable() {
573        if (Utils.hasGingerbread()) {
574            return Environment.isExternalStorageRemovable();
575        }
576        return true;
577    }
578
579    /**
580     * Get the external app cache directory.
581     *
582     * @param context The context to use
583     * @return The external cache dir
584     */
585    @TargetApi(8)
586    public static File getExternalCacheDir(Context context) {
587        if (Utils.hasFroyo()) {
588            return context.getExternalCacheDir();
589        }
590
591        // Before Froyo we need to construct the external cache dir ourselves
592        final String cacheDir = "/Android/data/" + context.getPackageName() + "/cache/";
593        return new File(Environment.getExternalStorageDirectory().getPath() + cacheDir);
594    }
595
596    /**
597     * Check how much usable space is available at a given path.
598     *
599     * @param path The path to check
600     * @return The space available in bytes
601     */
602    @TargetApi(9)
603    public static long getUsableSpace(File path) {
604        if (Utils.hasGingerbread()) {
605            return path.getUsableSpace();
606        }
607        final StatFs stats = new StatFs(path.getPath());
608        return (long) stats.getBlockSize() * (long) stats.getAvailableBlocks();
609    }
610
611    /**
612     * Locate an existing instance of this Fragment or if not found, create and
613     * add it using FragmentManager.
614     *
615     * @param fm The FragmentManager manager to use.
616     * @return The existing instance of the Fragment or the new instance if just
617     *         created.
618     */
619    private static RetainFragment findOrCreateRetainFragment(FragmentManager fm) {
620        // Check to see if we have retained the worker fragment.
621        RetainFragment mRetainFragment = (RetainFragment) fm.findFragmentByTag(TAG);
622
623        // If not retained (or first time running), we need to create and add it.
624        if (mRetainFragment == null) {
625            mRetainFragment = new RetainFragment();
626            fm.beginTransaction().add(mRetainFragment, TAG).commitAllowingStateLoss();
627        }
628
629        return mRetainFragment;
630    }
631
632    /**
633     * A simple non-UI Fragment that stores a single Object and is retained over configuration
634     * changes. It will be used to retain the ImageCache object.
635     */
636    public static class RetainFragment extends Fragment {
637        private Object mObject;
638
639        /**
640         * Empty constructor as per the Fragment documentation
641         */
642        public RetainFragment() {}
643
644        @Override
645        public void onCreate(Bundle savedInstanceState) {
646            super.onCreate(savedInstanceState);
647
648            // Make sure this Fragment is retained over a configuration change
649            setRetainInstance(true);
650        }
651
652        /**
653         * Store a single object in this Fragment.
654         *
655         * @param object The object to store
656         */
657        public void setObject(Object object) {
658            mObject = object;
659        }
660
661        /**
662         * Get the stored object.
663         *
664         * @return The stored object
665         */
666        public Object getObject() {
667            return mObject;
668        }
669    }
670
671}
672