Loading app/build.gradle +8 −5 Original line number Diff line number Diff line Loading @@ -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 } Loading @@ -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" Loading app/src/main/java/app/fedilab/android/mastodon/helper/CustomEmoji.java +105 −25 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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++; } Loading @@ -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 Loading @@ -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) { Loading @@ -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) { Loading Loading @@ -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 app/src/main/java/app/fedilab/android/mastodon/helper/EmojiLoader.java 0 → 100644 +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 app/src/main/java/app/fedilab/android/mastodon/ui/drawer/EmojiAdapter.java +3 −6 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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; } Loading app/src/main/java/app/fedilab/android/mastodon/ui/drawer/EmojiSearchAdapter.java +2 −5 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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 Loading
app/build.gradle +8 −5 Original line number Diff line number Diff line Loading @@ -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 } Loading @@ -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" Loading
app/src/main/java/app/fedilab/android/mastodon/helper/CustomEmoji.java +105 −25 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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++; } Loading @@ -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 Loading @@ -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) { Loading @@ -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) { Loading Loading @@ -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
app/src/main/java/app/fedilab/android/mastodon/helper/EmojiLoader.java 0 → 100644 +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
app/src/main/java/app/fedilab/android/mastodon/ui/drawer/EmojiAdapter.java +3 −6 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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; } Loading
app/src/main/java/app/fedilab/android/mastodon/ui/drawer/EmojiSearchAdapter.java +2 −5 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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