🔀 Commit

fixed sticker upload paths, added more mime-type/extensions
Commit11eee094f3c92cdba5d1aadd8b43da311d678459
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-06
Parent2ad7ec65
commit 11eee094f3c92cdba5d1aadd8b43da311d678459
Author: JabJab <noreply@xmpp.tel>
Date:   Mon Jul 6 12:33:20 2026 +0300

    fixed sticker upload paths, added more mime-type/extensions
---
 build.gradle                                       |  2 +-
 .../tel/xmpp/jabjab/http/HttpUploadConnection.java |  8 +-
 .../jabjab/services/XmppConnectionService.java     | 47 +++++++++--
 .../tel/xmpp/jabjab/ui/ConversationFragment.java   | 13 ++-
 .../java/tel/xmpp/jabjab/ui/StoriesActivity.java   |  3 +-
 .../java/tel/xmpp/jabjab/ui/StoryViewActivity.java | 15 ++--
 .../xmpp/jabjab/ui/util/ChannelMessageParser.java  | 47 +++++++++++
 .../xmpp/jabjab/ui/util/LinkPreviewFetcher.java    | 92 +++++++++++++++++++---
 .../java/tel/xmpp/jabjab/ui/util/StickerStore.java | 43 +++++++---
 src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java |  9 +++
 10 files changed, 238 insertions(+), 41 deletions(-)

diff --git a/build.gradle b/build.gradle
index b4ecaf1..d85cbb8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42289
+        versionCode 42290
         versionName "1.0.1"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
diff --git a/src/main/java/tel/xmpp/jabjab/http/HttpUploadConnection.java b/src/main/java/tel/xmpp/jabjab/http/HttpUploadConnection.java
index 0a89e16..dd4aba0 100644
--- a/src/main/java/tel/xmpp/jabjab/http/HttpUploadConnection.java
+++ b/src/main/java/tel/xmpp/jabjab/http/HttpUploadConnection.java
@@ -201,7 +201,8 @@ public class HttpUploadConnection
                         if (code == 200 || code == 201) {
                             final var file = upload.file;
                             final var transportSecurity = upload.transportSecurity();
-                            Log.d(Config.LOGTAG, "finished uploading file");
+                            Log.d(Config.LOGTAG, "STICKER finished uploading file uuid=" + message.getUuid()
+                                    + " stickerPath=" + message.getStickerFilePath());
                             final String get;
                             if (transportSecurity != null) {
                                 get =
@@ -228,7 +229,10 @@ public class HttpUploadConnection
                                 if (sf.exists()) {
                                     final tel.xmpp.jabjab.ui.util.StickerStore.Pack pack =
                                             tel.xmpp.jabjab.ui.util.StickerStore.packFromDir(sf.getParentFile());
-                                    tel.xmpp.jabjab.ui.util.StickerStore.setStickerUploadUrl(pack, sf, get);
+                                    final String accountJid = message.getConversation()
+                                            .getAccount().getJid().asBareJid().toString();
+                                    tel.xmpp.jabjab.ui.util.StickerStore.setStickerUploadUrl(
+                                            pack, sf, get, accountJid);
                                 }
                             }
                             finish();
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index 0c7938f..5f59481 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -463,15 +463,25 @@ public class XmppConnectionService extends Service {
         }
         message.setStickerPackNode(stickerPackNode);
         message.setStickerFilePath(stickerFilePath);
+        Log.d(Config.LOGTAG, "STICKER attachStickerToConversation:"
+                + " uuid=" + message.getUuid()
+                + " encryption=" + message.getEncryption()
+                + " uri=" + uri
+                + " packNode=" + stickerPackNode
+                + " filePath=" + stickerFilePath);
 
         // Check for a cached upload URL to avoid re-uploading the same sticker image
         if (stickerFilePath != null) {
             final java.io.File stickerFile = new java.io.File(stickerFilePath);
+            Log.d(Config.LOGTAG, "STICKER stickerFile exists=" + stickerFile.exists()
+                    + " path=" + stickerFile.getAbsolutePath());
             if (stickerFile.exists()) {
                 final tel.xmpp.jabjab.ui.util.StickerStore.Pack pack =
                         tel.xmpp.jabjab.ui.util.StickerStore.packFromDir(stickerFile.getParentFile());
                 final String cachedUrl =
-                        tel.xmpp.jabjab.ui.util.StickerStore.getStickerUploadUrl(pack, stickerFile);
+                        tel.xmpp.jabjab.ui.util.StickerStore.getStickerUploadUrl(pack, stickerFile,
+                                conversation.getAccount().getJid().asBareJid().toString());
+                Log.d(Config.LOGTAG, "STICKER cachedUrl=" + cachedUrl);
                 if (cachedUrl != null) {
                     // Copy the sticker into private storage and build the full
                     // url|size|width|height body so the sender's message has a
@@ -482,25 +492,36 @@ public class XmppConnectionService extends Service {
                                 try {
                                     getFileBackend().copyFileToPrivateStorage(
                                             message, uri, "image/webp");
+                                    Log.d(Config.LOGTAG, "STICKER file copy OK, updating params with cachedUrl");
                                 } catch (final tel.xmpp.jabjab.persistance.FileBackend.FileCopyException e) {
                                     Log.w(Config.LOGTAG,
-                                            "cached sticker: file copy failed, sending bare url");
+                                            "STICKER cached sticker: file copy failed, sending bare url: " + e.getMessage());
                                 }
                                 getFileBackend().updateFileParams(message, finalCachedUrl);
+                                Log.d(Config.LOGTAG, "STICKER message body after updateFileParams=" + message.getBody());
                             },
                             FILE_ATTACHMENT_EXECUTOR);
                     return Futures.transformAsync(
                             copyFuture,
-                            v -> encryptIfNeededAndSend(message),
+                            v -> {
+                                Log.d(Config.LOGTAG, "STICKER cached path: calling encryptIfNeededAndSend");
+                                return encryptIfNeededAndSend(message);
+                            },
                             MoreExecutors.directExecutor());
                 }
             }
         }
 
         // No cached URL — copy+attach file; HttpUploadConnection will cache the URL after upload
