Skip to content

Commit

Permalink
Support for animated WEBPs
Browse files Browse the repository at this point in the history
  • Loading branch information
RobbWatershed committed Feb 8, 2022
1 parent befd892 commit aca96cf
Show file tree
Hide file tree
Showing 17 changed files with 285 additions and 29 deletions.
3 changes: 3 additions & 0 deletions .proguard/proguard-glide-webp.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-keep public class com.bumptech.glide.integration.webp.WebpImage { *; }
-keep public class com.bumptech.glide.integration.webp.WebpFrame { *; }
-keep public class com.bumptech.glide.integration.webp.WebpBitmapFactory { *; }
7 changes: 7 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ dependencies {
exclude group: 'glide-parent'
}

// Animated WEBP support -> https://github.com/zjupure/GlideWebpDecoder
// webpdecoder
implementation "com.github.zjupure:webpdecoder:2.0.$glide_version"
// glide 4.10.0+
implementation "com.github.bumptech.glide:glide:$glide_version"
annotationProcessor "com.github.bumptech.glide:compiler:$glide_version"

// Animated PNG (apng) support -> https://github.com/penfeizhou/APNG4Android
implementation 'com.github.penfeizhou.android.animation:apng:2.17.2'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package me.devsaki.hentoid.customssiv;

