Commit fddccd72 authored by Thomas's avatar Thomas
Browse files

- Fix #600 APNG custom emoji animation

parent 1a51f5ce
Loading
Loading
Loading
Loading
+8 −5
Original line number Diff line number Diff line
@@ -121,9 +121,9 @@ dependencies {
    implementation 'com.vanniktech:emoji-google:0.21.0'
    implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0'
    implementation 'com.github.GrenderG:Toasty:1.5.2'
    implementation "com.github.bumptech.glide:glide:4.14.2"
    implementation "com.github.bumptech.glide:okhttp3-integration:4.14.2"
    implementation("com.github.bumptech.glide:recyclerview-integration:4.14.2") {
    implementation "com.github.bumptech.glide:glide:4.16.0"
    implementation "com.github.bumptech.glide:okhttp3-integration:4.16.0"
    implementation("com.github.bumptech.glide:recyclerview-integration:4.16.0") {
        // Excludes the support library because it's already included by Glide.
        transitive = false
    }
@@ -141,9 +141,12 @@ dependencies {

    implementation 'com.burhanrashid52:photoeditor:1.5.1'
    implementation("com.vanniktech:android-image-cropper:4.3.3")
    annotationProcessor "com.github.bumptech.glide:compiler:4.12.0"
    annotationProcessor "com.github.bumptech.glide:compiler:4.16.0"
    implementation 'jp.wasabeef:glide-transformations:4.3.0'
    implementation 'com.github.penfeizhou.android.animation:glide-plugin:3.0.5'
    implementation 'com.github.penfeizhou.android.animation:apng:3.0.5'
    implementation 'com.github.penfeizhou.android.animation:gif:3.0.5'
    implementation 'com.github.penfeizhou.android.animation:awebp:3.0.5'
    implementation 'com.github.penfeizhou.android.animation:avif:3.0.5'
    implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
    implementation "androidx.media3:media3-exoplayer:1.2.1"
    implementation "androidx.media3:media3-exoplayer-dash:1.2.1"
+105 −25
Original line number Diff line number Diff line
@@ -33,6 +33,7 @@ public class CustomEmoji extends ReplacementSpan {
    private final WeakReference<View> viewWeakReference;
    private float scale;
    private Drawable imageDrawable;
    private Drawable.Callback drawableCallback;
    private boolean callbackCalled;
    private boolean loadFailed;

@@ -57,10 +58,11 @@ public class CustomEmoji extends ReplacementSpan {
                while (matcher.find()) {
                    CustomEmoji customEmoji = new CustomEmoji(new WeakReference<>(viewWeakReference.get()));
                    content.setSpan(customEmoji, matcher.start(), matcher.end(), 0);
                    String emojiUrl = animate ? emoji.url : emoji.static_url;
                    Glide.with(viewWeakReference.get())
                            .asDrawable()
                            .load(animate ? emoji.url : emoji.static_url)
                            .into(customEmoji.getTarget(animate, count == emojiList.size() && !callbackCalled ? callback : null));
                            .load(emojiUrl)
                            .into(customEmoji.getTarget(animate, count == emojiList.size() && !callbackCalled ? callback : null, emojiUrl));
                }
                count++;
            }
@@ -68,28 +70,68 @@ public class CustomEmoji extends ReplacementSpan {
        return content;
    }

    private void onEmojiLoaded(Drawable resource, boolean animate, Status.Callback callback) {
        View view = viewWeakReference.get();

        if (animate && resource instanceof Animatable) {
            drawableCallback = new Drawable.Callback() {
                @Override
    public int getSize(@NonNull Paint paint, CharSequence charSequence, int i, int i1, @Nullable Paint.FontMetricsInt fontMetricsInt) {
        return (int) (paint.getTextSize() * scale);
                public void invalidateDrawable(@NonNull Drawable drawable) {
                    if (view != null) {
                        view.invalidate();
                    }
                }

                @Override
    public void draw(@NonNull Canvas canvas, CharSequence charSequence, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
        if (imageDrawable != null) {
            canvas.save();
            int emojiSize = (int) (paint.getTextSize() * scale);
            imageDrawable.setBounds(0, 0, emojiSize, emojiSize);
            int transY = bottom - imageDrawable.getBounds().bottom;
            transY -= (int) (paint.getFontMetrics().descent / 2);
            canvas.translate(x, (float) transY);
            imageDrawable.draw(canvas);
            canvas.restore();
        } else if (loadFailed) {
            canvas.drawText(charSequence, start, end, x, y, paint);
                public void scheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable, long l) {
                    if (view != null) {
                        view.postDelayed(runnable, l);
                    }
                }

                @Override
                public void unscheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable) {
                    if (view != null) {
                        view.removeCallbacks(runnable);
                    }
                }
            };
            resource.setCallback(drawableCallback);
            ((Animatable) resource).start();
        }
        imageDrawable = resource;
        if (view != null) {
            view.invalidate();
        }
        if (callback != null && !callbackCalled) {
            callbackCalled = true;
            callback.emojiFetched();
        }
    }

    private void onEmojiLoadFailed() {
        loadFailed = true;
        View view = viewWeakReference.get();
        if (view != null) {
            view.invalidate();
        }
    }

    public void loadEmoji(View view, String url, boolean animate) {
        EmojiLoader.loadEmojiSpan(view, url, animate, new EmojiLoader.DrawableCallback() {
            @Override
            public void onLoaded(Drawable drawable, boolean shouldAnimate) {
                onEmojiLoaded(drawable, shouldAnimate, null);
            }

            @Override
            public void onFailed() {
                onEmojiLoadFailed();
            }
        });
    }

    public Target<Drawable> getTarget(boolean animate, Status.Callback callback) {
    public Target<Drawable> getTarget(boolean animate, Status.Callback callback, String url) {
        return new CustomTarget<>() {

            @Override
@@ -111,7 +153,6 @@ public class CustomEmoji extends ReplacementSpan {
                View view = viewWeakReference.get();

                if (animate && resource instanceof Animatable) {

                    resource.setCallback(new Drawable.Callback() {
                        @Override
                        public void invalidateDrawable(@NonNull Drawable drawable) {
@@ -122,15 +163,32 @@ public class CustomEmoji extends ReplacementSpan {

                        @Override
                        public void scheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable, long l) {
                            if (view != null) {
                                view.postDelayed(runnable, l);
                            }
                        }

                        @Override
                        public void unscheduleDrawable(@NonNull Drawable drawable, @NonNull Runnable runnable) {
                            if (view != null) {
                                view.removeCallbacks(runnable);
                            }
                        }
                    });
                    ((Animatable) resource).start();
                } else if (animate && view != null) {
                    EmojiLoader.loadEmojiSpan(view, url, true, new EmojiLoader.DrawableCallback() {
                        @Override
                        public void onLoaded(Drawable drawable, boolean shouldAnimate) {
                            if (drawable instanceof Animatable) {
                                onEmojiLoaded(drawable, true, null);
                            }
                        }

                        @Override
                        public void onFailed() {
                        }
                    });
                }
                imageDrawable = resource;
                if (view != null) {
@@ -167,4 +225,26 @@ public class CustomEmoji extends ReplacementSpan {
            }
        };
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence charSequence, int i, int i1, @Nullable Paint.FontMetricsInt fontMetricsInt) {
        return (int) (paint.getTextSize() * scale);
    }

    @Override
    public void draw(@NonNull Canvas canvas, CharSequence charSequence, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
        if (imageDrawable != null) {
            canvas.save();
            int emojiSize = (int) (paint.getTextSize() * scale);
            imageDrawable.setBounds(0, 0, emojiSize, emojiSize);
            int transY = bottom - imageDrawable.getBounds().bottom;
            transY -= (int) (paint.getFontMetrics().descent / 2);
            canvas.translate(x, (float) transY);
            imageDrawable.draw(canvas);
            canvas.restore();
        } else if (loadFailed) {
            canvas.drawText(charSequence, start, end, x, y, paint);
        }
    }

}
 No newline at end of file
+251 −0
Original line number Diff line number Diff line
package app.fedilab.android.mastodon.helper;
/* Copyright 2026 Thomas Schneider
 *
 * This file is a part of Fedilab
 *
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 3 of the
 * License, or (at your option) any later version.
 *
 * Fedilab is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
 * Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Fedilab; if not,
 * see <http://www.gnu.org/licenses>. */

import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.widget.ImageView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.bumptech.glide.Glide;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.github.penfeizhou.animation.apng.APNGDrawable;
import com.github.penfeizhou.animation.gif.GifDrawable;
import com.github.penfeizhou.animation.webp.WebPDrawable;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class EmojiLoader {

    public static void loadEmoji(ImageView view, String url) {
        Glide.with(view)
                .asFile()
                .load(url)
                .into(new CustomTarget<File>() {
                    @Override
                    public void onResourceReady(@NonNull File file, @Nullable Transition<? super File> transition) {
                        Drawable drawable = decodeFile(view.getResources(), file);
                        if (drawable instanceof Animatable) {
                            ((Animatable) drawable).start();
                        }
                        view.setImageDrawable(drawable);
                    }

                    @Override
                    public void onLoadCleared(@Nullable Drawable placeholder) {
                    }
                });
    }

    public static void loadEmojiSpan(View view, String url, boolean animate, DrawableCallback callback) {
        if (!Helper.isValidContextForGlide(view.getContext())) {
            return;
        }
        Glide.with(view)
                .asFile()
                .load(url)
                .into(new CustomTarget<File>() {
                    @Override
                    public void onResourceReady(@NonNull File file, @Nullable Transition<? super File> transition) {
                        Drawable drawable = decodeFile(view.getResources(), file);
                        if (drawable != null) {
                            callback.onLoaded(drawable, animate);
                        } else {
                            callback.onFailed();
                        }
                    }

                    @Override
                    public void onLoadFailed(@Nullable Drawable errorDrawable) {
                        callback.onFailed();
                    }

                    @Override
                    public void onLoadCleared(@Nullable Drawable placeholder) {
                    }
                });
    }

    private static Drawable decodeFile(Resources resources, File file) {
        int fileType = getFileType(file);
        if (fileType == TYPE_APNG) {
            return APNGDrawable.fromFile(file.getAbsolutePath());
        } else if (fileType == TYPE_PNG) {
            Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
            if (bitmap != null) {
                return new BitmapDrawable(resources, bitmap);
            }
        } else if (fileType == TYPE_GIF_ANIMATED) {
            return GifDrawable.fromFile(file.getAbsolutePath());
        } else if (fileType == TYPE_GIF) {
            Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
            if (bitmap != null) {
                return new BitmapDrawable(resources, bitmap);
            }
        } else if (fileType == TYPE_WEBP_ANIMATED) {
            return WebPDrawable.fromFile(file.getAbsolutePath());
        } else if (fileType == TYPE_WEBP) {
            Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
            if (bitmap != null) {
                return new BitmapDrawable(resources, bitmap);
            }
        }
        // Fallback to BitmapDrawable for other formats (JPEG, etc.)
        Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
        if (bitmap != null) {
            return new BitmapDrawable(resources, bitmap);
        }
        return null;
    }

    private static final int TYPE_UNKNOWN = 0;
    private static final int TYPE_PNG = 1;
    private static final int TYPE_APNG = 2;
    private static final int TYPE_GIF = 3;
    private static final int TYPE_GIF_ANIMATED = 4;
    private static final int TYPE_WEBP = 5;
    private static final int TYPE_WEBP_ANIMATED = 6;

    private static int getFileType(File file) {
        try (FileInputStream fis = new FileInputStream(file)) {
            byte[] header = new byte[40];
            int bytesRead = fis.read(header);
            if (bytesRead < 12) {
                return TYPE_UNKNOWN;
            }
            // PNG signature
            if (header[0] == (byte) 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47
                    && header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A) {
                if (isAPNG(file)) {
                    return TYPE_APNG;
                }
                return TYPE_PNG;
            }
            // GIF signature
            if (header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46) {
                if (isAnimatedGif(file)) {
                    return TYPE_GIF_ANIMATED;
                }
                return TYPE_GIF;
            }
            // WebP signature
            if (header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
                    && header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50) {
                if (isAnimatedWebP(file)) {
                    return TYPE_WEBP_ANIMATED;
                }
                return TYPE_WEBP;
            }
        } catch (IOException ignored) {
        }
        return TYPE_UNKNOWN;
    }

    private static boolean isAPNG(File file) {
        try (FileInputStream fis = new FileInputStream(file)) {
            byte[] signature = new byte[8];
            if (fis.read(signature) < 8) {
                return false;
            }
            byte[] chunkHeader = new byte[8];
            while (fis.read(chunkHeader) == 8) {
                String chunkType = new String(chunkHeader, 4, 4);
                if ("acTL".equals(chunkType)) {
                    return true;
                }
                if ("IDAT".equals(chunkType)) {
                    return false;
                }
                int length = ((chunkHeader[0] & 0xFF) << 24) | ((chunkHeader[1] & 0xFF) << 16)
                        | ((chunkHeader[2] & 0xFF) << 8) | (chunkHeader[3] & 0xFF);
                fis.skip(length + 4); // data + CRC
            }
        } catch (IOException ignored) {
        }
        return false;
    }

    private static boolean isAnimatedGif(File file) {
        try (FileInputStream fis = new FileInputStream(file)) {
            byte[] buffer = new byte[1024];
            int bytesRead;
            int imageCount = 0;
            while ((bytesRead = fis.read(buffer)) != -1) {
                for (int i = 0; i < bytesRead - 1; i++) {
                    if (i + 14 < bytesRead && buffer[i] == 0x21 && buffer[i + 1] == (byte) 0xFF) {
                        if (buffer[i + 2] == 0x0B) {
                            String app = new String(buffer, i + 3, 11);
                            if ("NETSCAPE2.0".equals(app) || "ANIMEXTS1.0".equals(app)) {
                                return true;
                            }
                        }
                    }
                    if (buffer[i] == 0x2C) {
                        imageCount++;
                        if (imageCount > 1) {
                            return true;
                        }
                    }
                }
            }
        } catch (IOException ignored) {
        }
        return false;
    }

    private static boolean isAnimatedWebP(File file) {
        try (FileInputStream fis = new FileInputStream(file)) {
            byte[] header = new byte[32];
            if (fis.read(header) < 21) {
                return false;
            }
            if (header[12] == 'V' && header[13] == 'P' && header[14] == '8' && header[15] == 'X') {
                return (header[20] & 0x02) != 0;
            }
            fis.getChannel().position(12);
            byte[] chunkHeader = new byte[4];
            while (fis.read(chunkHeader) == 4) {
                String chunkType = new String(chunkHeader, 0, 4);
                if ("ANIM".equals(chunkType) || "ANMF".equals(chunkType)) {
                    return true;
                }
                // Read chunk size and skip
                byte[] sizeBytes = new byte[4];
                if (fis.read(sizeBytes) < 4) break;
                int size = (sizeBytes[0] & 0xFF) | ((sizeBytes[1] & 0xFF) << 8)
                        | ((sizeBytes[2] & 0xFF) << 16) | ((sizeBytes[3] & 0xFF) << 24);
                fis.skip(size + (size % 2));
            }
        } catch (IOException ignored) {
        }
        return false;
    }

    public interface DrawableCallback {
        void onLoaded(Drawable drawable, boolean animate);

        void onFailed();
    }
}
 No newline at end of file
+3 −6
Original line number Diff line number Diff line
@@ -24,13 +24,12 @@ import android.widget.BaseAdapter;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView;

import com.bumptech.glide.Glide;

import java.util.List;

import app.fedilab.android.R;
import app.fedilab.android.databinding.DrawerEmojiPickerBinding;
import app.fedilab.android.mastodon.client.entities.api.Emoji;
import app.fedilab.android.mastodon.helper.EmojiLoader;


public class EmojiAdapter extends BaseAdapter {
@@ -65,9 +64,7 @@ public class EmojiAdapter extends BaseAdapter {

        SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(holder.view.getContext());
        boolean disableAnimatedEmoji = sharedpreferences.getBoolean(parent.getContext().getString(R.string.SET_DISABLE_ANIMATED_EMOJI), false);
        Glide.with(holder.binding.imgCustomEmoji.getContext())
                .load(!disableAnimatedEmoji ? emoji.url : emoji.static_url)
                .into(holder.binding.imgCustomEmoji);
        EmojiLoader.loadEmoji(holder.binding.imgCustomEmoji, !disableAnimatedEmoji ? emoji.url : emoji.static_url);

        return holder.view;
    }
+2 −5
Original line number Diff line number Diff line
@@ -27,7 +27,7 @@ import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView;

import com.bumptech.glide.Glide;
import app.fedilab.android.mastodon.helper.EmojiLoader;

import java.util.ArrayList;
import java.util.List;
@@ -127,10 +127,7 @@ public class EmojiSearchAdapter extends ArrayAdapter<Emoji> implements Filterabl
            SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(context);
            boolean disableGif = sharedpreferences.getBoolean(context.getString(R.string.SET_DISABLE_GIF), false);
            String targetedUrl = disableGif ? emoji.static_url : emoji.url;
            Glide.with(holder.view.getContext())
                    .asDrawable()
                    .load(targetedUrl)
                    .into(holder.binding.emojiIcon);
            EmojiLoader.loadEmoji(holder.binding.emojiIcon, targetedUrl);
        }
        return holder.view;
    }
Loading