/* * Twidere - Twitter client for Android 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.tweetlanes.android.core.util; import android.app.Activity; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; import android.os.Environment; import android.widget.GridView; import android.widget.ImageView; import android.widget.ListView; import java.io.File; import java.io.FileFilter; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.ref.SoftReference; import java.net.HttpURLConnection; import java.net.Proxy; import java.net.URL; import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static android.os.Environment.getExternalStorageDirectory; import static android.os.Environment.getExternalStorageState; // import static org.mariotaku.twidere.util.Utils.getProxy; // import static com.tweetlanes.android.core.util.parseURL; // import static org.mariotaku.twidere.util.Utils.setIgnoreSSLError; /** * 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 { private final MemoryCache mMemoryCache; private final FileCache mFileCache; private final Map<ImageView, URL> mImageViews = Collections .synchronizedMap(new WeakHashMap<ImageView, URL>()); private final ExecutorService mExecutorService; private final int mFallbackRes; private final int mRequiredWidth, mRequiredHeight; private final boolean mNoScale; private final Context mContext; private Proxy mProxy; public LazyImageLoader(Context context, String cache_dir_name, int fallback_image_res, int required_width, int required_height, boolean noScale, int mem_cache_capacity) { mContext = context; mMemoryCache = new MemoryCache(mem_cache_capacity); mFileCache = new FileCache(mContext, cache_dir_name); mExecutorService = Executors.newFixedThreadPool(5); mFallbackRes = fallback_image_res; mRequiredWidth = required_width % 2 == 0 ? required_width : required_width + 1; mRequiredHeight = required_height % 2 == 0 ? required_height : required_height + 1; mNoScale = noScale; mProxy = Util.getProxy(mContext); } public void clearCache() { Set<URL> clearedUrls = mMemoryCache.clear(); mFileCache.clearUrls(clearedUrls); Set<URL> activeUrls = mMemoryCache.getActiveUrls(); mFileCache.clearUnrecognisedFiles(activeUrls); } public void displayImage(String url, ImageView imageview) { displayImage(Util.parseURL(url), imageview); } void displayImage(URL url, ImageView imageview) { if (imageview == null) return; if (url == null) { imageview.setImageResource(mFallbackRes); return; } mImageViews.put(imageview, url); final Bitmap bitmap = mMemoryCache.get(url, mFileCache); if (bitmap != null) { imageview.setImageBitmap(bitmap); } else { queuePhoto(url, imageview); imageview.setImageResource(mFallbackRes); } } private static void copyStream(InputStream is, OutputStream os) { final int buffer_size = 1024; try { final byte[] bytes = new byte[buffer_size]; int count = is.read(bytes, 0, buffer_size); while (count != -1) { os.write(bytes, 0, count); count = is.read(bytes, 0, buffer_size); } } catch (final IOException e) { // e.printStackTrace(); } } // decodes image and scales it to reduce memory consumption private Bitmap decodeFile(File f) { InputStream enter = null; InputStream exit = null; try { // decode image size enter = new FileInputStream(f); final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeStream(enter, null, options); // Find the correct scale value. It should be the power of 2. int scale = 1; if(!mNoScale) { int width_tmp = options.outWidth, height_tmp = options.outHeight; while (width_tmp / 2 >= mRequiredWidth || height_tmp / 2 >= mRequiredHeight) { width_tmp /= 2; height_tmp /= 2; scale *= 2; } } // decode with inSampleSize exit = new FileInputStream(f); final BitmapFactory.Options o2 = new BitmapFactory.Options(); o2.inSampleSize = scale; final Bitmap bitmap = BitmapFactory.decodeStream(exit, null, o2); if (bitmap == null) { // The file is corrupted, so we remove it from cache. if (f.isFile()) { f.delete(); } } return bitmap; } catch (final FileNotFoundException e) { // e.printStackTrace(); } finally { Util.closeQuietly(enter); Util.closeQuietly(exit); } return null; } private void queuePhoto(URL url, ImageView imageview) { final ImageToLoad p = new ImageToLoad(url, imageview); mExecutorService.submit(new ImageLoader(p)); } boolean imageViewReused(ImageToLoad imagetoload) { final Object tag = mImageViews.get(imagetoload.imageview); return tag == null || !tag.equals(imagetoload.source); } // Used to display bitmap in the UI thread private class BitmapDisplayer implements Runnable { final Bitmap mBitmap; final ImageToLoad mImageToLoad; public BitmapDisplayer(Bitmap b, ImageToLoad p) { mBitmap = b; mImageToLoad = p; } @Override public final void run() { if (imageViewReused(mImageToLoad)) return; if (mBitmap != null) { mImageToLoad.imageview.setImageBitmap(mBitmap); } else { mImageToLoad.imageview.setImageResource(mFallbackRes); } } } private static class FileCache { private final String mCacheDirName; private File mCacheDir; private final Context mContext; public FileCache(Context context, String cache_dir_name) { mContext = context; mCacheDirName = cache_dir_name; init(); } public File getFile(URL tag) { if (mCacheDir == null) return null; final String filename = getURLFilename(tag); if (filename == null) return null; return new File(mCacheDir, filename); } public void saveFile(Bitmap image, URL tag) { if (mCacheDir == null) return; final String filename = getURLFilename(tag); if (filename == null) return; final File file = new File(mCacheDir, filename); FileOutputStream fOut; try { fOut = new FileOutputStream(file); image.compress(Bitmap.CompressFormat.PNG, 100, fOut); fOut.flush(); fOut.close(); } catch (IOException e) { } } private void deleteFile(final URL tag) { if (mCacheDir == null) return; final File[] files = mCacheDir.listFiles(new FileFilter() { @Override public boolean accept(File file) { return file.getName() == getURLFilename(tag); } }); if (files == null) return; for (final File f : files) { f.delete(); } } public void clearUrls(Set<URL> urls) { for (URL url : urls) { deleteFile(url); } } public void clearUnrecognisedFiles(Set<URL> urls) { Calendar cal = Calendar.getInstance(); cal.add(Calendar.DATE, -1); final File[] files = mCacheDir.listFiles(); if (files == null) return; for (final File f : files) { Date lastModDate = new Date(f.lastModified()); if (lastModDate.before(cal.getTime())) { boolean fileInCache = false; for (URL url : urls) { if (f.getName() == getURLFilename(url)) { fileInCache = true; } } if (!fileInCache) { f.delete(); } } } } public void init() { /* Find the dir to save cached images. */ if (getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { mCacheDir = new File( Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO ? mContext .getExternalCacheDir() : new File(getExternalStorageDirectory() .getPath() + "/Android/data/" + mContext.getPackageName() + "/cache/"), mCacheDirName); } else { mCacheDir = new File(mContext.getCacheDir(), mCacheDirName); } if (mCacheDir != null && !mCacheDir.exists()) { mCacheDir.mkdirs(); } } private static String getURLFilename(URL url) { if (url == null) { return null; } return url.toString().replaceAll("[^a-zA-Z0-9]", "_"); } } private class ImageLoader implements Runnable { private final ImageToLoad mImageToLoad; public ImageLoader(ImageToLoad imagetoload) { this.mImageToLoad = imagetoload; } private Bitmap getBitmap(URL url) { if (url == null) return null; final File f = mFileCache.getFile(url); // from SD cache final Bitmap b = decodeFile(f); if (b != null) return b; // from web return DownloadBitmapFromWeb(url, f, false); } private Bitmap DownloadBitmapFromWeb(URL url, File f, Boolean isRetry) { try { Bitmap bitmap; final HttpURLConnection conn = (HttpURLConnection) url .openConnection(mProxy); conn.setConnectTimeout(30000); conn.setReadTimeout(30000); conn.setInstanceFollowRedirects(true); final InputStream is = conn.getInputStream(); final OutputStream os = new FileOutputStream(f); copyStream(is, os); is.close(); os.close(); bitmap = decodeFile(f); final int bitmapBytes = bitmap.getByteCount(); if (bitmapBytes == 0) { if (isRetry) { return null; } return DownloadBitmapFromWeb(url, f, true); } mFileCache.saveFile(bitmap, url); return bitmap; } catch (final FileNotFoundException e) { // Storage state may changed, so call FileCache.init() again. // e.printStackTrace(); mFileCache.init(); } catch (final IOException e) { // e.printStackTrace(); } return null; } @Override public void run() { final Bitmap bmp = getBitmap(mImageToLoad.source); if (imageViewReused(mImageToLoad) || mImageToLoad.source == null) return; mMemoryCache.put(mImageToLoad.source, bmp); if (imageViewReused(mImageToLoad)) return; final BitmapDisplayer bd = new BitmapDisplayer(bmp, mImageToLoad); final Activity a = (Activity) mImageToLoad.imageview.getContext(); a.runOnUiThread(bd); } } private static class ImageToLoad { public final URL source; public final ImageView imageview; public ImageToLoad(final URL source, final ImageView imageview) { this.source = source; this.imageview = imageview; } } public static class ExpiringBitmap { public final Bitmap image; public final Date expires; public ExpiringBitmap(final Bitmap image, final Date expires) { this.image = image; this.expires = expires; } } private static class MemoryCache { private final int mMaxCapacity; private final Map<URL, SoftReference<ExpiringBitmap>> mSoftCache; private final Map<URL, ExpiringBitmap> mHardCache; public MemoryCache(int max_capacity) { mMaxCapacity = max_capacity; mSoftCache = new ConcurrentHashMap<URL, SoftReference<ExpiringBitmap>>(); mHardCache = new LinkedHashMap<URL, ExpiringBitmap>(mMaxCapacity / 3, 0.75f, true) { private static final long serialVersionUID = 1347795807259717646L; @Override protected boolean removeEldestEntry( LinkedHashMap.Entry<URL, ExpiringBitmap> eldest) { // Moves the last used item in the hard cache to the soft // cache. if (size() > mMaxCapacity) { mSoftCache.put(eldest.getKey(), new SoftReference<ExpiringBitmap>(eldest.getValue())); return true; } else return false; } }; } public Set<URL> clear() { Set<URL> clearedUrls = new HashSet<URL>(); synchronized (mHardCache) { Calendar cal = Calendar.getInstance(); cal.add(Calendar.DATE, -1); Map<URL, ExpiringBitmap> copy = new HashMap<URL, ExpiringBitmap>(mHardCache); for (URL url : copy.keySet()) { ExpiringBitmap bitmap = mHardCache.get(url); if (bitmap != null) { if (bitmap.expires.before(cal.getTime())) { clearedUrls.add(url); } } } for (URL url : clearedUrls) { mHardCache.remove(url); mSoftCache.remove(url); } } return clearedUrls; } public Set<URL> getActiveUrls() { return new HashSet<URL>(mHardCache.keySet()); } public Bitmap get(final URL url, final FileCache fileCache) { synchronized (mHardCache) { ExpiringBitmap bitmap = mHardCache.get(url); if (bitmap != null) { if (bitmap.expires.before(new Date())) { mHardCache.remove(url); fileCache.deleteFile(url); } else { // Put bitmap on top of cache so it's purged last. try { mHardCache.remove(url); mHardCache.put(url, bitmap); } catch (Exception e) { } return bitmap.image; } } } final SoftReference<ExpiringBitmap> bitmapRef = mSoftCache.get(url); if (bitmapRef != null) { final ExpiringBitmap bitmap = bitmapRef.get(); if (bitmap != null) return bitmap.image; else { // Must have been collected by the Garbage Collector // so we remove the bucket from the cache. mSoftCache.remove(url); } } // Could not locate the bitmap in any of the caches, so we return // null. return null; } public void put(final URL url, final Bitmap bitmap) { if (url == null || bitmap == null) return; if (mHardCache.get(url) == null) { Calendar cal = Calendar.getInstance(); cal.add(Calendar.DATE, +1); mHardCache.put(url, new ExpiringBitmap(bitmap, cal.getTime())); } } } }