🔀 Commit

Animated profile banners, fixed avatar loading issues
Commit586ce88197b883eaf4d8d642c61f28f5c408c2d8
AuthorJabJab <noreply@xmpp.tel>
Date2026-08-08
Parent2ee396bf
commit 586ce88197b883eaf4d8d642c61f28f5c408c2d8
Author: JabJab <noreply@xmpp.tel>
Date:   Sat Aug 8 05:41:22 2026 +0300

    Animated profile banners, fixed avatar loading issues
---
 build.gradle                                       |   2 +-
 .../android/xmpp/model/banner/Banner.java          |  24 +++-
 .../java/tel/xmpp/jabjab/entities/Account.java     |  19 +++-
 .../java/tel/xmpp/jabjab/entities/Contact.java     |  19 +++-
 src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java |   5 +
 .../xmpp/jabjab/ui/ChannelDiscoveryActivity.java   |   5 +
 .../tel/xmpp/jabjab/ui/ContactDetailsActivity.java |   3 +-
 .../jabjab/ui/ConversationsOverviewFragment.java   |   9 ++
 .../tel/xmpp/jabjab/ui/EditAccountActivity.java    |   3 +-
 .../tel/xmpp/jabjab/ui/EditProfileActivity.java    |   6 +-
 .../tel/xmpp/jabjab/ui/PublishBannerActivity.java  | 125 ++++++++++++++++++---
 .../java/tel/xmpp/jabjab/ui/StoriesActivity.java   |   5 +
 src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java |   7 +-
 .../tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java  |  32 +++++-
 .../tel/xmpp/jabjab/ui/util/BannerImageLoader.java |  73 ++++++++++++
 .../java/tel/xmpp/jabjab/utils/AccountUtils.java   |  19 ++++
 .../xmpp/jabjab/xmpp/manager/BannerManager.java    |  35 ++++--
 src/main/res/values/strings.xml                    |   1 +
 18 files changed, 354 insertions(+), 38 deletions(-)

diff --git a/build.gradle b/build.gradle
index 5ffbf5a..1734e99 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42306
+        versionCode 42307
         versionName "1.0.6"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
diff --git a/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java b/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java
index 269cc62..251705d 100644
--- a/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java
+++ b/src/main/java/im/conversations/android/xmpp/model/banner/Banner.java
@@ -10,12 +10,18 @@ import im.conversations.android.xmpp.model.atom.Entry;
  *
  *   <entry xmlns="http://www.w3.org/2005/Atom">
  *     <published>ISO-8601</published>
- *     <link rel="enclosure" href="URL" sha1="…" width="…" height="…"/>
+ *     <link rel="enclosure" href="URL" type="mime/type" sha1="…" width="…" height="…"/>
  *   </entry>
