package cgeo.geocaching.network;
import cgeo.geocaching.CgeoApplication;
import cgeo.geocaching.R;
import cgeo.geocaching.connector.ConnectorFactory;
import cgeo.geocaching.storage.LocalStorage;
import cgeo.geocaching.utils.AndroidRxUtils;
import cgeo.geocaching.utils.DisplayUtils;
import cgeo.geocaching.utils.DisposableHandler;
import cgeo.geocaching.utils.FileUtils;
import cgeo.geocaching.utils.ImageUtils;
import cgeo.geocaching.utils.ImageUtils.ContainerDrawable;
import cgeo.geocaching.utils.Log;
import cgeo.geocaching.utils.RxUtils.ObservableCache;
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.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Html;
import android.widget.TextView;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import io.reactivex.Completable;
import io.reactivex.Observable;
import io.reactivex.ObservableEmitter;
import io.reactivex.ObservableOnSubscribe;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.functions.Cancellable;
import io.reactivex.functions.Function;
import io.reactivex.internal.disposables.CancellableDisposable;
import io.reactivex.processors.PublishProcessor;
import okhttp3.Response;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
/**
* All-purpose image getter that can also be used as a ImageGetter interface when displaying caches.
*/
public class HtmlImage implements Html.ImageGetter {
private static final String[] BLOCKED = {
"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";
@NonNull private final String geocode;
/**
* on error: return large error image, if {@code true}, otherwise empty 1x1 image
*/
private final boolean returnErrorImage;
private final boolean onlySave;
private final boolean userInitiatedRefresh;
private final int maxWidth;
private final int maxHeight;
private final Resources resources;
final WeakReference<TextView> viewRef;
private final Map<String, BitmapDrawable> cache = new HashMap<>();
private final ObservableCache<String, BitmapDrawable> observableCache = new ObservableCache<>(new Function<String, Observable<BitmapDrawable>>() {
@Override
public Observable<BitmapDrawable> apply(final String url) {
return fetchDrawableUncached(url);
}
});
// Background loading
// .cache() is not yet available on Completable instances as of RxJava 2.0.0, so we have to go back
// to the observable world to achieve the caching.
private final PublishProcessor<Completable> loading = PublishProcessor.create();
private final Completable waitForEnd = Completable.merge(loading).cache();
private final CompositeDisposable disposable = new CompositeDisposable(waitForEnd.subscribe());
/**
* Create a new HtmlImage object with different behaviors depending on <tt>onlySave</tt> and <tt>view</tt> values.
* There are the three possible use cases:
* <ul>
* <li>If onlySave is true, {@link #getDrawable(String)} will return <tt>null</tt> immediately and will queue the
* image retrieval and saving in the loading subject. Downloads will start in parallel when the blocking
* {@link #waitForEndCompletable(DisposableHandler)} method is called, and they can be
* cancelled through the given handler.</li>
* <li>If <tt>onlySave</tt> is <tt>false</tt> and the instance is called through {@link #fetchDrawable(String)},
* 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.</li>
* <li>If <tt>onlySave</tt> is <tt>false</tt> and the instance is used as an {@link android.text.Html.ImageGetter},
* only the final version of the image will be returned, unless a view has been provided. If it has, then a dummy
* drawable is returned and is updated when the image is available, possibly several times if we had a stale copy of
* the image and then got a new one from the network.</li>
* </ul>
*
* @param geocode
* the geocode of the item for which we are requesting the image, or {@link #SHARED} to use the shared
* cache directory
* @param returnErrorImage
* set to <tt>true</tt> if an error image should be returned in case of a problem, <tt>false</tt> to get
* a transparent 1x1 image instead
* @param onlySave
* if set to <tt>true</tt>, {@link #getDrawable(String)} will only fetch and store the image, not return
* it
* @param view
* if non-null, {@link #getDrawable(String)} will return an initially empty drawable which will be
* redrawn when the image is ready through an invalidation of the given view
* @param userInitiatedRefresh
* if `true`, even fresh images will be refreshed if they have changed
*/
public HtmlImage(@NonNull final String geocode, final boolean returnErrorImage, final boolean onlySave,
final TextView view, final boolean userInitiatedRefresh) {
this.geocode = geocode;
this.returnErrorImage = returnErrorImage;
this.onlySave = onlySave;
this.viewRef = new WeakReference<>(view);
this.userInitiatedRefresh = userInitiatedRefresh;
final Point displaySize = DisplayUtils.getDisplaySize();
this.maxWidth = displaySize.x - 25;
this.maxHeight = displaySize.y - 25;
this.resources = CgeoApplication.getInstance().getResources();
}
/**
* Create a new HtmlImage object with different behaviors depending on <tt>onlySave</tt> value. No view object
* will be tied to this HtmlImage.
*
* For documentation, see {@link #HtmlImage(String, boolean, boolean, TextView, boolean)}.
*/
public HtmlImage(@NonNull final String geocode, final boolean returnErrorImage, final boolean onlySave,
final boolean userInitiatedRefresh) {
this(geocode, returnErrorImage, onlySave, null, userInitiatedRefresh);
}
/**
* Retrieve and optionally display an image.
* See {@link #HtmlImage(String, boolean, boolean, TextView, boolean)} for the various behaviors.
*
* @param url
* the URL to fetch from cache or network
* @return a drawable containing the image, or <tt>null</tt> if <tt>onlySave</tt> is <tt>true</tt>
*/
@Nullable
@Override
public BitmapDrawable getDrawable(final String url) {
if (cache.containsKey(url)) {
return cache.get(url);
}
final Observable<BitmapDrawable> drawable = fetchDrawable(url);
if (onlySave) {
loading.onNext(drawable.ignoreElements());
cache.put(url, null);
return null;
}
final TextView textView = viewRef.get();
final BitmapDrawable result = textView == null ? drawable.blockingLast(null) : getContainerDrawable(textView, drawable);
cache.put(url, result);
return result;
}
protected BitmapDrawable getContainerDrawable(final TextView textView, final Observable<BitmapDrawable> drawable) {
return new ContainerDrawable(textView, drawable);
}
public Observable<BitmapDrawable> fetchDrawable(final String url) {
return observableCache.get(url);
}
// 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.
private Observable<BitmapDrawable> fetchDrawableUncached(final String url) {
if (StringUtils.isBlank(url) || ImageUtils.containsPattern(url, BLOCKED)) {
return Observable.just(ImageUtils.getTransparent1x1Drawable(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 Callable<Observable<BitmapDrawable>>() {
@Override
public Observable<BitmapDrawable> call() {
final Bitmap bitmap = loadCachedImage(FileUtils.urlToFile(url), true).left;
return bitmap != null ? Observable.just(ImageUtils.scaleBitmapToFitDisplay(bitmap)) : Observable.<BitmapDrawable>empty();
}
}).subscribeOn(AndroidRxUtils.computationScheduler);
}
final boolean shared = url.contains("/images/icons/icon_");
final String pseudoGeocode = shared ? SHARED : geocode;
return Observable.create(new ObservableOnSubscribe<BitmapDrawable>() {
@Override
public void subscribe(final ObservableEmitter<BitmapDrawable> emitter) throws Exception {
// Canceling disposable must sever this connection
final CancellableDisposable aborter = new CancellableDisposable(new Cancellable() {
@Override
public void cancel() throws Exception {
emitter.onComplete();
}
});
disposable.add(aborter);
// Canceling this subscription must dispose the data retrieval
emitter.setDisposable(AndroidRxUtils.computationScheduler.scheduleDirect(new Runnable() {
@Override
public void run() {
final ImmutablePair<BitmapDrawable, Boolean> loaded = loadFromDisk();
final BitmapDrawable bitmap = loaded.left;
if (loaded.right) {
if (!onlySave) {
emitter.onNext(bitmap);
}
emitter.onComplete();
return;
}
if (bitmap != null && !onlySave) {
emitter.onNext(bitmap);
}
AndroidRxUtils.networkScheduler.scheduleDirect(new Runnable() {
@Override public void run() {
downloadAndSave(emitter, aborter);
}
});
}
}));
}
private ImmutablePair<BitmapDrawable, Boolean> loadFromDisk() {
final ImmutablePair<Bitmap, Boolean> loadResult = loadImageFromStorage(url, pseudoGeocode, shared);
return scaleImage(loadResult);
}
private void downloadAndSave(final ObservableEmitter<BitmapDrawable> emitter, final Disposable disposable) {
final File file = LocalStorage.getGeocacheDataFile(pseudoGeocode, url, true, true);
if (url.startsWith("data:image/")) {
if (url.contains(";base64,")) {
ImageUtils.decodeBase64ToFile(StringUtils.substringAfter(url, ";base64,"), file);
} else {
Log.e("HtmlImage.fetchDrawableUncached: unable to decode non-base64 inline image");
emitter.onComplete();
return;
}
} else if (disposable.isDisposed() || downloadOrRefreshCopy(url, file)) {
// The existing copy was fresh enough or we were unsubscribed earlier.
emitter.onComplete();
return;
}
if (onlySave) {
emitter.onComplete();
return;
}
AndroidRxUtils.computationScheduler.scheduleDirect(new Runnable() {
@Override
public void run() {
final ImmutablePair<BitmapDrawable, Boolean> loaded = loadFromDisk();
final BitmapDrawable image = loaded.left;
if (image != null) {
emitter.onNext(image);
} else {
emitter.onNext(returnErrorImage ?
new BitmapDrawable(resources, BitmapFactory.decodeResource(resources, R.drawable.image_not_loaded)) :
ImageUtils.getTransparent1x1Drawable(resources));
}
emitter.onComplete();
}
});
}
});
}
protected ImmutablePair<BitmapDrawable, Boolean> scaleImage(final ImmutablePair<Bitmap, Boolean> loadResult) {
final Bitmap bitmap = loadResult.left;
return ImmutablePair.of(bitmap != null ? ImageUtils.scaleBitmapToFitDisplay(bitmap) : null, loadResult.right);
}
public Completable waitForEndCompletable(@Nullable final DisposableHandler handler) {
if (handler != null) {
handler.add(disposable);
}
loading.onComplete();
return waitForEnd;
}
/**
* Download or refresh the copy of {@code url} in {@code file}.
*
* @param url the url of the document
* @param file the file to save the document in
* @return {@code true} if the existing file was up-to-date, {@code false} otherwise
*/
private boolean downloadOrRefreshCopy(@NonNull final String url, final File file) {
final String absoluteURL = makeAbsoluteURL(url);
if (absoluteURL != null) {
try {
final Response httpResponse = Network.getRequest(absoluteURL, null, file).blockingGet();
if (httpResponse.isSuccessful()) {
FileUtils.saveEntityToFile(httpResponse, file);
} else if (httpResponse.code() == 304) {
if (!file.setLastModified(System.currentTimeMillis())) {
makeFreshCopy(file);
}
return true;
}
} catch (final Exception e) {
Log.w("Exception in HtmlImage.downloadOrRefreshCopy: " + e.toString());
}
}
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)) {
FileUtils.copy(tempFile, file);
FileUtils.deleteIgnoringFailure(tempFile);
} else {
Log.e("Could not reset timestamp of file " + file.getAbsolutePath());
}
}
/**
* 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 A pair whose first element is the bitmap if available, and the second one is {@code true} if the image is present and fresh enough.
*/
@NonNull
private ImmutablePair<Bitmap, Boolean> loadImageFromStorage(final String url, @NonNull final String pseudoGeocode, final boolean forceKeep) {
try {
final File file = LocalStorage.getGeocacheDataFile(pseudoGeocode, url, true, false);
final ImmutablePair<Bitmap, Boolean> image = loadCachedImage(file, forceKeep);
if (image.right || image.left != null) {
return image;
}
} catch (final Exception e) {
Log.w("HtmlImage.loadImageFromStorage", e);
}
return ImmutablePair.of((Bitmap) null, false);
}
@Nullable
private String makeAbsoluteURL(@NonNull final String url) {
// Check if uri is absolute or not, if not attach the connector hostname
if (Uri.parse(url).isAbsolute()) {
return url;
}
if (!StringUtils.startsWith(url, "/")) {
Log.w("unusable relative URL for geocache " + geocode + ": " + url);
return null;
}
final String hostUrl = ConnectorFactory.getConnector(geocode).getHostUrl();
if (StringUtils.isEmpty(hostUrl)) {
Log.w("unable to compute relative images URL for " + geocode);
return null;
}
return hostUrl + url;
}
/**
* 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} in the second component if the image was there and is fresh enough or {@code false} otherwise,
* and the image (possibly {@code null} if the second component is {@code false} and the image
* could not be loaded, or if the second component is {@code true} and {@code onlySave} is also
* {@code true})
*/
@NonNull
private ImmutablePair<Bitmap, Boolean> loadCachedImage(final File file, final boolean forceKeep) {
// An image is considered fresh enough if the image exists and one of those conditions is true:
// - forceKeep is true and the image has not been modified in the last 24 hours, to avoid reloading shared images;
// with every refreshed cache;
// - forceKeep is true and userInitiatedRefresh is false, as shared images are unlikely to change at all;
// - userInitiatedRefresh is false and the image has not been modified in the last 24 hours.
if (file.exists()) {
final boolean recentlyModified = file.lastModified() > (System.currentTimeMillis() - (24 * 60 * 60 * 1000));
final boolean freshEnough = (forceKeep && (recentlyModified || !userInitiatedRefresh)) ||
(recentlyModified && !userInitiatedRefresh);
if (freshEnough && onlySave) {
return ImmutablePair.of((Bitmap) 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 ImmutablePair.of((Bitmap) null, false);
}
return ImmutablePair.of(image, freshEnough);
}
return ImmutablePair.of((Bitmap) null, false);
}
private void setSampleSize(final File file, final BitmapFactory.Options bfOptions) {
//Decode image size only
final 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 (final 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;
}
}