add purpose detection to avatar http upload

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/Config.java                         |  2 
src/main/java/eu/siacs/conversations/entities/Account.java               |  5 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java        |  4 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 18 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java         | 13 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java            | 52 
src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java     | 63 
src/main/java/eu/siacs/conversations/xmpp/manager/HttpUploadManager.java | 69 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java |  3 
9 files changed, 151 insertions(+), 78 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/Config.java 🔗

@@ -114,7 +114,7 @@ public final class Config {
 
     public static final boolean ENABLE_CAPS_CACHE = true;
 
-    public static final boolean DISABLE_HTTP_UPLOAD = false;
+    public static final boolean ENABLE_HTTP_UPLOAD = true;
     public static final boolean EXTENDED_SM_LOGGING = false; // log stanza counts
     public static final boolean BACKGROUND_STANZA_LOGGING =
             false; // log all stanzas that were received while the app is in background

src/main/java/eu/siacs/conversations/entities/Account.java 🔗

@@ -28,6 +28,7 @@ import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
 import eu.siacs.conversations.xmpp.manager.BlockingManager;
 import eu.siacs.conversations.xmpp.manager.DiscoManager;
+import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
 import eu.siacs.conversations.xmpp.manager.RosterManager;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -209,8 +210,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                 cursor.getString(cursor.getColumnIndexOrThrow(FAST_TOKEN)));
     }
 
