From acc6683ea48e0ea4620e31aa7362244c33cc1495 Mon Sep 17 00:00:00 2001 From: Andrew Shaw Date: Fri, 27 Jan 2012 20:16:55 -0800 Subject: [PATCH] Caching activity images to disk --- .../astrid/actfm/sync/ActFmSyncService.java | 7 +- .../astrid/notes/EditNoteActivity.java | 42 ++- .../com/todoroo/astrid/helper/DiskCache.java | 272 ++++++++++++++++++ .../com/todoroo/astrid/helper/ImageCache.java | 249 ++++++++++++++++ 4 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 astrid/src/com/todoroo/astrid/helper/DiskCache.java create mode 100644 astrid/src/com/todoroo/astrid/helper/ImageCache.java diff --git a/astrid/plugin-src/com/todoroo/astrid/actfm/sync/ActFmSyncService.java b/astrid/plugin-src/com/todoroo/astrid/actfm/sync/ActFmSyncService.java index 032ba707e..cf1a2ffda 100644 --- a/astrid/plugin-src/com/todoroo/astrid/actfm/sync/ActFmSyncService.java +++ b/astrid/plugin-src/com/todoroo/astrid/actfm/sync/ActFmSyncService.java @@ -265,7 +265,12 @@ public final class ActFmSyncService { else result = actFmInvoker.post("comment_add", picture, params.toArray(new Object[params.size()])); update.setValue(Update.REMOTE_ID, result.optLong("id")); - update.setValue(Update.PICTURE, result.optString("picture")); +// ImageCache imageCache = ImageCache.getInstance(getContext()); + //TODO figure out a way to replace local image files with the url + if (TextUtils.isEmpty(update.getValue(Update.PICTURE)) || update.getValue(Update.PICTURE).equals(Update.PICTURE_LOADING)) { + update.setValue(Update.PICTURE, result.optString("picture")); + } + updateDao.saveExisting(update); } catch (IOException e) { if (notPermanentError(e)) diff --git a/astrid/plugin-src/com/todoroo/astrid/notes/EditNoteActivity.java b/astrid/plugin-src/com/todoroo/astrid/notes/EditNoteActivity.java index b87e54784..0ca758c66 100644 --- a/astrid/plugin-src/com/todoroo/astrid/notes/EditNoteActivity.java +++ b/astrid/plugin-src/com/todoroo/astrid/notes/EditNoteActivity.java @@ -2,6 +2,7 @@ package com.todoroo.astrid.notes; import greendroid.widget.AsyncImageView; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -21,6 +22,7 @@ import android.text.TextUtils; import android.text.TextWatcher; import android.text.format.DateUtils; import android.text.util.Linkify; +import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; @@ -52,6 +54,7 @@ import com.todoroo.astrid.dao.UpdateDao; import com.todoroo.astrid.data.Metadata; import com.todoroo.astrid.data.Task; import com.todoroo.astrid.data.Update; +import com.todoroo.astrid.helper.ImageCache; import com.todoroo.astrid.helper.ProgressBarSyncResultCallback; import com.todoroo.astrid.service.MetadataService; import com.todoroo.astrid.service.StatisticsConstants; @@ -85,6 +88,7 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene private ImageButton pictureButton; private Bitmap pendingCommentPicture = null; private final Fragment fragment; + private final ImageCache imageCache; private final List listeners = new LinkedList(); @@ -96,6 +100,7 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene public EditNoteActivity(Fragment fragment, View parent, long t) { super(fragment.getActivity()); + imageCache = ImageCache.getInstance(fragment.getActivity()); this.fragment = fragment; DependencyInjectionService.getInstance().inject(this); @@ -157,8 +162,8 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene commentField.addTextChangedListener(new TextWatcher() { @Override public void afterTextChanged(Editable s) { - commentButton.setVisibility((s.length() > 0) ? View.VISIBLE : View.GONE); - timerView.setVisibility((s.length() > 0) ? View.GONE : View.VISIBLE); + commentButton.setVisibility((s.length() > 0 || pendingCommentPicture != null) ? View.VISIBLE : View.GONE); + timerView.setVisibility((s.length() > 0 || pendingCommentPicture != null) ? View.GONE : View.VISIBLE); } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { @@ -347,7 +352,16 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene commentPictureView.setVisibility(View.GONE); else { commentPictureView.setVisibility(View.VISIBLE); - commentPictureView.setUrl(item.commentPicture); + if(imageCache.contains(item.commentPicture)) { + try { + commentPictureView.setDefaultImageBitmap(imageCache.get(item.commentPicture)); + } catch (IOException e) { + e.printStackTrace(); + } + } + else { + commentPictureView.setUrl(item.commentPicture); + } } } } @@ -398,7 +412,15 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene } + private String getPictureHashForUpdate(Update u) { + String s = u.getValue(Update.TASK) + "" + u.getValue(Update.CREATION_DATE); + return s; + } private void addComment(String message, String actionCode, boolean usePicture) { + // Allow for users to just add picture + if (TextUtils.isEmpty(message) && usePicture) { + message = " "; //$NON-NLS-1$ + } Update update = new Update(); update.setValue(Update.MESSAGE, message); update.setValue(Update.ACTION_CODE, actionCode); @@ -408,6 +430,14 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene if (usePicture && pendingCommentPicture != null) { update.setValue(Update.PICTURE, Update.PICTURE_LOADING); + try { + String updateString = getPictureHashForUpdate(update); + imageCache.put(updateString, pendingCommentPicture); + update.setValue(Update.PICTURE, updateString); + } + catch (Exception e) { + Log.e("EditNoteActivity", "Failed to put image to disk..."); + } } Flags.set(Flags.ACTFM_SUPPRESS_SYNC); updateDao.createNew(update); @@ -508,8 +538,8 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene addComment(String.format("%s %s", //$NON-NLS-1$ getContext().getString(R.string.TEA_timer_comment_started), DateUtilities.getTimeString(getContext(), new Date())), - "task_started", //$NON-NLS-1$ - false); + "task_started", //$NON-NLS-1$ + false); } @Override @@ -536,7 +566,7 @@ public class EditNoteActivity extends LinearLayout implements TimerActionListene }; return (ActFmCameraModule.activityResult((Activity)getContext(), requestCode, resultCode, data, callback)); - //Handled + //Handled } } diff --git a/astrid/src/com/todoroo/astrid/helper/DiskCache.java b/astrid/src/com/todoroo/astrid/helper/DiskCache.java new file mode 100644 index 000000000..d93e37f14 --- /dev/null +++ b/astrid/src/com/todoroo/astrid/helper/DiskCache.java @@ -0,0 +1,272 @@ +package com.todoroo.astrid.helper; +/* + * Copyright (C) 2011 MIT Mobile Experience Lab + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +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.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import android.util.Log; + +/** + * A simple disk cache. + * + * @author Steve Pomeroy + * + * @param the key to store/retrieve the value + * @param the value that will be stored to disk + */ +// TODO add automatic cache cleanup so low disk conditions can be met +public abstract class DiskCache { + private static final String TAG = "DiskCache"; + + private MessageDigest hash; + + private final File mCacheBase; + private final String mCachePrefix, mCacheSuffix; + + /** + * Creates a new disk cache with no cachePrefix or cacheSuffix + * + * @param cacheBase + */ + public DiskCache(File cacheBase) { + this(cacheBase, null, null); + } + + /** + * Creates a new disk cache. + * + * @param cacheBase The base directory within which all the cache files will be stored. + * @param cachePrefix If you want a prefix to the filenames, place one here. Otherwise, pass null. + * @param cacheSuffix A suffix to the cache filename. Null is also ok here. + */ + public DiskCache(File cacheBase, String cachePrefix, String cacheSuffix) { + mCacheBase = cacheBase; + mCachePrefix = cachePrefix; + mCacheSuffix = cacheSuffix; + + try { + hash = MessageDigest.getInstance("SHA-1"); + + } catch (final NoSuchAlgorithmException e) { + try { + hash = MessageDigest.getInstance("MD5"); + } catch (final NoSuchAlgorithmException e2) { + final RuntimeException re = new RuntimeException("No available hashing algorithm"); + re.initCause(e2); + throw re; + } + } + } + + /** + * Gets the cache filename for the given key. + * + * @param key + * @return + */ + protected File getFile(K key){ + return new File(mCacheBase, + (mCachePrefix != null ? mCachePrefix :"" ) + + hash(key) + + (mCacheSuffix != null ? mCacheSuffix : "") + ); + } + + /** + * Writes the value stored in the cache to disk by calling {@link #toDisk(Object, Object, OutputStream)}. + * + * @param key The key to find the value. + * @param value the data to be written to disk. + */ + public void put(K key, V value) throws IOException, FileNotFoundException { + final File saveHere = getFile(key); + + final OutputStream os = new FileOutputStream(saveHere); + toDisk(key, value, os); + os.close(); + } + + /** + * Writes the contents of the InputStream straight to disk. It is the + * caller's responsibility to ensure it's the same type as what would be + * written with {@link #toDisk(Object, Object, OutputStream)} + * + * @param key + * @param value + * @throws IOException + * @throws FileNotFoundException + */ + public void putRaw(K key, InputStream value) throws IOException, FileNotFoundException { + final File saveHere = getFile(key); + + final OutputStream os = new FileOutputStream(saveHere); + + inputStreamToOutputStream(value, os); + os.close(); + } + + /** + * Reads from an inputstream, dumps to an outputstream + * @param is + * @param os + * @throws IOException + */ + static public void inputStreamToOutputStream(InputStream is, OutputStream os) throws IOException { + final int bufsize = 8196 * 10; + final byte[] cbuf = new byte[bufsize]; + + for (int readBytes = is.read(cbuf, 0, bufsize); + readBytes > 0; + readBytes = is.read(cbuf, 0, bufsize)) { + os.write(cbuf, 0, readBytes); + } + } + + + /** + * Reads the value from disk using {@link #fromDisk(Object, InputStream)}. + * + * @param key + * @return The value for key or null if the key doesn't map to any existing entries. + */ + public V get(K key) throws IOException { + final File readFrom = getFile(key); + + if (!readFrom.exists()){ + return null; + } + + final InputStream is = new FileInputStream(readFrom); + final V out = fromDisk(key, is); + is.close(); + return out; + } + + /** + * Checks the disk cache for a given key. + * + * @param key + * @return true if the disk cache contains the given key + */ + public boolean contains(K key) { + final File readFrom = getFile(key); + + return readFrom.exists(); + } + + /** + * Removes the item from the disk cache. + * + * @param key + * @return true if the cached item has been removed or was already removed, false if it was not able to be removed. + */ + public boolean clear(K key){ + final File readFrom = getFile(key); + + if (!readFrom.exists()){ + return true; + } + + return readFrom.delete(); + } + + /** + * Clears the cache files from disk. + * + * Note: this only clears files that match the given prefix/suffix. + * + * @return true if the operation succeeded without error. It is possible that it will fail and the cache ends up being partially cleared. + */ + public boolean clear() { + boolean success = true; + + for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)){ + if (!cacheFile.delete()){ + // throw new IOException("cannot delete cache file"); + Log.e(TAG, "error deleting "+ cacheFile); + success = false; + } + } + return success; + } + + /** + * @return the size of the cache as it is on disk. + */ + public int getCacheSize(){ + return mCacheBase.listFiles(mCacheFileFilter).length; + } + + private final CacheFileFilter mCacheFileFilter = new CacheFileFilter(); + + private class CacheFileFilter implements FileFilter { + @Override + public boolean accept(File pathname) { + final String path = pathname.getName(); + return (mCachePrefix != null ? path.startsWith(mCachePrefix) : true) + && (mCacheSuffix != null ? path.endsWith(mCacheSuffix) : true); + } + }; + + /** + * Implement this to do the actual disk writing. Do not close the OutputStream; it will be closed for you. + * + * @param key + * @param in + * @param out + */ + protected abstract void toDisk(K key, V in, OutputStream out); + + /** + * Implement this to do the actual disk reading. + * @param key + * @param in + * @return a new instance of {@link V} containing the contents of in. + */ + protected abstract V fromDisk(K key, InputStream in); + + /** + * Using the key's {@link Object#toString() toString()} method, generates a string suitable for using as a filename. + * + * @param key + * @return a string uniquely representing the the key. + */ + public String hash(K key){ + final byte[] ba; + synchronized (hash) { + hash.update(key.toString().getBytes()); + ba = hash.digest(); + } + final BigInteger bi = new BigInteger(1, ba); + final String result = bi.toString(16); + if (result.length() % 2 != 0) { + return "0" + result; + } + return result; + + } +} diff --git a/astrid/src/com/todoroo/astrid/helper/ImageCache.java b/astrid/src/com/todoroo/astrid/helper/ImageCache.java new file mode 100644 index 000000000..ded03ba1a --- /dev/null +++ b/astrid/src/com/todoroo/astrid/helper/ImageCache.java @@ -0,0 +1,249 @@ +package com.todoroo.astrid.helper; +/* + * Copyright (C) 2011 MIT Mobile Experience Lab + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.apache.http.client.ClientProtocolException; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.util.Log; + +/** + *

+ * An image download-and-cacher that also knows how to efficiently generate + * thumbnails of various sizes. + *

+ * + *

+ * The cache is shared with the entire process, so make sure you + * {@link #registerOnImageLoadListener(OnImageLoadListener)} and + * {@link #unregisterOnImageLoadListener(OnImageLoadListener)} any load + * listeners in your activities. + *

+ * + * @author Steve Pomeroy + * + */ +public class ImageCache extends DiskCache { + private static final String TAG = ImageCache.class.getSimpleName(); + + static final boolean DEBUG = false; + + + private long mIDCounter = 0; + + private static ImageCache mInstance; + + + private final CompressFormat mCompressFormat; + private final int mQuality; + + private final Resources mRes; + + // TODO make it so this is customizable on the instance level. + /** + * Gets an instance of the cache. + * + * @param context + * @return an instance of the cache + */ + public static ImageCache getInstance(Context context) { + if (mInstance == null) { + mInstance = new ImageCache(context, CompressFormat.JPEG, 85); + } + return mInstance; + } + + private ImageCache(Context context, CompressFormat format, int quality) { + super(context.getCacheDir(), null, getExtension(format)); + mRes = context.getResources(); + + mCompressFormat = format; + mQuality = quality; + } + private static String getExtension(CompressFormat format) { + String extension; + switch (format) { + case JPEG: + extension = ".jpg"; + break; + + case PNG: + extension = ".png"; + break; + + default: + throw new IllegalArgumentException(); + } + + return extension; + } + + /** + * If loading a number of images where you don't have a unique ID to + * represent the individual load, this can be used to generate a sequential + * ID. + * + * @return a new unique ID + */ + public synchronized long getNewID() { + return mIDCounter++; + } + + @Override + protected Bitmap fromDisk(String key, InputStream in) { + + if (DEBUG) { + Log.d(TAG, "disk cache hit"); + } + try { + final Bitmap image = BitmapFactory.decodeStream(in); + return image; + + } catch (final OutOfMemoryError oom) { + return null; + } + } + + @Override + protected void toDisk(String key, Bitmap image, OutputStream out) { + if (DEBUG) { + Log.d(TAG, "cache write for key " + key); + } + if (image != null) { + if (!image.compress(mCompressFormat, mQuality, out)) { + Log.e(TAG, "error writing compressed image to disk for key " + + key); + } + } else { + Log.e(TAG, "attempting to write null image to cache"); + } + } + + + /** + * @param uri + * the image uri + * @return a key unique to the given uri + */ + public String getKey(Uri uri) { + return uri.toString(); + } + + + /** + * Returns an opaque cache key representing the given uri, width and height. + * + * @param uri + * an image uri + * @param width + * the desired image max width + * @param height + * the desired image max height + * @return a cache key unique to the given parameters + */ + public String getKey(Uri uri, int width, int height) { + return uri.buildUpon() + .appendQueryParameter("width", String.valueOf(width)) + .appendQueryParameter("height", String.valueOf(height)).build() + .toString(); + } + + + + /** + * Cancels all the asynchronous image loads. + * Note: currently does not function properly. + * + */ + public void cancelLoads() { + // TODO actually make it possible to cancel tasks + } + + /** + * Blocking call to scale a local file. Scales using preserving aspect ratio + * + * @param localFile + * local image file to be scaled + * @param width + * maximum width + * @param height + * maximum height + * @return the scaled image + * @throws ClientProtocolException + * @throws IOException + */ + private static Bitmap scaleLocalImage(File localFile, int width, int height) + throws ClientProtocolException, IOException { + + if (DEBUG){ + Log.d(TAG, "scaleLocalImage(" + localFile + ", "+ width +", "+ height + ")"); + } + + if (!localFile.exists()) { + throw new IOException("local file does not exist: " + localFile); + } + if (!localFile.canRead()) { + throw new IOException("cannot read from local file: " + localFile); + } + + // the below borrowed from: + // https://github.com/thest1/LazyList/blob/master/src/com/fedorvlasov/lazylist/ImageLoader.java + + // decode image size + final BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + + BitmapFactory.decodeStream(new FileInputStream(localFile), null, o); + + // Find the correct scale value. It should be the power of 2. + //final int REQUIRED_WIDTH = width, REQUIRED_HEIGHT = height; + int width_tmp = o.outWidth, height_tmp = o.outHeight; + int scale = 1; + while (true) { + if (width_tmp / 2 <= width || height_tmp / 2 <= height) { + break; + } + width_tmp /= 2; + height_tmp /= 2; + scale *= 2; + } + + // decode with inSampleSize + final BitmapFactory.Options o2 = new BitmapFactory.Options(); + o2.inSampleSize = scale; + final Bitmap prescale = BitmapFactory.decodeStream(new FileInputStream(localFile), null, o2); + + if (prescale == null) { + Log.e(TAG, localFile + " could not be decoded"); + } + + return prescale; + } + private static final boolean USE_APACHE_NC = true; + +}