+ *
+ * type is omitted for plain static images (server always serves those as image/webp) and only
+ * set for animated banners (image/gif or image/webp with multiple frames) — consumers use its
+ * presence to decide whether to route through the animated ImageDecoder path or the plain
+ * static-bitmap one, mirroring Story's own use of the same attribute.
  */
 public class Banner {
 
     private final String url;
+    private final String mime;
     private final String sha1;
     private final int width;
     private final int height;
@@ -23,11 +29,13 @@ public class Banner {
 
     private Banner(
             final String url,
+            final String mime,
             final String sha1,
             final int width,
             final int height,
             final String published) {
         this.url = url;
+        this.mime = mime;
         this.sha1 = sha1;
         this.width = width;
         this.height = height;
@@ -42,6 +50,7 @@ public class Banner {
         if (url == null || url.isEmpty()) return null;
         return new Banner(
                 url,
+                link.getAttribute("type"),
                 link.getAttribute("sha1"),
                 parseIntOrZero(link.getAttribute("width")),
                 parseIntOrZero(link.getAttribute("height")),
@@ -50,6 +59,7 @@ public class Banner {
 
     public static Entry toEntry(
             final String url,
+            final String mime,
             final String sha1,
             final int width,
             final int height,
@@ -61,6 +71,7 @@ public class Banner {
         final var link = entry.addChild("link");
         link.setAttribute("rel", "enclosure");
         link.setAttribute("href", url);
+        if (mime != null) link.setAttribute("type", mime);
         if (sha1 != null) link.setAttribute("sha1", sha1);
         if (width > 0) link.setAttribute("width", String.valueOf(width));
         if (height > 0) link.setAttribute("height", String.valueOf(height));
@@ -80,6 +91,17 @@ public class Banner {
         return url;
     }
 
+    public String getMime() {
+        return mime;
+    }
+
+    /** type is only ever set for animated banners (see class doc) — its mere presence, not
+     * any particular value, is the animated/static signal (image/webp is ambiguous between
+     * a static and an animated encode, so the value alone can't distinguish them). */
+    public boolean isAnimated() {
+        return mime != null && !mime.isEmpty();
+    }
+
     public String getSha1() {
         return sha1;
     }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Account.java b/src/main/java/tel/xmpp/jabjab/entities/Account.java
index 7eba3ee..d013861 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Account.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Account.java
@@ -594,18 +594,29 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
     // Not persisted (unlike avatar) — see the identical note on Contact.banner.
     private transient String banner;
 
+    // Set alongside banner (not persisted either) — present only when the banner is animated,
+    // see Banner.isAnimated()'s doc for why presence-not-value is the signal.
+    private transient String bannerMime;
+
     public boolean setBanner(final String banner) {
-        if (this.banner == null ? banner == null : this.banner.equals(banner)) {
-            return false;
-        }
+        return setBanner(banner, null);
+    }
+
+    public boolean setBanner(final String banner, final String bannerMime) {
+        final boolean unchanged = this.banner == null ? banner == null : this.banner.equals(banner);
         this.banner = banner;
-        return true;
+        this.bannerMime = bannerMime;
+        return !unchanged;
     }
 
     public String getBanner() {
         return this.banner;
     }
 
+    public String getBannerMime() {
+        return this.bannerMime;
+    }
+
     // Not persisted — same reasoning as banner. Cached vCard NOTE so UI (e.g. the mini
     // profile header on the account details screen) can show it instantly without an
     // async vCard fetch on every open; refreshed opportunistically whenever a vCard fetch
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Contact.java b/src/main/java/tel/xmpp/jabjab/entities/Contact.java
index bffab17..16cd683 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Contact.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Contact.java
@@ -458,18 +458,29 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
         return this.avatar;
     }
 
+    // Set alongside banner (not persisted either) — present only when the banner is animated,
+    // see Banner.isAnimated()'s doc for why presence-not-value is the signal.
+    private transient String bannerMime;
+
     public boolean setBanner(final String banner) {
-        if (this.banner == null ? banner == null : this.banner.equals(banner)) {
-            return false;
-        }
+        return setBanner(banner, null);
+    }
+
+    public boolean setBanner(final String banner, final String bannerMime) {
+        final boolean unchanged = this.banner == null ? banner == null : this.banner.equals(banner);
         this.banner = banner;
-        return true;
+        this.bannerMime = bannerMime;
+        return !unchanged;
     }
 
     public String getBanner() {
         return this.banner;
     }
 
+    public String getBannerMime() {
+        return this.bannerMime;
+    }
+
     public boolean mutualPresenceSubscription() {
         return getOption(Options.FROM) && getOption(Options.TO);
     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
index 81aa378..93c8411 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogActivity.java
@@ -101,6 +101,11 @@ public class BlogActivity extends XmppActivity
                             "translation_backend_priority");
                     startActivity(intent);
                 });
+        featureBanner.registerBanner(
+                "animated_banner_feature",
+                R.drawable.ic_photo_24dp,
+                getString(R.string.animated_banner_feature_banner_text),
+                () -> tel.xmpp.jabjab.utils.AccountUtils.launchEditProfile(this));
 
         searchInput = findViewById(R.id.blog_search_input);
         searchInput.addTextChangedListener(new TextWatcher() {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java
index 5d32fa0..c59a585 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ChannelDiscoveryActivity.java
@@ -131,6 +131,11 @@ public class ChannelDiscoveryActivity extends XmppActivity
                             "translation_backend_priority");
                     startActivity(intent);
                 });
+        binding.featureBanner.registerBanner(
+                "animated_banner_feature",
+                R.drawable.ic_photo_24dp,
+                getString(R.string.animated_banner_feature_banner_text),
+                () -> AccountUtils.launchEditProfile(this));
 
         // SearchBar — menu with discover / accounts / settings
         binding.searchBar.setNavigationOnClickListener(null);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
index 458fff1..2eb9b19 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
@@ -749,7 +749,8 @@ public class ContactDetailsActivity extends OmemoActivity
         final var bannerManager = conn.getManager(tel.xmpp.jabjab.xmpp.manager.BannerManager.class);
         final String cached = contact.getBanner();
         if (cached != null) {
-            tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(cached, binding.detailsBanner);
+            tel.xmpp.jabjab.ui.util.BannerImageLoader.load(
+                    this, cached, contact.getBannerMime(), binding.detailsBanner);
         }
         bannerManager.fetchAndCache(contact.getAddress().asBareJid());
     }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
index 9cd97cc..7b4b26f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
@@ -412,6 +412,7 @@ public class ConversationsOverviewFragment extends XmppFragment {
         this.binding.fab.setOnClickListener(
                 (view) -> StartConversationActivity.launch(getActivity()));
         showTranslationFeatureBanner();
+        showAnimatedBannerFeatureBanner();
 
         this.conversationsAdapter =
                 new ConversationAdapter(requireXmppActivity(), this.conversations);
@@ -452,6 +453,14 @@ public class ConversationsOverviewFragment extends XmppFragment {
                 });
     }
 
+    private void showAnimatedBannerFeatureBanner() {
+        this.binding.featureBanner.registerBanner(
+                "animated_banner_feature",
+                R.drawable.ic_photo_24dp,
+                getString(R.string.animated_banner_feature_banner_text),
+                () -> AccountUtils.launchEditProfile(requireXmppActivity()));
+    }
+
     private void startSearch(final String term) {
         final var intent = new Intent(requireContext(), SearchActivity.class);
         intent.putExtra(SearchActivity.EXTRA_SEARCH_TERM, term);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
index ca9b561..9da2ca2 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
@@ -493,7 +493,8 @@ public class EditAccountActivity extends OmemoActivity
 
         final String bannerUrl = mAccount.getBanner();
         if (bannerUrl != null) {
-            tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(bannerUrl, binding.miniProfileBanner);
+            tel.xmpp.jabjab.ui.util.BannerImageLoader.load(
+                    this, bannerUrl, mAccount.getBannerMime(), binding.miniProfileBanner);
         }
         mAccount.getXmppConnection()
                 .getManager(tel.xmpp.jabjab.xmpp.manager.BannerManager.class)
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java
index 0078003..dab83d3 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditProfileActivity.java
@@ -64,7 +64,8 @@ public class EditProfileActivity extends XmppActivity
         final String url = account.getBanner();
         if (url != null) {
             runOnUiThread(() ->
-                    tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(url, profileBanner));
+                    tel.xmpp.jabjab.ui.util.BannerImageLoader.load(
+                            this, url, account.getBannerMime(), profileBanner));
         }
     }
 
@@ -238,7 +239,8 @@ public class EditProfileActivity extends XmppActivity
         bannerManager.fetchAndCache(account.getJid().asBareJid());
         final String cached = account.getBanner();
         if (cached != null) {
-            tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(cached, profileBanner);
+            tel.xmpp.jabjab.ui.util.BannerImageLoader.load(
+                    this, cached, account.getBannerMime(), profileBanner);
         }
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java
index 8717859..698b70c 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/PublishBannerActivity.java
@@ -3,14 +3,20 @@ package tel.xmpp.jabjab.ui;
 import android.content.Intent;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
+import android.graphics.ImageDecoder;
+import android.graphics.drawable.AnimatedImageDrawable;
+import android.graphics.drawable.Drawable;
 import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 import android.util.Log;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
 import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
 import com.canhub.cropper.CropImageContract;
@@ -45,6 +51,12 @@ public class PublishBannerActivity extends XmppActivity {
     private Account account;
     private final ExecutorService executor = Executors.newSingleThreadExecutor();
 
+    // Set only for an animated (GIF / animated WebP) source — bypasses the crop step
+    // entirely, since CropImageContract always outputs a single static bitmap and would
+    // silently flatten the animation. Already-copied local file, ready to upload as-is.
+    private File animatedSourceFile;
+    private String animatedSourceMime;
+
     final ActivityResultLauncher<CropImageContractOptions> cropImage =
             registerForActivityResult(
                     new CropImageContract(),
@@ -54,6 +66,12 @@ public class PublishBannerActivity extends XmppActivity {
                         }
                     });
 
+    // Plain "pick any image" — runs first so we can inspect the source for animation before
+    // deciding whether to route it through the (static-only) cropper or straight to upload.
+    final ActivityResultLauncher<String> pickImage =
+            registerForActivityResult(
+                    new ActivityResultContracts.GetContent(), this::onSourceImagePicked);
+
     @Override
     public void onCreate(final Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -128,17 +146,83 @@ public class PublishBannerActivity extends XmppActivity {
     }
 
     private void pickBanner() {
-        final var cropImageOptions = new CropImageOptions();
-        // Wide banner aspect ratio — matches the header's actual display proportions
-        // (full device width x 200dp; ~2:1 approximates that across common phone widths,
-        // updated from the old 3:1 which was calibrated for the header's previous 140dp
-        // height).
-        cropImageOptions.aspectRatioX = 2;
-        cropImageOptions.aspectRatioY = 1;
-        cropImageOptions.fixAspectRatio = true;
-        cropImageOptions.outputCompressFormat = Bitmap.CompressFormat.JPEG;
-        cropImageOptions.imageSourceIncludeCamera = false;
-        this.cropImage.launch(new CropImageContractOptions(null, cropImageOptions));
+        this.animatedSourceFile = null;
+        this.animatedSourceMime = null;
+        pickImage.launch("image/*");
+    }
+
+    /**
+     * Runs before deciding whether to crop: copies the picked source to a local file (needed
+     * either way — ImageDecoder requires a File, and the crop library needs a stable local
+     * source too) and checks whether it has more than one frame. {@link AnimatedImageDrawable}
+     * detection (API 28+) is the only reliable signal for "is this actually animated" — a
+     * content-type/extension check can't distinguish a static WebP from an animated one, and
+     * mirrors the same detection MessageAdapter.loadAnimatedGif() already relies on for GIFs in
+     * chat bubbles. Below API 28, or on any decode failure, always falls back to the existing
+     * crop-and-publish-static flow — animated banners are simply unsupported there.
+     */
+    private void onSourceImagePicked(final Uri uri) {
+        if (uri == null) return;
+        Log.d(Config.LOGTAG, "BANNER source picked uri=" + uri);
+        executor.execute(() -> {
+            final File tempFile = new File(getCacheDir(), "banner_src_" + System.currentTimeMillis());
+            boolean animated = false;
+            String mime = getContentResolver().getType(uri);
+            try {
+                copyUriToFile(uri, tempFile);
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+                    final Drawable drawable =
+                            ImageDecoder.decodeDrawable(ImageDecoder.createSource(tempFile));
+                    animated = drawable instanceof AnimatedImageDrawable;
+                }
+            } catch (final Exception e) {
+                Log.d(Config.LOGTAG, "BANNER animation probe failed, treating as static", e);
+                animated = false;
+            }
+            Log.d(Config.LOGTAG, "BANNER source animated=" + animated + " mime=" + mime
+                    + " sdk=" + Build.VERSION.SDK_INT);
+            final boolean isAnimated = animated;
+            final String finalMime = mime;
+            runOnUiThread(() -> {
+                if (isAnimated) {
+                    this.animatedSourceFile = tempFile;
+                    this.animatedSourceMime = finalMime != null ? finalMime : "image/gif";
+                    this.bannerUri = uri;
+                    loadAnimatedPreview(tempFile);
+                } else {
+                    tempFile.delete();
+                    final var cropImageOptions = new CropImageOptions();
+                    // Wide banner aspect ratio — matches the header's actual display
+                    // proportions (full device width x 200dp; ~2:1 approximates that across
+                    // common phone widths, updated from the old 3:1 which was calibrated for
+                    // the header's previous 140dp height).
+                    cropImageOptions.aspectRatioX = 2;
+                    cropImageOptions.aspectRatioY = 1;
+                    cropImageOptions.fixAspectRatio = true;
+                    cropImageOptions.outputCompressFormat = Bitmap.CompressFormat.JPEG;
+                    cropImageOptions.imageSourceIncludeCamera = false;
+                    this.cropImage.launch(new CropImageContractOptions(uri, cropImageOptions));
+                }
+            });
+        });
+    }
+
+    @RequiresApi(Build.VERSION_CODES.P)
+    private void loadAnimatedPreview(final File file) {
+        try {
+            final Drawable drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(file));
+            binding.bannerImage.setImageDrawable(drawable);
+            if (drawable instanceof AnimatedImageDrawable animated) {
+                animated.setRepeatCount(AnimatedImageDrawable.REPEAT_INFINITE);
+                animated.start();
+            }
+            binding.hintOrWarning.setVisibility(View.GONE);
+            binding.publishButton.setEnabled(true);
+        } catch (final IOException e) {
+            Log.d(Config.LOGTAG, "BANNER failed to load animated preview", e);
+            binding.hintOrWarning.setVisibility(View.VISIBLE);
+            binding.hintOrWarning.setText(R.string.error_publish_banner_converting);
+        }
     }
 
     private void onBannerPicked(final Uri uri) {
@@ -176,8 +260,15 @@ public class PublishBannerActivity extends XmppActivity {
         binding.publishButton.setEnabled(false);
         executor.execute(() -> {
             try {
-                final File dest = new File(getCacheDir(), "banner_" + System.currentTimeMillis() + ".jpg");
-                copyUriToFile(uri, dest);
+                final File dest;
+                final boolean animated = this.animatedSourceFile != null;
+                if (animated) {
+                    // Already copied+probed in onSourceImagePicked() — no crop step to redo.
+                    dest = this.animatedSourceFile;
+                } else {
+                    dest = new File(getCacheDir(), "banner_" + System.currentTimeMillis() + ".jpg");
+                    copyUriToFile(uri, dest);
+                }
                 if (dest.length() > MAX_BANNER_BYTES) {
                     dest.delete();
                     runOnUiThread(() -> {
@@ -187,10 +278,16 @@ public class PublishBannerActivity extends XmppActivity {
                     });
                     return;
                 }
+                Log.d(Config.LOGTAG, "BANNER publishing animated=" + animated
+                        + " mime=" + (animated ? animatedSourceMime : "image/jpeg")
+                        + " sizeBytes=" + dest.length());
                 Futures.addCallback(
                         account.getXmppConnection()
                                 .getManager(BannerManager.class)
-                                .uploadAndPublish(dest, "image/jpeg"),
+                                .uploadAndPublish(
+                                        dest,
+                                        animated ? animatedSourceMime : "image/jpeg",
+                                        animated),
                         new FutureCallback<Void>() {
                             @Override
                             public void onSuccess(final Void result) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
index 25f0dd7..3ea6321 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
@@ -127,6 +127,11 @@ public class StoriesActivity extends XmppActivity
                             "translation_backend_priority");
                     startActivity(intent);
                 });
+        featureBanner.registerBanner(
+                "animated_banner_feature",
+                R.drawable.ic_photo_24dp,
+                getString(R.string.animated_banner_feature_banner_text),
+                () -> tel.xmpp.jabjab.utils.AccountUtils.launchEditProfile(this));
 
         final ImageButton storiesMenuBtn = findViewById(R.id.stories_menu_btn);
         storiesMenuBtn.setOnClickListener(v -> {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
index bfaaa43..e8d8d5a 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
@@ -1194,7 +1194,12 @@ public abstract class XmppActivity extends ActionBarActivity {
                 final AsyncDrawable asyncDrawable = new AsyncDrawable(getResources(), null, task);
                 imageView.setImageDrawable(asyncDrawable);
                 try {
-                    task.execute(message);
+                    // THREAD_POOL_EXECUTOR, not the default execute() — the default runs on
+                    // AsyncTask's single shared SERIAL_EXECUTOR app-wide, the same queue
+                    // AvatarWorkerTask used to use. A slow thumbnail decode here (video frame,
+                    // PDF render, big image) blocked that entire shared queue and starved
+                    // avatar loading indefinitely elsewhere in the app.
+                    task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, message);
                 } catch (final RejectedExecutionException ignored) {
                     ignored.printStackTrace();
                 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java b/src/main/java/tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java
index 424f973..ff85639 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java
@@ -6,8 +6,10 @@ import android.graphics.Bitmap;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.os.AsyncTask;
+import android.util.Log;
 import android.widget.ImageView;
 import androidx.annotation.DimenRes;
+import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Account;
 import tel.xmpp.jabjab.entities.Message;
@@ -29,12 +31,19 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
     @Override
     protected Bitmap doInBackground(AvatarService.Avatar... params) {
         this.avatar = params[0];
+        final long start = System.currentTimeMillis();
         final XmppActivity activity = XmppActivity.find(imageViewReference);
         if (activity == null) {
+            Log.d(Config.LOGTAG, "AVATAR_LOAD doInBackground abort: activity gone, avatar="
+                    + avatar.getDisplayName());
             return null;
         }
-        return activity.avatarService()
+        final Bitmap bm = activity.avatarService()
                 .get(avatar, (int) activity.getResources().getDimension(size), isCancelled());
+        Log.d(Config.LOGTAG, "AVATAR_LOAD doInBackground done avatar=" + avatar.getDisplayName()
+                + " result=" + (bm != null) + " tookMs=" + (System.currentTimeMillis() - start)
+                + " thread=" + Thread.currentThread().getName());
+        return bm;
     }
 
     @Override
@@ -42,9 +51,17 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
         if (bitmap != null && !isCancelled()) {
             final ImageView imageView = imageViewReference.get();
             if (imageView != null) {
+                Log.d(Config.LOGTAG, "AVATAR_LOAD onPostExecute applying bitmap avatar="
+                        + avatar.getDisplayName());
                 imageView.setImageBitmap(bitmap);
                 imageView.setBackgroundColor(0x00000000);
+            } else {
+                Log.d(Config.LOGTAG, "AVATAR_LOAD onPostExecute skip: imageView gone avatar="
+                        + avatar.getDisplayName());
             }
+        } else {
+            Log.d(Config.LOGTAG, "AVATAR_LOAD onPostExecute skip: bitmap=" + (bitmap != null)
+                    + " cancelled=" + isCancelled() + " avatar=" + avatar.getDisplayName());
         }
     }
 
@@ -79,6 +96,8 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
         if (cancelPotentialWork(avatar, imageView)) {
             final XmppActivity activity = XmppActivity.find(imageView);
             if (activity == null) {
+                Log.d(Config.LOGTAG, "AVATAR_LOAD loadAvatar abort: no activity, avatar="
+                        + avatar.getDisplayName());
                 return;
             }
             final Bitmap bm =
@@ -86,10 +105,13 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
                             .get(avatar, (int) activity.getResources().getDimension(size), true);
             setContentDescription(avatar, imageView);
             if (bm != null) {
+                Log.d(Config.LOGTAG, "AVATAR_LOAD cache-hit avatar=" + avatar.getDisplayName());
                 cancelPotentialWork(avatar, imageView);
                 imageView.setImageBitmap(bm);
                 imageView.setBackgroundColor(0x00000000);
             } else {
+                Log.d(Config.LOGTAG, "AVATAR_LOAD cache-miss, queuing task avatar="
+                        + avatar.getDisplayName());
                 imageView.setBackgroundColor(avatar.getAvatarBackgroundColor());
                 imageView.setImageDrawable(null);
                 final AvatarWorkerTask task = new AvatarWorkerTask(imageView, size);
@@ -97,7 +119,13 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
                         new AsyncDrawable(activity.getResources(), null, task);
                 imageView.setImageDrawable(asyncDrawable);
                 try {
-                    task.execute(avatar);
+                    // THREAD_POOL_EXECUTOR, not the default execute() — that uses AsyncTask's
+                    // single shared SERIAL_EXECUTOR app-wide, which is the same queue
+                    // XmppActivity.BitmapWorkerTask uses for message-image/sticker thumbnail
+                    // decoding. A slow thumbnail decode ahead of this in that shared queue was
+                    // starving avatar loading indefinitely (worst on screens with nothing
+                    // cached yet, e.g. a freshly opened channel list).
+                    task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, avatar);
                 } catch (final RejectedExecutionException ignored) {
                 }
             }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java b/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java
new file mode 100644
index 0000000..5de64a7
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java
@@ -0,0 +1,73 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import android.graphics.ImageDecoder;
+import android.graphics.drawable.AnimatedImageDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.widget.ImageView;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.Executors;
+import tel.xmpp.jabjab.Config;
+
+/**
+ * Shared render-side loader for profile banners (ContactDetailsActivity, EditProfileActivity,
+ * EditAccountActivity all show the same banner concept — this is the one place all of them call
+ * into, instead of duplicating the animated-vs-static decision four times).
+ *
+ * A banner with a non-null mime (see Banner.isAnimated()) is downloaded to a local file and
+ * decoded via ImageDecoder so multi-frame WebP/GIF actually animates — LinkPreviewFetcher's
+ * plain BitmapFactory-based path only ever keeps the first frame, which would silently flatten
+ * it exactly like the (now-fixed) server-side bug this mirrors. Static banners, or any decode
+ * failure, fall back to the existing LinkPreviewFetcher.loadImage() path unchanged.
+ */
+public final class BannerImageLoader {
+
+    private BannerImageLoader() {}
+
+    public static void load(
+            final Context context, final String url, final String mime, final ImageView imageView) {
+        Log.d(Config.LOGTAG, "BANNER load url=" + url + " mime=" + mime);
+        if (url == null) return;
+        final boolean animated = mime != null && !mime.isEmpty();
+        if (!animated || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+            LinkPreviewFetcher.loadImage(url, imageView);
+            return;
+        }
+        Executors.newSingleThreadExecutor().execute(() -> loadAnimated(context, url, imageView));
+    }
+
+    private static void loadAnimated(final Context context, final String url, final ImageView imageView) {
+        final File dest = new File(context.getCacheDir(), "banner_preview_" + Math.abs(url.hashCode()));
+        final Handler main = new Handler(Looper.getMainLooper());
+        try {
+            final var request = new okhttp3.Request.Builder().url(url).build();
+            try (var response = LinkPreviewFetcher.client(context).newCall(request).execute()) {
+                if (!response.isSuccessful() || response.body() == null) {
+                    throw new IOException("banner download http=" + response.code());
+                }
+                try (var in = response.body().byteStream();
+                        var out = java.nio.file.Files.newOutputStream(dest.toPath())) {
+                    in.transferTo(out);
+                }
+            }
+            final Drawable drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(dest));
+            Log.d(Config.LOGTAG, "BANNER animated decode ok, animated="
+                    + (drawable instanceof AnimatedImageDrawable));
+            main.post(() -> {
+                imageView.setImageDrawable(drawable);
+                if (drawable instanceof AnimatedImageDrawable animatedDrawable) {
+                    animatedDrawable.setRepeatCount(AnimatedImageDrawable.REPEAT_INFINITE);
+                    animatedDrawable.start();
+                }
+            });
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "BANNER animated decode failed, falling back to static", e);
+            main.post(() -> LinkPreviewFetcher.loadImage(url, imageView));
+        }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/utils/AccountUtils.java b/src/main/java/tel/xmpp/jabjab/utils/AccountUtils.java
index a1816a0..2ef87cd 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/AccountUtils.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/AccountUtils.java
@@ -108,6 +108,25 @@ public class AccountUtils {
         xmppActivity.switchToAccount(account);
     }
 
+    /**
+     * Opens EditProfileActivity for whichever account the user picks — auto-selects if only
+     * one enabled account exists (the common case), otherwise shows the same account-chooser
+     * dialog used elsewhere (AccountPickerDialog.Enabled). Callers that aren't already
+     * account-scoped (e.g. a cross-screen "new feature" banner) should go through this rather
+     * than guessing an account, since JabJab is always multi-account.
+     */
+    public static void launchEditProfile(final XmppActivity xmppActivity) {
+        new tel.xmpp.jabjab.ui.widget.AccountPickerDialog.Enabled(xmppActivity)
+                .pick(account -> {
+                    final Intent intent =
+                            new Intent(xmppActivity, tel.xmpp.jabjab.ui.EditProfileActivity.class);
+                    intent.putExtra(
+                            tel.xmpp.jabjab.ui.EditProfileActivity.EXTRA_ACCOUNT,
+                            account.getJid().asBareJid().toString());
+                    xmppActivity.startActivity(intent);
+                });
+    }
+
     private static Class<?> getManageAccountActivityClass() {
         try {
             return Class.forName("tel.xmpp.jabjab.ui.ManageAccountActivity");
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java
index 93e305f..666b381 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/BannerManager.java
@@ -57,32 +57,35 @@ public class BannerManager extends AbstractManager {
         final var bareJid = from.asBareJid();
         final var account = getAccount();
         final String url = banner == null ? null : banner.getUrl();
+        final String mime = banner == null ? null : banner.getMime();
+        Log.d(Config.LOGTAG, "BANNER applyToContact from=" + from + " url=" + url
+                + " mime=" + mime + " animated=" + (banner != null && banner.isAnimated()));
         if (account.getJid().asBareJid().equals(bareJid)) {
-            if (account.setBanner(url)) {
+            if (account.setBanner(url, mime)) {
                 service.updateAccountUi();
             }
             return;
         }
         final var contact = getManager(RosterManager.class).getContact(bareJid);
-        if (contact.setBanner(url)) {
+        if (contact.setBanner(url, mime)) {
             getManager(RosterManager.class).writeToDatabaseAsync();
             service.updateRosterUi();
         }
     }
 
     public ListenableFuture<Void> publishBanner(
-            final String url, final String sha1, final int width, final int height) {
+            final String url, final String mime, final String sha1, final int width, final int height) {
         final var now = DateTimeFormatter.ISO_INSTANT.format(Instant.now());
         final var selfJid = getAccount().getJid().asBareJid();
-        final var entry = Banner.toEntry(url, sha1, width, height, now);
+        final var entry = Banner.toEntry(url, mime, sha1, width, height, now);
         final var pubSubManager = getManager(PubSubManager.class);
-        Log.d(Config.LOGTAG, "BANNER publishing url=" + url + " node=" + BANNER_NODE);
+        Log.d(Config.LOGTAG, "BANNER publishing url=" + url + " mime=" + mime + " node=" + BANNER_NODE);
         return Futures.transform(
                 pubSubManager.publishSingleton(selfJid, entry, BANNER_NODE, NodeConfiguration.PRESENCE),
                 v -> {
                     Log.d(Config.LOGTAG, "BANNER publish succeeded");
                     final var account = getAccount();
-                    if (account.setBanner(url)) {
+                    if (account.setBanner(url, mime)) {
                         service.updateAccountUi();
                     }
                     return v;
@@ -145,6 +148,21 @@ public class BannerManager extends AbstractManager {
      * there; no permanent-category guarantee off xmpp.tel).
      */
     public ListenableFuture<Void> uploadAndPublish(final File file, final String mime) {
+        return uploadAndPublish(file, mime, false);
+    }
+
+    /**
+     * @param animated Whether the local source file has multiple frames (GIF or animated WebP,
+     *     detected client-side by the caller — see PublishBannerActivity). The server
+     *     re-encodes to animated WebP and preserves all frames regardless of this flag; it only
+     *     controls whether {@code mime} is also published in the PubSub entry's {@code type}
+     *     attribute, which is how other clients know to route this banner through an animated
+     *     decode path instead of treating it as a plain static image.
+     */
+    public ListenableFuture<Void> uploadAndPublish(
+            final File file, final String mime, final boolean animated) {
+        Log.d(Config.LOGTAG, "BANNER uploadAndPublish mime=" + mime + " animated=" + animated
+                + " sizeBytes=" + file.length());
         final var selfJid = getAccount().getJid().asBareJid();
         final ListenableFuture<okhttp3.HttpUrl> uploadFuture;
         if (Config.UPLOAD_BYPASS_DOMAIN.equals(selfJid.getDomain().toString())) {
@@ -174,7 +192,10 @@ public class BannerManager extends AbstractManager {
                             .setQueryParameter("v", String.valueOf(System.currentTimeMillis()))
                             .build()
                             .toString();
-                    return publishBanner(cacheBustedUrl, null, 0, 0);
+                    // Always re-encoded to image/webp server-side (animated or not) — this is
+                    // what's actually served, so it's what we publish, not the source mime.
+                    final String publishedMime = animated ? "image/webp" : null;
+                    return publishBanner(cacheBustedUrl, publishedMime, null, 0, 0);
                 },
                 MoreExecutors.directExecutor());
     }
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 93809ff..ad963a9 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1581,6 +1581,7 @@
     <string name="offline_translator_fetching">Fetching latest release…</string>
     <string name="offline_translator_apk_not_found">Couldn\'t find an APK in the latest release — opening the releases page instead</string>
     <string name="translation_feature_banner_text">New On-Device offline translation feature added. Tap here to find out more and how to set it up.</string>
+    <string name="animated_banner_feature_banner_text">Profile banners can now be animated (GIF/WebP). Tap here to set one up.</string>
     <string name="dismiss">Dismiss</string>
     <string name="translation_backend_private">Private (Qwen3.6)</string>
     <string name="translation_backend_offline_device">On-device (offline-translator app)</string>

Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.

Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.