Last active
December 26, 2015 05:19
-
-
Save thom-nic/7100065 to your computer and use it in GitHub Desktop.
Enhanced Android image downloader with persistent local cache based on a hash of the image URL. Cached files are automatically expired based on last access time. First revision shows the original source from http://goo.gl/zUxFD6 so it's clear what I've added.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright (C) 2010 The Android Open Source Project | |
* | |
* 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 org.thomnichols.android.socialwidget.thirdparty; | |
import java.io.File; | |
import java.io.FileInputStream; | |
import java.io.FileOutputStream; | |
import java.io.FilterInputStream; | |
import java.io.IOException; | |
import java.io.InputStream; | |
import java.io.OutputStream; | |
import java.lang.ref.SoftReference; | |
import java.lang.ref.WeakReference; | |
import java.util.HashMap; | |
import java.util.LinkedHashMap; | |
import java.util.Map; | |
import java.util.concurrent.ConcurrentHashMap; | |
import org.apache.http.HttpEntity; | |
import org.apache.http.HttpResponse; | |
import org.apache.http.HttpStatus; | |
import org.apache.http.client.HttpClient; | |
import org.apache.http.client.methods.HttpGet; | |
import org.apache.http.client.params.HttpClientParams; | |
import org.apache.http.impl.client.DefaultHttpClient; | |
import android.graphics.Bitmap; | |
import android.graphics.BitmapFactory; | |
import android.graphics.Color; | |
import android.graphics.drawable.ColorDrawable; | |
import android.graphics.drawable.Drawable; | |
import android.net.http.AndroidHttpClient; | |
import android.os.AsyncTask; | |
import android.os.Handler; | |
import android.util.Log; | |
import android.widget.ImageView; | |
/** | |
* This helper class download images from the Internet and binds those with the provided ImageView. | |
* | |
* <p>It requires the INTERNET permission, which should be added to your application's manifest | |
* file.</p> | |
* | |
* A local cache of downloaded images is maintained internally to improve performance. | |
*/ | |
public class ImageDownloader { | |
private static final String LOG_TAG = "ImageDownloader"; | |
public enum Mode { NO_ASYNC_TASK, NO_DOWNLOADED_DRAWABLE, CORRECT } | |
private Mode mode = Mode.CORRECT; | |
private File cacheDir; | |
public ImageDownloader() { | |
this(null); | |
} | |
public ImageDownloader(File cacheDir) { | |
this.cacheDir = cacheDir; | |
if ( cacheDir != null ) cacheDir.mkdirs(); | |
} | |
/** | |
* Download the specified image from the Internet and binds it to the provided ImageView. The | |
* binding is immediate if the image is found in the cache and will be done asynchronously | |
* otherwise. A null bitmap will be associated to the ImageView if an error occurs. | |
* | |
* @param url The URL of the image to download. | |
* @param imageView The ImageView to bind the downloaded image to. | |
*/ | |
public void download(String url, ImageView imageView) { | |
if ( url == null ) { | |
Log.w(LOG_TAG,"Request to download null image!"); | |
return; // defensive programming | |
} | |
if ( imageView == null ) { | |
Log.w(LOG_TAG,"Passed null imageView!"); | |
return; | |
} | |
resetPurgeTimer(); | |
Bitmap bitmap = getBitmapFromCache(url); | |
if (bitmap == null) { | |
forceDownload(url, imageView); | |
} else { | |
cancelPotentialDownload(url, imageView); | |
imageView.setImageBitmap(bitmap); | |
} | |
} | |
/* | |
* Same as download but the image is always downloaded and the cache is not used. | |
* Kept private at the moment as its interest is not clear. | |
private void forceDownload(String url, ImageView view) { | |
forceDownload(url, view, null); | |
} | |
*/ | |
/** | |
* Same as download but the image is always downloaded and the cache is not used. | |
* Kept private at the moment as its interest is not clear. | |
*/ | |
private void forceDownload(String url, ImageView imageView) { | |
// State sanity: url is guaranteed to never be null in DownloadedDrawable and cache keys. | |
if (url == null) { | |
imageView.setImageDrawable(null); | |
return; | |
} | |
if (cancelPotentialDownload(url, imageView)) { | |
switch (mode) { | |
case NO_ASYNC_TASK: | |
Bitmap bitmap = downloadBitmap(url); | |
addBitmapToCache(url, bitmap); | |
imageView.setImageBitmap(bitmap); | |
break; | |
case NO_DOWNLOADED_DRAWABLE: | |
imageView.setMinimumHeight(156); | |
BitmapDownloaderTask task = new BitmapDownloaderTask(imageView); | |
task.execute(url); | |
break; | |
case CORRECT: | |
task = new BitmapDownloaderTask(imageView); | |
DownloadedDrawable downloadedDrawable = new DownloadedDrawable(task); | |
imageView.setImageDrawable(downloadedDrawable); | |
imageView.setMinimumHeight(156); | |
task.execute(url); | |
break; | |
} | |
} | |
} | |
/** | |
* Returns true if the current download has been canceled or if there was no download in | |
* progress on this image view. | |
* Returns false if the download in progress deals with the same url. The download is not | |
* stopped in that case. | |
*/ | |
private static boolean cancelPotentialDownload(String url, ImageView imageView) { | |
BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView); | |
if (bitmapDownloaderTask != null) { | |
String bitmapUrl = bitmapDownloaderTask.url; | |
if ((bitmapUrl == null) || (!bitmapUrl.equals(url))) { | |
bitmapDownloaderTask.cancel(true); | |
} else { | |
// The same URL is already being downloaded. | |
return false; | |
} | |
} | |
return true; | |
} | |
/** | |
* @param imageView Any imageView | |
* @return Retrieve the currently active download task (if any) associated with this imageView. | |
* null if there is no such task. | |
*/ | |
private static BitmapDownloaderTask getBitmapDownloaderTask(ImageView imageView) { | |
if (imageView != null) { | |
Drawable drawable = imageView.getDrawable(); | |
if (drawable instanceof DownloadedDrawable) { | |
DownloadedDrawable downloadedDrawable = (DownloadedDrawable)drawable; | |
return downloadedDrawable.getBitmapDownloaderTask(); | |
} | |
} | |
return null; | |
} | |
Bitmap downloadBitmap(String url) { | |
// final int IO_BUFFER_SIZE = 4 * 1024; | |
// AndroidHttpClient is not allowed to be used from the main thread | |
final HttpClient client = (mode == Mode.NO_ASYNC_TASK) ? new DefaultHttpClient() : | |
AndroidHttpClient.newInstance("Android"); | |
HttpClientParams.setRedirecting(client.getParams(), true); | |
final HttpGet getRequest = new HttpGet(url); | |
try { | |
HttpResponse response = client.execute(getRequest); | |
final int statusCode = response.getStatusLine().getStatusCode(); | |
if (statusCode != HttpStatus.SC_OK) { | |
Log.w("ImageDownloader", "Error " + statusCode + | |
" while retrieving bitmap from " + url); | |
return null; | |
} | |
final HttpEntity entity = response.getEntity(); | |
if (entity != null) { | |
InputStream inputStream = null; | |
try { | |
inputStream = entity.getContent(); | |
inputStream = this.saveToFile( url, new FlushedInputStream(inputStream) ); | |
return BitmapFactory.decodeStream(inputStream); | |
} finally { | |
if (inputStream != null) { | |
inputStream.close(); | |
} | |
entity.consumeContent(); | |
} | |
} | |
} catch (IOException e) { | |
getRequest.abort(); | |
Log.w(LOG_TAG, "I/O error while retrieving bitmap from " + url, e); | |
} catch (IllegalStateException e) { | |
getRequest.abort(); | |
Log.w(LOG_TAG, "Incorrect URL: " + url); | |
} catch (Exception e) { | |
getRequest.abort(); | |
Log.w(LOG_TAG, "Error while retrieving bitmap from " + url, e); | |
} finally { | |
if ((client instanceof AndroidHttpClient)) { | |
((AndroidHttpClient) client).close(); | |
} | |
} | |
return null; | |
} | |
/* | |
* Consistent hashing scheme for file URLs | |
* TODO MD5 is probably more reliable but also likely slower | |
* If we're not saving 100k+ files, this is probably good enough | |
*/ | |
private File getFile(String url) { | |
if ( this.cacheDir == null ) return null; | |
return new File( this.cacheDir, | |
String.format("%x",url.hashCode()) ); | |
} | |
/* | |
* Save the image to the filesystem, and return an open InputStream to that file | |
*/ | |
private InputStream saveToFile( String url, InputStream inputStream ) throws IOException { | |
File cacheFile = this.getFile( url ); | |
// if no cache dir, just return the HTTP stream | |
if ( cacheFile == null ) return inputStream; | |
OutputStream outStream = null; | |
try { | |
outStream = new FileOutputStream( cacheFile ); | |
IOUtils.copy( inputStream, outStream ); | |
inputStream.close(); | |
outStream.flush(); | |
return new FileInputStream( cacheFile ); | |
} | |
finally { | |
try { outStream.close(); } catch ( Exception ex ) {} | |
} | |
} | |
/* | |
* Returns null if the file is not saved on the filesystem | |
*/ | |
private Bitmap getFromFile( String url ) { | |
File cacheFile = this.getFile( url ); | |
if ( cacheFile == null ) return null; | |
if ( ! cacheFile.exists() ) return null; | |
InputStream inputStream = null; | |
try{ | |
inputStream = new FileInputStream( cacheFile ); | |
return BitmapFactory.decodeStream(inputStream); | |
} | |
catch ( IOException ex ) { | |
Log.w(LOG_TAG,"Error reading file from cache!", ex); | |
} | |
return null; | |
} | |
private void updateLastAccessed( String url ) { | |
File cacheFile = this.getFile( url ); | |
cacheFile.setLastModified( System.currentTimeMillis() ); | |
} | |
/* | |
* An InputStream that skips the exact number of bytes provided, unless it reaches EOF. | |
*/ | |
static class FlushedInputStream extends FilterInputStream { | |
public FlushedInputStream(InputStream inputStream) { | |
super(inputStream); | |
} | |
@Override | |
public long skip(long n) throws IOException { | |
long totalBytesSkipped = 0L; | |
while (totalBytesSkipped < n) { | |
long bytesSkipped = in.skip(n - totalBytesSkipped); | |
if (bytesSkipped == 0L) { | |
int b = read(); | |
if (b < 0) { | |
break; // we reached EOF | |
} else { | |
bytesSkipped = 1; // we read one byte | |
} | |
} | |
totalBytesSkipped += bytesSkipped; | |
} | |
return totalBytesSkipped; | |
} | |
} | |
/** | |
* The actual AsyncTask that will asynchronously download the image. | |
*/ | |
class BitmapDownloaderTask extends AsyncTask<String, Void, Bitmap> { | |
private String url; | |
private final WeakReference<ImageView> imageViewReference; | |
public BitmapDownloaderTask(ImageView imageView) { | |
imageViewReference = new WeakReference<ImageView>(imageView); | |
} | |
/** | |
* Actual download method. | |
*/ | |
@Override | |
protected Bitmap doInBackground(String... params) { | |
url = params[0]; | |
return downloadBitmap(url); | |
} | |
/** | |
* Once the image is downloaded, associates it to the imageView | |
*/ | |
@Override | |
protected void onPostExecute(Bitmap bitmap) { | |
if (isCancelled()) { | |
bitmap = null; | |
} | |
addBitmapToCache(url, bitmap); | |
if (imageViewReference != null) { | |
ImageView imageView = imageViewReference.get(); | |
BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView); | |
// Change bitmap only if this process is still associated with it | |
// Or if we don't use any bitmap to task association (NO_DOWNLOADED_DRAWABLE mode) | |
if ((this == bitmapDownloaderTask) || (mode != Mode.CORRECT)) { | |
imageView.setImageBitmap(bitmap); | |
} | |
} | |
} | |
} | |
/** | |
* A fake Drawable that will be attached to the imageView while the download is in progress. | |
* | |
* <p>Contains a reference to the actual download task, so that a download task can be stopped | |
* if a new binding is required, and makes sure that only the last started download process can | |
* bind its result, independently of the download finish order.</p> | |
*/ | |
static class DownloadedDrawable extends ColorDrawable { | |
private final WeakReference<BitmapDownloaderTask> bitmapDownloaderTaskReference; | |
public DownloadedDrawable(BitmapDownloaderTask bitmapDownloaderTask) { | |
super(Color.BLACK); | |
bitmapDownloaderTaskReference = | |
new WeakReference<BitmapDownloaderTask>(bitmapDownloaderTask); | |
} | |
public BitmapDownloaderTask getBitmapDownloaderTask() { | |
return bitmapDownloaderTaskReference.get(); | |
} | |
} | |
public void setMode(Mode mode) { | |
this.mode = mode; | |
clearCache(); | |
} | |
/* | |
* Cache-related fields and methods. | |
* | |
* We use a hard and a soft cache. A soft reference cache is too aggressively cleared by the | |
* Garbage Collector. | |
*/ | |
private static final int HARD_CACHE_CAPACITY = 10; | |
private static final int DELAY_BEFORE_PURGE = 10 * 1000; // in milliseconds | |
// Hard cache, with a fixed maximum capacity and a life duration | |
@SuppressWarnings("serial") | |
private final HashMap<String, Bitmap> sHardBitmapCache = | |
new LinkedHashMap<String, Bitmap>(HARD_CACHE_CAPACITY / 2, 0.75f, true) { | |
@Override | |
protected boolean removeEldestEntry(Map.Entry<String, Bitmap> eldest) { | |
if (size() > HARD_CACHE_CAPACITY) { | |
// Entries push-out of hard reference cache are transferred to soft reference cache | |
sSoftBitmapCache.put(eldest.getKey(), new SoftReference<Bitmap>(eldest.getValue())); | |
return true; | |
} else | |
return false; | |
} | |
}; | |
// Soft cache for bitmaps kicked out of hard cache | |
private final static ConcurrentHashMap<String, SoftReference<Bitmap>> sSoftBitmapCache = | |
new ConcurrentHashMap<String, SoftReference<Bitmap>>(HARD_CACHE_CAPACITY / 2); | |
private final Handler purgeHandler = new Handler(); | |
private final Runnable purger = new Runnable() { | |
public void run() { | |
clearCache(); | |
} | |
}; | |
/** | |
* Adds this bitmap to the cache. | |
* @param bitmap The newly downloaded bitmap. | |
*/ | |
private void addBitmapToCache(String url, Bitmap bitmap) { | |
if (bitmap != null) { | |
synchronized (sHardBitmapCache) { | |
sHardBitmapCache.put(url, bitmap); | |
} | |
} | |
} | |
/** | |
* @param url The URL of the image that will be retrieved from the cache. | |
* @return The cached bitmap or null if it was not found. | |
*/ | |
private Bitmap getBitmapFromCache(String url) { | |
// First try the hard reference cache | |
synchronized (sHardBitmapCache) { | |
final Bitmap bitmap = sHardBitmapCache.get(url); | |
if (bitmap != null) { | |
// Bitmap found in hard cache | |
// Move element to first position, so that it is removed last | |
sHardBitmapCache.remove(url); | |
sHardBitmapCache.put(url, bitmap); | |
return bitmap; | |
} | |
} | |
// Then try the soft reference cache | |
SoftReference<Bitmap> bitmapReference = sSoftBitmapCache.get(url); | |
if (bitmapReference != null) { | |
final Bitmap bitmap = bitmapReference.get(); | |
if (bitmap != null) { | |
// Bitmap found in soft cache | |
return bitmap; | |
} else { | |
// Soft reference has been Garbage Collected | |
sSoftBitmapCache.remove(url); | |
} | |
} | |
// check file cache | |
final Bitmap bitmap = getFromFile( url ); | |
if ( bitmap != null ) { | |
addBitmapToCache( url, bitmap ); | |
updateLastAccessed( url ); | |
} | |
return bitmap; | |
} | |
/** | |
* Clears the image cache used internally to improve performance. Note that for memory | |
* efficiency reasons, the cache will automatically be cleared after a certain inactivity delay. | |
*/ | |
public void clearCache() { | |
sHardBitmapCache.clear(); | |
sSoftBitmapCache.clear(); | |
} | |
/** | |
* Allow a new delay before the automatic cache clear is done. | |
*/ | |
private void resetPurgeTimer() { | |
purgeHandler.removeCallbacks(purger); | |
purgeHandler.postDelayed(purger, DELAY_BEFORE_PURGE); | |
} | |
public static class ClearCacheFilesTask extends AsyncTask<Void, Void, Integer> { | |
File cacheDir; | |
Long deleteOlderThan; | |
public ClearCacheFilesTask( File cacheDir, Long age ) { | |
this.cacheDir = cacheDir; | |
this.deleteOlderThan = System.currentTimeMillis() - age; | |
} | |
@Override | |
protected Integer doInBackground( Void... params ) { | |
int deletedCount = 0; | |
if ( cacheDir == null || ! cacheDir.exists() ) { | |
Log.w(LOG_TAG,"Clear cache files: No cache dir!"); | |
return 0; | |
} | |
for ( File f : cacheDir.listFiles() ) { | |
if ( f.lastModified() < this.deleteOlderThan ) { | |
if ( f.delete() ) deletedCount++; | |
else Log.w(LOG_TAG,"Couldn't delete " + f.getPath()); | |
} | |
} | |
return deletedCount; | |
} | |
public void executeSync() { | |
this.onPreExecute(); | |
Integer result = this.doInBackground(); | |
this.onPostExecute( result ); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Most of this is boilerplate, just an example to show how you tie an ImageView to the | |
* ImageDownloader | |
*/ | |
public class ImageDownloaderActivity extends Activity implements LoaderCallbacks<Cursor> { | |
static final String PHOTO_CACHE_DIR = "photo_cache" | |
static final String KEY_LAST_IMG_CACHE_CLEANUP = "last_image_cache_cleanup"; | |
static final long IMG_CACHE_AGE = 5*24*60*60*1000; // 5 days without being touched | |
ImageDownloader downloader; | |
@Override | |
protected void onCreate( Bundle savedInstanceState ) { | |
super.onCreate( savedInstanceState ); | |
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); | |
final File cacheDir = new File(ctx.getCacheDir(), PHOTO_CACHE_DIR) | |
this.downloader = new ImageDownloader( cacheDir ); | |
/* | |
* Note: a background service (perferably) or a main activity should periodically run | |
* a cache cleanup task like so: | |
final long now = System.currentTimeMillis(); | |
long lastImgCleanup = prefs.getLong( | |
KEY_LAST_IMG_CACHE_CLEANUP, | |
now - IMG_CACHE_AGE - 1 ); | |
if ( lastImgCleanup + IMG_CACHE_AGE < now ) { | |
// run image cleanup | |
ImageDownloader.ClearCacheFilesTask task = | |
new ImageDownloader.ClearCacheFilesTask( | |
cacheDir, | |
IMG_CACHE_AGE ) { | |
protected void onPostExecute(Integer result) { | |
Log.d(TAG,"Deleted " + result + " photos older than " + | |
new Date(now-IMG_CACHE_AGE) ); | |
prefs.edit() | |
.putLong( KEY_LAST_IMG_CACHE_CLEANUP, now ) | |
.commit(); | |
} | |
}; | |
task.execute(); | |
} | |
*/ | |
} | |
@Override | |
public void onLoadFinished( Loader<Cursor> loader, Cursor data ) { | |
this.currentCursor = data; | |
// if this is the first launch, create the view | |
if ( findViewById( R.id.label_title ) == null ) { | |
setContentView( R.layout.post_detail ); | |
} | |
getNextCursorResult(); | |
} | |
/** | |
* Move to the next result & load data into the view | |
*/ | |
protected void getNextCursorResult() { | |
Cursor data = this.currentCursor; | |
if ( data == null ) { | |
Log.e(TAG,"No current cursor!!!!!!!"); | |
return; | |
} | |
if ( ! data.moveToNext() ) { | |
Log.w(TAG,"No data from cursor!"); | |
finish(); | |
return; | |
} | |
String avatarURL = data.getString( data.getColumnIndex( Post.Cols.USER_AVATAR_URL ) ); | |
Log.d(TAG,"Downloading avatar: " + avatarURL); | |
ImageView avatarView = (ImageView)findViewById( R.id.img_avatar ); | |
/****** THIS IS WHERE THE MAGIC HAPPENS ******/ | |
downloader.download( avatarURL, avatarView ); | |
/*************** END MAGIC *******************/ | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment