diff options
Diffstat (limited to 'main/src/cgeo/geocaching/network/HtmlImage.java')
| -rw-r--r-- | main/src/cgeo/geocaching/network/HtmlImage.java | 293 |
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; |