/**
* Generic file-related utility class
*/
public class FileHelper {

private FileHelper() {
throw new IllegalStateException("Utility class");
}

public static final int FILE_IO_BUFFER_SIZE = 32 * 1024;


/**
* Return the position of the given sequence in the given data array
*
* @param data Data where to find the sequence
* @param initialPos Initial position to start from
* @param sequence Sequence to look for
* @param limit Limit not to cross (in bytes counted from the initial position); 0 for unlimited
* @return Position of the sequence in the data array; -1 if not found within the given initial position and limit
*/
static int findSequencePosition(byte[] data, int initialPos, byte[] sequence, int limit) {
int remainingBytes;
int iSequence = 0;

if (initialPos < 0 || initialPos > data.length) return -1;

remainingBytes = (limit > 0) ? Math.min(data.length - initialPos, limit) : data.length;

for (int i = initialPos; i < remainingBytes; i++) {
if (sequence[iSequence] == data[i]) iSequence++;
else if (iSequence > 0) iSequence = 0;

if (sequence.length == iSequence) return i - sequence.length;
}

// Target sequence not found
return -1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

import android.os.Looper;

import androidx.annotation.NonNull;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public final class Helper {

private Helper() {
Expand All @@ -13,4 +19,21 @@ static void assertNonUiThread() {
throw new IllegalStateException("This should not be run on the UI thread");
}
}

/**
* Copy all data from the given InputStream to the given OutputStream
*
* @param in InputStream to read data from
* @param out OutputStream to write data to
* @throws IOException If something horrible happens during I/O
*/
public static void copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException {
// Transfer bytes from in to out
byte[] buf = new byte[FileHelper.FILE_IO_BUFFER_SIZE];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
out.flush();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package me.devsaki.hentoid.customssiv;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
* Generic utility class
*/
public final class ImageHelper {

private static final Charset CHARSET_LATIN_1 = StandardCharsets.ISO_8859_1;

public static final String MIME_IMAGE_GENERIC = "image/*";
public static final String MIME_IMAGE_WEBP = "image/webp";
public static final String MIME_IMAGE_JPEG = "image/jpeg";
public static final String MIME_IMAGE_GIF = "image/gif";
public static final String MIME_IMAGE_PNG = "image/png";
public static final String MIME_IMAGE_APNG = "image/apng";


private ImageHelper() {
throw new IllegalStateException("Utility class");
}


/**
* Determine the MIME-type of the given binary data if it's a picture
*
* @param binary Picture binary data to determine the MIME-type for
* @return MIME-type of the given binary data; empty string if not supported
*/
public static String getMimeTypeFromPictureBinary(byte[] binary) {
if (binary.length < 12) return "";

// In Java, byte type is signed !
// => Converting all raw values to byte to be sure they are evaluated as expected
if ((byte) 0xFF == binary[0] && (byte) 0xD8 == binary[1] && (byte) 0xFF == binary[2])
return MIME_IMAGE_JPEG;
else if ((byte) 0x89 == binary[0] && (byte) 0x50 == binary[1] && (byte) 0x4E == binary[2]) {
// Detect animated PNG : To be recognized as APNG an 'acTL' chunk must appear in the stream before any 'IDAT' chunks
int acTlPos = FileHelper.findSequencePosition(binary, 0, "acTL".getBytes(CHARSET_LATIN_1), (int) (binary.length * 0.2));
if (acTlPos > -1) {
long idatPos = FileHelper.findSequencePosition(binary, acTlPos, "IDAT".getBytes(CHARSET_LATIN_1), (int) (binary.length * 0.1));
if (idatPos > -1) return MIME_IMAGE_APNG;
}
return MIME_IMAGE_PNG;
} else if ((byte) 0x47 == binary[0] && (byte) 0x49 == binary[1] && (byte) 0x46 == binary[2])
return MIME_IMAGE_GIF;
else if ((byte) 0x52 == binary[0] && (byte) 0x49 == binary[1] && (byte) 0x46 == binary[2] && (byte) 0x46 == binary[3]
&& (byte) 0x57 == binary[8] && (byte) 0x45 == binary[9] && (byte) 0x42 == binary[10] && (byte) 0x50 == binary[11])
return MIME_IMAGE_WEBP;
else if ((byte) 0x42 == binary[0] && (byte) 0x4D == binary[1]) return "image/bmp";
else return MIME_IMAGE_GENERIC;
}

// If format is supported by Android, true if animated (animated GIF, APNG, animated WEBP); false if not
// TODO complete doc
public static boolean isImageAnimated(byte[] binary) {
if (binary.length < 400) return false;

switch (getMimeTypeFromPictureBinary(binary)) {
case MIME_IMAGE_APNG:
return true;
case MIME_IMAGE_GIF:
return FileHelper.findSequencePosition(binary, 0, "NETSCAPE".getBytes(CHARSET_LATIN_1), 400) > -1;
case MIME_IMAGE_WEBP:
return FileHelper.findSequencePosition(binary, 0, "ANIM".getBytes(CHARSET_LATIN_1), 400) > -1;
default:
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;

import me.devsaki.hentoid.customssiv.CustomSubsamplingScaleImageView;
import me.devsaki.hentoid.customssiv.Helper;
import me.devsaki.hentoid.customssiv.ImageHelper;

/**
* Default implementation of {@link ImageDecoder}
Expand Down Expand Up @@ -55,7 +59,7 @@ public SkiaImageDecoder(@Nullable Bitmap.Config bitmapConfig) {
public Bitmap decode(@NonNull final Context context, @NonNull final Uri uri) throws IOException, PackageManager.NameNotFoundException {
String uriString = uri.toString();
BitmapFactory.Options options = new BitmapFactory.Options();
Bitmap bitmap;
Bitmap bitmap = null;
options.inPreferredConfig = bitmapConfig;
// If that is not set, some PNGs are read with a ColorSpace of code "Unknown" (-1),
// which makes resizing buggy (generates a black picture)
Expand All @@ -68,13 +72,35 @@ public Bitmap decode(@NonNull final Context context, @NonNull final Uri uri) thr
} else if (uriString.startsWith(ASSET_PREFIX)) {
String assetName = uriString.substring(ASSET_PREFIX.length());
bitmap = BitmapFactory.decodeStream(context.getAssets().open(assetName), null, options);
} else if (uriString.startsWith(FILE_PREFIX)) {
}/* else if (uriString.startsWith(FILE_PREFIX)) {
bitmap = BitmapFactory.decodeFile(uriString.substring(FILE_PREFIX.length()), options);
} else {
} */ else {
InputStream headerStream;
InputStream fileStream;
try (InputStream input = context.getContentResolver().openInputStream(uri)) {
if (input == null)
throw new RuntimeException("Content resolver returned null stream. Unable to initialise with uri.");
bitmap = BitmapFactory.decodeStream(input, null, options);

try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Helper.copy(input, baos);

headerStream = new ByteArrayInputStream(baos.toByteArray(), 0, 400);
fileStream = new ByteArrayInputStream(baos.toByteArray());
}
}

try {
byte[] header = new byte[400];
if (headerStream.read(header) > 0 && ImageHelper.isImageAnimated(header))
throw new RuntimeException("SSIV doesn't handle animated pictures");
} finally {
headerStream.close();
}

try {
bitmap = BitmapFactory.decodeStream(fileStream, null, options);
} finally {
fileStream.close();
}
}
if (bitmap == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package me.devsaki.hentoid.adapters;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
Expand All @@ -21,11 +22,13 @@
import androidx.recyclerview.widget.RecyclerView;
import androidx.vectordrawable.graphics.drawable.Animatable2Compat;

import com.bumptech.glide.Glide;
import com.bumptech.glide.integration.webp.decoder.WebpDrawable;
import com.bumptech.glide.integration.webp.decoder.WebpDrawableTransformation;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.load.resource.bitmap.CenterInside;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.Target;
import com.github.penfeizhou.animation.apng.APNGDrawable;
import com.github.penfeizhou.animation.io.FilterReader;
Expand All @@ -41,6 +44,7 @@
import java.util.Objects;

import me.devsaki.hentoid.R;
import me.devsaki.hentoid.core.GlideApp;
import me.devsaki.hentoid.core.HentoidApp;
import me.devsaki.hentoid.customssiv.CustomSubsamplingScaleImageView;
import me.devsaki.hentoid.customssiv.ImageSource;
Expand All @@ -53,7 +57,7 @@

public final class ImagePagerAdapter extends ListAdapter<ImageFile, ImagePagerAdapter.ImageViewHolder> {

private static final int IMG_TYPE_OTHER = 0; // PNGs and JPEGs -> use CustomSubsamplingScaleImageView
private static final int IMG_TYPE_OTHER = 0; // PNGs, JPEGs and WEBPs -> use CustomSubsamplingScaleImageView; will fallback to Glide if animation detected
private static final int IMG_TYPE_GIF = 1; // Static and animated GIFs -> use native Glide
private static final int IMG_TYPE_APNG = 2; // Animated PNGs -> use APNG4Android library

Expand All @@ -71,9 +75,6 @@ public final class ImagePagerAdapter extends ListAdapter<ImageFile, ImagePagerAd

private static final int PAGE_MIN_HEIGHT = (int) HentoidApp.getInstance().getResources().getDimension(R.dimen.page_min_height);


private final RequestOptions glideRequestOptions = new RequestOptions().centerInside();

private View.OnTouchListener itemTouchListener;
private RecyclerView recyclerView;

Expand Down Expand Up @@ -154,7 +155,8 @@ private int getImageType(ImageFile img) {
int getItemViewType(int position) {
int imageType = getImageType(getImageAt(position));

if (IMG_TYPE_GIF == imageType || IMG_TYPE_APNG == imageType) return ViewType.DEFAULT;
if (IMG_TYPE_GIF == imageType || IMG_TYPE_APNG == imageType)
return ViewType.DEFAULT;
if (Preferences.Constant.VIEWER_DISPLAY_STRETCH == displayMode)
return ViewType.IMAGEVIEW_STRETCH;
if (Preferences.Constant.VIEWER_ORIENTATION_VERTICAL == viewerOrientation)
Expand Down Expand Up @@ -331,6 +333,7 @@ void setImage(@NonNull ImageFile img) {
Timber.d("Picture %d : binding viewholder %s %s", getAbsoluteAdapterPosition(), imgType, uri);

if (!isImageView) { // SubsamplingScaleImageView
Timber.d("Using SSIV");
ssiv.recycle();
ssiv.setMinimumScaleType(getScaleType());
ssiv.setOnImageEventListener(this);
Expand All @@ -349,13 +352,18 @@ void setImage(@NonNull ImageFile img) {
} else { // ImageView
ImageView view = (ImageView) imgView;
if (IMG_TYPE_APNG == imgType) {
Timber.d("Using APNGDrawable");
APNGDrawable apngDrawable = new APNGDrawable(new ImgLoader(uri));
apngDrawable.registerAnimationCallback(animationCallback);
view.setImageDrawable(apngDrawable);
} else {
Glide.with(view)
Timber.d("Using Glide");
Transformation<Bitmap> centerInside = new CenterInside();
GlideApp.with(view)
.load(uri)
.apply(glideRequestOptions)
.optionalTransform(centerInside)
.optionalTransform(WebpDrawable.class, new WebpDrawableTransformation(centerInside))
// .set(WebpFrameLoader.FRAME_CACHE_STRATEGY, WebpFrameCacheStrategy.ALL)
.listener(this)
.into(view);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package me.devsaki.hentoid.fragments.viewer;

import static me.devsaki.hentoid.util.ImageHelper.tintBitmap;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
Expand All @@ -17,6 +19,10 @@
import androidx.lifecycle.ViewModelProvider;

import com.bumptech.glide.Glide;
import com.bumptech.glide.integration.webp.decoder.WebpDrawable;
import com.bumptech.glide.integration.webp.decoder.WebpDrawableTransformation;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.resource.bitmap.CenterInside;
import com.bumptech.glide.request.RequestOptions;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;

Expand All @@ -30,8 +36,6 @@
import me.devsaki.hentoid.viewmodels.ImageViewerViewModel;
import me.devsaki.hentoid.viewmodels.ViewModelFactory;

import static me.devsaki.hentoid.util.ImageHelper.tintBitmap;

public class ViewerBottomContentFragment extends BottomSheetDialogFragment {

private static final RequestOptions glideRequestOptions;
Expand All @@ -49,8 +53,10 @@ public class ViewerBottomContentFragment extends BottomSheetDialogFragment {
Bitmap bmp = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_hentoid_trans);
Drawable d = new BitmapDrawable(context.getResources(), tintBitmap(bmp, tintColor));

final Transformation<Bitmap> centerInside = new CenterInside();
glideRequestOptions = new RequestOptions()
.centerInside()
.optionalTransform(centerInside)
.optionalTransform(WebpDrawable.class, new WebpDrawableTransformation(centerInside))
.error(d);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
import androidx.lifecycle.ViewModelProvider;

import com.bumptech.glide.Glide;
import com.bumptech.glide.integration.webp.decoder.WebpDrawable;
import com.bumptech.glide.integration.webp.decoder.WebpDrawableTransformation;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.resource.bitmap.CenterInside;
import com.bumptech.glide.request.RequestOptions;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
Expand Down Expand Up @@ -77,8 +81,10 @@ public class ViewerBottomImageFragment extends BottomSheetDialogFragment {
Bitmap bmp = BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_hentoid_trans);
Drawable d = new BitmapDrawable(context.getResources(), tintBitmap(bmp, tintColor));

final Transformation<Bitmap> centerInside = new CenterInside();
glideRequestOptions = new RequestOptions()
.centerInside()
.optionalTransform(centerInside)
.optionalTransform(WebpDrawable.class, new WebpDrawableTransformation(centerInside))
.error(d);
}

Expand Down
Loading

0 comments on commit aca96cf

Please sign in to comment.