// Copyright 2011 NPR
//
// 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.npr.android.news;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.util.Log;
import org.npr.android.util.Base64;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.Thread.State;
import java.lang.ref.SoftReference;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
/**
* This is a tool to load remote images on a background thread.
*
* @author Jeremy Wadsack
*/
public class ImageThreadLoader {
private static final String LOG_TAG = ImageThreadLoader.class.getName();
private final Context context;
// Global cache of images.
private final Cache cache;
private final class QueueItem {
public String url;
public ImageLoadedListener listener;
}
private final ArrayList<QueueItem> queue;
// Assumes that this is started from the main (UI) thread
private final Handler handler = new Handler();
private Thread thread;
private final QueueRunner runner = new QueueRunner();
private ImageThreadLoader(Cache cache,Context context) {
thread = new Thread(runner);
this.cache = cache;
this.context = context;
queue = new ArrayList<QueueItem>();
}
/**
* Creates a new instance of the ImageThreadLoader that uses on-device
* memory to store the images and garbage collects them rapidly.
* @return an ImageThreadLoader that uses memory for caching
*/
public static ImageThreadLoader getInMemoryInstance(Context context) {
return new ImageThreadLoader(new MemoryCache(),context);
}
/**
* Creates a new instance of the ImageThreadLoader that uses the disk
* storage to cache the images.
*
* @param context An application context for accessing storage.
* @return an ImageThreadLoader that uses on-disk cache
*/
public static ImageThreadLoader getOnDiskInstance(Context context) {
return new ImageThreadLoader(new DiskCache(context),context);
}
/**
* Defines an interface for a callback that will handle
* responses from the thread loader when an image is done
* being loaded.
*/
public interface ImageLoadedListener {
public void imageLoaded(Drawable imageBitmap);
}
/**
* Provides a Runnable class to handle loading
* the image from the URL and settings the
* ImageView on the UI thread.
*/
private class QueueRunner implements Runnable {
@Override
public void run() {
synchronized (this) {
while (queue.size() > 0) {
final QueueItem item = queue.remove(0);
// If in the cache, return that copy and be done
if (cache.containsKey(item.url) && cache.get(item.url) != null) {
getCachedItem(item);
} else {
getRemoteItem(item);
}
}
}
}
private void getRemoteItem(final QueueItem item) {
final Bitmap bmp = DownloadDrawable.createBitmapFromUrl(item.url);
if (bmp != null) {
cache.put(item.url, bmp);
// Use a handler to get back onto the UI thread for the update
handler.post(new Runnable() {
@Override
public void run() {
if (item.listener != null) {
item.listener.imageLoaded(new BitmapDrawable(context.getResources(),bmp));
}
}
});
} else {
Log.e(LOG_TAG, "Image from <" + item.url + "> was null!");
}
}
private void getCachedItem(final QueueItem item) {
// Use a handler to get back onto the UI thread for the update
handler.post(new Runnable() {
public void run() {
if (item.listener != null) {
// NB: There's a potential race condition here where the
// cache item could get garbage collected between when we
// post the runnable and it's executed. Ideally we would
// re-run the network load or something.
Bitmap ref = cache.get(item.url);
if (ref != null) {
item.listener.imageLoaded(new BitmapDrawable(context.getResources(),ref));
} else {
Log.w(LOG_TAG, "Image loader lost the image to GC.");
}
}
}
});
}
}
/**
* Queues up a URI to load an image from for a given image view.
*
* @param uri The URI source of the image
* @param listener The listener class to call when the image is loaded
* @return A Bitmap image if the image is in the cache, else null.
*/
public Drawable loadImage(final String uri, final ImageLoadedListener
listener) {
// If it's in the cache, just get it and quit it
if (cache.containsKey(uri)) {
Bitmap ref = cache.get(uri);
if (ref != null) {
return new BitmapDrawable(context.getResources(),ref);
}
}
QueueItem item = new QueueItem();
item.url = uri;
item.listener = listener;
queue.add(item);
// start the thread if needed
if (thread.getState() == State.NEW) {
thread.start();
} else if (thread.getState() == State.TERMINATED) {
thread = new Thread(runner);
thread.start();
}
return null;
}
/**
* A cache is a service that stores references to images
*/
protected static interface Cache {
Bitmap get(String uri);
boolean containsKey(String uri);
void put(String uri, Bitmap image);
}
/**
* An on-disk cache for storing images by URL
*/
protected static class DiskCache implements Cache {
private final Context context;
/**
* Creates a new DiskCache that stores files in local storage
*
* @param context An application context.
*/
public DiskCache(Context context) {
this.context = context;
cleanCache();
}
@Override
public Bitmap get(String uri) {
if (uri == null) {
return null;
}
Bitmap value = null;
try {
FileInputStream stream =
new FileInputStream(
new File(getCachePath(context), makeCacheFileName(uri)))
;
value = BitmapFactory.decodeStream(stream);
stream.close();
Log.d(LOG_TAG, "Cache hit: " + uri);
} catch (FileNotFoundException e) {
Log.e(LOG_TAG, "Error getting cache file.", e);
} catch (IOException e) {
Log.e(LOG_TAG, "Error closing cache file.", e);
}
return value;
}
@Override
public boolean containsKey(String uri) {
if (uri == null) {
return false;
}
File file = new File(getCachePath(context), makeCacheFileName(uri));
return file.exists();
}
@Override
public void put(String uri, Bitmap image) {
if (uri == null) {
return;
}
try {
Bitmap.CompressFormat compression = Bitmap.CompressFormat.JPEG;
if (uri.toLowerCase().endsWith("png")) {
compression = Bitmap.CompressFormat.PNG;
}
FileOutputStream stream =
new FileOutputStream(
new File(getCachePath(context), makeCacheFileName(uri))
);
image.compress(compression, 50, stream);
stream.flush();
stream.close();
} catch (FileNotFoundException e) {
Log.e(LOG_TAG, "Error writing cache file. Is the path wrong?", e);
} catch (IOException e) {
Log.e(LOG_TAG, "Error closing cache file.", e);
}
}
/**
* Method to create the file name used to save images in the cache.
*
* @param uri The URI of the original image.
*
* @return A hash tag that's the filename used in the cache. Returns null
* if the provided uri is null or if an error occurs creating the name.
*/
public static String makeCacheFileName(String uri) {
if (uri == null) {
return null;
}
String key = null;
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(uri.getBytes("iso-8859-1"), 0, uri.length());
key = Base64.encodeBytes(digest.digest()).replace('/','-');
} catch (NoSuchAlgorithmException e) {
Log.e(LOG_TAG, "Error making image key name", e);
} catch (UnsupportedEncodingException e) {
Log.e(LOG_TAG, "Error making image key name", e);
}
return key;
}
/**
* Gets the path where cache files are stored.
*
* This stores cache files within the applications cache folder which
* allows users to clear their cache from the Manage Applications app
*
* @param context The application context for creating the path.
*
* @return The absolute path to the location where cache images are stored.
*/
public static String getCachePath(Context context) {
// We could use external storage, but the images average about 6k each
// and about a dozen images per day of news browsing. Android will
// clean this folder for us if it needs space and we don't have to deal
// with checking if the external storage is present, available,
// readable and writable.
File path = new File(context.getCacheDir(),
ImageThreadLoader.class.getName());
if (!path.exists()) {
//noinspection ResultOfMethodCallIgnored
path.mkdirs();
}
return path.getAbsolutePath();
}
/**
* At each launch, run a thread that cleans anything older than a week.
*
* Note this also cleans up the old cache file location from the first
* BETA2.0 release.
*/
private void cleanCache() {
new Thread(new Runnable(){
@Override
public void run() {
String oldPath = context.getFilesDir().getAbsolutePath();
removeFiles(oldPath, ".{22}==$", 0);
removeFiles(getCachePath(context), ".{22}==$", 7);
}
private void removeFiles(String path, String filePattern, int daysOld) {
final long oldFileDate = new Date().getTime() - (daysOld * 86400000);
File folder = new File(path);
if (folder.exists()) {
String[] filenames = folder.list();
for (String filename : filenames) {
if (filename.matches(filePattern)) {
File file = new File(path, filename);
if (file.lastModified() < oldFileDate) {
Log.d(LOG_TAG, "Removing from cache: " + filename);
//noinspection ResultOfMethodCallIgnored
file.delete();
}
}
}
}
}
}).start();
}
}
/**
* An in-memory cache that uses SoftReference to encourage garbage
* collection.
*/
protected static class MemoryCache implements Cache {
// Using SoftReference to allow garbage collector to clean cache if needed
private final HashMap<String, SoftReference<Bitmap>> cache;
public MemoryCache() {
cache = new HashMap<String, SoftReference<Bitmap>>();
}
@Override
public Bitmap get(String uri) {
return cache.get(uri).get();
}
@Override
public boolean containsKey(String uri) {
return cache.containsKey(uri);
}
@Override
public void put(String uri, Bitmap image) {
cache.put(uri, new SoftReference<Bitmap>(image));
}
}
}