pick higher resolution avatar from metadata node

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/AppSettings.java                    |  22 
src/main/java/eu/siacs/conversations/entities/Contact.java               |  28 
src/main/java/eu/siacs/conversations/entities/MucOptions.java            |  18 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java          |   8 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java          |  11 
src/main/java/eu/siacs/conversations/services/AvatarService.java         |  34 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |  60 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java         |  30 
src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java     | 383 
src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java        |   4 
src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java     |  11 
src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java   |   5 
12 files changed, 432 insertions(+), 182 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/AppSettings.java πŸ”—

@@ -5,6 +5,7 @@ import android.content.SharedPreferences;
 import android.net.Uri;
 import android.os.Environment;
 import androidx.annotation.BoolRes;
+import androidx.annotation.IntegerRes;
 import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
 import com.google.common.base.Joiner;
@@ -14,6 +15,7 @@ import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.utils.Compatibility;
 import java.security.SecureRandom;
+import java.util.Optional;
 
 public class AppSettings {
 
@@ -52,6 +54,7 @@ public class AppSettings {
     public static final String CALL_INTEGRATION = "call_integration";
     public static final String ALIGN_START = "align_start";
     public static final String BACKUP_LOCATION = "backup_location";
+    public static final String AUTO_ACCEPT_FILE_SIZE = "auto_accept_file_size";
 
     private static final String ACCEPT_INVITES_FROM_STRANGERS = "accept_invites_from_strangers";
     private static final String INSTALLATION_ID = "im.conversations.android.install_id";
@@ -163,12 +166,23 @@ public class AppSettings {
                 || getBooleanPreference(KEEP_FOREGROUND_SERVICE, R.bool.enable_foreground_service);
     }
 
-    private boolean getBooleanPreference(@NonNull final String name, @BoolRes int res) {
+    private boolean getBooleanPreference(@NonNull final String name, @BoolRes final int res) {
         final SharedPreferences sharedPreferences =
                 PreferenceManager.getDefaultSharedPreferences(context);
         return sharedPreferences.getBoolean(name, context.getResources().getBoolean(res));
     }
 
+    private long getLongPreference(final String name, @IntegerRes final int res) {
+        final long defaultValue = context.getResources().getInteger(res);
+        final SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        try {
+            return Long.parseLong(sharedPreferences.getString(name, String.valueOf(defaultValue)));
+        } catch (final NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+
     public String getOmemo() {
         final SharedPreferences sharedPreferences =
                 PreferenceManager.getDefaultSharedPreferences(context);
@@ -246,6 +260,12 @@ public class AppSettings {
         return installationId;
     }
 
+    public Optional<Long> getAutoAcceptFileSize() {
+        final long autoAcceptFileSize =
+                getLongPreference(AUTO_ACCEPT_FILE_SIZE, R.integer.auto_accept_filesize);
+        return autoAcceptFileSize <= 0 ? Optional.empty() : Optional.of(autoAcceptFileSize);
+    }
+
     public synchronized void resetInstallationId() {
         final var secureRandom = new SecureRandom();
         final var installationId = secureRandom.nextLong();

src/main/java/eu/siacs/conversations/entities/Contact.java πŸ”—

@@ -16,7 +16,6 @@ import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
-import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -58,7 +57,7 @@ public class Contact implements ListItem, Blockable {
     private JSONArray groups = new JSONArray();
     private final Presences presences = new Presences(this);
     protected Account account;
-    protected Avatar avatar;
+    protected String avatar;
 
     private boolean mActive = false;
     private long mLastseen = 0;
@@ -95,11 +94,7 @@ public class Contact implements ListItem, Blockable {
             tmpJsonObject = new JSONObject();
         }
         this.keys = tmpJsonObject;
-        if (avatar != null) {
-            this.avatar = new Avatar();
-            this.avatar.sha1sum = avatar;
-            this.avatar.origin = Avatar.Origin.VCARD; // always assume worst
-        }
+        this.avatar = avatar;
         try {
             this.groups = (groups == null ? new JSONArray() : new JSONArray(groups));
         } catch (JSONException e) {
@@ -241,7 +236,7 @@ public class Contact implements ListItem, Blockable {
             values.put(SYSTEMACCOUNT, systemAccount != null ? systemAccount.toString() : null);
             values.put(PHOTOURI, photoUri);
             values.put(KEYS, keys.toString());
-            values.put(AVATAR, avatar == null ? null : avatar.getFilename());
+            values.put(AVATAR, avatar);
             values.put(LAST_PRESENCE, mLastPresence);
             values.put(LAST_TIME, mLastseen);
             values.put(GROUPS, groups.toString());
@@ -437,25 +432,16 @@ public class Contact implements ListItem, Blockable {
         return getJid().getDomain().toString();
     }
 
-    public boolean setAvatar(final Avatar avatar) {
+    public boolean setAvatar(final String avatar) {
         if (this.avatar != null && this.avatar.equals(avatar)) {
             return false;
         }
-        if (this.avatar != null
-                && this.avatar.origin == Avatar.Origin.PEP
-                && avatar.origin == Avatar.Origin.VCARD) {
-            return false;
-        }
         this.avatar = avatar;
         return true;
     }
 
-    public String getAvatarFilename() {
-        return avatar == null ? null : avatar.getFilename();
-    }
-
-    public Avatar getAvatar() {
-        return avatar;
+    public String getAvatar() {
+        return this.avatar;
     }
 
     public boolean mutualPresenceSubscription() {
@@ -570,7 +556,7 @@ public class Contact implements ListItem, Blockable {
     }
 
     public boolean hasAvatarOrPresenceName() {
-        return (avatar != null && avatar.getFilename() != null) || presenceName != null;
+        return avatar != null || presenceName != null;
     }
 
     public boolean refreshRtpCapability() {

src/main/java/eu/siacs/conversations/entities/MucOptions.java πŸ”—

@@ -158,7 +158,7 @@ public class MucOptions {
     }
 
     public String getAvatar() {
-        return account.getRoster().getContact(conversation.getJid()).getAvatarFilename();
+        return account.getRoster().getContact(conversation.getJid()).getAvatar();
     }
 
     public boolean hasFeature(String feature) {
@@ -903,14 +903,20 @@ public class MucOptions {
         }
 
         public String getAvatar() {
+
+            // TODO prefer potentially better quality avatars from contact
+            // TODO use getContact and if that’s not null and avatar is set use that
+
+            getContact();
+
             if (avatar != null) {
                 return avatar.getFilename();
             }
-            Avatar avatar =
-                    realJid != null
-                            ? getAccount().getRoster().getContact(realJid).getAvatar()
-                            : null;
-            return avatar == null ? null : avatar.getFilename();
+            if (realJid == null) {
+                return null;
+            }
+            final var contact = getAccount().getRoster().getContact(realJid);
+            return contact.getAvatar();
         }
 
         public Account getAccount() {

src/main/java/eu/siacs/conversations/generator/IqGenerator.java πŸ”—

@@ -87,14 +87,6 @@ public class IqGenerator extends AbstractGenerator {
         return packet;
     }
 
-    public Iq retrieveAvatarMetaData(final Jid to) {
-        final Iq packet = retrieve("urn:xmpp:avatar:metadata", null);
-        if (to != null) {
-            packet.setTo(to);
-        }
-        return packet;
-    }
-
     public Iq retrieveDeviceIds(final Jid to) {
         final var packet = retrieve(AxolotlService.PEP_DEVICE_LIST, null);
         if (to != null) {

src/main/java/eu/siacs/conversations/parser/PresenceParser.java πŸ”—

@@ -168,13 +168,16 @@ public class PresenceParser extends AbstractParser
                                 if (user.setAvatar(avatar)) {
                                     mXmppConnectionService.getAvatarService().clear(user);
                                 }
+
+                                // TODO don’t do that. This will just overwrite (better) PEP avatars
+
                                 if (user.getRealJid() != null) {
                                     final Contact c =
                                             conversation
                                                     .getAccount()
                                                     .getRoster()
                                                     .getContact(user.getRealJid());
-                                    if (c.setAvatar(avatar)) {
+                                    if (c.setAvatar(avatar.sha1sum)) {
                                         connection
                                                 .getManager(RosterManager.class)
                                                 .writeToDatabaseAsync();
@@ -342,6 +345,10 @@ public class PresenceParser extends AbstractParser
         final Contact contact = account.getRoster().getContact(from);
         if (type == null) {
             final String resource = from.isBareJid() ? "" : from.getResource();
+
+            // TODO simply don’t parse avatars for contacts at all. Only if presence is bare and a
+            // MUC
+
             final Avatar avatar =
                     Avatar.parsePresence(packet.findChild("x", "vcard-temp:x:update"));
             if (avatar != null && (!contact.isSelf() || account.getAvatar() == null)) {
@@ -354,7 +361,7 @@ public class PresenceParser extends AbstractParser
                         mXmppConnectionService.updateConversationUi();
                         mXmppConnectionService.updateAccountUi();
                     } else {
-                        if (contact.setAvatar(avatar)) {
+                        if (contact.setAvatar(avatar.sha1sum)) {
                             connection.getManager(RosterManager.class).writeToDatabaseAsync();
                             mXmppConnectionService.getAvatarService().clear(contact);
                             mXmppConnectionService.updateConversationUi();

src/main/java/eu/siacs/conversations/services/AvatarService.java πŸ”—

@@ -107,11 +107,8 @@ public class AvatarService {
         if (avatar != null || cachedOnly) {
             return avatar;
         }
-        if (contact.getAvatarFilename() != null && QuickConversationsService.isQuicksy()) {
-            avatar =
-                    mXmppConnectionService
-                            .getFileBackend()
-                            .getAvatar(contact.getAvatarFilename(), size);
+        if (contact.getAvatar() != null && QuickConversationsService.isQuicksy()) {
+            avatar = mXmppConnectionService.getFileBackend().getAvatar(contact.getAvatar(), size);
         }
         if (avatar == null && contact.getProfilePhoto() != null) {
             avatar =
@@ -119,11 +116,8 @@ public class AvatarService {
                             .getFileBackend()
                             .cropCenterSquare(Uri.parse(contact.getProfilePhoto()), size);
         }
-        if (avatar == null && contact.getAvatarFilename() != null) {
-            avatar =
-                    mXmppConnectionService
-                            .getFileBackend()
-                            .getAvatar(contact.getAvatarFilename(), size);
+        if (avatar == null && contact.getAvatar() != null) {
+            avatar = mXmppConnectionService.getFileBackend().getAvatar(contact.getAvatar(), size);
         }
         if (avatar == null) {
             avatar =
@@ -227,7 +221,7 @@ public class AvatarService {
         Contact c = user.getContact();
         if (c != null
                 && (c.getProfilePhoto() != null
-                        || c.getAvatarFilename() != null
+                        || c.getAvatar() != null
                         || user.getAvatar() == null)) {
             return get(c, size, cachedOnly);
         } else {
@@ -322,7 +316,7 @@ public class AvatarService {
                 Jid jid = bookmark.getJid();
                 Account account = bookmark.getAccount();
                 Contact contact = jid == null ? null : account.getRoster().getContact(jid);
-                if (contact != null && contact.getAvatarFilename() != null) {
+                if (contact != null && contact.getAvatar() != null) {
                     return get(contact, size, cachedOnly);
                 }
                 String seed = jid != null ? jid.asBareJid().toString() : null;
@@ -497,7 +491,7 @@ public class AvatarService {
             return get(message.getCounterparts(), size, cachedOnly);
         } else if (message.getStatus() == Message.STATUS_RECEIVED) {
             Contact c = message.getContact();
-            if (c != null && (c.getProfilePhoto() != null || c.getAvatarFilename() != null)) {
+            if (c != null && (c.getProfilePhoto() != null || c.getAvatar() != null)) {
                 return get(c, size, cachedOnly);
             } else if (conversation instanceof Conversation
                     && message.getConversation().getMode() == Conversation.MODE_MULTI) {
@@ -621,18 +615,12 @@ public class AvatarService {
         Contact contact = user.getContact();
         if (contact != null) {
             Uri uri = null;
-            if (contact.getAvatarFilename() != null && QuickConversationsService.isQuicksy()) {
-                uri =
-                        mXmppConnectionService
-                                .getFileBackend()
-                                .getAvatarUri(contact.getAvatarFilename());
+            if (contact.getAvatar() != null && QuickConversationsService.isQuicksy()) {
+                uri = mXmppConnectionService.getFileBackend().getAvatarUri(contact.getAvatar());
             } else if (contact.getProfilePhoto() != null) {
                 uri = Uri.parse(contact.getProfilePhoto());
-            } else if (contact.getAvatarFilename() != null) {
-                uri =
-                        mXmppConnectionService
-                                .getFileBackend()
-                                .getAvatarUri(contact.getAvatarFilename());
+            } else if (contact.getAvatar() != null) {
+                uri = mXmppConnectionService.getFileBackend().getAvatarUri(contact.getAvatar());
             }
             if (drawTile(canvas, uri, left, top, right, bottom)) {
                 return true;

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java πŸ”—

@@ -147,9 +147,7 @@ import eu.siacs.conversations.xmpp.manager.VCardManager;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.Entity;
 import im.conversations.android.xmpp.IqErrorException;
-import im.conversations.android.xmpp.model.avatar.Metadata;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
-import im.conversations.android.xmpp.model.pubsub.PubSub;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import im.conversations.android.xmpp.model.up.Push;
 import java.io.File;
@@ -4292,6 +4290,8 @@ public class XmppConnectionService extends Service {
         connection.getManager(RosterManager.class).deleteRosterItem(contact);
     }
 
+    // TODO get thumbnail via AvatarManager
+    // TODO call AvatarManager.getInbandAvatar form vcard manager and simplify publication process
     public void publishMucAvatar(
             final Conversation conversation, final Uri image, final OnAvatarPublication callback) {
         new Thread(
@@ -4316,6 +4316,7 @@ public class XmppConnectionService extends Service {
                 .start();
     }
 
+    // TODO get rid of the async part. Manager is already async
     public void publishAvatarAsync(
             final Account account,
             final Uri image,
@@ -4374,7 +4375,7 @@ public class XmppConnectionService extends Service {
                     public void onSuccess(Void result) {
                         Log.d(Config.LOGTAG, "published muc avatar");
                         final var c = account.getRoster().getContact(avatar.owner);
-                        c.setAvatar(avatar);
+                        c.setAvatar(avatar.sha1sum);
                         getAvatarService().clear(c);
                         getAvatarService().clear(conversation.getMucOptions());
                         callback.onAvatarPublicationSucceeded();
@@ -4459,7 +4460,7 @@ public class XmppConnectionService extends Service {
                                 } else {
                                     final Contact contact =
                                             account.getRoster().getContact(avatar.owner);
-                                    contact.setAvatar(avatar);
+                                    contact.setAvatar(avatar.sha1sum);
                                     account.getXmppConnection()
                                             .getManager(RosterManager.class)
                                             .writeToDatabaseAsync();
@@ -4522,6 +4523,7 @@ public class XmppConnectionService extends Service {
                 MoreExecutors.directExecutor());
     }
 
+    // TODO move this into VCard manager
     private void setVCardAvatar(final Account account, final Avatar avatar) {
         Log.d(
                 Config.LOGTAG,
@@ -4541,7 +4543,7 @@ public class XmppConnectionService extends Service {
                 // TODO if this is a MUC clear MucOptions too
                 // TODO do the same clearing for when setting a cached version
                 final Contact contact = account.getRoster().getContact(avatar.owner);
-                contact.setAvatar(avatar);
+                contact.setAvatar(avatar.sha1sum);
                 account.getXmppConnection().getManager(RosterManager.class).writeToDatabaseAsync();
                 getAvatarService().clear(contact);
                 updateRosterUi();
@@ -4557,9 +4559,10 @@ public class XmppConnectionService extends Service {
                         updateConversationUi();
                         updateMucRosterUi();
                     }
+                    // TODO don’t do that. this will put lower quality vCard avatars into contacts
                     if (user.getRealJid() != null) {
                         Contact contact = account.getRoster().getContact(user.getRealJid());
-                        contact.setAvatar(avatar);
+                        contact.setAvatar(avatar.sha1sum);
                         account.getXmppConnection()
                                 .getManager(RosterManager.class)
                                 .writeToDatabaseAsync();
@@ -4571,46 +4574,11 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    public void checkForAvatar(final Account account, final UiCallback<Avatar> callback) {
-        final Iq packet = this.mIqGenerator.retrieveAvatarMetaData(null);
-        this.sendIqPacket(
-                account,
-                packet,
-                response -> {
-                    if (response.getType() != Iq.Type.RESULT) {
-                        callback.error(0, null);
-                    }
-                    final var pubsub = packet.getExtension(PubSub.class);
-                    if (pubsub == null) {
-                        callback.error(0, null);
-                        return;
-                    }
-                    final var items = pubsub.getItems();
-                    if (items == null) {
-                        callback.error(0, null);
-                        return;
-                    }
-                    final var item = items.getFirstItemWithId(Metadata.class);
-                    if (item == null) {
-                        callback.error(0, null);
-                        return;
-                    }
-                    final var avatar = Avatar.parseMetadata(item.getKey(), item.getValue());
-                    if (avatar == null) {
-                        callback.error(0, null);
-                        return;
-                    }
-                    avatar.owner = account.getJid().asBareJid();
-                    if (fileBackend.isAvatarCached(avatar)) {
-                        if (account.setAvatar(avatar.getFilename())) {
-                            databaseBackend.updateAccount(account);
-                        }
-                        getAvatarService().clear(account);
-                        callback.success(avatar);
-                    } else {
-                        fetchAvatarPep(account, avatar, callback);
-                    }
-                });
+    public ListenableFuture<Void> checkForAvatar(final Account account) {
+        final var connection = account.getXmppConnection();
+        return connection
+                .getManager(AvatarManager.class)
+                .fetchAndStore(account.getJid().asBareJid());
     }
 
     public void notifyAccountAvatarHasChanged(final Account account) {

src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java πŸ”—

@@ -41,6 +41,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.android.material.textfield.TextInputLayout;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
 import de.gultsch.common.Linkify;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
@@ -79,7 +82,6 @@ import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.XmppConnection.Features;
 import eu.siacs.conversations.xmpp.forms.Data;
 import eu.siacs.conversations.xmpp.manager.CarbonsManager;
-import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.Arrays;
 import java.util.List;
@@ -116,22 +118,19 @@ public class EditAccountActivity extends OmemoActivity
                 deleteAccountAndReturnIfNecessary();
                 finish();
             };
-    private final UiCallback<Avatar> mAvatarFetchCallback =
-            new UiCallback<Avatar>() {
 
+    private final FutureCallback<Void> mAvatarFetchCallback =
+            new FutureCallback<>() {
                 @Override
-                public void userInputRequired(final PendingIntent pi, final Avatar avatar) {
-                    finishInitialSetup(avatar);
+                public void onSuccess(Void result) {
+                    Log.d(Config.LOGTAG, "found pre-existing avatar");
+                    finishInitialSetup(true);
                 }
 
                 @Override
-                public void success(final Avatar avatar) {
-                    finishInitialSetup(avatar);
-                }
-
-                @Override
-                public void error(final int errorCode, final Avatar avatar) {
-                    finishInitialSetup(avatar);
+                public void onFailure(@NonNull Throwable t) {
+                    Log.d(Config.LOGTAG, "failed to fetch avatar", t);
+                    finishInitialSetup(false);
                 }
             };
     private final OnClickListener mAvatarClickListener =
@@ -454,7 +453,8 @@ public class EditAccountActivity extends OmemoActivity
         } else if (mInitMode && mAccount != null && mAccount.getStatus() == Account.State.ONLINE) {
             if (!mFetchingAvatar) {
                 mFetchingAvatar = true;
-                xmppConnectionService.checkForAvatar(mAccount, mAvatarFetchCallback);
+                final var future = xmppConnectionService.checkForAvatar(mAccount);
+                Futures.addCallback(future, mAvatarFetchCallback, MoreExecutors.directExecutor());
             }
         }
         if (mAccount != null) {
@@ -521,7 +521,7 @@ public class EditAccountActivity extends OmemoActivity
         refreshUi();
     }
 
-    protected void finishInitialSetup(final Avatar avatar) {
+    protected void finishInitialSetup(final boolean avatar) {
         runOnUiThread(
                 () -> {
                     SoftKeyboardUtils.hideSoftKeyboard(EditAccountActivity.this);
@@ -530,7 +530,7 @@ public class EditAccountActivity extends OmemoActivity
                     final boolean wasFirstAccount =
                             xmppConnectionService != null
                                     && xmppConnectionService.getAccounts().size() == 1;
-                    if (avatar != null || (connection != null && !connection.getFeatures().pep())) {
+                    if (avatar || (connection != null && !connection.getFeatures().pep())) {
                         intent =
                                 new Intent(
                                         getApplicationContext(), StartConversationActivity.class);

src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java πŸ”—

@@ -6,16 +6,22 @@ import android.util.Log;
 import androidx.annotation.NonNull;
 import androidx.heifwriter.AvifWriter;
 import androidx.heifwriter.HeifWriter;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
 import com.google.common.hash.Hashing;
 import com.google.common.hash.HashingOutputStream;
-import com.google.common.io.BaseEncoding;
+import com.google.common.io.ByteStreams;
 import com.google.common.io.Files;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.SettableFuture;
+import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.persistance.FileBackend;
@@ -25,7 +31,6 @@ import eu.siacs.conversations.utils.PhoneHelper;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
-import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.NodeConfiguration;
 import im.conversations.android.xmpp.model.ByteContent;
 import im.conversations.android.xmpp.model.avatar.Data;
@@ -38,14 +43,55 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
 import java.util.UUID;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
 
 public class AvatarManager extends AbstractManager {
 
+    private static final Object RENAME_LOCK = new Object();
+
+    private static final List<String> SUPPORTED_CONTENT_TYPES;
+
+    private static final Ordering<Info> AVATAR_ORDERING =
+            new Ordering<>() {
+                @Override
+                public int compare(Info left, Info right) {
+                    return ComparisonChain.start()
+                            .compare(
+                                    right.getWidth() * right.getHeight(),
+                                    left.getWidth() * left.getHeight())
+                            .compare(
+                                    ImageFormat.formatPriority(right.getType()),
+                                    ImageFormat.formatPriority(left.getType()))
+                            .result();
+                }
+            };
+
+    static {
+        final ImmutableList.Builder<ImageFormat> builder = new ImmutableList.Builder<>();
+        builder.add(ImageFormat.JPEG, ImageFormat.PNG, ImageFormat.WEBP);
+        if (Compatibility.twentyEight()) {
+            builder.add(ImageFormat.HEIF);
+        }
+        if (Compatibility.thirtyFour()) {
+            builder.add(ImageFormat.AVIF);
+        }
+        final var supportedFormats = builder.build();
+        SUPPORTED_CONTENT_TYPES =
+                ImmutableList.copyOf(
+                        Collections2.transform(supportedFormats, ImageFormat::toContentType));
+    }
+
     private static final Executor AVATAR_COMPRESSION_EXECUTOR =
             MoreExecutors.newSequentialExecutor(Executors.newSingleThreadScheduledExecutor());
 
@@ -56,43 +102,154 @@ public class AvatarManager extends AbstractManager {
         this.service = service;
     }
 
-    public ListenableFuture<byte[]> fetch(final Jid address, final String itemId) {
+    private ListenableFuture<byte[]> fetch(final Jid address, final String itemId) {
         final var future = getManager(PubSubManager.class).fetchItem(address, itemId, Data.class);
         return Futures.transform(future, ByteContent::asBytes, MoreExecutors.directExecutor());
     }
 
-    public ListenableFuture<Void> fetchAndStore(final Avatar avatar) {
-        final var future = fetch(avatar.owner, avatar.sha1sum);
-        return Futures.transform(
+    private ListenableFuture<Info> fetchAndStoreWithFallback(
+            final Jid address, final Info picked, final Info fallback) {
+        Preconditions.checkArgument(fallback.getUrl() == null, "fallback avatar must be in-band");
+        final var url = picked.getUrl();
+        if (url != null) {
+            final var httpDownloadFuture = fetchAndStoreHttp(url, picked);
+            return Futures.catchingAsync(
+                    httpDownloadFuture,
+                    Exception.class,
+                    ex -> {
+                        Log.d(
+                                Config.LOGTAG,
+                                getAccount().getJid().asBareJid()
+                                        + ": could not download avatar for "
+                                        + address
+                                        + " from "
+                                        + url,
+                                ex);
+                        return fetchAndStoreInBand(address, fallback);
+                    },
+                    MoreExecutors.directExecutor());
+        } else {
+            return fetchAndStoreInBand(address, picked);
+        }
+    }
+
+    private ListenableFuture<Info> fetchAndStoreInBand(final Jid address, final Info avatar) {
+        final var future = fetch(address, avatar.getId());
+        return Futures.transformAsync(
                 future,
                 data -> {
-                    avatar.image = BaseEncoding.base64().encode(data);
-                    if (service.getFileBackend().save(avatar)) {
-                        setPepAvatar(avatar);
-                        return null;
-                    } else {
-                        throw new IllegalStateException("Could not store avatar");
+                    final var actualHash = Hashing.sha1().hashBytes(data).toString();
+                    if (!actualHash.equals(avatar.getId())) {
+                        throw new IllegalStateException(
+                                String.format("In-band avatar hash of %s did not match", address));
+                    }
+
+                    final var file = FileBackend.getAvatarFile(context, avatar.getId());
+                    if (file.exists()) {
+                        return Futures.immediateFuture(avatar);
                     }
+                    return Futures.transform(
+                            write(file, data), v -> avatar, MoreExecutors.directExecutor());
                 },
                 MoreExecutors.directExecutor());
     }
 
-    private void setPepAvatar(final Avatar avatar) {
+    private ListenableFuture<Void> write(final File destination, byte[] bytes) {
+        return Futures.submit(
+                () -> {
+                    final var randomFile =
+                            new File(context.getCacheDir(), UUID.randomUUID().toString());
+                    Files.write(bytes, randomFile);
+                    if (moveAvatarIntoCache(randomFile, destination)) {
+                        return null;
+                    }
+                    throw new IllegalStateException(
+                            String.format(
+                                    "Could not move file to %s", destination.getAbsolutePath()));
+                },
+                AVATAR_COMPRESSION_EXECUTOR);
+    }
+
+    private ListenableFuture<Info> fetchAndStoreHttp(final HttpUrl url, final Info avatar) {
+        final SettableFuture<Info> settableFuture = SettableFuture.create();
+        final OkHttpClient client =
+                service.getHttpConnectionManager().buildHttpClient(url, getAccount(), 30, false);
+        final var request = new Request.Builder().url(url).get().build();
+        client.newCall(request)
+                .enqueue(
+                        new Callback() {
+                            @Override
+                            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+                                settableFuture.setException(e);
+                            }
+
+                            @Override
+                            public void onResponse(@NonNull Call call, @NonNull Response response) {
+                                if (response.isSuccessful()) {
+                                    try {
+                                        write(avatar, response);
+                                    } catch (final Exception e) {
+                                        settableFuture.setException(e);
+                                        return;
+                                    }
+                                    settableFuture.set(avatar);
+                                } else {
+                                    settableFuture.setException(
+                                            new IOException("HTTP call was not successful"));
+                                }
+                            }
+                        });
+        return settableFuture;
+    }
+
+    private void write(final Info avatar, Response response) throws IOException {
+        final var body = response.body();
+        if (body == null) {
+            throw new IOException("Body was null");
+        }
+        final long bytes = avatar.getBytes();
+        final long actualBytes;
+        final var inputStream = ByteStreams.limit(body.byteStream(), avatar.getBytes());
+        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+        final String actualHash;
+        try (final var fileOutputStream = new FileOutputStream(randomFile);
+                var hashingOutputStream =
+                        new HashingOutputStream(Hashing.sha1(), fileOutputStream)) {
+            actualBytes = ByteStreams.copy(inputStream, hashingOutputStream);
+            actualHash = hashingOutputStream.hash().toString();
+        }
+        if (actualBytes != bytes) {
+            throw new IllegalStateException("File size did not meet expected size");
+        }
+        if (!actualHash.equals(avatar.getId())) {
+            throw new IllegalStateException("File hash did not match");
+        }
+        final var avatarFile = FileBackend.getAvatarFile(context, avatar.getId());
+        if (moveAvatarIntoCache(randomFile, avatarFile)) {
+            return;
+        }
+        throw new IOException("Could not move avatar to avatar location");
+    }
+
+    private void setAvatar(final Jid from, final Info info) {
+        Log.d(Config.LOGTAG, "setting avatar for " + from + " to " + info.getId());
         final var account = getAccount();
-        if (account.getJid().asBareJid().equals(avatar.owner)) {
-            if (account.setAvatar(avatar.getFilename())) {
+        if (account.getJid().asBareJid().equals(from)) {
+            if (account.setAvatar(info.getId())) {
                 getDatabase().updateAccount(account);
+                service.notifyAccountAvatarHasChanged(account);
             }
-            this.service.getAvatarService().clear(account);
-            this.service.updateConversationUi();
-            this.service.updateAccountUi();
+            service.getAvatarService().clear(account);
+            service.updateConversationUi();
+            service.updateAccountUi();
         } else {
-            final Contact contact = account.getRoster().getContact(avatar.owner);
-            contact.setAvatar(avatar);
-            account.getXmppConnection().getManager(RosterManager.class).writeToDatabaseAsync();
-            this.service.getAvatarService().clear(contact);
-            this.service.updateConversationUi();
-            this.service.updateRosterUi();
+            final Contact contact = account.getRoster().getContact(from);
+            if (contact.setAvatar(info.getId())) {
+                connection.getManager(RosterManager.class).writeToDatabaseAsync();
+                service.getAvatarService().clear(contact);
+                service.updateConversationUi();
+                service.updateRosterUi();
+            }
         }
     }
 
@@ -100,43 +257,34 @@ public class AvatarManager extends AbstractManager {
         final var account = getAccount();
         // TODO support retract
         final var entry = items.getFirstItemWithId(Metadata.class);
-        final var avatar =
-                entry == null ? null : Avatar.parseMetadata(entry.getKey(), entry.getValue());
+        if (entry == null) {
+            return;
+        }
+        final var avatar = getPreferredFallback(entry);
         if (avatar == null) {
-            Log.d(Config.LOGTAG, "could not parse avatar metadata from " + from);
             return;
         }
-        avatar.owner = from.asBareJid();
-        if (service.getFileBackend().isAvatarCached(avatar)) {
-            if (account.getJid().asBareJid().equals(from)) {
-                if (account.setAvatar(avatar.getFilename())) {
-                    service.databaseBackend.updateAccount(account);
-                    service.notifyAccountAvatarHasChanged(account);
-                }
-                service.getAvatarService().clear(account);
-                service.updateConversationUi();
-                service.updateAccountUi();
-            } else {
-                final Contact contact = account.getRoster().getContact(from);
-                if (contact.setAvatar(avatar)) {
-                    connection.getManager(RosterManager.class).writeToDatabaseAsync();
-                    service.getAvatarService().clear(contact);
-                    service.updateConversationUi();
-                    service.updateRosterUi();
-                }
-            }
+
+        Log.d(Config.LOGTAG, "picked avatar from " + from + ": " + avatar.preferred);
+
+        final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
+
+        if (cache.exists()) {
+            setAvatar(from, avatar.preferred);
         } else if (service.isDataSaverDisabled()) {
-            final var future = this.fetchAndStore(avatar);
+            final var future =
+                    this.fetchAndStoreWithFallback(from, avatar.preferred, avatar.fallback);
             Futures.addCallback(
                     future,
-                    new FutureCallback<Void>() {
+                    new FutureCallback<Info>() {
                         @Override
-                        public void onSuccess(Void result) {
+                        public void onSuccess(Info result) {
+                            setAvatar(from, result);
                             Log.d(
                                     Config.LOGTAG,
                                     account.getJid().asBareJid()
                                             + ": successfully fetched pep avatar for "
-                                            + avatar.owner);
+                                            + from);
                         }
 
                         @Override
@@ -148,6 +296,46 @@ public class AvatarManager extends AbstractManager {
         }
     }
 
+    private PreferredFallback getPreferredFallback(final Map.Entry<String, Metadata> entry) {
+        final var mainItemId = entry.getKey();
+        final var infos = entry.getValue().getInfos();
+
+        final var inBandAvatar = Iterables.find(infos, i -> mainItemId.equals(i.getId()), null);
+
+        if (inBandAvatar == null || inBandAvatar.getUrl() != null) {
+            return null;
+        }
+
+        final var optionalAutoAcceptSize = new AppSettings(context).getAutoAcceptFileSize();
+        if (optionalAutoAcceptSize.isEmpty()) {
+            return new PreferredFallback(inBandAvatar);
+        } else {
+
+            final var supported =
+                    Collections2.filter(
+                            infos,
+                            i ->
+                                    Objects.nonNull(i.getId())
+                                            && i.getBytes() > 0
+                                            && i.getHeight() > 0
+                                            && i.getWidth() > 0
+                                            && SUPPORTED_CONTENT_TYPES.contains(i.getType()));
+
+            final var autoAcceptSize = optionalAutoAcceptSize.get();
+
+            final var supportedBelowLimit =
+                    Collections2.filter(supported, i -> i.getBytes() <= autoAcceptSize);
+
+            if (supportedBelowLimit.isEmpty()) {
+                return new PreferredFallback(inBandAvatar);
+            } else {
+                final var preferred =
+                        Iterables.getFirst(AVATAR_ORDERING.sortedCopy(supportedBelowLimit), null);
+                return new PreferredFallback(preferred, inBandAvatar);
+            }
+        }
+    }
+
     public void handleDelete(final Jid from) {
         final var account = getAccount();
         final boolean isAccount = account.getJid().asBareJid().equals(from);
@@ -202,7 +390,7 @@ public class AvatarManager extends AbstractManager {
         hashingOutputStream.close();
         final var sha1 = hashingOutputStream.hash().toString();
         final var avatarFile = FileBackend.getAvatarFile(context, sha1);
-        if (randomFile.renameTo(avatarFile)) {
+        if (moveAvatarIntoCache(randomFile, avatarFile)) {
             return new Info(
                     sha1,
                     avatarFile.length(),
@@ -260,7 +448,7 @@ public class AvatarManager extends AbstractManager {
             throws IOException {
         final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
         final var avatarFile = FileBackend.getAvatarFile(context, sha1);
-        if (randomFile.renameTo(avatarFile)) {
+        if (moveAvatarIntoCache(randomFile, avatarFile)) {
             return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
         }
         throw new IllegalStateException(
@@ -374,14 +562,59 @@ public class AvatarManager extends AbstractManager {
                 MoreExecutors.directExecutor());
     }
 
-    private String asContentType(final ImageFormat format) {
-        return switch (format) {
-            case WEBP -> "image/webp";
-            case PNG -> "image/png";
-            case JPEG -> "image/jpeg";
-            case AVIF -> "image/avif";
-            case HEIF -> "image/heif";
-        };
+    public ListenableFuture<Void> fetchAndStore(final Jid address) {
+        final var metaDataFuture =
+                getManager(PubSubManager.class).fetchItems(address, Metadata.class);
+        return Futures.transformAsync(
+                metaDataFuture,
+                metaData -> {
+                    final var entry = Iterables.getFirst(metaData.entrySet(), null);
+                    if (entry == null) {
+                        throw new IllegalStateException("Metadata item not found");
+                    }
+                    final var avatar = getPreferredFallback(entry);
+
+                    if (avatar == null) {
+                        throw new IllegalStateException("No avatar found");
+                    }
+
+                    final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
+
+                    if (cache.exists()) {
+                        Log.d(
+                                Config.LOGTAG,
+                                "fetchAndStore. file existed " + cache.getAbsolutePath());
+                        setAvatar(address, avatar.preferred);
+                        return Futures.immediateVoidFuture();
+                    } else {
+                        final var future =
+                                this.fetchAndStoreWithFallback(
+                                        address, avatar.preferred, avatar.fallback);
+                        return Futures.transform(
+                                future,
+                                info -> {
+                                    setAvatar(address, info);
+                                    return null;
+                                },
+                                MoreExecutors.directExecutor());
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private static boolean moveAvatarIntoCache(final File randomFile, final File destination) {
+        synchronized (RENAME_LOCK) {
+            if (destination.exists()) {
+                return true;
+            }
+            final var directory = destination.getParentFile();
+            if (directory != null && directory.mkdirs()) {
+                Log.d(
+                        Config.LOGTAG,
+                        "create avatar cache directory: " + directory.getAbsolutePath());
+            }
+            return randomFile.renameTo(destination);
+        }
     }
 
     public enum ImageFormat {
@@ -401,6 +634,22 @@ public class AvatarManager extends AbstractManager {
             };
         }
 
+        public static int formatPriority(final String type) {
+            final var format = ofContentType(type);
+            return format == null ? Integer.MIN_VALUE : format.ordinal();
+        }
+
+        private static ImageFormat ofContentType(final String type) {
+            return switch (type) {
+                case "image/png" -> PNG;
+                case "image/jpeg" -> JPEG;
+                case "image/webp" -> WEBP;
+                case "image/heif" -> HEIF;
+                case "image/avif" -> AVIF;
+                default -> null;
+            };
+        }
+
         public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
             return switch (compressFormat) {
                 case PNG -> PNG;
@@ -410,4 +659,18 @@ public class AvatarManager extends AbstractManager {
             };
         }
     }
+
+    private static final class PreferredFallback {
+        private final Info preferred;
+        private final Info fallback;
+
+        private PreferredFallback(final Info fallback) {
+            this(fallback, fallback);
+        }
+
+        private PreferredFallback(Info preferred, Info fallback) {
+            this.preferred = preferred;
+            this.fallback = fallback;
+        }
+    }
 }

src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java πŸ”—

@@ -22,6 +22,10 @@ public class PepManager extends AbstractManager {
         return pubSubManager().fetchItems(pepService(), clazz);
     }
 
+    public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(final Class<T> clazz) {
+        return pubSubManager().fetchMostRecentItem(pepService(), clazz);
+    }
+
     public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(
             final String node, final Class<T> clazz) {
         return pubSubManager().fetchMostRecentItem(pepService(), node, clazz);

src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java πŸ”—

@@ -136,6 +136,17 @@ public class PubSubManager extends AbstractManager {
                 MoreExecutors.directExecutor());
     }
 
+    public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(
+            final Jid address, final Class<T> clazz) {
+        final var id = ExtensionFactory.id(clazz);
+        if (id == null) {
+            return Futures.immediateFailedFuture(
+                    new IllegalArgumentException(
+                            String.format("%s is not a registered extension", clazz.getName())));
+        }
+        return fetchMostRecentItem(address, id.namespace, clazz);
+    }
+
     public <T extends Extension> ListenableFuture<T> fetchMostRecentItem(
             final Jid address, final String node, final Class<T> clazz) {
         final Iq request = new Iq(Iq.Type.GET);

src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java πŸ”—

@@ -3,6 +3,7 @@ package im.conversations.android.xmpp.model.avatar;
 import eu.siacs.conversations.xml.Namespace;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Collection;
 
 @XmlElement(namespace = Namespace.AVATAR_METADATA)
 public class Metadata extends Extension {
@@ -10,4 +11,8 @@ public class Metadata extends Extension {
     public Metadata() {
         super(Metadata.class);
     }
+
+    public Collection<Info> getInfos() {
+        return this.getExtensions(Info.class);
+    }
 }