commit 359ddbaababe3c08407fab18d65f4b9542a1d38c
Author: JabJab <noreply@xmpp.tel>
Date: Sun Aug 9 18:57:42 2026 +0300
fixed avatars not loading
---
build.gradle | 2 +-
.../xmpp/jabjab/persistance/DatabaseBackend.java | 59 +++++++++++++++++++++-
.../tel/xmpp/jabjab/persistance/FileBackend.java | 16 ++++--
.../tel/xmpp/jabjab/ui/BlogComposerActivity.java | 2 +-
.../tel/xmpp/jabjab/ui/ConversationFragment.java | 22 ++++++--
.../tel/xmpp/jabjab/ui/PinnedMessagesActivity.java | 3 +-
src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java | 21 +++++---
.../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java | 28 +++++++++-
.../tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java | 51 ++++++++++++-------
.../tel/xmpp/jabjab/ui/util/BannerImageLoader.java | 3 +-
.../xmpp/jabjab/xmpp/manager/AvatarManager.java | 8 ++-
11 files changed, 174 insertions(+), 41 deletions(-)
diff --git a/build.gradle b/build.gradle
index cd96fa3..8d10109 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
defaultConfig {
minSdkVersion 23
- versionCode 42308
+ versionCode 42310
versionName "1.0.6"
applicationId "tel.xmpp.jabjab"
resValue "string", "applicationId", applicationId
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
index a7d1b3b..b687ee5 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
@@ -80,7 +80,7 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
public class DatabaseBackend extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "history";
- private static final int DATABASE_VERSION = 59;
+ private static final int DATABASE_VERSION = 60;
private static final String CREATE_STORY_CACHE_TABLE =
"CREATE TABLE IF NOT EXISTS story_cache ("
@@ -94,6 +94,21 @@ public class DatabaseBackend extends SQLiteOpenHelper {
+ "PRIMARY KEY (item_id, account_jid)"
+ ")";
+ // Full-row snapshot of a message taken at pin time, keyed by the same uuid as the
+ // "messages" table. Pinned messages otherwise become permanently unresolvable once the
+ // live row is gone — e.g. expireOldMessages() hard-deletes messages past the user's
+ // retention window with no exemption for pins, silently orphaning the reference stored
+ // on the conversation. "CREATE TABLE ... AS SELECT ... WHERE 0" mirrors the messages
+ // schema exactly (same column set Message.fromCursor() already knows how to read) without
+ // hand-duplicating ~30 column definitions that could drift out of sync.
+ private static final String CREATE_PINNED_MESSAGE_CACHE_TABLE =
+ "CREATE TABLE IF NOT EXISTS pinned_message_cache AS SELECT * FROM "
+ + Message.TABLENAME
+ + " WHERE 0";
+ private static final String CREATE_PINNED_MESSAGE_CACHE_INDEX =
+ "CREATE UNIQUE INDEX IF NOT EXISTS pinned_message_cache_uuid_index ON"
+ + " pinned_message_cache(" + Message.UUID + ")";
+
private static boolean requiresMessageIndexRebuild = false;
private static DatabaseBackend instance = null;
private static final String CREATE_CONTACTS_STATEMENT =
@@ -546,6 +561,8 @@ public class DatabaseBackend extends SQLiteOpenHelper {
db.execSQL(CREATE_MESSAGE_UPDATE_TRIGGER);
db.execSQL(CREATE_MESSAGE_DELETE_TRIGGER);
db.execSQL(CREATE_STORY_CACHE_TABLE);
+ db.execSQL(CREATE_PINNED_MESSAGE_CACHE_TABLE);
+ db.execSQL(CREATE_PINNED_MESSAGE_CACHE_INDEX);
db.execSQL(CREATE_CAPS_CACHE_TABLE);
db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS);
db.execSQL(CREATE_CAPS_CACHE_INDEX_CAPS2);
@@ -1160,6 +1177,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
if (oldVersion < 59 && newVersion >= 59) {
db.execSQL(CREATE_STORY_CACHE_TABLE);
}
+ if (oldVersion < 60 && newVersion >= 60) {
+ db.execSQL(CREATE_PINNED_MESSAGE_CACHE_TABLE);
+ db.execSQL(CREATE_PINNED_MESSAGE_CACHE_INDEX);
+ }
}
private void canonicalizeJids(SQLiteDatabase db) {
@@ -1659,6 +1680,42 @@ public class DatabaseBackend extends SQLiteOpenHelper {
return message;
}
+ /** Snapshots a message's full row into pinned_message_cache so it stays resolvable even
+ * after the live row is gone (e.g. expired by retention cleanup). Call at pin time. */
+ public void cachePinnedMessage(final String uuid) {
+ final var db = this.getWritableDatabase();
+ db.execSQL(
+ "INSERT OR REPLACE INTO pinned_message_cache SELECT * FROM "
+ + Message.TABLENAME
+ + " WHERE "
+ + Message.UUID
+ + "=?",
+ new Object[] {uuid});
+ }
+
+ /** Removes a message's cached snapshot. Call when the message is unpinned. */
+ public void deletePinnedMessageCache(final String uuid) {
+ final var db = this.getWritableDatabase();
+ db.delete("pinned_message_cache", Message.UUID + "=?", new String[] {uuid});
+ }
+
+ /** Resolves a pinned message: live row first (fresh edits/reactions), falling back to the
+ * pin-time snapshot if the live row has since been deleted. */
+ public Message resolvePinnedMessage(final Conversation conversation, final String uuid) {
+ final Message live = getMessageWithUuidOrRemoteId(conversation, uuid);
+ if (live != null) {
+ return live;
+ }
+ final var db = this.getReadableDatabase();
+ final var sql = "select * from pinned_message_cache where " + Message.UUID + "=? LIMIT 1";
+ try (final Cursor cursor = db.rawQuery(sql, new String[] {uuid})) {
+ if (cursor.moveToFirst()) {
+ return Message.fromCursor(context, cursor, conversation);
+ }
+ }
+ return null;
+ }
+
public Message getIndividualMessage(final String uuid) {
final var db = this.getReadableDatabase();
final String sql = "select * from messages where uuid=? LIMIT 1";
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java
index b50a424..62cb60a 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java
@@ -62,7 +62,6 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
@@ -1580,7 +1579,13 @@ public class FileBackend {
String.format(
"RECORDING_%s.%s",
TIMESTAMP_FORMATTER.format(Instant.now()), extension);
- final var recordingsDirectory = new File(context.getCacheDir(), DIRECTORY_RECORDINGS);
+ // getFilesDir(), not getCacheDir() — same reasoning as LinkPreviewFetcher's
+ // thumbCacheFile(): Android is free to auto-clear the cache dir under storage
+ // pressure at any time, with no warning. A recording can sit here for a while
+ // (added to the compose bar's media preview, not sent immediately) — long enough
+ // for that eviction to actually happen, silently deleting it out from under the
+ // attach flow and surfacing as "file not found" with the recording unrecoverable.
+ final var recordingsDirectory = new File(context.getFilesDir(), DIRECTORY_RECORDINGS);
final var file = new File(recordingsDirectory, filename);
if (recordingsDirectory.mkdirs()) {
Log.d(Config.LOGTAG, "create directory " + recordingsDirectory.getAbsolutePath());
@@ -1597,9 +1602,12 @@ public class FileBackend {
if (parent == null) {
return false;
}
- if (Arrays.asList(DIRECTORY_CAMERA, DIRECTORY_RECORDINGS)
- .contains(directory.getName())) {
+ if (DIRECTORY_CAMERA.equals(directory.getName())) {
return parent.equals(context.getCacheDir());
+ } else if (DIRECTORY_RECORDINGS.equals(directory.getName())) {
+ // See recording()'s comment — recordings live in getFilesDir(), not
+ // getCacheDir(), unlike every other staging file this class manages.
+ return parent.equals(context.getFilesDir());
} else {
return false;
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java b/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java
index 759dc5a..7e108a6 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/BlogComposerActivity.java
@@ -303,7 +303,7 @@ public class BlogComposerActivity extends XmppActivity
final Request request = new Request.Builder()
.url(BLOG_API_URL.replace("/api/posts", "/api/upload-image"))
.header("User-Agent", tel.xmpp.jabjab.http.HttpConnectionManager.getUserAgent() + " (+https://xmpp.tel)")
- .header("Author-Jid", authorJid)
+ .header("Author-Jid", tel.xmpp.jabjab.utils.HeaderUtils.encodeJidForHeader(authorJid))
.post(body)
.build();
try (final var response = new OkHttpClient().newCall(request).execute()) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 3dcde8b..3ff9171 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -2915,7 +2915,15 @@ public class ConversationFragment extends XmppFragment
currentPinnedIndex = 0;
}
final String uuid = pinned.get(currentPinnedIndex);
- final Message message = conversation.findMessageWithUuid(uuid);
+ Message message = conversation.findMessageWithUuid(uuid);
+ if (message == null) {
+ // Not in the currently loaded in-memory page (or older than it) — fall back to a
+ // DB lookup, then the pin-time cache snapshot if the live row has since expired.
+ message = requireXmppActivity()
+ .xmppConnectionService
+ .databaseBackend
+ .resolvePinnedMessage(conversation, uuid);
+ }
this.binding.pinnedMessagesBanner.setVisibility(View.VISIBLE);
this.binding.pinnedMessagesTitle.setText(
getString(R.string.pinned_message)
@@ -2961,8 +2969,11 @@ public class ConversationFragment extends XmppFragment
if (pinned.isEmpty() || currentPinnedIndex >= pinned.size()) {
return;
}
- conversation.unpinMessage(pinned.get(currentPinnedIndex));
- requireXmppActivity().xmppConnectionService.updateConversation(conversation);
+ final String uuid = pinned.get(currentPinnedIndex);
+ conversation.unpinMessage(uuid);
+ final var xmppConnectionService = requireXmppActivity().xmppConnectionService;
+ xmppConnectionService.updateConversation(conversation);
+ xmppConnectionService.databaseBackend.deletePinnedMessageCache(uuid);
refreshPinnedMessagesBanner();
this.messageListAdapter.notifyDataSetChanged();
}
@@ -2974,12 +2985,15 @@ public class ConversationFragment extends XmppFragment
}
private void togglePinMessage(final Message message, final boolean pin) {
+ final var xmppConnectionService = requireXmppActivity().xmppConnectionService;
if (pin) {
conversation.pinMessage(message.getUuid());
+ xmppConnectionService.databaseBackend.cachePinnedMessage(message.getUuid());
} else {
conversation.unpinMessage(message.getUuid());
+ xmppConnectionService.databaseBackend.deletePinnedMessageCache(message.getUuid());
}
- requireXmppActivity().xmppConnectionService.updateConversation(conversation);
+ xmppConnectionService.updateConversation(conversation);
currentPinnedIndex = 0;
refreshPinnedMessagesBanner();
this.messageListAdapter.notifyDataSetChanged();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java b/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java
index 750a163..df7efbd 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/PinnedMessagesActivity.java
@@ -83,7 +83,7 @@ public class PinnedMessagesActivity extends XmppActivity
final List<Message> resolved = new ArrayList<>();
for (final String uuid : uuids) {
final Message message =
- xmppConnectionService.databaseBackend.getMessageWithUuidOrRemoteId(
+ xmppConnectionService.databaseBackend.resolvePinnedMessage(
conversation, uuid);
if (message != null) {
resolved.add(message);
@@ -117,6 +117,7 @@ public class PinnedMessagesActivity extends XmppActivity
if (conversation != null) {
conversation.unpinMessage(message.getUuid());
xmppConnectionService.updateConversation(conversation);
+ xmppConnectionService.databaseBackend.deletePinnedMessageCache(message.getUuid());
loadPinnedMessages();
}
return true;
diff --git a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
index e8d8d5a..50fe341 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
@@ -1194,14 +1194,9 @@ public abstract class XmppActivity extends ActionBarActivity {
final AsyncDrawable asyncDrawable = new AsyncDrawable(getResources(), null, task);
imageView.setImageDrawable(asyncDrawable);
try {
- // 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();
+ task.executeOnExecutor(BitmapWorkerTask.EXECUTOR, message);
+ } catch (final RejectedExecutionException e) {
+ e.printStackTrace();
}
}
}
@@ -1250,6 +1245,16 @@ public abstract class XmppActivity extends ActionBarActivity {
}
static class BitmapWorkerTask extends AsyncTask<Message, Void, Bitmap> {
+ // Dedicated pool, separate from AsyncTask.THREAD_POOL_EXECUTOR (which
+ // AvatarWorkerTask also used to use). That shared pool has a bounded 128-slot
+ // queue and silently drops (RejectedExecutionException, swallowed) anything
+ // submitted once full — heavy thumbnail decodes (video frame, PDF render, big
+ // image) racing against avatar loads could permanently starve one or the other
+ // with no retry. Executors.newFixedThreadPool uses an unbounded queue, so tasks
+ // only ever wait, never get rejected.
+ private static final java.util.concurrent.Executor EXECUTOR =
+ java.util.concurrent.Executors.newFixedThreadPool(4);
+
private final WeakReference<ImageView> imageViewReference;
private Message message = null;
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 9cfb0de..8e4daba 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -1509,11 +1509,23 @@ public class MessageAdapter extends ArrayAdapter<Message> {
viewHolder.image().setVisibility(View.VISIBLE);
final FileParams params = message.getFileParams();
final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
+ // Stickers/GIFs are typically authored at small native resolutions (100-300px) —
+ // well under the target box in *file* pixels, but that's not the same scale as
+ // *screen* pixels (target is already density-multiplied). Letting them through the
+ // "already fits, don't upscale" branch below like a regular (much larger) photo
+ // rendered them at their raw file-pixel count as if it were already screen-sized —
+ // e.g. a 150px sticker as a ~150px-wide box, only ~55dp on a 2.75x-density screen.
+ // Always aspect-fit these up to the target instead; unlike photos, upscaling clean
+ // sticker/GIF art doesn't introduce visible quality loss the way it would for a photo.
+ final boolean allowUpscale =
+ "image/webp".equals(message.getMimeType())
+ || "image/gif".equals(message.getMimeType())
+ || message.getStickerPackNode() != null;
final int scaledW;
final int scaledH;
if (params.width <= 0 || params.height <= 0) {
scaledW = scaledH = (int) target;
- } else if (Math.max(params.width, params.height) <= target) {
+ } else if (!allowUpscale && Math.max(params.width, params.height) <= target) {
scaledW = params.width;
scaledH = params.height;
} else if (params.width <= params.height) {
@@ -2912,7 +2924,19 @@ public class MessageAdapter extends ArrayAdapter<Message> {
thumbView.setImageBitmap(bm);
thumbView.setVisibility(View.VISIBLE);
iconView.setVisibility(View.GONE);
- if (!isVideo) applyReplyCardAlphaBackground(viewHolder, bm);
+ if (isVideo) {
+ // Video frames are always opaque (no alpha channel to
+ // check via applyReplyCardAlphaBackground), but the card's
+ // grey colorSurfaceVariant backdrop is still visible as a
+ // border around the thumbnail through the row's 6dp
+ // padding once a frame has actually loaded — same visual
+ // issue the alpha check solves for images, just needs an
+ // unconditional clear here since there's no alpha to test.
+ viewHolder.replyPreviewCard().setCardBackgroundColor(
+ android.graphics.Color.TRANSPARENT);
+ } else {
+ applyReplyCardAlphaBackground(viewHolder, bm);
+ }
}
}
}.executeOnExecutor(android.os.AsyncTask.THREAD_POOL_EXECUTOR);
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 ff85639..0f890a1 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/AvatarWorkerTask.java
@@ -16,9 +16,20 @@ import tel.xmpp.jabjab.entities.Message;
import tel.xmpp.jabjab.services.AvatarService;
import tel.xmpp.jabjab.ui.XmppActivity;
import java.lang.ref.WeakReference;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitmap> {
+ // Dedicated pool, separate from AsyncTask.THREAD_POOL_EXECUTOR (which XmppActivity's
+ // BitmapWorkerTask also uses for message thumbnails). That shared pool has a bounded
+ // 128-slot queue and silently drops (RejectedExecutionException, swallowed) anything
+ // submitted once full — a burst of avatar binds (e.g. opening a long channel list)
+ // racing against thumbnail decodes could permanently lose avatars with no retry.
+ // Executors.newFixedThreadPool uses an unbounded queue, so tasks only ever wait, never
+ // get rejected.
+ private static final Executor AVATAR_EXECUTOR = Executors.newFixedThreadPool(4);
+
private final WeakReference<ImageView> imageViewReference;
private AvatarService.Avatar avatar = null;
private @DimenRes final int size;
@@ -93,16 +104,27 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
final AvatarService.Avatar avatar,
final ImageView imageView,
final @DimenRes int size) {
+ 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 =
+ activity.avatarService()
+ .get(avatar, (int) activity.getResources().getDimension(size), true);
+ // Some screens (list adapters reacting to frequent XMPP events, e.g. presence bursts
+ // in a busy channel) can call loadAvatar() for the same (avatar, imageView) pair many
+ // times in a very short span. If the exact bitmap we'd apply is already showing, skip
+ // straight past the cache lookup's log line, cancelPotentialWork(), and a redundant
+ // setImageBitmap()/invalidate() — this was previously unconditional on every call,
+ // which under a fast-repeating trigger produced a flood of no-op work and log spam.
+ if (bm != null
+ && imageView.getDrawable() instanceof BitmapDrawable current
+ && current.getBitmap() == bm) {
+ return;
+ }
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 =
- activity.avatarService()
- .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());
@@ -119,14 +141,9 @@ public class AvatarWorkerTask extends AsyncTask<AvatarService.Avatar, Void, Bitm
new AsyncDrawable(activity.getResources(), null, task);
imageView.setImageDrawable(asyncDrawable);
try {
- // 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) {
+ task.executeOnExecutor(AVATAR_EXECUTOR, avatar);
+ } catch (final RejectedExecutionException e) {
+ Log.w(Config.LOGTAG, "AVATAR_LOAD rejected avatar=" + avatar.getDisplayName(), e);
}
}
}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java b/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java
index 5de64a7..3cfc22d 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/BannerImageLoader.java
@@ -52,7 +52,8 @@ public final class BannerImageLoader {
}
try (var in = response.body().byteStream();
var out = java.nio.file.Files.newOutputStream(dest.toPath())) {
- in.transferTo(out);
+ // InputStream.transferTo() is Java 9+/API 33+ — minSdk here is 23.
+ com.google.common.io.ByteStreams.copy(in, out);
}
}
final Drawable drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(dest));
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/AvatarManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/AvatarManager.java
index dee882f..9afa473 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/AvatarManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/AvatarManager.java
@@ -273,8 +273,14 @@ public class AvatarManager extends AbstractManager {
if (account.setAvatar(id)) {
getDatabase().updateAccount(account);
service.notifyAccountAvatarHasChanged(account);
+ // Only clear the cache (forcing a recompute) when the avatar actually
+ // changed — some servers redundantly re-publish/echo the same PEP avatar
+ // metadata item, and clearing unconditionally on every notification raced
+ // the in-flight AvatarWorkerTask for this account's rows: a fresh clear
+ // could cancel a task whose decode had already finished but not yet
+ // reached onPostExecute, silently dropping that avatar update.
+ service.getAvatarService().clear(account);
}
- service.getAvatarService().clear(account);
service.updateConversationUi();
service.updateAccountUi();
} else {
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.