From 26882baa8b42eb9d5f6245a36f82c7d921a06b4a Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Wed, 28 May 2025 11:44:38 +0200 Subject: [PATCH] pick higher resolution avatar from metadata node --- .../eu/siacs/conversations/AppSettings.java | 22 +- .../siacs/conversations/entities/Contact.java | 28 +- .../conversations/entities/MucOptions.java | 18 +- .../conversations/generator/IqGenerator.java | 8 - .../conversations/parser/PresenceParser.java | 11 +- .../conversations/services/AvatarService.java | 34 +- .../services/XmppConnectionService.java | 60 +-- .../conversations/ui/EditAccountActivity.java | 30 +- .../xmpp/manager/AvatarManager.java | 383 +++++++++++++++--- .../xmpp/manager/PepManager.java | 4 + .../xmpp/manager/PubSubManager.java | 11 + .../android/xmpp/model/avatar/Metadata.java | 5 + 12 files changed, 432 insertions(+), 182 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/AppSettings.java b/src/main/java/eu/siacs/conversations/AppSettings.java index 7318091ea0d97689fa4b2824f03070435a39b434..2f68ddea7cc4086b3a686fd592b4e924b44b492d 100644 --- a/src/main/java/eu/siacs/conversations/AppSettings.java +++ b/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 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(); diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 8b023b9b46a3bab029dcd80099f6dbdd0a278759..86b14ace6e87a0ba7278bec02afb8757223fdc55 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/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() { diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index 2dbed2c1470e2713377aa973a9204ef1096d29fc..a425de52b84c2a6639717ca46e2c7ca13e7d1488 100644 --- a/src/main/java/eu/siacs/conversations/entities/MucOptions.java +++ b/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() { diff --git a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java b/src/main/java/eu/siacs/conversations/generator/IqGenerator.java index 512248c06734289970afb04b1b006fefc00b8680..3a4fe249444650a68539c48ae63ea7e7e2600f5c 100644 --- a/src/main/java/eu/siacs/conversations/generator/IqGenerator.java +++ b/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) { diff --git a/src/main/java/eu/siacs/conversations/parser/PresenceParser.java b/src/main/java/eu/siacs/conversations/parser/PresenceParser.java index d5ebbf1deacaaf2f5040b2075851dc4674bd3a74..4e73be5f880507a7dff46702e82174cea32be4c2 100644 --- a/src/main/java/eu/siacs/conversations/parser/PresenceParser.java +++ b/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(); diff --git a/src/main/java/eu/siacs/conversations/services/AvatarService.java b/src/main/java/eu/siacs/conversations/services/AvatarService.java index fd2cf0099a2c8ab267713ee4ae8dbb3a4fe0b072..304bf0a0e6aaf604735aa4f09cef3ee617d692d9 100644 --- a/src/main/java/eu/siacs/conversations/services/AvatarService.java +++ b/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; diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 288afe6809fdb953f567aaa56e48a177b1aa22ff..08f6cdb2c69e65976dfc1ba384a986493959bb76 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/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 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 checkForAvatar(final Account account) { + final var connection = account.getXmppConnection(); + return connection + .getManager(AvatarManager.class) + .fetchAndStore(account.getJid().asBareJid()); } public void notifyAccountAvatarHasChanged(final Account account) { diff --git a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java index c531fb7b8c87d6a7c94ff5994f94b65166c35d4d..95f25e8ab2bb73b7abf6062c062d6879e8c79983 100644 --- a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java +++ b/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 mAvatarFetchCallback = - new UiCallback() { + private final FutureCallback 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); diff --git a/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java b/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java index b98da6a5204f5be805627dddb0e0afa562fdf9d6..f60d45eb6f74907fa3c6a8184cc9299cc1218456 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java +++ b/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 SUPPORTED_CONTENT_TYPES; + + private static final Ordering 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 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 fetch(final Jid address, final String itemId) { + private ListenableFuture 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 fetchAndStore(final Avatar avatar) { - final var future = fetch(avatar.owner, avatar.sha1sum); - return Futures.transform( + private ListenableFuture 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 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 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 fetchAndStoreHttp(final HttpUrl url, final Info avatar) { + final SettableFuture 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() { + new FutureCallback() { @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 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 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; + } + } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java b/src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java index fd5bd6f155b9040d2ff37797021914e077486c24..b77286c53d41fa02330a6bcfe14079b0c63948ce 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/manager/PepManager.java +++ b/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 ListenableFuture fetchMostRecentItem(final Class clazz) { + return pubSubManager().fetchMostRecentItem(pepService(), clazz); + } + public ListenableFuture fetchMostRecentItem( final String node, final Class clazz) { return pubSubManager().fetchMostRecentItem(pepService(), node, clazz); diff --git a/src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java b/src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java index 956049335d9db2f2e724b16807b0cb1f4991afb7..0dfd2637f34c4241cd83e75f559bcae08b7e39ad 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/manager/PubSubManager.java @@ -136,6 +136,17 @@ public class PubSubManager extends AbstractManager { MoreExecutors.directExecutor()); } + public ListenableFuture fetchMostRecentItem( + final Jid address, final Class 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 ListenableFuture fetchMostRecentItem( final Jid address, final String node, final Class clazz) { final Iq request = new Iq(Iq.Type.GET); diff --git a/src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java b/src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java index 400f989572c2813aaeacdce5863e012ba128af3e..708b0e2018099acba02805e8105fed54ee8d58ac 100644 --- a/src/main/java/im/conversations/android/xmpp/model/avatar/Metadata.java +++ b/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 getInfos() { + return this.getExtensions(Info.class); + } }