-    public boolean httpUploadAvailable(long size) {
-        return xmppConnection != null && xmppConnection.getFeatures().httpUpload(size);
+    public boolean httpUploadAvailable(long fileSize) {
+        return xmppConnection.getManager(HttpUploadManager.class).isAvailableForSize(fileSize);
     }
 
     public boolean httpUploadAvailable() {

src/main/java/eu/siacs/conversations/persistance/FileBackend.java 🔗

@@ -107,11 +107,11 @@ public class FileBackend {
     }
 
     public static boolean allFilesUnderSize(
-            Context context, List<Attachment> attachments, long max) {
+            Context context, List<Attachment> attachments, final Long max) {
         final boolean compressVideo =
                 !AttachFileToConversationRunnable.getVideoCompression(context)
                         .equals("uncompressed");
-        if (max <= 0) {
+        if (max == null || max <= 0) {
             Log.d(Config.LOGTAG, "server did not report max file size for http upload");
             return true; // exception to be compatible with HTTP Upload < v0.2
         }

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -127,6 +127,7 @@ import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
+import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
 import eu.siacs.conversations.xmpp.manager.PresenceManager;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import java.util.ArrayList;
@@ -2229,9 +2230,9 @@ public class ConversationFragment extends XmppFragment
                 if (!message.hasFileOnRemoteHost()
                         && xmppConnection != null
                         && conversation.getMode() == Conversational.MODE_SINGLE
-                        && (!xmppConnection
-                                        .getFeatures()
-                                        .httpUpload(message.getFileParams().getSize())
+                        && (!conversation
+                                        .getAccount()
+                                        .httpUploadAvailable(message.getFileParams().getSize())
                                 || forceP2P)) {
                     activity.selectPresence(
                             conversation,
@@ -2917,9 +2918,14 @@ public class ConversationFragment extends XmppFragment
         mSendingPgpMessage.set(false);
     }
 
-    public long getMaxHttpUploadSize(Conversation conversation) {
-        final XmppConnection connection = conversation.getAccount().getXmppConnection();
-        return connection == null ? -1 : connection.getFeatures().getMaxHttpUploadSize();
+    public Long getMaxHttpUploadSize(final Conversation conversation) {
+
+        final var connection = conversation.getAccount().getXmppConnection();
+        final var httpUploadService = connection.getManager(HttpUploadManager.class).getService();
+        if (httpUploadService == null) {
+            return -1L;
+        }
+        return httpUploadService.getMaxFileSize();
     }
 
     private void updateEditablity() {

src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java 🔗

@@ -81,6 +81,7 @@ import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.XmppConnection.Features;
 import eu.siacs.conversations.xmpp.manager.CarbonsManager;
+import eu.siacs.conversations.xmpp.manager.HttpUploadManager;
 import eu.siacs.conversations.xmpp.manager.RegistrationManager;
 import im.conversations.android.xmpp.model.data.Data;
 import im.conversations.android.xmpp.model.stanza.Presence;
@@ -1295,13 +1296,15 @@ public class EditAccountActivity extends OmemoActivity
             } else {
                 this.binding.serverInfoPep.setText(R.string.server_info_unavailable);
             }
-            if (features.httpUpload(0)) {
-                final long maxFileSize = features.getMaxHttpUploadSize();
-                if (maxFileSize > 0) {
+            final var httpUploadManager = connection.getManager(HttpUploadManager.class);
+            final var uploadService = httpUploadManager.getService();
+            if (uploadService != null) {
+                final Long maxFileSize = uploadService.getMaxFileSize();
+                if (maxFileSize == null) {
+                    this.binding.serverInfoHttpUpload.setText(R.string.server_info_available);
+                } else {
                     this.binding.serverInfoHttpUpload.setText(
                             UIHelper.filesizeToString(maxFileSize));
-                } else {
-                    this.binding.serverInfoHttpUpload.setText(R.string.server_info_available);
                 }
             } else {
                 this.binding.serverInfoHttpUpload.setText(R.string.server_info_unavailable);

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -3063,58 +3063,6 @@ public class XmppConnection implements Runnable {
             return HttpUrl.parse(address);
         }
 
-        public boolean httpUpload(long fileSize) {
-            if (Config.DISABLE_HTTP_UPLOAD) {
-                return false;
-            }
-            final var result =
-                    getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
-            if (result == null) {
-                return false;
-            }
-            final long maxSize;
-            try {
-                maxSize =
-                        Long.parseLong(
-                                result.getValue()
-                                        .getServiceDiscoveryExtension(
-                                                Namespace.HTTP_UPLOAD, "max-file-size"));
-            } catch (final Exception e) {
-                return true;
-            }
-            if (fileSize <= maxSize) {
-                return true;
-            } else {
-                Log.d(
-                        Config.LOGTAG,
-                        account.getJid().asBareJid()
-                                + ": http upload is not available for files with"
-                                + " size "
-                                + fileSize
-                                + " (max is "
-                                + maxSize
-                                + ")");
-                return false;
-            }
-        }
-
-        public long getMaxHttpUploadSize() {
-            final var result =
-                    getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
-            if (result == null) {
-                return -1;
-            }
-            try {
-                return Long.parseLong(
-                        result.getValue()
-                                .getServiceDiscoveryExtension(
-                                        Namespace.HTTP_UPLOAD, "max-file-size"));
-            } catch (final Exception e) {
-                return -1;
-                // ignored
-            }
-        }
-
         public boolean stanzaIds() {
             return hasDiscoFeature(account.getJid().asBareJid(), Namespace.STANZA_IDS);
         }

src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java 🔗

@@ -1,6 +1,7 @@
 package eu.siacs.conversations.xmpp.manager;
 
 import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
 import android.net.Uri;
 import android.util.Log;
 import androidx.annotation.NonNull;
@@ -520,6 +521,16 @@ public class AvatarManager extends AbstractManager {
             avifWriter.addBitmap(image);
             avifWriter.stop(3_000);
         }
+        var readCheck = BitmapFactory.decodeFile(randomFile.getAbsolutePath());
+        if (readCheck == null) {
+            throw new AvifCompressionException("AVIF image was null after trying to decode");
+        }
+        if (readCheck.getWidth() != image.getWidth()
+                || readCheck.getHeight() != image.getHeight()) {
+            readCheck.recycle();
+            throw new AvifCompressionException("AVIF had wrong image bounds");
+        }
+        readCheck.recycle();
         return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
     }
 
@@ -535,34 +546,49 @@ public class AvatarManager extends AbstractManager {
                 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
     }
 
-    private ListenableFuture<List<Info>> uploadAvatar(final Uri image) {
+    private ListenableFuture<Collection<Info>> uploadAvatar(final Uri image) {
         return Futures.transformAsync(
                 hasAlphaChannel(image),
                 hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
                 MoreExecutors.directExecutor());
     }
 
-    private ListenableFuture<List<Info>> uploadAvatar(
+    private ListenableFuture<Collection<Info>> uploadAvatar(
             final Uri image, final boolean hasAlphaChannel) {
         final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
 
         final ListenableFuture<Info> avatarThumbnailFuture;
-        final ListenableFuture<Info> avatarFuture;
         if (hasAlphaChannel) {
             avatarThumbnailFuture =
                     resizeAndStoreAvatarAsync(
                             image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
-            avatarFuture =
-                    resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
         } else {
-            final int autoAcceptFileSize =
-                    context.getResources().getInteger(R.integer.auto_accept_filesize);
             avatarThumbnailFuture =
                     resizeAndStoreAvatarAsync(
                             image,
                             Config.AVATAR_THUMBNAIL_SIZE,
                             ImageFormat.JPEG,
                             Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
+        }
+
+        final var uploadManager = getManager(HttpUploadManager.class);
+
+        final var uploadService = uploadManager.getService();
+        if (uploadService == null || !uploadService.supportsPurpose(Profile.class)) {
+            Log.d(
+                    Config.LOGTAG,
+                    getAccount().getJid() + ": 'profile' upload purpose not supported");
+            return Futures.transform(
+                    avatarThumbnailFuture, ImmutableList::of, MoreExecutors.directExecutor());
+        }
+
+        final ListenableFuture<Info> avatarFuture;
+        if (hasAlphaChannel) {
+            avatarFuture =
+                    resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
+        } else {
+            final int autoAcceptFileSize =
+                    context.getResources().getInteger(R.integer.auto_accept_filesize);
             avatarFuture =
                     resizeAndStoreAvatarAsync(
                             image, Config.AVATAR_FULL_SIZE, ImageFormat.JPEG, autoAcceptFileSize);
@@ -589,7 +615,16 @@ public class AvatarManager extends AbstractManager {
                 final var avatarAvifWithUrlFuture =
                         Futures.transformAsync(
                                 avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
-                avatarFutures.add(avatarAvifWithUrlFuture);
+                final var caughtAvifWithUrlFuture =
+                        Futures.catching(
+                                avatarAvifWithUrlFuture,
+                                Exception.class,
+                                ex -> {
+                                    Log.d(Config.LOGTAG, "ignoring AVIF compression failure", ex);
+                                    return null;
+                                },
+                                MoreExecutors.directExecutor());
+                avatarFutures.add(caughtAvifWithUrlFuture);
             }
         }
         avatarFutures.add(avatarThumbnailFuture);
@@ -597,7 +632,11 @@ public class AvatarManager extends AbstractManager {
                 Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
         avatarFutures.add(avatarWithUrlFuture);
 
-        return Futures.allAsList(avatarFutures.build());
+        final var all = Futures.allAsList(avatarFutures.build());
+        return Futures.transform(
+                all,
+                input -> Collections2.filter(input, Objects::nonNull),
+                MoreExecutors.directExecutor());
     }
 
     private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
@@ -801,6 +840,12 @@ public class AvatarManager extends AbstractManager {
                 AVATAR_COMPRESSION_EXECUTOR);
     }
 
+    private static final class AvifCompressionException extends IllegalStateException {
+        AvifCompressionException(final String message) {
+            super(message);
+        }
+    }
+
     public enum ImageFormat {
         PNG,
         JPEG,

src/main/java/eu/siacs/conversations/xmpp/manager/HttpUploadManager.java 🔗

@@ -6,6 +6,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Longs;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
@@ -16,6 +17,8 @@ import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
+import im.conversations.android.xmpp.ExtensionFactory;
+import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import im.conversations.android.xmpp.model.upload.Request;
 import im.conversations.android.xmpp.model.upload.purpose.Purpose;
@@ -147,6 +150,15 @@ public class HttpUploadManager extends AbstractManager {
                 MoreExecutors.directExecutor());
     }
 
+    public Service getService() {
+        if (Config.ENABLE_HTTP_UPLOAD) {
+            final var entry =
+                    getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
+            return entry == null ? null : new Service(entry);
+        }
+        return null;
+    }
+
     private static String convertFilename(final String name) {
         int pos = name.indexOf('.');
         if (pos < 0) {
@@ -165,6 +177,63 @@ public class HttpUploadManager extends AbstractManager {
         }
     }
 
+    public boolean isAvailableForSize(final long size) {
+        final var result = getManager(HttpUploadManager.class).getService();
+        if (result == null) {
+            return false;
+        }
+        final Long maxSize = result.getMaxFileSize();
+        if (maxSize == null) {
+            return true;
+        }
+        if (size <= maxSize) {
+            return true;
+        } else {
+            Log.d(
+                    Config.LOGTAG,
+                    getAccount().getJid().asBareJid()
+                            + ": http upload is not available for files with"
+                            + " size "
+                            + size
+                            + " (max is "
+                            + maxSize
+                            + ")");
+            return false;
+        }
+    }
+
+    public static final class Service {
+        private final Map.Entry<Jid, InfoQuery> addressInfoQuery;
+
+        public Service(final Map.Entry<Jid, InfoQuery> addressInfoQuery) {
+            this.addressInfoQuery = addressInfoQuery;
+        }
+
+        public Jid getAddress() {
+            return this.addressInfoQuery.getKey();
+        }
+
+        public InfoQuery getInfoQuery() {
+            return this.addressInfoQuery.getValue();
+        }
+
+        public boolean supportsPurpose(final Class<? extends Purpose> purpose) {
+            final var id = ExtensionFactory.id(purpose);
+            if (id == null) {
+                throw new IllegalStateException("Purpose has not been annotated as @XmlElement");
+            }
+            final var feature = String.format("%s#%s", id.namespace, id.name);
+            return getInfoQuery().hasFeature(feature);
+        }
+
+        public Long getMaxFileSize() {
+            final var value =
+                    getInfoQuery()
+                            .getServiceDiscoveryExtension(Namespace.HTTP_UPLOAD, "max-file-size");
+            return value == null ? null : Longs.tryParse(value);
+        }
+    }
+
     public static class Slot {
         public final HttpUrl put;
         public final HttpUrl get;

src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java 🔗

@@ -39,7 +39,8 @@ public class BindProcessor extends XmppConnection.Delegate implements Runnable {
             sosModified = false;
         }
         final boolean gainedFeature =
-                account.setOption(Account.OPTION_HTTP_UPLOAD_AVAILABLE, features.httpUpload(0));
+                account.setOption(
+                        Account.OPTION_HTTP_UPLOAD_AVAILABLE, account.httpUploadAvailable(0));
         if (loggedInSuccessfully || gainedFeature || sosModified) {
             service.databaseBackend.updateAccount(account);
         }