/*
* Copyright 2012 GitHub Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.gh4a.utils;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Point;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.v4.content.ContextCompat;
import android.support.v4.os.AsyncTaskCompat;
import android.text.Html.ImageGetter;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ImageSpan;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;
import com.caverock.androidsvg.SVG;
import com.caverock.androidsvg.SVGParseException;
import com.gh4a.R;
import com.gh4a.fragment.SettingsFragment;
import java.io.File;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.lang.ref.WeakReference;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import pl.droidsonroids.gif.GifDrawable;
public class HttpImageGetter implements ImageGetter {
private static class LoadingImageGetter implements ImageGetter {
private final Drawable mImage;
private LoadingImageGetter(final Context context) {
mImage = ContextCompat.getDrawable(context,
UiUtils.resolveDrawable(context, R.attr.loadingPictureIcon));
mImage.setBounds(0, 0, mImage.getIntrinsicWidth(), mImage.getIntrinsicHeight());
}
@Override
public Drawable getDrawable(String source) {
return mImage;
}
}
private static class GifCallback implements Drawable.Callback {
private final List<WeakReference<TextView>> mViewRefs;
private final Handler mHandler = new Handler();
public GifCallback(List<WeakReference<TextView>> viewRefs) {
mViewRefs = viewRefs;
}
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
for (WeakReference<TextView> ref : mViewRefs) {
TextView view = ref.get();
if (view != null) {
view.invalidate();
// make sure the TextView's display list is regenerated
boolean enabled = view.isEnabled();
view.setEnabled(!enabled);
view.setEnabled(enabled);
}
}
}
@Override
public void scheduleDrawable(@NonNull Drawable drawable,
@NonNull Runnable runnable, long when) {
mHandler.postAtTime(runnable, when);
}
@Override
public void unscheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable) {
mHandler.removeCallbacks(runnable);
}
}
private static class GifInfo {
final WeakReference<GifDrawable> mDrawable;
final GifCallback mCallback;
public GifInfo(GifDrawable d, List<WeakReference<TextView>> viewRefs) {
mCallback = new GifCallback(viewRefs);
mDrawable = new WeakReference<>(d);
d.setCallback(mCallback);
}
public void destroy() {
GifDrawable drawable = mDrawable.get();
if (drawable != null) {
drawable.setCallback(null);
drawable.stop();
drawable.recycle();
}
}
}
// interface just used for tracking purposes
private static class LoadedBitmapDrawable extends BitmapDrawable {
public LoadedBitmapDrawable(Resources res, Bitmap bitmap) {
super(res, bitmap);
}
}
private static class ObjectInfo {
private final ArrayList<WeakReference<TextView>> mViewRefs = new ArrayList<>();
private final List<GifInfo> mGifs = new ArrayList<>();
private final List<WeakReference<Bitmap>> mBitmaps = new ArrayList<>();
private CharSequence mRawHtml;
private CharSequence mEncodedHtml;
private ImageGetterAsyncTask mTask;
private boolean mResumed = true;
void bind(TextView view, String html, HttpImageGetter getter) {
addView(view);
if (mEncodedHtml != null) {
apply(mEncodedHtml);
return;
}
if (mRawHtml == null) {
CharSequence encoded = HtmlUtils.encode(view.getContext(),
html, getter.mLoadingGetter);
if (containsImages(html)) {
mRawHtml = encoded;
} else {
mRawHtml = null;
mEncodedHtml = encoded;
apply(mEncodedHtml);
return;
}
}
apply(mRawHtml);
if (mTask == null) {
mTask = new ImageGetterAsyncTask(getter, html, this);
AsyncTaskCompat.executeParallel(mTask);
}
}
void unbind(TextView view) {
removeView(view);
}
void encode(Context context, String html, ImageGetter loadingGetter) {
CharSequence encoded = HtmlUtils.encode(context, html, loadingGetter);
synchronized (this) {
if (containsImages(html)) {
mRawHtml = encoded;
} else {
mRawHtml = null;
mEncodedHtml = encoded;
}
}
}
void onEncodingDone(CharSequence encoded) {
mRawHtml = null;
mEncodedHtml = encoded;
discardLoadedImages();
Spanned spanned = (Spanned) encoded;
ImageSpan[] spans = spanned.getSpans(0, encoded.length(), ImageSpan.class);
for (ImageSpan span : spans) {
Drawable d = span.getDrawable();
if (d instanceof GifDrawable) {
GifDrawable gd = (GifDrawable) d;
if (mResumed) {
gd.start();
}
mGifs.add(new GifInfo(gd, mViewRefs));
} else if (d instanceof LoadedBitmapDrawable) {
BitmapDrawable bd = (BitmapDrawable) d;
mBitmaps.add(new WeakReference<>(bd.getBitmap()));
}
}
apply(mEncodedHtml);
}
void setResumed(boolean resumed) {
mResumed = resumed;
for (GifInfo info : mGifs) {
GifDrawable drawable = info.mDrawable.get();
if (drawable == null) {
continue;
}
if (resumed) {
drawable.start();
} else {
drawable.stop();
}
}
}
private void discardLoadedImages() {
for (WeakReference<Bitmap> ref : mBitmaps) {
Bitmap bitmap = ref.get();
if (bitmap != null) {
bitmap.recycle();
}
}
mBitmaps.clear();
for (GifInfo info : mGifs) {
info.destroy();
}
mGifs.clear();
}
void clearHtmlCache() {
if (mTask != null) {
mTask.cancel(true);
mTask = null;
}
mRawHtml = null;
mEncodedHtml = null;
}
private void apply(CharSequence text) {
int visibility = TextUtils.isEmpty(text) ? View.GONE : View.VISIBLE;
for (int i = 0; i < mViewRefs.size(); i++) {
TextView view = mViewRefs.get(i).get();
if (view != null) {
view.setText(text);
view.setVisibility(visibility);
}
}
}
private void addView(TextView view) {
boolean alreadyPresent = false;
for (int i = 0; i < mViewRefs.size(); i++) {
TextView existing = mViewRefs.get(i).get();
if (existing == null) {
mViewRefs.remove(i);
} else if (existing == view) {
alreadyPresent = true;
}
}
if (!alreadyPresent) {
mViewRefs.add(new WeakReference<>(view));
}
}
private void removeView(TextView view) {
for (int i = 0; i < mViewRefs.size(); i++) {
TextView existing = mViewRefs.get(i).get();
if (existing == null || existing == view) {
mViewRefs.remove(i);
}
}
}
}
private static boolean containsImages(final String html) {
return html.contains("<img");
}
private Map<Object, ObjectInfo> mObjectInfos = new HashMap<>();
private final LoadingImageGetter mLoadingGetter;
private final Drawable mErrorDrawable;
private final Context mContext;
private final File mCacheDir;
private final int mWidth;
private final int mHeight;
private boolean mDestroyed;
private boolean mResumed;
public HttpImageGetter(Context context) {
mContext = context;
mCacheDir = context.getCacheDir();
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
final Point size = new Point();
wm.getDefaultDisplay().getSize(size);
mWidth= size.x;
mHeight = size.y;
mLoadingGetter = new LoadingImageGetter(context);
mErrorDrawable = ContextCompat.getDrawable(context,
UiUtils.resolveDrawable(context, R.attr.contentPictureIcon));
mErrorDrawable.setBounds(0, 0,
mErrorDrawable.getIntrinsicWidth(), mErrorDrawable.getIntrinsicHeight());
}
public void pause() {
mResumed = false;
for (ObjectInfo info : mObjectInfos.values()) {
info.setResumed(false);
}
}
public void resume() {
mResumed = true;
for (ObjectInfo info : mObjectInfos.values()) {
info.setResumed(true);
}
}
public void clearHtmlCache() {
for (ObjectInfo info : mObjectInfos.values()) {
info.clearHtmlCache();
}
}
public void destroy() {
for (ObjectInfo info : mObjectInfos.values()) {
info.discardLoadedImages();
}
mObjectInfos.clear();
mDestroyed = true;
}
public void encode(final Context context, final Object id, final String html) {
findOrCreateInfo(id).encode(context, html, mLoadingGetter);
}
public void bind(final TextView view, final String html, final Object id) {
unbind(view);
findOrCreateInfo(id).bind(view, html, this);
}
public void unbind(final TextView view) {
for (ObjectInfo info : mObjectInfos.values()) {
info.unbind(view);
}
}
private ObjectInfo findOrCreateInfo(Object id) {
ObjectInfo info = mObjectInfos.get(id);
if (info == null) {
info = new ObjectInfo();
mObjectInfos.put(id, info);
}
return info;
}
private static class ImageGetterAsyncTask extends AsyncTask<Void, Void, CharSequence> {
private HttpImageGetter mImageGetter;
private final String mHtml;
private final ObjectInfo mInfo;
public ImageGetterAsyncTask(HttpImageGetter getter, String html, ObjectInfo info) {
mImageGetter = getter;
mHtml = html;
mInfo = info;
}
@Override
protected CharSequence doInBackground(Void... params) {
return HtmlUtils.encode(mImageGetter.mContext, mHtml, mImageGetter);
}
protected void onPostExecute(CharSequence result) {
if (!isCancelled()) {
mInfo.onEncodingDone(result);
}
}
}
@Override
public Drawable getDrawable(String source) {
Bitmap bitmap = null;
if (!mDestroyed) {
File output = null;
InputStream is = null;
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(source).openConnection();
is = connection.getInputStream();
if (is != null) {
String mime = connection.getContentType();
if (mime == null) {
mime = URLConnection.guessContentTypeFromName(source);
}
if (mime == null) {
mime = URLConnection.guessContentTypeFromStream(is);
}
if (mime != null && mime.startsWith("image/svg")) {
bitmap = renderSvgToBitmap(mContext.getResources(), is, mWidth, mHeight);
} else {
boolean isGif = mime != null && mime.startsWith("image/gif");
if (!isGif || canLoadGif()) {
output = File.createTempFile("image", ".tmp", mCacheDir);
if (FileUtils.save(output, is)) {
if (isGif) {
GifDrawable d = new GifDrawable(output);
d.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight());
return d;
} else {
bitmap = getBitmap(output, mWidth, mHeight);
}
}
}
}
}
} catch (IOException e) {
// fall through to showing the error bitmap
} finally {
if (output != null) {
output.delete();
}
if (is != null) {
try {
is.close();
} catch (IOException e) {
// ignored
}
}
if (connection != null) {
connection.disconnect();
}
}
}
synchronized (this) {
if (mDestroyed && bitmap != null) {
bitmap.recycle();
bitmap = null;
}
}
if (bitmap == null) {
return mErrorDrawable;
}
BitmapDrawable drawable = new LoadedBitmapDrawable(mContext.getResources(), bitmap);
drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight());
return drawable;
}
private boolean canLoadGif() {
SharedPreferences prefs = mContext.getSharedPreferences(SettingsFragment.PREF_NAME,
Context.MODE_PRIVATE);
int mode = prefs.getInt(SettingsFragment.KEY_GIF_LOADING, 1);
switch (mode) {
case 1: // load via Wifi
return !UiUtils.downloadNeedsWarning(mContext);
case 2: // always load
return true;
default:
return false;
}
}
private static Bitmap getBitmap(final File image, int width, int height) {
final BitmapFactory.Options options = new BitmapFactory.Options();
RandomAccessFile file = null;
try {
file = new RandomAccessFile(image.getAbsolutePath(), "r");
FileDescriptor fd = file.getFD();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fd, null, options);
int scale = 1;
while (options.outWidth >= width || options.outHeight >= height) {
options.outWidth /= 2;
options.outHeight /= 2;
scale *= 2;
}
options.inJustDecodeBounds = false;
options.inDither = false;
options.inSampleSize = scale;
return BitmapFactory.decodeFileDescriptor(fd, null, options);
} catch (IOException e) {
return null;
} finally {
if (file != null) {
try {
file.close();
} catch (IOException e) {
// ignored
}
}
}
}
private static Bitmap renderSvgToBitmap(Resources res, InputStream is,
int maxWidth, int maxHeight) {
//noinspection TryWithIdenticalCatches
try {
SVG svg = SVG.getFromInputStream(is);
if (svg != null) {
svg.setRenderDPI(DisplayMetrics.DENSITY_DEFAULT);
Float density = res.getDisplayMetrics().density;
int docWidth = (int) (svg.getDocumentWidth() * density);
int docHeight = (int) (svg.getDocumentHeight() * density);
if (docWidth < 0 || docHeight < 0) {
float aspectRatio = svg.getDocumentAspectRatio();
if (aspectRatio > 0) {
float heightForAspect = (float) maxWidth / aspectRatio;
float widthForAspect = (float) maxHeight * aspectRatio;
if (widthForAspect < heightForAspect) {
docWidth = Math.round(widthForAspect);
docHeight = maxHeight;
} else {
docWidth = maxWidth;
docHeight = Math.round(heightForAspect);
}
} else {
docWidth = maxWidth;
docHeight = maxHeight;
}
// we didn't take density into account anymore when calculating docWidth
// and docHeight, so don't scale with it and just let the renderer
// figure out the scaling
density = null;
}
while (docWidth >= maxWidth || docHeight >= maxHeight) {
docWidth /= 2;
docHeight /= 2;
if (density != null) {
density /= 2;
}
}
Bitmap bitmap = Bitmap.createBitmap(docWidth, docHeight, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
if (density != null) {
canvas.scale(density, density);
}
svg.renderToCanvas(canvas);
return bitmap;
}
} catch (SVGParseException e) {
// fall through
} catch (NullPointerException e) {
// https://github.com/BigBadaboom/androidsvg/issues/81
// remove me when there's a 1.2.3 release
}
return null;
}
}