Android Open Source - WebImageView Image Cache






From Project

Back to project page WebImageView.

License

The source code is released under:

Apache License

If you think the Android project WebImageView listed in this page is inappropriate, such as containing malicious code/tools or violating the copyright, please email info at java2s dot com, thanks.

Java Source Code

/* Copyright (c) 2009 Matthias Kppler, Yelp Inc
 *//from  w ww.  ja va2  s .c  om
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.yelp.android.webimageview;

import android.annotation.TargetApi;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapFactory.Options;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Environment;
import android.os.StatFs;
import android.os.SystemClock;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;

import com.yelp.common.collect.MapMaker;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.Comparator;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * <p>
 * A simple 2-level cache for bitmap images consisting of a small and fast
 * in-memory cache (1st level cache) and a slower but bigger disk cache (2nd
 * level cache). For second level caching, the application's cache directory
 * will be used. Please note that Android may at any point decide to wipe that
 * directory.
 * </p>
 * <p>
 * When pulling from the cache, it will first attempt to load the image from
 * memory. If that fails, it will try to load it from disk. If that succeeds,
 * the image will be put in the 1st level cache and returned. Otherwise it's a
 * cache miss, and the caller is responsible for loading the image from
 * elsewhere (probably the Internet).
 * </p>
 * <p>
 * Pushes to the cache are always write-through (i.e., the image will be stored
 * both on disk and in memory). Fetches can only go to memory or go to memory
 * and on-disk cache.
 * </p>
 *
 * @author Matthias Kaeppler, modified by Alex Pretzlav
 */
public class ImageCache {

  private static final String TAG = "DroidFu.ImageCache";

  private static final AtomicInteger COUNTER = new AtomicInteger();

  private static final int MEGABYTE_IN_BYTES = 1024 * 1024;
  private static final int MAX_INTERNAL = MEGABYTE_IN_BYTES;
  private static final int MAX_EXTERNAL = MEGABYTE_IN_BYTES * 5;

  /** trimCache() will be called every time this many new files are created on disk */
  private static final int CACHE_CLEAR_FREQUENCY = 75;

  private static final OptionsFactory OPTIONS = Integer.valueOf(VERSION.SDK) >= Build.VERSION_CODES.DONUT ? new EfficientOptionsFactory() : new OptionsFactory();

  /**
   * Directory where cached images will be stored. This object is also used as
   * the lock around the cache directory.
   */
  /* package */ File mSecondLevelCacheDir;

  final File mInternalCacheDir;
  File mExternalCacheDir;

  Context mContext;

  /* package */ final File mPermanentCacheDir;

  private final ConcurrentMap<String, Bitmap> mCache;

  private int mInMemoryCacheMissCount;

  private BroadcastReceiver mExternalStorageReceiver;

  public ImageCache(Context context, int initialCapacity, int concurrencyLevel) {
    mContext = context;
    this.mCache = new MapMaker().initialCapacity(initialCapacity).concurrencyLevel(
      concurrencyLevel).softValues().makeMap();
    this.mPermanentCacheDir = new File(context.getApplicationContext().getCacheDir()
        .getAbsolutePath() + "/permanent_images");
    this.mInternalCacheDir = new File(context.getApplicationContext().getCacheDir()
        + "/droidfu/imagecache");
    updateExternalStorageState(context);
    registerForExternalStorageUpdates(context);
  }

