package cgeo.geocaching.network; import cgeo.geocaching.CgeoApplication; import cgeo.geocaching.R; 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 cgeo.geocaching.utils.RxUtils; import ch.boye.httpclientandroidlib.HttpResponse; 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.Subscriber; import rx.functions.Action0; import rx.functions.Func0; import rx.functions.Func1; import rx.subjects.PublishSubject; import rx.subscriptions.CompositeSubscription; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Point; import android.graphics.drawable.BitmapDrawable; import android.net.Uri; import android.text.Html; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.util.Date; import java.util.concurrent.Executor; 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", "cachercounter/?", "gccounter/imgcount.php", "flagcounter.com", "compteur-blog.net", "counter.digits.com", "andyhoppe", "besucherzaehler-homepage.de", "hitwebcounter.com", "kostenloser-counter.eu", "trendcounter.com", "hit-counter-download.com", "gcwetterau.de/counter" }; public static final String SHARED = "shared"; final private String geocode; /** * on error: return large error image, if true, otherwise empty 1x1 image */ final private boolean returnErrorImage; final private int listId; final private boolean onlySave; final private int maxWidth; final private int maxHeight; final private Resources resources; // Background loading final private PublishSubject> loading = PublishSubject.create(); final Observable waitForEnd = Observable.merge(loading).publish().refCount(); final CompositeSubscription subscription = new CompositeSubscription(waitForEnd.subscribe()); final private Executor downloadExecutor = new ThreadPoolExecutor(10, 10, 5, TimeUnit.SECONDS, new LinkedBlockingQueue()); 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; 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) { final Observable drawable = fetchDrawable(url); if (onlySave) { loading.onNext(drawable.map(new Func1() { @Override public String call(final BitmapDrawable bitmapDrawable) { return url; } })); return null; } return drawable.toBlockingObservable().lastOrDefault(null); } // Caches are loaded from disk on a computation scheduler to avoid using more threads than cores while decoding // the image. Downloads happen on downloadScheduler, in parallel with image decoding. public Observable fetchDrawable(final String url) { if (StringUtils.isBlank(url) || ImageUtils.containsPattern(url, BLOCKED)) { return Observable.from(getTransparent1x1Image(resources)); } // Explicit local file URLs are loaded from the filesystem regardless of their age. The IO part is short // enough to make the whole operation on the computation scheduler. if (FileUtils.isFileUrl(url)) { return Observable.defer(new Func0>() { @Override public Observable call() { final Bitmap bitmap = loadCachedImage(FileUtils.urlToFile(url), true).getLeft(); return bitmap != null ? Observable.from(ImageUtils.scaleBitmapToFitDisplay(bitmap)) : Observable.empty(); } }).subscribeOn(RxUtils.computationScheduler); } final boolean shared = url.contains("/images/icons/icon_"); final String pseudoGeocode = shared ? SHARED : geocode; return Observable.create(new OnSubscribe() { @Override public void call(final Subscriber subscriber) { subscription.add(subscriber); subscriber.add(RxUtils.computationScheduler.createWorker().schedule(new Action0() { @Override public void call() { final Pair loaded = loadFromDisk(); final BitmapDrawable bitmap = loaded.getLeft(); if (loaded.getRight()) { subscriber.onNext(bitmap); subscriber.onCompleted(); return; } if (bitmap != null && !onlySave) { subscriber.onNext(bitmap); } downloadExecutor.execute(new Runnable() { @Override public void run() { downloadAndSave(subscriber); } }); } })); } private Pair loadFromDisk() { final Pair loadResult = loadImageFromStorage(url, pseudoGeocode, shared); final Bitmap bitmap = loadResult.getLeft(); return new ImmutablePair(bitmap != null ? ImageUtils.scaleBitmapToFitDisplay(bitmap) : null, loadResult.getRight() ); } private void downloadAndSave(final Subscriber subscriber) { final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, true); if (url.startsWith("data:image/")) { if (url.contains(";base64,")) { ImageUtils.decodeBase64ToFile(StringUtils.substringAfter(url, ";base64,"), file); } else { Log.e("HtmlImage.getDrawable: unable to decode non-base64 inline image"); subscriber.onCompleted(); return; } } else { if (subscriber.isUnsubscribed() || downloadOrRefreshCopy(url, file)) { // The existing copy was fresh enough or we were unsubscribed earlier. subscriber.onCompleted(); return; } } if (onlySave) { subscriber.onCompleted(); } else { RxUtils.computationScheduler.createWorker().schedule(new Action0() { @Override public void call() { final Pair 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(); } }); } } }); } public void waitForBackgroundLoading(@Nullable final CancellableHandler handler) { if (handler != null) { handler.unsubscribeIfCancelled(subscription); } loading.onCompleted(); waitForEnd.toBlockingObservable().lastOrDefault(null); } /** * Download or refresh the copy of url in file. * * @param url the url of the document * @param file the file to save the document in * @return true if the existing file was up-to-date, false otherwise */ private boolean downloadOrRefreshCopy(final String url, final File file) { 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); } return true; } } } catch (Exception e) { Log.e("HtmlImage.downloadOrRefreshCopy", e); } } return false; } /** * Make a fresh copy of the file to reset its timestamp. On some storage, it is impossible * to modify the modified time after the fact, in which case a brand new file must be * created if we want to be able to use the time as validity hint. * * See Android issue 1699. * * @param file the file to refresh */ private static void makeFreshCopy(final File file) { final File tempFile = new File(file.getParentFile(), file.getName() + "-temp"); if (file.renameTo(tempFile)) { LocalStorage.copy(tempFile, file); FileUtils.deleteIgnoringFailure(tempFile); } else { Log.e("Could not reset timestamp of file " + file.getAbsolutePath()); } } private BitmapDrawable getTransparent1x1Image(final Resources res) { return new BitmapDrawable(res, BitmapFactory.decodeResource(resources, R.drawable.image_no_placement)); } /** * 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 true if the image was there and is fresh enough, false otherwise */ @NonNull private Pair loadImageFromStorage(final String url, final String pseudoGeocode, final boolean forceKeep) { try { final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, false); final Pair 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.loadImageFromStorage", e); } return new ImmutablePair(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 if (Uri.parse(url).isAbsolute()) { return url; } final String host = ConnectorFactory.getConnector(geocode).getHost(); if (StringUtils.isNotEmpty(host)) { final StringBuilder builder = new StringBuilder("http://"); builder.append(host); if (!StringUtils.startsWith(url, "/")) { // FIXME: explain why the result URL would be valid if the path does not start with // a '/', or signal an error. builder.append('/'); } builder.append(url); return builder.toString(); } return null; } /** * 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 true if the image was there and is fresh enough or false otherwise, * and the image (possibly null if the first component is false and the image * could not be loaded, or if the first component is true and onlySave is also * true) */ @NonNull private Pair loadCachedImage(final File file, final boolean forceKeep) { if (file.exists()) { final boolean freshEnough = listId >= StoredList.STANDARD_LIST_ID || file.lastModified() > (new Date().getTime() - (24 * 60 * 60 * 1000)) || forceKeep; if (onlySave) { return new ImmutablePair(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(null, false); } return new ImmutablePair(image, freshEnough); } return new ImmutablePair(null, false); } private void setSampleSize(final File file, final BitmapFactory.Options bfOptions) { //Decode image size only BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BufferedInputStream stream = null; try { stream = new BufferedInputStream(new FileInputStream(file)); BitmapFactory.decodeStream(stream, null, options); } catch (FileNotFoundException e) { Log.e("HtmlImage.setSampleSize", e); } finally { IOUtils.closeQuietly(stream); } int scale = 1; if (options.outHeight > maxHeight || options.outWidth > maxWidth) { scale = Math.max(options.outHeight / maxHeight, options.outWidth / maxWidth); } bfOptions.inSampleSize = scale; } }