Detailed changes
@@ -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();
@@ -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() {
@@ -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() {
@@ -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) {
@@ -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();
@@ -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;
@@ -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) {
@@ -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);
@@ -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;
+ }
+ }
}
@@ -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);
@@ -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);
@@ -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);
+ }
}