Detailed changes
  
  
    
    @@ -6,7 +6,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:8.9.1'
+        classpath 'com.android.tools.build:gradle:8.9.3'
         classpath "com.diffplug.spotless:spotless-plugin-gradle:7.0.2"
     }
 }
@@ -51,14 +51,15 @@ dependencies {
     implementation 'androidx.emoji2:emoji2:1.5.0'
     freeImplementation 'androidx.emoji2:emoji2-bundled:1.5.0'
     implementation 'androidx.emoji2:emoji2-emojipicker:1.5.0'
-    implementation 'androidx.exifinterface:exifinterface:1.4.0'
+    implementation 'androidx.exifinterface:exifinterface:1.4.1'
+    implementation 'androidx.heifwriter:heifwriter:1.1.0-beta01'
     implementation 'androidx.preference:preference:1.2.1'
     implementation 'androidx.sharetarget:sharetarget:1.2.0'
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
     implementation 'androidx.viewpager:viewpager:1.1.0'
-    implementation 'androidx.work:work-runtime:2.10.0'
+    implementation 'androidx.work:work-runtime:2.10.1'
     implementation 'com.github.open-keychain.open-keychain:openpgp-api:v5.7.1'
-    implementation 'com.google.android.material:material:1.13.0-alpha12'
+    implementation 'com.google.android.material:material:1.13.0-alpha13'
     implementation 'com.google.guava:guava:33.4.6-android'
     implementation 'com.google.zxing:core:3.5.3'
     implementation 'com.leinardi.android:speed-dial:3.3.0'
  
  
  
    
    @@ -60,6 +60,8 @@
         android:name="android.hardware.microphone"
         android:required="false" />
 
+    <uses-sdk tools:overrideLibrary="androidx.heifwriter"/>
+
     <queries>
         <package android:name="org.sufficientlysecure.keychain" />
         <package android:name="org.torproject.android" />
  
  
  
    
    @@ -79,6 +79,7 @@ public final class Config {
     // and webp
     public static final int AVATAR_SIZE = 192;
     public static final Bitmap.CompressFormat AVATAR_FORMAT = Bitmap.CompressFormat.JPEG;
+    public static final int AVATAR_FULL_SIZE = 1024;
     public static final int AVATAR_CHAR_LIMIT = 9400;
 
     public static final int IMAGE_SIZE = 1920;
@@ -131,6 +132,7 @@ public final class Config {
             false; // require a/v calls to be verified with OMEMO
     public static final boolean JINGLE_MESSAGE_INIT_STRICT_OFFLINE_CHECK = false;
     public static final boolean JINGLE_MESSAGE_INIT_STRICT_DEVICE_TIMEOUT = false;
+    // TODO extend this to 12s
     public static final long DEVICE_DISCOVERY_TIMEOUT = 6000; // in milliseconds
 
     public static final boolean ONLY_INTERNAL_STORAGE =
  
  
  
    
    @@ -136,7 +136,7 @@ public class HttpConnectionManager extends AbstractConnectionManager {
         return buildHttpClient(url, account, 30, interactive);
     }
 
-    OkHttpClient buildHttpClient(
+    public OkHttpClient buildHttpClient(
             final HttpUrl url, final Account account, int readTimeout, boolean interactive) {
         final String slotHostname = url.host();
         final boolean onionSlot = slotHostname.endsWith(".onion");
  
  
  
    
    @@ -773,7 +773,7 @@ public class FileBackend {
                 throw new ImageCompressionException("Source file had alpha channel");
             }
             Bitmap scaledBitmap = resize(originalBitmap, Config.IMAGE_SIZE);
-            final int rotation = getRotation(image);
+            final int rotation = getRotation(mXmppConnectionService, image);
             scaledBitmap = rotate(scaledBitmap, rotation);
             boolean targetSizeReached = false;
             int quality = Config.IMAGE_QUALITY;
@@ -930,9 +930,8 @@ public class FileBackend {
         }
     }
 
-    private int getRotation(final Uri image) {
-        try (final InputStream is =
-                mXmppConnectionService.getContentResolver().openInputStream(image)) {
+    private static int getRotation(final Context context, final Uri image) {
+        try (final InputStream is = context.getContentResolver().openInputStream(image)) {
             return is == null ? 0 : getRotation(is);
         } catch (final Exception e) {
             return 0;
@@ -1123,7 +1122,6 @@ public class FileBackend {
         }
     }
 
-    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
     private Bitmap cropCenterSquarePdf(final Uri uri, final int size) {
         try {
             ParcelFileDescriptor fileDescriptor =
@@ -1137,7 +1135,6 @@ public class FileBackend {
         }
     }
 
-    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
     private Bitmap renderPdfDocument(
             ParcelFileDescriptor fileDescriptor, int targetSize, boolean fit) throws IOException {
         final PdfRenderer pdfRenderer = new PdfRenderer(fileDescriptor);
@@ -1173,7 +1170,8 @@ public class FileBackend {
         return getUriForFile(mXmppConnectionService, file);
     }
 
-    public Avatar getPepAvatar(Uri image, int size, Bitmap.CompressFormat format) {
+    public Avatar getPepAvatar(
+            final Uri image, final int size, final Bitmap.CompressFormat format) {
 
         final Avatar uncompressAvatar = getUncompressedAvatar(image);
         if (uncompressAvatar != null
@@ -1261,6 +1259,7 @@ public class FileBackend {
         }
     }
 
+    // this was used by republishAvatarIfNeeded()
     public Avatar getStoredPepAvatar(String hash) {
         if (hash == null) {
             return null;
@@ -1385,8 +1384,12 @@ public class FileBackend {
         return new File(mXmppConnectionService.getFilesDir(), "/avatars/");
     }
 
-    private File getAvatarFile(String avatar) {
-        return new File(mXmppConnectionService.getCacheDir(), "/avatars/" + avatar);
+    public File getAvatarFile(final String avatar) {
+        return getAvatarFile(mXmppConnectionService, avatar);
+    }
+
+    public static File getAvatarFile(Context context, final String avatar) {
+        return new File(context.getCacheDir(), "/avatars/" + avatar);
     }
 
     public Uri getAvatarUri(String avatar) {
@@ -1394,18 +1397,21 @@ public class FileBackend {
     }
 
     public Bitmap cropCenterSquare(final Uri image, final int size) {
+        return cropCenterSquare(mXmppConnectionService, image, size);
+    }
+
+    public static Bitmap cropCenterSquare(final Context context, final Uri image, final int size) {
         if (image == null) {
             return null;
         }
         final BitmapFactory.Options options = new BitmapFactory.Options();
         try {
-            options.inSampleSize = calcSampleSize(image, size);
+            options.inSampleSize = calcSampleSize(context, image, size);
         } catch (final IOException | SecurityException e) {
             Log.d(Config.LOGTAG, "unable to calculate sample size for " + image, e);
             return null;
         }
-        try (final InputStream is =
-                mXmppConnectionService.getContentResolver().openInputStream(image)) {
+        try (final InputStream is = context.getContentResolver().openInputStream(image)) {
             if (is == null) {
                 return null;
             }
@@ -1413,7 +1419,7 @@ public class FileBackend {
             if (originalBitmap == null) {
                 return null;
             } else {
-                final var bitmap = rotate(originalBitmap, getRotation(image));
+                final var bitmap = rotate(originalBitmap, getRotation(context, image));
                 return cropCenterSquare(bitmap, size);
             }
         } catch (final SecurityException | IOException e) {
@@ -1465,7 +1471,7 @@ public class FileBackend {
         }
     }
 
-    public Bitmap cropCenterSquare(Bitmap input, int size) {
+    public static Bitmap cropCenterSquare(Bitmap input, int size) {
         int w = input.getWidth();
         int h = input.getHeight();
 
@@ -1487,10 +1493,14 @@ public class FileBackend {
     }
 
     private int calcSampleSize(final Uri image, int size) throws IOException, SecurityException {
+        return calcSampleSize(mXmppConnectionService, image, size);
+    }
+
+    private static int calcSampleSize(final Context context, final Uri image, int size)
+            throws IOException, SecurityException {
         final BitmapFactory.Options options = new BitmapFactory.Options();
         options.inJustDecodeBounds = true;
-        try (final InputStream inputStream =
-                mXmppConnectionService.getContentResolver().openInputStream(image)) {
+        try (final InputStream inputStream = context.getContentResolver().openInputStream(image)) {
             BitmapFactory.decodeStream(inputStream, null, options);
             return calcSampleSize(options, size);
         }
  
  
  
    
    @@ -4329,40 +4329,29 @@ public class XmppConnectionService extends Service {
             final Uri image,
             final boolean open,
             final OnAvatarPublication callback) {
-        final Bitmap.CompressFormat format = Config.AVATAR_FORMAT;
-        final int size = Config.AVATAR_SIZE;
-        final Avatar avatar = getFileBackend().getPepAvatar(image, size, format);
-        if (avatar == null) {
-            callback.onAvatarPublicationFailed(R.string.error_publish_avatar_converting);
-            return;
-        }
-        if (fileBackend.save(avatar)) {
-            final var connection = account.getXmppConnection();
-            final var future = connection.getManager(AvatarManager.class).publish(avatar, open);
-            Futures.addCallback(
-                    future,
-                    new FutureCallback<Void>() {
-                        @Override
-                        public void onSuccess(Void result) {
-                            callback.onAvatarPublicationSucceeded();
-                        }
 
-                        @Override
-                        public void onFailure(@NonNull Throwable t) {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    account.getJid().asBareJid() + ": could not publish avatar",
-                                    t);
-                            callback.onAvatarPublicationFailed(
-                                    R.string.error_publish_avatar_server_reject);
-                        }
-                    },
-                    MoreExecutors.directExecutor());
+        final var connection = account.getXmppConnection();
+        final var publicationFuture =
+                connection.getManager(AvatarManager.class).uploadAndPublish(image, open);
 
-        } else {
-            Log.d(Config.LOGTAG, "could not save avatar");
-            callback.onAvatarPublicationFailed(R.string.error_saving_avatar);
-        }
+        Futures.addCallback(
+                publicationFuture,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Void result) {
+                        Log.d(Config.LOGTAG, "published avatar");
+                        callback.onAvatarPublicationSucceeded();
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull final Throwable t) {
+                        Log.d(Config.LOGTAG, "avatar upload failed", t);
+                        // TODO actually figure out what went wrong
+                        callback.onAvatarPublicationFailed(
+                                R.string.error_publish_avatar_server_reject);
+                    }
+                },
+                MoreExecutors.directExecutor());
     }
 
     private void publishMucAvatar(
  
  
  
    
    @@ -37,6 +37,10 @@ public class Compatibility {
         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P;
     }
 
+    public static boolean thirtyFour() {
+        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+    }
+
     public static void startService(final Context context, final Intent intent) {
         try {
             if (Compatibility.twentySix()) {
  
  
  
    
    @@ -33,6 +33,7 @@ public final class Namespace {
     public static final String REGISTER_STREAM_FEATURE = "http://jabber.org/features/iq-register";
     public static final String BYTE_STREAMS = "http://jabber.org/protocol/bytestreams";
     public static final String HTTP_UPLOAD = "urn:xmpp:http:upload:0";
+    public static final String HTTP_UPLOAD_PURPOSE = "urn:xmpp:http:upload:purpose:0";
     public static final String STANZA_IDS = "urn:xmpp:sid:0";
     public static final String IDLE = "urn:xmpp:idle:1";
     public static final String DATA = "jabber:x:data";
  
  
  
    
    @@ -1,15 +1,27 @@
 package eu.siacs.conversations.xmpp.manager;
 
+import android.graphics.Bitmap;
+import android.net.Uri;
 import android.util.Log;
 import androidx.annotation.NonNull;
+import androidx.heifwriter.AvifWriter;
+import androidx.heifwriter.HeifWriter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.hash.Hashing;
+import com.google.common.hash.HashingOutputStream;
 import com.google.common.io.BaseEncoding;
+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 eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.Compatibility;
+import eu.siacs.conversations.utils.PhoneHelper;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.XmppConnection;
@@ -20,9 +32,23 @@ import im.conversations.android.xmpp.model.avatar.Data;
 import im.conversations.android.xmpp.model.avatar.Info;
 import im.conversations.android.xmpp.model.avatar.Metadata;
 import im.conversations.android.xmpp.model.pubsub.Items;
+import im.conversations.android.xmpp.model.upload.purpose.Profile;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
 
 public class AvatarManager extends AbstractManager {
 
+    private static final Executor AVATAR_COMPRESSION_EXECUTOR =
+            MoreExecutors.newSequentialExecutor(Executors.newSingleThreadScheduledExecutor());
+
     private final XmppConnectionService service;
 
     public AvatarManager(final XmppConnectionService service, XmppConnection connection) {
@@ -133,29 +159,207 @@ public class AvatarManager extends AbstractManager {
         }
     }
 
-    public ListenableFuture<Void> publish(final Avatar avatar, final boolean open) {
+    private Info resizeAndStoreAvatar(
+            final Uri image, final int size, final ImageFormat format, final Integer charLimit)
+            throws Exception {
+        final var centerSquare = FileBackend.cropCenterSquare(context, image, size);
+        if (charLimit == null || format == ImageFormat.PNG) {
+            return resizeAndStoreAvatar(centerSquare, format, 90);
+        } else {
+            Info avatar = null;
+            for (int quality = 90; quality >= 50; quality = quality - 2) {
+                if (avatar != null) {
+                    FileBackend.getAvatarFile(context, avatar.getId()).delete();
+                }
+                Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
+                avatar = resizeAndStoreAvatar(centerSquare, format, quality);
+                if (avatar.getBytes() <= charLimit) {
+                    return avatar;
+                }
+            }
+            return avatar;
+        }
+    }
+
+    private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
+            throws Exception {
+        return switch (format) {
+            case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
+            case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
+            case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
+            case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
+            case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
+        };
+    }
+
+    private Info resizeAndStoreAvatar(
+            final Bitmap image, final Bitmap.CompressFormat format, final int quality)
+            throws IOException {
+        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+        final var fileOutputStream = new FileOutputStream(randomFile);
+        final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
+        image.compress(format, quality, hashingOutputStream);
+        hashingOutputStream.close();
+        final var sha1 = hashingOutputStream.hash().toString();
+        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
+        if (randomFile.renameTo(avatarFile)) {
+            return new Info(
+                    sha1,
+                    avatarFile.length(),
+                    ImageFormat.of(format).toContentType(),
+                    image.getHeight(),
+                    image.getWidth());
+        }
+        throw new IllegalStateException(
+                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
+    }
+
+    private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
+            throws Exception {
+        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+        try (final var fileOutputStream = new FileOutputStream(randomFile);
+                final var heifWriter =
+                        new HeifWriter.Builder(
+                                        fileOutputStream.getFD(),
+                                        image.getWidth(),
+                                        image.getHeight(),
+                                        HeifWriter.INPUT_MODE_BITMAP)
+                                .setMaxImages(1)
+                                .setQuality(quality)
+                                .build()) {
+
+            heifWriter.start();
+            heifWriter.addBitmap(image);
+            heifWriter.stop(3_000);
+        }
+        return storeAsAvatar(randomFile, ImageFormat.HEIF, image.getHeight(), image.getWidth());
+    }
+
+    private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
+            throws Exception {
+        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
+        try (final var fileOutputStream = new FileOutputStream(randomFile);
+                final var avifWriter =
+                        new AvifWriter.Builder(
+                                        fileOutputStream.getFD(),
+                                        image.getWidth(),
+                                        image.getHeight(),
+                                        AvifWriter.INPUT_MODE_BITMAP)
+                                .setMaxImages(1)
+                                .setQuality(quality)
+                                .build()) {
+            avifWriter.start();
+            avifWriter.addBitmap(image);
+            avifWriter.stop(3_000);
+        }
+        return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
+    }
+
+    private Info storeAsAvatar(
+            final File randomFile, final ImageFormat type, final int height, final int width)
+            throws IOException {
+        final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
+        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
+        if (randomFile.renameTo(avatarFile)) {
+            return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
+        }
+        throw new IllegalStateException(
+                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
+    }
+
+    public ListenableFuture<List<Info>> uploadAvatar(final Uri image, final int size) {
+        final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
+        final var avatarFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.JPEG);
+        final var avatarWithUrlFuture =
+                Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
+        avatarFutures.add(avatarWithUrlFuture);
+
+        if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
+            final var avatarHeifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.HEIF);
+            final var avatarHeifWithUrlFuture =
+                    Futures.transformAsync(
+                            avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
+            avatarFutures.add(avatarHeifWithUrlFuture);
+        }
+        if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
+            final var avatarAvifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.AVIF);
+            final var avatarAvifWithUrlFuture =
+                    Futures.transformAsync(
+                            avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
+            avatarFutures.add(avatarAvifWithUrlFuture);
+        }
+
+        final var avatarThumbnailFuture =
+                resizeAndStoreAvatarAsync(
+                        image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
+        avatarFutures.add(avatarThumbnailFuture);
+
+        return Futures.allAsList(avatarFutures.build());
+    }
+
+    private ListenableFuture<Info> upload(final Info avatar) {
+        final var file = FileBackend.getAvatarFile(context, avatar.getId());
+        final var urlFuture =
+                getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
+        return Futures.transform(
+                urlFuture,
+                url -> {
+                    avatar.setUrl(url);
+                    return avatar;
+                },
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<Info> resizeAndStoreAvatarAsync(
+            final Uri image, final int size, final ImageFormat format) {
+        return resizeAndStoreAvatarAsync(image, size, format, null);
+    }
+
+    private ListenableFuture<Info> resizeAndStoreAvatarAsync(
+            final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
+        return Futures.submit(
+                () -> resizeAndStoreAvatar(image, size, format, charLimit),
+                AVATAR_COMPRESSION_EXECUTOR);
+    }
+
+    public ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
+        final Info mainAvatarInfo;
+        final byte[] mainAvatar;
+        try {
+            mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
+            mainAvatar =
+                    Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
+                            .read();
+        } catch (final IOException | NoSuchElementException e) {
+            return Futures.immediateFailedFuture(e);
+        }
         final NodeConfiguration configuration =
                 open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
         final var avatarData = new Data();
-        avatarData.setContent(avatar.getImageAsBytes());
+        avatarData.setContent(mainAvatar);
         final var future =
-                getManager(PepManager.class).publish(avatarData, avatar.sha1sum, configuration);
+                getManager(PepManager.class)
+                        .publish(avatarData, mainAvatarInfo.getId(), configuration);
         return Futures.transformAsync(
                 future,
                 v -> {
-                    final var id = avatar.sha1sum;
+                    final var id = mainAvatarInfo.getId();
                     final var metadata = new Metadata();
-                    final var info = metadata.addExtension(new Info());
-                    info.setBytes(avatar.size);
-                    info.setId(avatar.sha1sum);
-                    info.setHeight(avatar.height);
-                    info.setWidth(avatar.width);
-                    info.setType(avatar.type);
+                    metadata.addExtensions(avatars);
                     return getManager(PepManager.class).publish(metadata, id, configuration);
                 },
                 MoreExecutors.directExecutor());
     }
 
+    public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
+        final var infoFuture =
+                connection
+                        .getManager(AvatarManager.class)
+                        .uploadAvatar(image, Config.AVATAR_FULL_SIZE);
+        return Futures.transformAsync(
+                infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
+    }
+
     public boolean hasPepToVCardConversion() {
         return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
     }
@@ -169,4 +373,41 @@ public class AvatarManager extends AbstractManager {
                 vs -> null,
                 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 enum ImageFormat {
+        PNG,
+        JPEG,
+        WEBP,
+        HEIF,
+        AVIF;
+
+        public String toContentType() {
+            return switch (this) {
+                case WEBP -> "image/webp";
+                case PNG -> "image/png";
+                case JPEG -> "image/jpeg";
+                case AVIF -> "image/avif";
+                case HEIF -> "image/heif";
+            };
+        }
+
+        public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
+            return switch (compressFormat) {
+                case PNG -> PNG;
+                case WEBP -> WEBP;
+                case JPEG -> JPEG;
+                default -> throw new AssertionError("Not implemented");
+            };
+        }
+    }
 }
  
  
  
    
    @@ -1,47 +1,122 @@
 package eu.siacs.conversations.xmpp.manager;
 
-import android.content.Context;
 import android.util.Base64;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 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.Config;
 import eu.siacs.conversations.entities.DownloadableFile;
+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.model.stanza.Iq;
 import im.conversations.android.xmpp.model.upload.Request;
+import im.conversations.android.xmpp.model.upload.purpose.Purpose;
+import java.io.File;
+import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.Map;
 import java.util.UUID;
+import okhttp3.Call;
+import okhttp3.Callback;
 import okhttp3.Headers;
 import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.RequestBody;
+import okhttp3.Response;
 
 public class HttpUploadManager extends AbstractManager {
 
-    public HttpUploadManager(final Context context, final XmppConnection connection) {
-        super(context, connection);
+    private final XmppConnectionService service;
+
+    public HttpUploadManager(final XmppConnectionService service, final XmppConnection connection) {
+        super(service.getApplicationContext(), connection);
+        this.service = service;
     }
 
     public ListenableFuture<Slot> request(final DownloadableFile file, final String mime) {
+        return request(file.getName(), mime, file.getExpectedSize(), null);
+    }
+
+    public ListenableFuture<Slot> request(
+            final String filename,
+            final String mime,
+            final long size,
+            @Nullable final Purpose purpose) {
         final var result =
                 getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
         if (result == null) {
             return Futures.immediateFailedFuture(
                     new IllegalStateException("No HTTP upload host found"));
         }
-        return requestHttpUpload(result.getKey(), file, mime);
+        return requestHttpUpload(result.getKey(), filename, mime, size, purpose);
+    }
+
+    public ListenableFuture<HttpUrl> upload(
+            final File file, final String mime, final Purpose purpose) {
+        final var filename = file.getName();
+        final var size = file.length();
+        final var slotFuture = request(filename, mime, size, purpose);
+        return Futures.transformAsync(
+                slotFuture, slot -> upload(file, mime, slot), MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<HttpUrl> upload(final File file, final String mime, final Slot slot) {
+        final SettableFuture<HttpUrl> future = SettableFuture.create();
+        final OkHttpClient client =
+                service.getHttpConnectionManager()
+                        .buildHttpClient(slot.put, getAccount(), 0, false);
+        final var body = RequestBody.create(MediaType.parse(mime), file);
+        final okhttp3.Request request =
+                new okhttp3.Request.Builder().url(slot.put).put(body).headers(slot.headers).build();
+        client.newCall(request)
+                .enqueue(
+                        new Callback() {
+                            @Override
+                            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+                                future.setException(e);
+                            }
+
+                            @Override
+                            public void onResponse(@NonNull Call call, @NonNull Response response) {
+                                if (response.isSuccessful()) {
+                                    future.set(slot.get);
+                                } else {
+                                    future.setException(
+                                            new IllegalStateException(
+                                                    String.format(
+                                                            "Response code was %s",
+                                                            response.code())));
+                                }
+                            }
+                        });
+        return future;
     }
 
     private ListenableFuture<Slot> requestHttpUpload(
-            final Jid host, final DownloadableFile file, final String mime) {
+            final Jid host,
+            final String filename,
+            final String mime,
+            final long size,
+            @Nullable final Purpose purpose) {
         final Iq iq = new Iq(Iq.Type.GET);
         iq.setTo(host);
         final var request = iq.addExtension(new Request());
-        request.setFilename(convertFilename(file.getName()));
-        request.setSize(file.getExpectedSize());
+        request.setFilename(convertFilename(filename));
+        request.setSize(size);
         request.setContentType(mime);
+        if (purpose != null) {
+            request.addExtension(purpose);
+        }
+        Log.d(Config.LOGTAG, "-->" + iq);
         final var iqFuture = this.connection.sendIqPacket(iq);
         return Futures.transform(
                 iqFuture,
@@ -104,5 +179,15 @@ public class HttpUploadManager extends AbstractManager {
         private Slot(final HttpUrl put, final HttpUrl get, final Map<String, String> headers) {
             this(put, get, Headers.of(headers));
         }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return MoreObjects.toStringHelper(this)
+                    .add("put", put)
+                    .add("get", get)
+                    .add("headers", headers)
+                    .toString();
+        }
     }
 }
  
  
  
    
    @@ -1,12 +1,31 @@
 package eu.siacs.conversations.xmpp.pep;
 
 import android.util.Base64;
+import androidx.annotation.NonNull;
+import com.google.common.base.MoreObjects;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xmpp.Jid;
 import im.conversations.android.xmpp.model.avatar.Metadata;
+import okhttp3.HttpUrl;
 
 public class Avatar {
 
+    @Override
+    @NonNull
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("type", type)
+                .add("sha1sum", sha1sum)
+                .add("url", url)
+                .add("image", image)
+                .add("height", height)
+                .add("width", width)
+                .add("size", size)
+                .add("owner", owner)
+                .add("origin", origin)
+                .toString();
+    }
+
     public enum Origin {
         PEP,
         VCARD
@@ -14,6 +33,7 @@ public class Avatar {
 
     public String type;
     public String sha1sum;
+    public HttpUrl url;
     public String image;
     public int height;
     public int width;
  
  
  
    
    @@ -1,8 +1,10 @@
 package im.conversations.android.xmpp.model.avatar;
 
+import com.google.common.base.Strings;
 import eu.siacs.conversations.xml.Namespace;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import okhttp3.HttpUrl;
 
 @XmlElement(namespace = Namespace.AVATAR_METADATA)
 public class Info extends Extension {
@@ -11,6 +13,20 @@ public class Info extends Extension {
         super(Info.class);
     }
 
+    public Info(
+            final String id,
+            final long bytes,
+            final String type,
+            final int height,
+            final int width) {
+        this();
+        this.setId(id);
+        this.setBytes(bytes);
+        this.setType(type);
+        this.setHeight(height);
+        this.setWidth(width);
+    }
+
     public long getHeight() {
         return this.getLongAttribute("height");
     }
@@ -27,8 +43,12 @@ public class Info extends Extension {
         return this.getAttribute("type");
     }
 
-    public String getUrl() {
-        return this.getAttribute("url");
+    public HttpUrl getUrl() {
+        final var url = this.getAttribute("url");
+        if (Strings.isNullOrEmpty(url)) {
+            return null;
+        }
+        return HttpUrl.parse(url);
     }
 
     public String getId() {
@@ -54,4 +74,8 @@ public class Info extends Extension {
     public void setType(final String type) {
         this.setAttribute("type", type);
     }
+
+    public void setUrl(final HttpUrl url) {
+        this.setAttribute("url", url.toString());
+    }
 }
  
  
  
    
    @@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Ephemeral extends Purpose {
+
+    public Ephemeral() {
+        super(Ephemeral.class);
+    }
+}
  
  
  
    
    @@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Message extends Purpose {
+
+    public Message() {
+        super(Purpose.class);
+    }
+}
  
  
  
    
    @@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Permanent extends Purpose {
+
+    public Permanent() {
+        super(Permanent.class);
+    }
+}
  
  
  
    
    @@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.annotation.XmlElement;
+
+@XmlElement
+public class Profile extends Purpose {
+
+    public Profile() {
+        super(Profile.class);
+    }
+}
  
  
  
    
    @@ -0,0 +1,10 @@
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import im.conversations.android.xmpp.model.Extension;
+
+public abstract class Purpose extends Extension {
+
+    protected Purpose(final Class<? extends Purpose> clazz) {
+        super(clazz);
+    }
+}
  
  
  
    
    @@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.HTTP_UPLOAD_PURPOSE)
+package im.conversations.android.xmpp.model.upload.purpose;
+
+import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;