  /**
   * http://developer.android.com/reference/android/os/Environment.html#getExternalStorageDirectory()
   */
  void registerForExternalStorageUpdates(Context context) {
    mExternalStorageReceiver = new BroadcastReceiver() {
      @Override
      public void onReceive(Context context, Intent intent) {
        updateExternalStorageState(context);
      }
    };
    IntentFilter filter = new IntentFilter();
    filter.addAction(Intent.ACTION_MEDIA_MOUNTED);
    filter.addAction(Intent.ACTION_MEDIA_REMOVED);
    filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED);
    filter.addDataScheme("file");
    // TODO: Can this cause the app to leak ImageCaches if there are multiple created?
    context.registerReceiver(mExternalStorageReceiver, filter);
  }

  void updateExternalStorageState(Context context) {
    if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
      if (mExternalCacheDir == null) {
        File externalCacheDir = getExternalCacheDir(context);
        if (externalCacheDir != null) {
          mExternalCacheDir = new File(externalCacheDir, "images");
        } else {
          useInternalCacheDir();
          return;
        }
      }
      this.mExternalCacheDir.mkdirs();
      if (!this.mExternalCacheDir.exists()) {
        useInternalCacheDir();
        return;
      }

      // If less than our max cache size is available on SD, use internal cache
      if (!checkExternalFreeSpace() || !assertExternalMountWritable()) {
        useInternalCacheDir();
      } else {
        this.mSecondLevelCacheDir = this.mExternalCacheDir;
        // Clear internal dir
        clearDirectory(mInternalCacheDir, Long.MAX_VALUE);
      }
    } else {
      useInternalCacheDir();
    }
  }

  // Because sometimes Android seems to lie :(
  boolean assertExternalMountWritable() {
    File temp = new File(this.mExternalCacheDir, "cache.dir" + SystemClock.elapsedRealtime());
    boolean safe = false;
    try {
      safe = temp.createNewFile();
      safe &= temp.delete();
    } catch(IOException e) {
      safe = false;
    }
    return safe;
  }
  boolean checkExternalFreeSpace() {
    StatFs stats = new StatFs(this.mExternalCacheDir.getAbsolutePath());
    return ((long)stats.getAvailableBlocks() * (long)stats.getBlockSize()) > MAX_EXTERNAL;
  }

  void useInternalCacheDir() {
    this.mSecondLevelCacheDir = this.mInternalCacheDir;
    this.mSecondLevelCacheDir.mkdirs();
  }

  final boolean isUsingExternalCache() {
    return mSecondLevelCacheDir == mExternalCacheDir;
  }

  /**
   * Checks only the in-memory cache for the requested key. Use getFile() to
   * check the on-disk cache.
   */
  public Bitmap get(Object key) {
    String imageUrl = String.valueOf(key);
    Bitmap bitmap = mCache.get(imageUrl);

    if (bitmap != null) {
      // 1st level cache hit (memory)
      return bitmap;
    }
    // cache miss
    return null;
  }

  /**
   * Double-checks the in-memory cache and if not available decodes
   * the on-disk cached image instead if available and inserts
   * it into the in-memory cache.
   * @param key The URL of the image to fetch from cache.
   * @return
   */
  public Bitmap getBitmap(Object key) {
    String imageUrl = String.valueOf(key);
    // Double-check cache before breaking down and reading flash memory
    Bitmap bitmap = get(imageUrl);
    if (bitmap == null) {
      synchronized(this.mSecondLevelCacheDir) {
        File imageFile = getImageFile(this.mSecondLevelCacheDir, imageUrl);
        if (!imageFile.exists()) {
          imageFile = getImageFile(this.mPermanentCacheDir, imageUrl);
        }
        if (imageFile.exists()) {
          // 2nd level cache hit (disk)

          bitmap = BitmapFactory.decodeFile(imageFile.getAbsolutePath(), OPTIONS.getOptions());
          if (bitmap == null) {
            // treat decoding errors as a cache miss
            return null;
          }
          // Set file modified date to now to attempt to keep on disk longer
          imageFile.setLastModified(System.currentTimeMillis());
          if (BuildConfig.DEBUG) {
            mInMemoryCacheMissCount++;
            Log.i("ImageCache", "In-memory cache miss #" + mInMemoryCacheMissCount);
          }
          mCache.put(imageUrl, bitmap);
        }
      }
    }
    return bitmap;
  }

  /**
   * Writes the provided image data to on-disk cache and simultaneously
   * decodes it to a bitmap and stores it in the in-memory cache.
   *
   * The original Droid-Fu implementaion of this method took a Bitmap object
   * which was re-compressed to a jpeg in the on-disk cache. This version
   * saves the image as sent by the server and thus doesn't cause any
   * compression or reformatting issues.
   *
   * @param imageUrl
   *            The URL for the image to use for caching.
   * @param data
   * @return The decoded Bitmap built from data or null if data is invalid.
   * @throws IOException
   */
  public Bitmap put(String imageUrl, InputStream data) throws IOException {
    return put(imageUrl, data, false);
  }

  /**
   * Writes the provided image data to on-disk cache and simultaneously
   * decodes it to a bitmap and stores it in the in-memory cache.
   *
   * The original Droid-Fu implementaion of this method took a Bitmap object
   * which was re-compressed to a jpeg in the on-disk cache. This version
   * saves the image as sent by the server and thus doesn't cause any
   * compression or reformatting issues.
   *
   * @param imageUrl
   *            The URL for the image to use for caching.
   * @param data
   *            A stream to read the bitmap information from
   * @param cachePermanently
   *            If true, the image will be stored permanently in the
   *            Application data instead of in the cache directory.
   * @return
   * @throws IOException
   */
  public Bitmap put(String imageUrl, InputStream data, boolean cachePermanently) throws IOException {
    incrementAndTrim();
    File imageFile;
    // NOTE: Android can clear the cache at any time, so we need to make sure our directories
    // exist every time we write to them.
    if (cachePermanently) {
      this.mPermanentCacheDir.mkdirs();
      imageFile = getImageFile(this.mPermanentCacheDir, imageUrl);
    } else {
      this.mSecondLevelCacheDir.mkdirs();
      imageFile = getImageFile(this.mSecondLevelCacheDir, imageUrl);
    }
    FileWritingInputStream stream = null;
    try {
      stream = new FileWritingInputStream(data, new FileOutputStream(imageFile));
    } catch (FileNotFoundException e) {
      // SD card may have been unmounted and is now inaccessible
      if (isUsingExternalCache()) {
        updateExternalStorageState(mContext);
        if (!isUsingExternalCache()) {
          return put(imageUrl, data, cachePermanently);
        } else {
          throw e;
        }
      } else {
        throw e;
      }
    }
    Bitmap image = null;
    try {
      image = BitmapFactory.decodeStream(stream, null, OPTIONS.getOptions());
    } finally {
      stream.close();
    }
    if (image == null) { // Delete potentially corrupt partial file
      imageFile.delete();
      // We might be out of external storage space, fail over to internal and retry
      if (isUsingExternalCache()) {
        updateExternalStorageState(mContext);
        if (!isUsingExternalCache()) {
          return put(imageUrl, data, cachePermanently);
        }
      }
    } else {
      mCache.put(imageUrl, image);
    }
    return image;
  }

  private void incrementAndTrim() {
    if (COUNTER.incrementAndGet() >= CACHE_CLEAR_FREQUENCY) {
      trimCache();
      COUNTER.set(0);
    }
  }

  public void clear() {
    mCache.clear();
  }

  File getImageFile(File directory, String imageUrl) {
    String fileName = Integer.toHexString(imageUrl.hashCode());
    return new File(directory, fileName);
  }

  /**
   * Trim the disc cache from droid fu to be of the size we want.
   * Oldest files should be deleted first.
   *
   * @author greg
   */
  public void trimCache() {
    File dir = mSecondLevelCacheDir;
    synchronized(mSecondLevelCacheDir) {
      if (dir.exists() && dir.isDirectory()) {
        File[] files = dir.listFiles();
        if (files != null) {
          try {
            // Have the newest files first
            Arrays.sort(files, FILE_COMPARATOR);
            Log.i(TAG, "Sorting by oldest last");
          } catch(RuntimeException e) {
            try {
              Arrays.sort(files, FILE_COMPARATOR_FALLBACK);
              Log.w(TAG, "Purging files by file name comparision rather than age");
            } catch(RuntimeException e1) {
              Log.w(TAG, "Tried to purge cache in a smart order, but failed ... going to purge randomly");
            }
          }
          long sizeOnDisk = 0;
          long longSizeOneDay = System.currentTimeMillis() - DateUtils.DAY_IN_MILLIS;
          int numberOfFiles = 0;
          for (File file : files) {
            int maxSize = this.isUsingExternalCache() ? MAX_EXTERNAL : MAX_INTERNAL;
            if (sizeOnDisk < maxSize && longSizeOneDay > file.lastModified()) {
              // Count until MAX
              sizeOnDisk += file.length();
            } else {
              // Then the rest of the files are old
              // and we want to delete them
              numberOfFiles++;
              // Should be safe since it's the lowest level cache
              if (!file.delete()) {
                // Not guaranteed to do anything, but it's an attempt
                // android makes it hard to define, onExit();
                file.deleteOnExit();
              }
            }
          }
          Log.d(TAG, String.format("Purged %d files and left with %d bytes on disk", numberOfFiles, sizeOnDisk));
        }
      }
      // Trim permanent cache for any file older than a week
      long oneWeekAgo = System.currentTimeMillis() - (DateUtils.DAY_IN_MILLIS * 7);
      clearDirectory(mPermanentCacheDir, oneWeekAgo);
    }
  }

  /**
   * Deletes all files in the provided directory that were last modified
   * before the date given in milliseconds since epoch.
   *
   * @param directory
   * @param olderThanDate
   */
  static void clearDirectory(File directory, long olderThanDate) {
    File[] files = directory.listFiles();
    if (files != null && files.length > 0) {
      for (File file : files) {
        if (file.lastModified() < olderThanDate) {
          if (!file.delete()) {
            file.deleteOnExit();
          }
        }
      }
    }
  }

  /**
   * Returns a file pointing to /<sdcard>/Android/data/<app package name>/cache
   * This is equivalent to the default implementaion of
   * Context.getExternalCacheDir() in Froyo. Does not make the directory
   * if it does not exist.
   * @param context Used to resolve the Application package name.
   * @return
   */
  public static File getExternalCacheDir(Context context) {
    try {
      return ExternalStorageWrapper.getExternalCacheDir(context);
    } catch (Throwable t) {
      return new File(TextUtils.join(File.separator, new String[] {
          Environment.getExternalStorageDirectory().getAbsolutePath(), "Android", "data",
          context.getPackageName(), "cache"}));
    }
  }

  /**
   * Class to wrap calls to Context.getExternalCacheDir while catching
   * verifier errors.
   * @see http://developer.android.com/resources/articles/backward-compatibility.html
   * @author pretz
   *
   */
  @TargetApi(8)
  public static class ExternalStorageWrapper {

    public static File getExternalCacheDir(Context context) {
      return context.getExternalCacheDir();
    }
  }

  private static final Comparator<File> FILE_COMPARATOR = new Comparator<File>() {
    @Override
    public int compare(File object1, File object2) {
      int diff = (int) (object2.lastModified() - object1.lastModified());
      return diff != 0 ? diff : object2.compareTo(object1);
    }
  };

  private static final Comparator<File> FILE_COMPARATOR_FALLBACK = new Comparator<File>() {
    @Override
    public int compare(File object1, File object2) {
      return object2.getName().compareTo(object2.getName());
    }
  };

  private static class OptionsFactory {
    public BitmapFactory.Options getOptions() {
      return new BitmapFactory.Options();
    }
  }

  private static class EfficientOptionsFactory extends OptionsFactory {

    @Override
    public Options getOptions() {
      Options options = super.getOptions();
      options.inInputShareable = true;
      options.inPurgeable = true;
      return options;
    }
  }
}




Java Source Code List

com.yelp.android.webimageview.DebugLogger.java
com.yelp.android.webimageview.FileWritingInputStream.java
com.yelp.android.webimageview.ImageCache.java
com.yelp.android.webimageview.ImageLoaderHandler.java
com.yelp.android.webimageview.ImageLoader.java
com.yelp.android.webimageview.ReferenceWatcher.java
com.yelp.android.webimageview.WebImageView.java
com.yelp.common.base.FinalizableReferenceQueue.java
com.yelp.common.base.FinalizableReference.java
com.yelp.common.base.FinalizableSoftReference.java
com.yelp.common.base.FinalizableWeakReference.java
com.yelp.common.base.Function.java
com.yelp.common.base.Objects.java
com.yelp.common.base.internal.Finalizer.java
com.yelp.common.collect.AbstractMapEntry.java
com.yelp.common.collect.AsynchronousComputationException.java
com.yelp.common.collect.ComputationException.java
com.yelp.common.collect.CustomConcurrentHashMap.java
com.yelp.common.collect.ExpirationTimer.java
com.yelp.common.collect.MapMaker.java
com.yelp.common.collect.NullOutputException.java