+        Log.d(Config.LOGTAG, "STICKER no cached URL, submitting fresh upload");
         final var future = submitAttachToConversation(uri, "image/webp", message);
         return Futures.transformAsync(
-                future, v -> encryptIfNeededAndSend(message), MoreExecutors.directExecutor());
+                future,
+                v -> {
+                    Log.d(Config.LOGTAG, "STICKER upload path: calling encryptIfNeededAndSend, body=" + message.getBody());
+                    return encryptIfNeededAndSend(message);
+                },
+                MoreExecutors.directExecutor());
     }
 
     private ListenableFuture<Void> submitAttachToConversation(
@@ -1795,7 +1816,20 @@ public class XmppConnectionService extends Service {
             availableCandidates =
                     Futures.transformAsync(
                             candidates,
-                            urls -> mHttpConnectionManager.checkAvailability(account, urls),
+                            urls -> {
+                                // Reject stale prosody-filer URLs on upload.xmpp.tel — the old
+                                // server used /upload/, the new one uses /files/. Only scope to
+                                // our own domain so other servers using /upload/ are unaffected.
+                                final var filtered = urls.stream()
+                                        .filter(u -> !u.contains("upload.xmpp.tel/upload/"))
+                                        .collect(java.util.stream.Collectors.toList());
+                                if (filtered.size() < urls.size()) {
+                                    Log.d(Config.LOGTAG, "sendFileMessage: dropped "
+                                            + (urls.size() - filtered.size())
+                                            + " stale prosody-filer URL(s)");
+                                }
+                                return mHttpConnectionManager.checkAvailability(account, filtered);
+                            },
                             MoreExecutors.directExecutor());
         } else {
             // PGP files are encrypted per recipient
@@ -1949,7 +1983,8 @@ public class XmppConnectionService extends Service {
                                 if (pack != null) {
                                     final File sf = new File(filePath);
                                     if (sf.exists()) {
-                                        StickerStore.setStickerUploadUrl(pack, sf, uploadUrl);
+                                        StickerStore.setStickerUploadUrl(pack, sf, uploadUrl,
+                                                account.getJid().asBareJid().toString());
                                     }
                                     publishStickerPack(account, pack);
                                 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 149161c..f4b9ffe 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -1573,10 +1573,17 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void translateMessage(final Message message) {
-        final String body = message.getBody();
+        String body = message.getBody();
         if (Strings.isNullOrEmpty(body)) {
             return;
         }
+        // Strip markdown for channel cards so the translator sees clean prose
+        if (tel.xmpp.jabjab.ui.util.ChannelMessageParser.isChannelMessage(body)) {
+            body = tel.xmpp.jabjab.ui.util.ChannelMessageParser.toPlainText(
+                    tel.xmpp.jabjab.ui.util.ChannelMessageParser.parse(body));
+            if (Strings.isNullOrEmpty(body)) return;
+        }
+        final String textToTranslate = body;
         final var progress =
                 new MaterialAlertDialogBuilder(requireActivity())
                         .setTitle(R.string.translation_title)
@@ -1586,7 +1593,7 @@ public class ConversationFragment extends XmppFragment
         final String targetLang = java.util.Locale.getDefault().getLanguage();
         tel.xmpp.jabjab.ui.util.TranslationService.translate(
                 requireContext(),
-                body,
+                textToTranslate,
                 targetLang,
                 new tel.xmpp.jabjab.ui.util.TranslationService.Callback() {
                     @Override
@@ -1594,7 +1601,7 @@ public class ConversationFragment extends XmppFragment
                             final String translatedText, @Nullable final String detectedSourceLang) {
                         if (!isAdded()) return;
                         progress.dismiss();
-                        showTranslationResult(body, translatedText);
+                        showTranslationResult(textToTranslate, translatedText);
                     }
 
                     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
index 9e37911..1054e32 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoriesActivity.java
@@ -617,7 +617,8 @@ public class StoriesActivity extends XmppActivity
                 target.setImageBitmap(cached);
                 return;
             }
-            // Tag the view so stale async results are dropped on recycle
+            // Clear stale bitmap and tag so a recycled view doesn't show the wrong story
+            target.setImageBitmap(null);
             target.setTag(url);
             thumbnailExecutor.execute(() -> {
                 Bitmap frame = null;
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
index 9ebeee3..e12d1c0 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StoryViewActivity.java
@@ -91,6 +91,7 @@ public class StoryViewActivity extends XmppActivity {
     private Runnable advanceRunnable;
     private AnimatedImageDrawable currentGif;
     private long pausedAt = 0;
+    private long playResumedAt = 0;  // wall-clock ms when current play segment started
     private long remainingMs = STORY_DURATION_MS;
     private long currentTotalMs = STORY_DURATION_MS;
     private OkHttpClient httpClient;
@@ -528,6 +529,7 @@ public class StoryViewActivity extends XmppActivity {
         currentAnimator = ObjectAnimator.ofFloat(fill, "scaleX", startScale, 1f);
         currentAnimator.setDuration(remainingMs);
         currentAnimator.start();
+        playResumedAt = System.currentTimeMillis();
 
         advanceRunnable = () -> { if (!isDestroyed()) showStory(currentIndex + 1); };
         handler.postDelayed(advanceRunnable, remainingMs);
@@ -535,16 +537,19 @@ public class StoryViewActivity extends XmppActivity {
 
     private void togglePause() {
         if (isPaused) {
-            // Resume
+            // Resume — resume the existing animator in-place to avoid any visual jump
             isPaused = false;
             btnPause.setImageResource(R.drawable.ic_pause_24dp);
-            final long elapsed = System.currentTimeMillis() - pausedAt;
-            remainingMs = Math.max(0, remainingMs - elapsed);
             if (currentIsVideo) videoView.start();
-            startProgressAndAdvance(currentTotalMs, remainingMs);
+            if (currentAnimator != null) currentAnimator.resume();
+            playResumedAt = System.currentTimeMillis();
+            advanceRunnable = () -> { if (!isDestroyed()) showStory(currentIndex + 1); };
+            handler.postDelayed(advanceRunnable, remainingMs);
         } else {
-            // Pause
+            // Pause — snapshot exactly how much time is left based on how long we've played
             isPaused = true;
+            final long played = System.currentTimeMillis() - playResumedAt;
+            remainingMs = Math.max(0, remainingMs - played);
             pausedAt = System.currentTimeMillis();
             btnPause.setImageResource(R.drawable.ic_play_arrow_24dp);
             if (videoView.isPlaying()) videoView.pause();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java b/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java
index a2169e4..8331702 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/ChannelMessageParser.java
@@ -34,6 +34,53 @@ public class ChannelMessageParser {
                 || BLOCKQUOTE.matcher(body).find();
     }
 
+    /**
+     * Returns a clean plain-text string suitable for translation or clipboard copy.
+     * Structural elements (image, heading, blockquote, date) are handled by parse();
+     * this method additionally strips inline markdown from the body.
+     */
+    public static String toPlainText(final ChannelCard card) {
+        final StringBuilder sb = new StringBuilder();
+        if (card.title() != null && !card.title().isEmpty()) {
+            sb.append(card.title()).append("\n\n");
+        }
+        if (card.body() != null && !card.body().isEmpty()) {
+            sb.append(stripInlineMarkdown(card.body()));
+        }
+        if (card.blockquote() != null && !card.blockquote().isEmpty()) {
+            if (sb.length() > 0) sb.append("\n\n");
+            sb.append(card.blockquote());
+        }
+        return sb.toString().trim();
+    }
+
+    // Strip **bold**, *italic*, _italic_, __italic__, `code`, ```blocks```, +bold+
+    private static final Pattern INLINE_MD = Pattern.compile(
+            "```[\\s\\S]*?```|`([^`]+)`|\\*\\*([^*]+)\\*\\*|\\+([^+]+)\\+|__([^_]+)__|\\*([^*]+)\\*|_([^_]+)_");
+
+    private static String stripInlineMarkdown(final String text) {
+        final java.util.regex.Matcher m = INLINE_MD.matcher(text);
+        final StringBuffer sb = new StringBuffer();
+        while (m.find()) {
+            // For ```block``` replace with the raw block content (group 0 minus fences)
+            final String full = m.group(0);
+            if (full.startsWith("```")) {
+                m.appendReplacement(sb, full.substring(3, full.length() - 3).trim()
+                        .replace("$", "\\$").replace("\\", "\\\\"));
+                continue;
+            }
+            // For all other patterns, use the first non-null capture group (the inner text)
+            String inner = null;
+            for (int i = 1; i <= m.groupCount(); i++) {
+                if (m.group(i) != null) { inner = m.group(i); break; }
+            }
+            m.appendReplacement(sb, (inner != null ? inner : full)
+                    .replace("$", "\\$").replace("\\", "\\\\"));
+        }
+        m.appendTail(sb);
+        return sb.toString();
+    }
+
     public static ChannelCard parse(final String raw) {
         if (raw == null) return new ChannelCard(null, null, "", null, null);
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java b/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
index 8764933..9540ca5 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/LinkPreviewFetcher.java
@@ -13,6 +13,8 @@ import java.io.File;
 import java.io.FileOutputStream;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.Locale;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executor;
@@ -199,13 +201,29 @@ public class LinkPreviewFetcher {
         });
     }
 
-    private static final long THUMB_CACHE_MAX_AGE_MS = TimeUnit.DAYS.toMillis(30);
+    private static final long THUMB_MAX_AGE_MS = TimeUnit.DAYS.toMillis(60);
 
+    // Stored in getFilesDir() (not getCacheDir()) so Android never auto-clears them;
+    // channel card images are permanent since their messages never expire in the DB.
     private static File thumbCacheFile(final ImageView imageView, final String imageUrl) {
-        final File dir = new File(imageView.getContext().getCacheDir(), "link_thumbs");
+        return thumbCacheFile(imageView.getContext(), imageUrl);
+    }
+
+    private static File thumbCacheFile(final Context ctx, final String imageUrl) {
+        final File dir = new File(ctx.getFilesDir(), "link_images");
         dir.mkdirs();
-        final String hex = String.format(Locale.US, "%016x", imageUrl.hashCode());
-        return new File(dir, hex + ".jpg");
+        // SHA-256 avoids the 32-bit hashCode() collision space
+        String hash;
+        try {
+            final byte[] digest = MessageDigest.getInstance("SHA-256")
+                    .digest(imageUrl.getBytes(StandardCharsets.UTF_8));
+            final StringBuilder sb = new StringBuilder(64);
+            for (final byte b : digest) sb.append(String.format("%02x", b));
+            hash = sb.toString();
+        } catch (final NoSuchAlgorithmException e) {
+            hash = String.format(Locale.US, "%016x", (long) imageUrl.hashCode());
+        }
+        return new File(dir, hash + ".jpg");
     }
 
     public static void loadImage(final String imageUrl, final ImageView imageView) {
@@ -222,35 +240,78 @@ public class LinkPreviewFetcher {
         // Show instantly from memory cache — eliminates the reload flicker on tab switches.
         final Bitmap cached = bitmapCache.get(imageUrl);
         if (cached != null) {
+            android.util.Log.d(Config.LOGTAG, "LOADIMAGE mem-hit " + imageUrl);
             imageView.setImageBitmap(cached);
             imageView.setVisibility(View.VISIBLE);
             if (onLoaded != null) onLoaded.run();
             return; // disk + network already up-to-date from the previous load
         }
+        // Clear any stale bitmap from a recycled ViewHolder so it doesn't show while
+        // the correct image loads asynchronously.
+        imageView.setImageBitmap(null);
+        android.util.Log.d(Config.LOGTAG, "LOADIMAGE async-start " + imageUrl);
         executor.execute(() -> {
             try {
                 final File diskCached = thumbCacheFile(imageView, imageUrl);
                 byte[] bytes = null;
                 if (diskCached.exists()
-                        && System.currentTimeMillis() - diskCached.lastModified() < THUMB_CACHE_MAX_AGE_MS) {
+                        && System.currentTimeMillis() - diskCached.lastModified() < THUMB_MAX_AGE_MS) {
+                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE disk-hit " + diskCached.getName()
+                            + " age=" + ((System.currentTimeMillis() - diskCached.lastModified()) / 1000) + "s"
+                            + " url=" + imageUrl);
                     bytes = java.nio.file.Files.readAllBytes(diskCached.toPath());
+                } else if (diskCached.exists()) {
+                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE disk-expired " + diskCached.getName()
+                            + " age=" + ((System.currentTimeMillis() - diskCached.lastModified()) / 1000) + "s"
+                            + " url=" + imageUrl);
                 }
                 if (bytes == null) {
+                    // Skip URLs that previously returned a permanent error (404/410).
+                    // Sentinel expires after 24 h so we retry once a day in case the CDN recovers.
+                    final File sentinel = new File(diskCached.getParent(),
+                            diskCached.getName().replace(".jpg", ".404"));
+                    if (sentinel.exists()
+                            && System.currentTimeMillis() - sentinel.lastModified()
+                                    < TimeUnit.DAYS.toMillis(1)) {
+                        android.util.Log.d(Config.LOGTAG, "LOADIMAGE skip-404-sentinel " + imageUrl);
+                        return;
+                    }
+                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE network-fetch " + imageUrl);
                     final Request request = new Request.Builder()
                             .url(imageUrl)
                             .header("User-Agent", "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36 JabJab/2.20.1")
                             .build();
                     try (Response response =
                             client(imageView.getContext()).newCall(request).execute()) {
-                        if (!response.isSuccessful() || response.body() == null) return;
+                        if (!response.isSuccessful() || response.body() == null) {
+                            android.util.Log.w(Config.LOGTAG, "LOADIMAGE network-fail http="
+                                    + response.code() + " url=" + imageUrl);
+                            // Write sentinel for permanent errors so we don't hammer dead URLs
+                            if (response.code() == 404 || response.code() == 410
+                                    || response.code() == 403) {
+                                try { sentinel.createNewFile(); } catch (Exception ignored) {}
+                            }
+                            // Clear tag so the next render cycle can retry (or hit the sentinel)
+                            mainHandler.post(() -> {
+                                if (imageUrl.equals(imageView.getTag())) imageView.setTag(null);
+                            });
+                            return;
+                        }
                         bytes = response.body().bytes();
+                        android.util.Log.d(Config.LOGTAG, "LOADIMAGE network-ok bytes=" + bytes.length
+                                + " url=" + imageUrl);
                         try (final FileOutputStream fos = new FileOutputStream(diskCached)) {
                             fos.write(bytes);
-                        } catch (Exception ignored) {}
+                        } catch (Exception e) {
+                            android.util.Log.w(Config.LOGTAG, "LOADIMAGE disk-write-fail " + e.getMessage()
+                                    + " url=" + imageUrl);
+                        }
                     }
                 }
                 final Bitmap bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
                 if (bm != null) {
+                    android.util.Log.d(Config.LOGTAG, "LOADIMAGE decoded " + bm.getWidth() + "x" + bm.getHeight()
+                            + " url=" + imageUrl);
                     bitmapCache.put(imageUrl, bm);
                     mainHandler.post(() -> {
                         if (imageUrl.equals(imageView.getTag())) {
@@ -258,14 +319,25 @@ public class LinkPreviewFetcher {
                             imageView.setVisibility(View.VISIBLE);
                             if (onLoaded != null) onLoaded.run();
                             keepScrolledToBottomIfNeeded(imageView);
+                        } else {
+                            android.util.Log.d(Config.LOGTAG, "LOADIMAGE tag-mismatch (view recycled) url=" + imageUrl);
                         }
                     });
                 } else {
-                    // Corrupt cached file — delete it so the next load re-fetches from network.
+                    // Corrupt cached file — delete it and clear tag so next render retries
                     android.util.Log.w(Config.LOGTAG, "PREVIEW corrupt cached image, purging: " + diskCached.getName());
                     diskCached.delete();
+                    mainHandler.post(() -> {
+                        if (imageUrl.equals(imageView.getTag())) imageView.setTag(null);
+                    });
                 }
-            } catch (Exception ignored) {}
+            } catch (Exception e) {
+                // Network error — clear tag so the next render cycle will retry
+                android.util.Log.d(Config.LOGTAG, "PREVIEW loadImage failed for " + imageUrl + ": " + e.getMessage());
+                mainHandler.post(() -> {
+                    if (imageUrl.equals(imageView.getTag())) imageView.setTag(null);
+                });
+            }
         });
     }
 
@@ -303,7 +375,7 @@ public class LinkPreviewFetcher {
                 final File cached = thumbCacheFile(thumbnailView, imageUrl);
                 byte[] bytes = null;
                 if (cached.exists()
-                        && System.currentTimeMillis() - cached.lastModified() < THUMB_CACHE_MAX_AGE_MS) {
+                        && System.currentTimeMillis() - cached.lastModified() < THUMB_MAX_AGE_MS) {
                     bytes = java.nio.file.Files.readAllBytes(cached.toPath());
                 }
                 if (bytes == null) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
index cc4eac2..2833dfc 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
@@ -459,22 +459,35 @@ public class StickerStore {
         return null;
     }
 
-    public static String getStickerUploadUrl(final Pack pack, final File sticker) {
+    // Upload URLs are keyed by sender bare JID so that a sticker sent from account A
+    // on server X never reuses an upload URL that belongs to account B on server Y.
+    public static String getStickerUploadUrl(final Pack pack, final File sticker,
+                                             final String accountJid) {
         final JSONObject meta = readMeta(pack.dir());
         if (meta == null) return null;
         final JSONArray stickers = meta.optJSONArray("stickers");
         if (stickers == null) return null;
         for (int i = 0; i < stickers.length(); i++) {
             final JSONObject entry = stickers.optJSONObject(i);
-            if (entry != null && sticker.getName().equals(entry.optString("file"))) {
-                final String url = entry.optString("uploadUrl", "");
-                return url.isEmpty() ? null : url;
+            if (entry == null || !sticker.getName().equals(entry.optString("file"))) continue;
+            final JSONObject uploadUrls = entry.optJSONObject("uploadUrls");
+            if (uploadUrls == null) return null;
+            final String url = uploadUrls.optString(accountJid, "");
+            if (url.isEmpty()) return null;
+            // Reject stale prosody-filer URLs on upload.xmpp.tel — scoped to our domain
+            // so other servers that legitimately use /upload/ are not affected.
+            if (url.contains("upload.xmpp.tel/upload/")) {
+                Log.d(tel.xmpp.jabjab.Config.LOGTAG,
+                        "StickerStore: evicting stale prosody-filer URL for " + accountJid + ": " + url);
+                return null;
             }
+            return url;
         }
         return null;
     }
 
-    public static void setStickerUploadUrl(final Pack pack, final File sticker, final String url) {
+    public static void setStickerUploadUrl(final Pack pack, final File sticker,
+                                           final String url, final String accountJid) {
         try {
             JSONObject meta = readMeta(pack.dir());
             if (meta == null) meta = buildBaseMeta(pack);
@@ -483,21 +496,25 @@ public class StickerStore {
                 stickers = new JSONArray();
                 meta.put("stickers", stickers);
             }
-            boolean found = false;
+            JSONObject target = null;
             for (int i = 0; i < stickers.length(); i++) {
                 final JSONObject entry = stickers.optJSONObject(i);
                 if (entry != null && sticker.getName().equals(entry.optString("file"))) {
-                    entry.put("uploadUrl", url);
-                    found = true;
+                    target = entry;
                     break;
                 }
             }
-            if (!found) {
-                final JSONObject entry = new JSONObject();
-                entry.put("file", sticker.getName());
-                entry.put("uploadUrl", url);
-                stickers.put(entry);
+            if (target == null) {
+                target = new JSONObject();
+                target.put("file", sticker.getName());
+                stickers.put(target);
+            }
+            JSONObject uploadUrls = target.optJSONObject("uploadUrls");
+            if (uploadUrls == null) {
+                uploadUrls = new JSONObject();
+                target.put("uploadUrls", uploadUrls);
             }
+            uploadUrls.put(accountJid, url);
             writeMeta(pack.dir(), meta);
         } catch (Exception ignored) {}
     }
diff --git a/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java b/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
index d10faab..882e6a8 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
@@ -182,6 +182,12 @@ public final class MimeUtils {
         add("application/x-gnumeric", "gnumeric");
         add("application/x-go-sgf", "sgf");
         add("application/x-graphing-calculator", "gcf");
+        add("application/gzip", "gz");          // IANA RFC 6713; covers .tar.gz via lastExtension
+        add("application/x-gzip", "gz");        // legacy alias, same extension
+        add("application/x-bzip2", "bz2");
+        add("application/x-bzip2", "bz");
+        add("application/x-xz", "xz");
+        add("application/zstd", "zst");
         add("application/x-gtar", "tgz");
         add("application/x-gtar", "gtar");
         add("application/x-gtar", "taz");
@@ -591,6 +597,9 @@ public final class MimeUtils {
         final String mimeTypeFromName = Strings.isNullOrEmpty(name) ? null : guessFromPath(name);
         final String path = uri.getPath();
         final String mimeTypeFromPath = Strings.isNullOrEmpty(path) ? null : guessFromPath(path);
+        Log.d(Config.LOGTAG, "guessMimeTypeFromUri uri=" + uri
+                + " name=" + name + " resolver=" + mimeTypeContentResolver
+                + " fromName=" + mimeTypeFromName + " fromPath=" + mimeTypeFromPath);
         if (PATH_PRECEDENCE_MIME_TYPE.contains(mimeTypeFromName)) {
             return mimeTypeFromName;
         }

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.