aboutsummaryrefslogtreecommitdiffstats
path: root/main/src/cgeo/geocaching/network/HtmlImage.java
diff options
context:
space:
mode:
Diffstat (limited to 'main/src/cgeo/geocaching/network/HtmlImage.java')
-rw-r--r--main/src/cgeo/geocaching/network/HtmlImage.java293
1 files changed, 215 insertions, 78 deletions
diff --git a/main/src/cgeo/geocaching/network/HtmlImage.java b/main/src/cgeo/geocaching/network/HtmlImage.java
index 0daa588..524617c 100644
--- a/main/src/cgeo/geocaching/network/HtmlImage.java
+++ b/main/src/cgeo/geocaching/network/HtmlImage.java
@@ -6,14 +6,31 @@ import cgeo.geocaching.compatibility.Compatibility;
import cgeo.geocaching.connector.ConnectorFactory;
import cgeo.geocaching.files.LocalStorage;
import cgeo.geocaching.list.StoredList;
+import cgeo.geocaching.utils.CancellableHandler;
import cgeo.geocaching.utils.FileUtils;
import cgeo.geocaching.utils.ImageUtils;
import cgeo.geocaching.utils.Log;
import ch.boye.httpclientandroidlib.HttpResponse;
import ch.boye.httpclientandroidlib.androidextra.Base64;
+
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.ImmutablePair;
+import org.apache.commons.lang3.tuple.Pair;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+
+import rx.Observable;
+import rx.Observable.OnSubscribe;
+import rx.Scheduler;
+import rx.Scheduler.Inner;
+import rx.Subscriber;
+import rx.functions.Action1;
+import rx.functions.Func1;
+import rx.schedulers.Schedulers;
+import rx.subjects.PublishSubject;
+import rx.subscriptions.CompositeSubscription;
import android.content.res.Resources;
import android.graphics.Bitmap;
@@ -31,9 +48,23 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Date;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
public class HtmlImage implements Html.ImageGetter {
+ // This class implements an all-purpose image getter that can also be used as a ImageGetter interface
+ // when displaying caches. An instance mainly has three possible use cases:
+ // - If onlySave is true, getDrawable() will return null immediately and will queue the image retrieval
+ // and saving in the loading subject. Downloads will start in parallel when the blocking
+ // waitForBackgroundLoading() method is called, and they can be cancelled through the given handler.
+ // - If onlySave is false and the instance is called through fetchDrawable(), then an observable for the
+ // given URL will be returned. This observable will emit the local copy of the image if it is present,
+ // regardless of its freshness, then if needed an updated fresher copy after retrieving it from the network.
+ // - If onlySave is false and the instance is used as an ImageGetter, only the final version of the
+ // image will be returned.
+
private static final String[] BLOCKED = new String[] {
"gccounter.de",
"gccounter.com",
@@ -59,105 +90,183 @@ public class HtmlImage implements Html.ImageGetter {
final private boolean returnErrorImage;
final private int listId;
final private boolean onlySave;
- final private BitmapFactory.Options bfOptions;
final private int maxWidth;
final private int maxHeight;
final private Resources resources;
+ // Background loading
+ final private PublishSubject<Observable<String>> loading = PublishSubject.create();
+ final Observable<String> waitForEnd = Observable.merge(loading).publish().refCount();
+ final CompositeSubscription subscription = new CompositeSubscription(waitForEnd.subscribe());
+ final private Scheduler downloadScheduler = Schedulers.executor(new ThreadPoolExecutor(10, 10, 5, TimeUnit.SECONDS,
+ new LinkedBlockingQueue<Runnable>()));
+
public HtmlImage(final String geocode, final boolean returnErrorImage, final int listId, final boolean onlySave) {
this.geocode = geocode;
this.returnErrorImage = returnErrorImage;
this.listId = listId;
this.onlySave = onlySave;
- bfOptions = new BitmapFactory.Options();
- bfOptions.inTempStorage = new byte[16 * 1024];
- bfOptions.inPreferredConfig = Bitmap.Config.RGB_565;
-
Point displaySize = Compatibility.getDisplaySize();
this.maxWidth = displaySize.x - 25;
this.maxHeight = displaySize.y - 25;
this.resources = CgeoApplication.getInstance().getResources();
}
+ @Nullable
@Override
public BitmapDrawable getDrawable(final String url) {
- // Reject empty and counter images URL
+ final Observable<BitmapDrawable> drawable = fetchDrawable(url);
+ if (onlySave) {
+ loading.onNext(drawable.map(new Func1<BitmapDrawable, String>() {
+ @Override
+ public String call(final BitmapDrawable bitmapDrawable) {
+ return url;
+ }
+ }));
+ return null;
+ }
+ return drawable.toBlockingObservable().lastOrDefault(null);
+ }
+
+ // Caches are loaded from disk on Schedulers.computation() to avoid using more threads than processors
+ // on the phone while decoding the image. Downloads happen on downloadScheduler, in parallel with image
+ // decoding.
+ public Observable<BitmapDrawable> fetchDrawable(final String url) {
+
if (StringUtils.isBlank(url) || isCounter(url)) {
- return new BitmapDrawable(resources, getTransparent1x1Image());
+ return Observable.from(getTransparent1x1Image(resources));
}
final boolean shared = url.contains("/images/icons/icon_");
final String pseudoGeocode = shared ? SHARED : geocode;
- Bitmap imagePre = loadImageFromStorage(url, pseudoGeocode, shared);
-
- // Download image and save it to the cache
- if (imagePre == null) {
- final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, true);
- if (url.startsWith("data:image/")) {
- if (url.contains(";base64,")) {
- // TODO: when we use SDK level 8 or above, we can use the streaming version of the base64
- // Android utilities.
- byte[] decoded = Base64.decode(StringUtils.substringAfter(url, ";base64,"), Base64.DEFAULT);
- OutputStream out = null;
- try {
- out = new FileOutputStream(file);
- out.write(decoded);
- } catch (final IOException e) {
- Log.e("HtmlImage.getDrawable: cannot write file for decoded inline image", e);
- return null;
- } finally {
- IOUtils.closeQuietly(out);
+ return Observable.create(new OnSubscribe<BitmapDrawable>() {
+ @Override
+ public void call(final Subscriber<? super BitmapDrawable> subscriber) {
+ Schedulers.computation().schedule(new Action1<Inner>() {
+ @Override
+ public void call(final Inner inner) {
+ final Pair<BitmapDrawable, Boolean> loaded = loadFromDisk();
+ final BitmapDrawable bitmap = loaded.getLeft();
+ if (loaded.getRight()) {
+ subscriber.onNext(bitmap);
+ subscriber.onCompleted();
+ return;
+ }
+ if (bitmap != null && !onlySave) {
+ subscriber.onNext(bitmap);
+ }
+ downloadScheduler.schedule(new Action1<Inner>() {
+ @Override
+ public void call(final Inner inner) {
+ downloadAndSave(subscriber);
+ }
+ });
+ }
+ });
+ }
+
+ private Pair<BitmapDrawable, Boolean> loadFromDisk() {
+ final Pair<Bitmap, Boolean> loadResult = loadImageFromStorage(url, pseudoGeocode, shared);
+ final Bitmap bitmap = loadResult.getLeft();
+ return new ImmutablePair<BitmapDrawable, Boolean>(bitmap != null ?
+ ImageUtils.scaleBitmapToFitDisplay(bitmap) :
+ null,
+ loadResult.getRight());
+ }
+
+ private void downloadAndSave(final Subscriber<? super BitmapDrawable> subscriber) {
+ final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, true);
+ if (url.startsWith("data:image/")) {
+ if (url.contains(";base64,")) {
+ saveBase64ToFile(url, file);
+ } else {
+ Log.e("HtmlImage.getDrawable: unable to decode non-base64 inline image");
+ subscriber.onCompleted();
+ return;
}
} else {
- Log.e("HtmlImage.getDrawable: unable to decode non-base64 inline image");
- return null;
+ if (subscription.isUnsubscribed() || downloadOrRefreshCopy(url, file)) {
+ // The existing copy was fresh enough or we were unsubscribed earlier.
+ subscriber.onCompleted();
+ return;
+ }
}
- } else {
- final String absoluteURL = makeAbsoluteURL(url);
-
- if (absoluteURL != null) {
- try {
- final HttpResponse httpResponse = Network.getRequest(absoluteURL, null, file);
- if (httpResponse != null) {
- final int statusCode = httpResponse.getStatusLine().getStatusCode();
- if (statusCode == 200) {
- LocalStorage.saveEntityToFile(httpResponse, file);
- } else if (statusCode == 304) {
- if (!file.setLastModified(System.currentTimeMillis())) {
- makeFreshCopy(file);
- }
+ if (onlySave) {
+ subscriber.onCompleted();
+ } else {
+ Schedulers.computation().schedule(new Action1<Inner>() {
+ @Override
+ public void call(final Inner inner) {
+ final Pair<BitmapDrawable, Boolean> loaded = loadFromDisk();
+ final BitmapDrawable image = loaded.getLeft();
+ if (image != null) {
+ subscriber.onNext(image);
+ } else {
+ subscriber.onNext(returnErrorImage ?
+ new BitmapDrawable(resources, BitmapFactory.decodeResource(resources, R.drawable.image_not_loaded)) :
+ getTransparent1x1Image(resources));
}
+ subscriber.onCompleted();
}
- } catch (Exception e) {
- Log.e("HtmlImage.getDrawable (downloading from web)", e);
- }
+ });
}
}
- }
-
- if (onlySave) {
- return null;
- }
+ });
+ }
- // now load the newly downloaded image
- if (imagePre == null) {
- imagePre = loadImageFromStorage(url, pseudoGeocode, shared);
+ public void waitForBackgroundLoading(@Nullable final CancellableHandler handler) {
+ if (handler != null) {
+ handler.unsubscribeIfCancelled(subscription);
}
+ loading.onCompleted();
+ waitForEnd.toBlockingObservable().lastOrDefault(null);
+ }
- // get image and return
- if (imagePre == null) {
- Log.d("HtmlImage.getDrawable: Failed to obtain image");
+ /**
+ * Download or refresh the copy of <code>url</code> in <code>file</code>.
+ *
+ * @param url the url of the document
+ * @param file the file to save the document in
+ * @return <code>true</code> if the existing file was up-to-date, <code>false</code> otherwise
+ */
+ private boolean downloadOrRefreshCopy(final String url, final File file) {
+ final String absoluteURL = makeAbsoluteURL(url);
- if (returnErrorImage) {
- imagePre = BitmapFactory.decodeResource(resources, R.drawable.image_not_loaded);
- } else {
- imagePre = getTransparent1x1Image();
+ if (absoluteURL != null) {
+ try {
+ final HttpResponse httpResponse = Network.getRequest(absoluteURL, null, file);
+ if (httpResponse != null) {
+ final int statusCode = httpResponse.getStatusLine().getStatusCode();
+ if (statusCode == 200) {
+ LocalStorage.saveEntityToFile(httpResponse, file);
+ } else if (statusCode == 304) {
+ if (!file.setLastModified(System.currentTimeMillis())) {
+ makeFreshCopy(file);
+ }
+ return true;
+ }
+ }
+ } catch (Exception e) {
+ Log.e("HtmlImage.downloadOrRefreshCopy", e);
}
}
+ return false;
+ }
- return imagePre != null ? ImageUtils.scaleBitmapToFitDisplay(imagePre) : null;
+ private static void saveBase64ToFile(final String url, final File file) {
+ // TODO: when we use SDK level 8 or above, we can use the streaming version of the base64
+ // Android utilities.
+ OutputStream out = null;
+ try {
+ out = new FileOutputStream(file);
+ out.write(Base64.decode(StringUtils.substringAfter(url, ";base64,"), Base64.DEFAULT));
+ } catch (final IOException e) {
+ Log.e("HtmlImage.saveBase64ToFile: cannot write file for decoded inline image", e);
+ } finally {
+ IOUtils.closeQuietly(out);
+ }
}
/**
@@ -180,25 +289,35 @@ public class HtmlImage implements Html.ImageGetter {
}
}
- private Bitmap getTransparent1x1Image() {
- return BitmapFactory.decodeResource(resources, R.drawable.image_no_placement);
+ private BitmapDrawable getTransparent1x1Image(final Resources res) {
+ return new BitmapDrawable(res, BitmapFactory.decodeResource(resources, R.drawable.image_no_placement));
}
- private Bitmap loadImageFromStorage(final String url, final String pseudoGeocode, final boolean forceKeep) {
+ /**
+ * Load an image from primary or secondary storage.
+ *
+ * @param url the image URL
+ * @param pseudoGeocode the geocode or the shared name
+ * @param forceKeep keep the image if it is there, without checking its freshness
+ * @return <code>true</code> if the image was there and is fresh enough, <code>false</code> otherwise
+ */
+ @NonNull
+ private Pair<Bitmap, Boolean> loadImageFromStorage(final String url, final String pseudoGeocode, final boolean forceKeep) {
try {
final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, false);
- final Bitmap image = loadCachedImage(file, forceKeep);
- if (image != null) {
+ final Pair<Bitmap, Boolean> image = loadCachedImage(file, forceKeep);
+ if (image.getRight() || image.getLeft() != null) {
return image;
}
final File fileSec = LocalStorage.getStorageSecFile(pseudoGeocode, url, true);
return loadCachedImage(fileSec, forceKeep);
} catch (Exception e) {
- Log.w("HtmlImage.getDrawable (reading cache)", e);
+ Log.w("HtmlImage.loadImageFromStorage", e);
}
- return null;
+ return new ImmutablePair<Bitmap, Boolean>(null, false);
}
+ @Nullable
private String makeAbsoluteURL(final String url) {
// Check if uri is absolute or not, if not attach the connector hostname
// FIXME: that should also include the scheme
@@ -222,21 +341,39 @@ public class HtmlImage implements Html.ImageGetter {
return null;
}
- private Bitmap loadCachedImage(final File file, final boolean forceKeep) {
+ /**
+ * Load a previously saved image.
+ *
+ * @param file the file on disk
+ * @param forceKeep keep the image if it is there, without checking its freshness
+ * @return a pair with <code>true</code> if the image was there and is fresh enough or <code>false</code> otherwise,
+ * and the image (possibly <code>null</code> if the first component is <code>false</code> and the image
+ * could not be loaded, or if the first component is <code>true</code> and <code>onlySave</code> is also
+ * <code>true</code>)
+ */
+ @NonNull
+ private Pair<Bitmap, Boolean> loadCachedImage(final File file, final boolean forceKeep) {
if (file.exists()) {
- if (listId >= StoredList.STANDARD_LIST_ID || file.lastModified() > (new Date().getTime() - (24 * 60 * 60 * 1000)) || forceKeep) {
- setSampleSize(file);
- final Bitmap image = BitmapFactory.decodeFile(file.getPath(), bfOptions);
- if (image == null) {
- Log.e("Cannot decode bitmap from " + file.getPath());
- }
- return image;
+ final boolean freshEnough = listId >= StoredList.STANDARD_LIST_ID || file.lastModified() > (new Date().getTime() - (24 * 60 * 60 * 1000)) || forceKeep;
+ if (onlySave) {
+ return new ImmutablePair<Bitmap, Boolean>(null, true);
+ }
+ final BitmapFactory.Options bfOptions = new BitmapFactory.Options();
+ bfOptions.inTempStorage = new byte[16 * 1024];
+ bfOptions.inPreferredConfig = Bitmap.Config.RGB_565;
+ setSampleSize(file, bfOptions);
+ final Bitmap image = BitmapFactory.decodeFile(file.getPath(), bfOptions);
+ if (image == null) {
+ Log.e("Cannot decode bitmap from " + file.getPath());
+ return new ImmutablePair<Bitmap, Boolean>(null, false);
}
+ return new ImmutablePair<Bitmap, Boolean>(image,
+ freshEnough);
}
- return null;
+ return new ImmutablePair<Bitmap, Boolean>(null, false);
}
- private void setSampleSize(final File file) {
+ private void setSampleSize(final File file, final BitmapFactory.Options bfOptions) {
//Decode image size only
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;