/* * Tweetings - Twitter client for Android * * Copyright (C) 2012-2013 RBD Solutions Limited <apps@tweetings.net> * Copyright (C) 2012 Mariotaku Lee <mariotaku.lee@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.dwdesign.tweetings.util; import static com.dwdesign.tweetings.util.Utils.copyStream; import static com.dwdesign.tweetings.util.Utils.getBestCacheDir; import static com.dwdesign.tweetings.util.Utils.getImageLoaderHttpClient; import static com.dwdesign.tweetings.util.Utils.getRedirectedHttpResponse; import static com.dwdesign.tweetings.util.Utils.resizeBitmap; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.ref.Reference; import java.lang.ref.SoftReference; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import com.dwdesign.tweetings.BuildConfig; import com.dwdesign.tweetings.Constants; import twitter4j.TwitterException; import twitter4j.internal.http.HttpClientWrapper; import twitter4j.internal.http.HttpResponse; import android.app.Activity; import android.content.Context; import android.content.res.Resources.NotFoundException; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.TransitionDrawable; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.widget.GridView; import android.widget.ImageView; import android.widget.ListView; /** * Lazy image loader for {@link ListView} and {@link GridView} etc.</br> </br> * Inspired by <a href="https://github.com/thest1/LazyList">LazyList</a>, this * class has extra features like image loading/caching image to * /mnt/sdcard/Android/data/[package name]/cache features.</br> </br> Requires * Android 2.2, you can modify {@link Context#getExternalCacheDir()} to other to * support Android 2.1 and below. * * @author mariotaku * */ public class LazyImageLoader implements Constants { private static final String LOGTAG = LazyImageLoader.class.getSimpleName(); private static final int DELAY_BEFORE_PURGE = 40000; private final ArrayList<String> mBlacklist; private final MemoryCache mMemoryCache; private final Context mContext; private final FileCache mFileCache; private final Map<ImageView, String> mImageViews = Collections .synchronizedMap(new WeakHashMap<ImageView, String>()); private final ExecutorService mExecutor; private final int mFallbackRes; private final int mRequiredWidth, mRequiredHeight; private final Handler mPurgeHandler; private final MemoryPurger mPurger; final ThreadPoolExecutor cacheExecutor; int crossFadeMillis = 0; private HttpClientWrapper mClient; private Handler handler; public LazyImageLoader(final Context context, final String cache_dir_name, final int fallback_image_res, final int required_width, final int required_height, final int mem_cache_capacity) { mContext = context; mMemoryCache = new MemoryCache(mem_cache_capacity); mFileCache = new FileCache(context, cache_dir_name); mExecutor = Executors.newFixedThreadPool(8, new LowerPriorityThreadFactory()); mFallbackRes = fallback_image_res; mPurgeHandler = new Handler(); mPurger = new MemoryPurger(this); mBlacklist = new ArrayList<String>(); mRequiredWidth = required_width % 2 == 0 ? required_width : required_width + 1; mRequiredHeight = required_height % 2 == 0 ? required_height : required_height + 1; cacheExecutor = (ThreadPoolExecutor) Executors.newFixedThreadPool(1); handler = new Handler(Looper.getMainLooper()); reloadConnectivitySettings(); } /** * Cancels any downloads, shuts down the executor pool, and then purges the * caches. */ public void cancel() { // We could also terminate it immediately, // but that may lead to synchronization issues. if (!mExecutor.isShutdown()) { mExecutor.shutdown(); } stopPurgeTimer(); clearMemoryCache(); } public void clearFileCache() { mFileCache.clear(); } public void clearMemoryCache() { mMemoryCache.clear(); mBlacklist.clear(); System.gc(); } public void displayImage(final URL url, final ImageView view) { this.displayImage(url.toString(), view); } public void displayImage(final String url, final ImageView view) { if (view == null) return; if (url == null) { view.setImageResource(mFallbackRes); return; } mImageViews.put(view, url); long submitted = System.nanoTime(); Runnable r = new ReadFromCacheRunnable(this, view, url, submitted); cacheExecutor.remove(r); cacheExecutor.execute(r); /*final Bitmap bitmap = mMemoryCache.get(url); if (bitmap != null) { view.setImageBitmap(bitmap); resetPurgeTimer(); } else if (!mBlacklist.contains(url)) { queuePhoto(url, view); view.setImageResource(mFallbackRes); resetPurgeTimer(); } else { view.setImageResource(mFallbackRes); }*/ } // Used to display bitmap in the UI thread class BitmapDisplayer implements Runnable { Bitmap bitmap; ImageToLoad imagetoload; public BitmapDisplayer(final Bitmap b, final ImageToLoad p) { bitmap = b; imagetoload = p; } @Override public final void run() { if (imageViewReused(imagetoload)) return; if (bitmap != null) { imagetoload.view.setImageBitmap(bitmap); } else { imagetoload.view.setImageResource(mFallbackRes); } } } static class SetBitmapRunnable extends ImageViewRunnable { private final Bitmap bitmap; private final int crossFadeMillis; public SetBitmapRunnable(LazyImageLoader imageFetcher, ImageView imageView, Bitmap bitmap, int crossFadeMillis) { super(imageFetcher, imageView); this.bitmap = bitmap; this.crossFadeMillis = crossFadeMillis; } @Override public void run() { if (crossFadeMillis > 0) { Drawable prevDrawable = imageView.getDrawable(); if (prevDrawable == null) { prevDrawable = new ColorDrawable(Color.TRANSPARENT); } Drawable nextDrawable = new BitmapDrawable( imageView.getResources(), bitmap); TransitionDrawable transitionDrawable = new TransitionDrawable( new Drawable[] { prevDrawable, nextDrawable }); imageView.setImageDrawable(transitionDrawable); transitionDrawable.startTransition(crossFadeMillis); } else { imageView.setImageBitmap(bitmap); } } } static abstract class ImageViewRunnable implements Runnable { protected final LazyImageLoader imageFetcher; protected final ImageView imageView; public ImageViewRunnable(LazyImageLoader imageFetcher, ImageView imageView) { this.imageFetcher = imageFetcher; this.imageView = imageView; } @Override public boolean equals(Object o) { boolean eq = false; if (this == o) { eq = true; } else if (o instanceof ImageViewRunnable) { eq = imageView.equals(((ImageViewRunnable) o).imageView); } return eq; } @Override public int hashCode() { return imageView.hashCode(); } } private void runOnUiThread(Runnable r) { boolean success = handler.post(r); // a hack while (!success) { handler = new Handler(Looper.getMainLooper()); success = handler.post(r); } } static class ReadFromCacheRunnable extends ImageViewRunnable { protected final String imgUrl; protected final long submitted; public ReadFromCacheRunnable(LazyImageLoader imageFetcher, ImageView imageView, String imgUrl, long submitted) { super(imageFetcher, imageView); this.imgUrl = imgUrl; this.submitted = submitted; } @Override public void run() { final Bitmap bitmap = imageFetcher.mMemoryCache.get(imgUrl); if (bitmap != null) { //imageView.setImageBitmap(bitmap); SetBitmapRunnable r = new SetBitmapRunnable(imageFetcher, imageView, bitmap, imageFetcher.crossFadeMillis); imageFetcher.runOnUiThread(r); imageFetcher.resetPurgeTimer(); } else if (!imageFetcher.mBlacklist.contains(imgUrl)) { imageFetcher.queuePhoto(imgUrl, imageView); try { Bitmap bm = BitmapFactory.decodeResource(imageFetcher.mContext.getResources(), imageFetcher.mFallbackRes); SetBitmapRunnable r = new SetBitmapRunnable(imageFetcher, imageView, bm, imageFetcher.crossFadeMillis); imageFetcher.runOnUiThread(r); } catch (NotFoundException e) { } //imageView.setImageResource(imageFetcher.mFallbackRes); imageFetcher.resetPurgeTimer(); } else { try { Bitmap bm = BitmapFactory.decodeResource(imageFetcher.mContext.getResources(), imageFetcher.mFallbackRes); SetBitmapRunnable r = new SetBitmapRunnable(imageFetcher, imageView, bm, imageFetcher.crossFadeMillis); imageFetcher.runOnUiThread(r); } catch (NotFoundException e) { } //imageView.setImageResource(imageFetcher.mFallbackRes); } } } public File getCachedImageFile(final String url) { if (mFileCache == null) return null; final File f = mFileCache.getFile(url); if (ImageValidator.checkImageValidity(f)) return f; else { queuePhoto(url); } return null; } public void queuePhoto(final String url) { queuePhoto(url, null); } public void reloadConnectivitySettings() { mClient = getImageLoaderHttpClient(mContext); } /** * Stops the cache purger from running until it is reset again. */ public void stopPurgeTimer() { mPurgeHandler.removeCallbacks(mPurger); } /** * The file to decode. * * @return The resized and resampled bitmap, if can not be decoded it * returns null. */ private Bitmap decodeFile(final File file, final String url) { if (file == null || !file.exists()) return null; final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; options.outWidth = 0; options.outHeight = 0; options.inSampleSize = 1; final String filePath = file.getAbsolutePath(); BitmapFactory.decodeFile(filePath, options); if (options.outWidth > 0 && options.outHeight > 0) { // Now see how much we need to scale it down. int widthFactor = (options.outWidth + mRequiredWidth - 1) / mRequiredWidth; final int heightFactor = (options.outHeight + mRequiredHeight - 1) / mRequiredHeight; widthFactor = Math.max(widthFactor, heightFactor); widthFactor = Math.max(widthFactor, 1); // Now turn it into a power of two. if (widthFactor > 1) { if ((widthFactor & widthFactor - 1) != 0) { while ((widthFactor & widthFactor - 1) != 0) { widthFactor &= widthFactor - 1; } widthFactor <<= 1; } } options.inSampleSize = widthFactor; options.inJustDecodeBounds = false; final Bitmap bitmap = resizeBitmap(BitmapFactory.decodeFile(filePath, options), mRequiredWidth, mRequiredHeight); if (bitmap != null) return bitmap; } else { if (file.isFile() && file.length() == 0) { file.delete(); } // Must not be a bitmap, so we add it to the blacklist. if (!mBlacklist.contains(url)) { mBlacklist.add(url); } } return null; } private void queuePhoto(final String url, final ImageView imageview) { final ImageToLoad p = new ImageToLoad(url, imageview); mExecutor.submit(new ImageLoader(p)); } /** * Purges the cache every (DELAY_BEFORE_PURGE) milliseconds. * * @see DELAY_BEFORE_PURGE */ private void resetPurgeTimer() { mPurgeHandler.removeCallbacks(mPurger); mPurgeHandler.postDelayed(mPurger, DELAY_BEFORE_PURGE); } // Both hard and soft caches are purged after 40 seconds idling. boolean imageViewReused(final ImageToLoad imagetoload) { final Object tag = mImageViews.get(imagetoload.view); if (tag == null || !tag.equals(imagetoload.source)) return true; return false; } static class FileCache { private final String mCacheDirName; private File mCacheDir; private final Context mContext; public FileCache(final Context context, final String cache_dir_name) { mContext = context; mCacheDirName = cache_dir_name; init(); } public void clear() { if (mCacheDir == null) return; final File[] files = mCacheDir.listFiles(); if (files == null) return; for (final File f : files) { f.delete(); } } public File getFile(final String url) { if (mCacheDir == null) return null; final String filename = getFilename(url); if (filename == null) return null; final File file = new File(mCacheDir, filename); return file; } public void init() { /* Find the dir to save cached images. */ mCacheDir = getBestCacheDir(mContext, mCacheDirName); if (mCacheDir != null && !mCacheDir.exists()) { mCacheDir.mkdirs(); } } private String getFilename(final String url) { if (url == null) return null; return url.replaceFirst("https?:\\/\\/", "").replaceAll("[^\\w\\d]", "_"); } } class ImageLoader implements Runnable { private final ImageToLoad imagetoload; public ImageLoader(final ImageToLoad imagetoload) { this.imagetoload = imagetoload; } public Bitmap getBitmap(final String url) { if (url == null) return null; final File cache_file = mFileCache.getFile(url); // from SD cache final Bitmap cached_bitmap = decodeFile(cache_file, url); if (cached_bitmap != null) return cached_bitmap; // from web try { final HttpResponse resp = getRedirectedHttpResponse(mClient, url); if (resp != null && resp.getStatusCode() == 200) { final InputStream is = resp.asStream(); final OutputStream os = new FileOutputStream(cache_file); copyStream(is, os); os.flush(); os.close(); final Bitmap bitmap = decodeFile(cache_file, url); if (bitmap == null) { // The file is corrupted, so we remove it from cache. if (cache_file.isFile() && cache_file.length() == 0) { cache_file.delete(); } } else return bitmap; } } catch (final FileNotFoundException e) { // Storage state may changed, so call FileCache.init() again. Log.w(LOGTAG, e); mFileCache.init(); } catch (final IOException e) { Log.w(LOGTAG, e); } catch (final TwitterException e) { Log.w(LOGTAG, e); } return null; } @Override public void run() { if (imageViewReused(imagetoload) || imagetoload.source == null) return; final Bitmap bmp = getBitmap(imagetoload.source); mMemoryCache.put(imagetoload.source, bmp); if (imageViewReused(imagetoload)) return; final BitmapDisplayer bd = new BitmapDisplayer(bmp, imagetoload); final Activity a = (Activity) imagetoload.view.getContext(); a.runOnUiThread(bd); } } static class ImageToLoad { public final String source; public final ImageView view; public ImageToLoad(final String source, final ImageView imageview) { this.source = source; view = imageview; } } static class LowerPriorityThreadFactory implements ThreadFactory { @Override public Thread newThread(final Runnable r) { final Thread t = new Thread(r); t.setPriority(3); return t; } } static class MemoryCache { private final Map<String, SoftReference<Bitmap>> mSoftCache; private final LinkedHashMap<String, Bitmap> mHardCache; public MemoryCache(final int max_capacity) { mSoftCache = new HashMap<String, SoftReference<Bitmap>>(max_capacity / 2); mHardCache = new HardBitmapCache(mSoftCache, max_capacity / 2); } public void clear() { try { mHardCache.clear(); mSoftCache.clear(); } catch (final Exception e) { Log.e(LOGTAG, "Unknown exception", e); } } public Bitmap get(final String key) { if (key == null) return null; try { synchronized (mHardCache) { final Bitmap bitmap = mHardCache.get(key); if (bitmap != null && key != null) { // Put bitmap on top of cache so it's purged last. mHardCache.remove(key); mHardCache.put(key, bitmap); return bitmap; } } final Reference<Bitmap> bitmapRef = mSoftCache.get(key); if (bitmapRef != null) { final Bitmap bitmap = bitmapRef.get(); if (bitmap != null) return bitmap; else { // Must have been collected by the Garbage Collector // so we remove the bucket from the cache. mSoftCache.remove(key); } } } catch (final Exception e) { Log.e(LOGTAG, "Unknown exception", e); } // Could not locate the bitmap in any of the caches, so we return // null. return null; } public void put(final String key, final Bitmap bitmap) { if (key == null || bitmap == null) return; try { mHardCache.put(key, bitmap); } catch (final Exception e) { Log.e(LOGTAG, "Unknown exception", e); } } static class HardBitmapCache extends LinkedHashMap<String, Bitmap> { private static final long serialVersionUID = 1347795807259717646L; private final Map<String, SoftReference<Bitmap>> soft_cache; private final int capacity; HardBitmapCache(final Map<String, SoftReference<Bitmap>> soft_cache, final int capacity) { super(capacity); this.soft_cache = soft_cache; this.capacity = capacity; } @Override protected boolean removeEldestEntry(final LinkedHashMap.Entry<String, Bitmap> eldest) { // Moves the last used item in the hard cache to the soft cache. if (size() > capacity) { soft_cache.put(eldest.getKey(), new SoftReference<Bitmap>(eldest.getValue())); return true; } else return false; } } } static class MemoryPurger implements Runnable { private final LazyImageLoader loader; MemoryPurger(final LazyImageLoader loader) { this.loader = loader; } @Override public void run() { loader.clearMemoryCache(); } } }