AvatarManager.java

  1package eu.siacs.conversations.xmpp.manager;
  2
  3import android.graphics.Bitmap;
  4import android.graphics.BitmapFactory;
  5import android.net.Uri;
  6import android.util.Log;
  7import androidx.annotation.NonNull;
  8import androidx.annotation.Nullable;
  9import androidx.heifwriter.AvifWriter;
 10import androidx.heifwriter.HeifWriter;
 11import com.google.common.base.CaseFormat;
 12import com.google.common.base.Preconditions;
 13import com.google.common.base.Strings;
 14import com.google.common.collect.Collections2;
 15import com.google.common.collect.ComparisonChain;
 16import com.google.common.collect.ImmutableList;
 17import com.google.common.collect.Iterables;
 18import com.google.common.collect.Ordering;
 19import com.google.common.hash.Hashing;
 20import com.google.common.hash.HashingOutputStream;
 21import com.google.common.io.ByteStreams;
 22import com.google.common.io.Files;
 23import com.google.common.util.concurrent.FutureCallback;
 24import com.google.common.util.concurrent.Futures;
 25import com.google.common.util.concurrent.ListenableFuture;
 26import com.google.common.util.concurrent.MoreExecutors;
 27import com.google.common.util.concurrent.SettableFuture;
 28import eu.siacs.conversations.AppSettings;
 29import eu.siacs.conversations.Config;
 30import eu.siacs.conversations.R;
 31import eu.siacs.conversations.android.Device;
 32import eu.siacs.conversations.entities.Contact;
 33import eu.siacs.conversations.entities.Conversation;
 34import eu.siacs.conversations.entities.Conversational;
 35import eu.siacs.conversations.persistance.FileBackend;
 36import eu.siacs.conversations.services.XmppConnectionService;
 37import eu.siacs.conversations.utils.Compatibility;
 38import eu.siacs.conversations.xml.Namespace;
 39import eu.siacs.conversations.xmpp.Jid;
 40import eu.siacs.conversations.xmpp.XmppConnection;
 41import im.conversations.android.xmpp.NodeConfiguration;
 42import im.conversations.android.xmpp.model.ByteContent;
 43import im.conversations.android.xmpp.model.avatar.Data;
 44import im.conversations.android.xmpp.model.avatar.Info;
 45import im.conversations.android.xmpp.model.avatar.Metadata;
 46import im.conversations.android.xmpp.model.pubsub.Items;
 47import im.conversations.android.xmpp.model.upload.purpose.Profile;
 48import im.conversations.android.xmpp.model.vcard.update.VCardUpdate;
 49import java.io.File;
 50import java.io.FileOutputStream;
 51import java.io.IOException;
 52import java.util.Collection;
 53import java.util.List;
 54import java.util.Map;
 55import java.util.NoSuchElementException;
 56import java.util.Objects;
 57import java.util.UUID;
 58import java.util.concurrent.Executor;
 59import java.util.concurrent.Executors;
 60import okhttp3.Call;
 61import okhttp3.Callback;
 62import okhttp3.HttpUrl;
 63import okhttp3.OkHttpClient;
 64import okhttp3.Request;
 65import okhttp3.Response;
 66
 67public class AvatarManager extends AbstractManager {
 68
 69    private static final Object RENAME_LOCK = new Object();
 70
 71    private static final List<String> SUPPORTED_CONTENT_TYPES;
 72
 73    private static final Ordering<Info> AVATAR_ORDERING =
 74            new Ordering<>() {
 75                @Override
 76                public int compare(Info left, Info right) {
 77                    return ComparisonChain.start()
 78                            .compare(
 79                                    right.getWidth() * right.getHeight(),
 80                                    left.getWidth() * left.getHeight())
 81                            .compare(
 82                                    ImageFormat.formatPriority(right.getType()),
 83                                    ImageFormat.formatPriority(left.getType()))
 84                            .result();
 85                }
 86            };
 87
 88    static {
 89        final ImmutableList.Builder<ImageFormat> builder = new ImmutableList.Builder<>();
 90        builder.add(ImageFormat.JPEG, ImageFormat.PNG, ImageFormat.WEBP, ImageFormat.SVG);
 91        if (Compatibility.twentyEight()) {
 92            builder.add(ImageFormat.HEIF);
 93        }
 94        if (Compatibility.thirtyFour()) {
 95            builder.add(ImageFormat.AVIF);
 96        }
 97        final var supportedFormats = builder.build();
 98        SUPPORTED_CONTENT_TYPES =
 99                ImmutableList.copyOf(
100                        Collections2.transform(supportedFormats, ImageFormat::toContentType));
101    }
102
103    private static final Executor AVATAR_COMPRESSION_EXECUTOR =
104            MoreExecutors.newSequentialExecutor(Executors.newSingleThreadScheduledExecutor());
105
106    private final XmppConnectionService service;
107
108    public AvatarManager(final XmppConnectionService service, XmppConnection connection) {
109        super(service, connection);
110        this.service = service;
111    }
112
113    public ListenableFuture<NodeConfiguration.AccessModel> getPepAccessModel() {
114        final var nodeConfiguration =
115                getManager(PepManager.class).getNodeConfiguration(Namespace.AVATAR_DATA);
116        return Futures.transform(
117                nodeConfiguration,
118                data -> {
119                    final var accessModel = data.getValue(NodeConfiguration.ACCESS_MODEL);
120                    if (Strings.isNullOrEmpty(accessModel)) {
121                        throw new IllegalStateException(
122                                "Access model missing from node configuration");
123                    }
124                    return NodeConfiguration.AccessModel.valueOf(
125                            CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, accessModel));
126                },
127                MoreExecutors.directExecutor());
128    }
129
130    private ListenableFuture<byte[]> fetch(final Jid address, final String itemId) {
131        if (context.isBlockedMediaSha1(itemId)) {
132            final SettableFuture<byte[]> future = SettableFuture.create();
133            future.setException(new XmppConnectionService.BlockedMediaException());
134            return future;
135        }
136        final var future = getManager(PubSubManager.class).fetchItem(address, itemId, Data.class);
137        return Futures.transform(future, ByteContent::asBytes, MoreExecutors.directExecutor());
138    }
139
140    private ListenableFuture<Info> fetchAndStoreWithFallback(
141            final Jid address, final Info picked, final Info fallback) {
142        Preconditions.checkArgument(fallback.getUrl() == null, "fallback avatar must be in-band");
143        final var url = picked.getUrl();
144        if (url != null) {
145            final var httpDownloadFuture = fetchAndStoreHttp(url, picked);
146            return Futures.catchingAsync(
147                    httpDownloadFuture,
148                    Exception.class,
149                    ex -> {
150                        Log.d(
151                                Config.LOGTAG,
152                                getAccount().getJid().asBareJid()
153                                        + ": could not download avatar for "
154                                        + address
155                                        + " from "
156                                        + url,
157                                ex);
158                        return fetchAndStoreInBand(address, fallback);
159                    },
160                    MoreExecutors.directExecutor());
161        } else {
162            return fetchAndStoreInBand(address, picked);
163        }
164    }
165
166    private ListenableFuture<Info> fetchAndStoreInBand(final Jid address, final Info avatar) {
167        final var future = fetch(address, avatar.getId());
168        return Futures.transformAsync(
169                future,
170                data -> {
171                    final var actualHash = Hashing.sha1().hashBytes(data).toString();
172                    if (!actualHash.equals(avatar.getId())) {
173                        throw new IllegalStateException(
174                                String.format("In-band avatar hash of %s did not match", address));
175                    }
176
177                    final var file = FileBackend.getAvatarFile(context, avatar.getId());
178                    if (file.exists()) {
179                        return Futures.immediateFuture(avatar);
180                    }
181                    return Futures.transform(
182                            write(file, data), v -> avatar, MoreExecutors.directExecutor());
183                },
184                MoreExecutors.directExecutor());
185    }
186
187    private ListenableFuture<Void> write(final File destination, byte[] bytes) {
188        return Futures.submit(
189                () -> {
190                    final var randomFile =
191                            new File(context.getCacheDir(), UUID.randomUUID().toString());
192                    Files.write(bytes, randomFile);
193                    if (moveAvatarIntoCache(randomFile, destination)) {
194                        return null;
195                    }
196                    throw new IllegalStateException(
197                            String.format(
198                                    "Could not move file to %s", destination.getAbsolutePath()));
199                },
200                AVATAR_COMPRESSION_EXECUTOR);
201    }
202
203    private ListenableFuture<Info> fetchAndStoreHttp(final HttpUrl url, final Info avatar) {
204        final SettableFuture<Info> settableFuture = SettableFuture.create();
205        final OkHttpClient client =
206                service.getHttpConnectionManager().buildHttpClient(url, getAccount(), 30, false);
207        final var request = new Request.Builder().url(url).get().build();
208        client.newCall(request)
209                .enqueue(
210                        new Callback() {
211                            @Override
212                            public void onFailure(@NonNull Call call, @NonNull IOException e) {
213                                settableFuture.setException(e);
214                            }
215
216                            @Override
217                            public void onResponse(@NonNull Call call, @NonNull Response response) {
218                                if (response.isSuccessful()) {
219                                    try {
220                                        write(avatar, response);
221                                    } catch (final Exception e) {
222                                        settableFuture.setException(e);
223                                        return;
224                                    }
225                                    settableFuture.set(avatar);
226                                } else {
227                                    settableFuture.setException(
228                                            new IOException("HTTP call was not successful"));
229                                }
230                            }
231                        });
232        return settableFuture;
233    }
234
235    private void write(final Info avatar, Response response) throws IOException {
236        final var body = response.body();
237        if (body == null) {
238            throw new IOException("Body was null");
239        }
240        final long bytes = avatar.getBytes();
241        final long actualBytes;
242        final var inputStream = ByteStreams.limit(body.byteStream(), avatar.getBytes());
243        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
244        final String actualHash;
245        try (final var fileOutputStream = new FileOutputStream(randomFile);
246                var hashingOutputStream =
247                        new HashingOutputStream(Hashing.sha1(), fileOutputStream)) {
248            actualBytes = ByteStreams.copy(inputStream, hashingOutputStream);
249            actualHash = hashingOutputStream.hash().toString();
250        }
251        if (actualBytes != bytes) {
252            throw new IllegalStateException("File size did not meet expected size");
253        }
254        if (!actualHash.equals(avatar.getId())) {
255            throw new IllegalStateException("File hash did not match");
256        }
257        final var avatarFile = FileBackend.getAvatarFile(context, avatar.getId());
258        if (moveAvatarIntoCache(randomFile, avatarFile)) {
259            return;
260        }
261        throw new IOException("Could not move avatar to avatar location");
262    }
263
264    private void setAvatarInfo(final Jid address, @NonNull final Info info) {
265        setAvatar(address, info.getId());
266    }
267
268    private void setAvatar(final Jid from, @Nullable final String id) {
269        if (context.isBlockedMediaSha1(id)) {
270            return;
271        }
272        Log.d(Config.LOGTAG, "setting avatar for " + from + " to " + id);
273        if (from.isBareJid()) {
274            setAvatarContact(from, id);
275        } else {
276            setAvatarMucUser(from, id);
277        }
278    }
279
280    private void setAvatarContact(final Jid from, @Nullable final String id) {
281        final var account = getAccount();
282        if (account.getJid().asBareJid().equals(from)) {
283            if (account.setAvatar(id)) {
284                getDatabase().updateAccount(account);
285                service.notifyAccountAvatarHasChanged(account);
286            }
287            service.getAvatarService().clear(account);
288            service.updateConversationUi();
289            service.updateAccountUi();
290        } else {
291            final Contact contact = account.getRoster().getContact(from);
292            if (contact.setAvatar(id)) {
293                connection.getManager(RosterManager.class).writeToDatabaseAsync();
294                service.getAvatarService().clear(contact);
295
296                final var conversation = service.find(account, from);
297                if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI) {
298                    service.getAvatarService().clear(conversation.getMucOptions());
299                }
300
301                service.updateConversationUi();
302                service.updateRosterUi(XmppConnectionService.UpdateRosterReason.AVATAR);
303            }
304        }
305    }
306
307    private void setAvatarMucUser(final Jid from, final String id) {
308        final var account = getAccount();
309        final Conversation conversation = service.find(account, from.asBareJid());
310        if (conversation == null || conversation.getMode() != Conversation.MODE_MULTI) {
311            return;
312        }
313        final var user = conversation.getMucOptions().findUserByFullJid(from);
314        if (user == null) {
315            return;
316        }
317        if (user.setAvatar(id)) {
318            service.getAvatarService().clear(user);
319            service.updateConversationUi();
320            service.updateMucRosterUi();
321        }
322    }
323
324    public void handleItems(final Jid from, final Items items) {
325        final var account = getAccount();
326        // TODO support retract
327        final var entry = items.getFirstItemWithId(Metadata.class);
328        if (entry == null) {
329            return;
330        }
331        final var avatar = getPreferredFallback(entry);
332        if (avatar == null) {
333            return;
334        }
335
336        Log.d(Config.LOGTAG, "picked avatar from " + from + ": " + avatar.preferred);
337
338        final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
339
340        if (cache.exists()) {
341            setAvatarInfo(from, avatar.preferred);
342        } else if (service.isDataSaverDisabled()) {
343            final var contact = getManager(RosterManager.class).getContactFromContactList(from);
344            final ListenableFuture<Info> future;
345            if (contact != null && contact.showInContactList()) {
346                future = this.fetchAndStoreWithFallback(from, avatar.preferred, avatar.fallback);
347            } else {
348                future = fetchAndStoreInBand(from, avatar.fallback);
349            }
350            Futures.addCallback(
351                    future,
352                    new FutureCallback<>() {
353                        @Override
354                        public void onSuccess(Info result) {
355                            setAvatarInfo(from, result);
356                            Log.d(
357                                    Config.LOGTAG,
358                                    account.getJid().asBareJid()
359                                            + ": successfully fetched pep avatar for "
360                                            + from);
361                        }
362
363                        @Override
364                        public void onFailure(@NonNull Throwable t) {
365                            Log.d(Config.LOGTAG, "could not fetch avatar", t);
366                        }
367                    },
368                    MoreExecutors.directExecutor());
369        }
370    }
371
372    public void handleVCardUpdate(final Jid address, final VCardUpdate vCardUpdate) {
373        final var hash = vCardUpdate.getHash();
374        if (hash == null) {
375            return;
376        }
377        handleVCardUpdate(address, hash);
378    }
379
380    public void handleVCardUpdate(final Jid address, final String hash) {
381        Preconditions.checkArgument(VCardUpdate.isValidSHA1(hash));
382        final var avatarFile = FileBackend.getAvatarFile(context, hash);
383        if (avatarFile.exists()) {
384            setAvatar(address, hash);
385        } else if (service.isDataSaverDisabled()) {
386            final var future = this.fetchAndStoreVCard(address, hash);
387            Futures.addCallback(
388                    future,
389                    new FutureCallback<Void>() {
390                        @Override
391                        public void onSuccess(Void result) {
392                            Log.d(Config.LOGTAG, "successfully fetch vCard avatar for " + address);
393                        }
394
395                        @Override
396                        public void onFailure(@NonNull Throwable t) {
397                            Log.d(Config.LOGTAG, "could not fetch avatar for " + address, t);
398                        }
399                    },
400                    MoreExecutors.directExecutor());
401        }
402    }
403
404    private PreferredFallback getPreferredFallback(final Map.Entry<String, Metadata> entry) {
405        final var mainItemId = entry.getKey();
406        final var infos = entry.getValue().getInfos();
407
408        final var inBandAvatar = Iterables.find(infos, i -> mainItemId.equals(i.getId()), null);
409
410        if (inBandAvatar == null || inBandAvatar.getUrl() != null) {
411            return null;
412        }
413
414        final var optionalAutoAcceptSize = new AppSettings(context).getAutoAcceptFileSize();
415        if (optionalAutoAcceptSize.isEmpty()) {
416            return new PreferredFallback(inBandAvatar);
417        } else {
418
419            final var supported =
420                    Collections2.filter(
421                            infos,
422                            i ->
423                                    Objects.nonNull(i.getId()) && !context.isBlockedMediaSha1(i.getId())
424                                            && i.getBytes() > 0
425                                            && i.getHeight() > 0
426                                            && i.getWidth() > 0
427                                            && SUPPORTED_CONTENT_TYPES.contains(i.getType()));
428
429            final var autoAcceptSize = optionalAutoAcceptSize.get();
430
431            final var supportedBelowLimit =
432                    Collections2.filter(supported, i -> i.getBytes() <= autoAcceptSize);
433
434            if (supportedBelowLimit.isEmpty()) {
435                return new PreferredFallback(inBandAvatar);
436            } else {
437                final var preferred =
438                        Iterables.getFirst(AVATAR_ORDERING.sortedCopy(supportedBelowLimit), null);
439                return new PreferredFallback(preferred, inBandAvatar);
440            }
441        }
442    }
443
444    public void handleDelete(final Jid from) {
445        Preconditions.checkArgument(
446                from.isBareJid(), "node deletion can only be triggered from bare JIDs");
447        setAvatar(from, null);
448    }
449
450    private Info resizeAndStoreAvatar(
451            final Uri image, final int size, final ImageFormat format, final Integer charLimit)
452            throws Exception {
453        final var centerSquare = context.getFileBackend().cropCenterSquare(image, size);
454        final var info = resizeAndStoreAvatar(centerSquare, format, charLimit);
455        centerSquare.recycle();
456        return info;
457    }
458
459    private Info resizeAndStoreAvatar(
460            final Bitmap centerSquare, final ImageFormat format, final Integer charLimit)
461            throws Exception {
462        if (charLimit == null || format == ImageFormat.PNG) {
463            return resizeAndStoreAvatar(centerSquare, format, 90);
464        } else {
465            Info avatar = null;
466            for (int quality = 90; quality >= 50; quality = quality - 2) {
467                if (avatar != null) {
468                    FileBackend.getAvatarFile(context, avatar.getId()).delete();
469                }
470                Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
471                avatar = resizeAndStoreAvatar(centerSquare, format, quality);
472                if (avatar.getBytes() <= charLimit) {
473                    return avatar;
474                }
475            }
476            return avatar;
477        }
478    }
479
480    private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
481            throws Exception {
482        return switch (format) {
483            case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
484            case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
485            case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
486            case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
487            case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
488            case SVG -> throw new RuntimeException("SVG cannot be a Bitmap?");
489        };
490    }
491
492    private Info resizeAndStoreAvatar(
493            final Bitmap image, final Bitmap.CompressFormat format, final int quality)
494            throws IOException {
495        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
496        final var fileOutputStream = new FileOutputStream(randomFile);
497        final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
498        image.compress(format, quality, hashingOutputStream);
499        hashingOutputStream.close();
500        final var sha1 = hashingOutputStream.hash().toString();
501        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
502        if (moveAvatarIntoCache(randomFile, avatarFile)) {
503            return new Info(
504                    sha1,
505                    avatarFile.length(),
506                    ImageFormat.of(format).toContentType(),
507                    image.getWidth(),
508                    image.getHeight());
509        }
510        throw new IllegalStateException(
511                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
512    }
513
514    private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
515            throws Exception {
516        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
517        try (final var fileOutputStream = new FileOutputStream(randomFile);
518                final var heifWriter =
519                        new HeifWriter.Builder(
520                                        fileOutputStream.getFD(),
521                                        image.getWidth(),
522                                        image.getHeight(),
523                                        HeifWriter.INPUT_MODE_BITMAP)
524                                .setMaxImages(1)
525                                .setQuality(quality)
526                                .build()) {
527
528            heifWriter.start();
529            heifWriter.addBitmap(image);
530            heifWriter.stop(3_000);
531        }
532        final var width = image.getWidth();
533        final var height = image.getHeight();
534        checkDecoding(randomFile, ImageFormat.HEIF, width, height);
535        return storeAsAvatar(randomFile, ImageFormat.HEIF, width, height);
536    }
537
538    private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
539            throws Exception {
540        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
541        try (final var fileOutputStream = new FileOutputStream(randomFile);
542                final var avifWriter =
543                        new AvifWriter.Builder(
544                                        fileOutputStream.getFD(),
545                                        image.getWidth(),
546                                        image.getHeight(),
547                                        AvifWriter.INPUT_MODE_BITMAP)
548                                .setMaxImages(1)
549                                .setQuality(quality)
550                                .build()) {
551            avifWriter.start();
552            avifWriter.addBitmap(image);
553            avifWriter.stop(3_000);
554        }
555        final var width = image.getWidth();
556        final var height = image.getHeight();
557        checkDecoding(randomFile, ImageFormat.AVIF, width, height);
558        return storeAsAvatar(randomFile, ImageFormat.AVIF, width, height);
559    }
560
561    private void checkDecoding(
562            final File randomFile, final ImageFormat format, final int width, final int height) {
563        var readCheck = BitmapFactory.decodeFile(randomFile.getAbsolutePath());
564        if (readCheck == null) {
565            throw new ImageCompressionException(
566                    String.format("%s image was null after trying to decode", format));
567        }
568        if (readCheck.getWidth() != width || readCheck.getHeight() != height) {
569            readCheck.recycle();
570            throw new ImageCompressionException(String.format("%s had wrong image bounds", format));
571        }
572        readCheck.recycle();
573    }
574
575    private Info storeAsAvatar(
576            final File randomFile, final ImageFormat type, final int width, final int height)
577            throws IOException {
578        final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
579        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
580        if (moveAvatarIntoCache(randomFile, avatarFile)) {
581            return new Info(sha1, avatarFile.length(), type.toContentType(), width, height);
582        }
583        throw new IllegalStateException(
584                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
585    }
586
587    private ListenableFuture<Collection<Info>> uploadAvatar(final Uri image) {
588        return Futures.transformAsync(
589                hasAlphaChannel(image),
590                hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
591                MoreExecutors.directExecutor());
592    }
593
594    private ListenableFuture<Collection<Info>> uploadAvatar(
595            final Uri image, final boolean hasAlphaChannel) {
596        final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
597
598        final ListenableFuture<Info> avatarThumbnailFuture;
599        if (hasAlphaChannel) {
600            avatarThumbnailFuture =
601                    resizeAndStoreAvatarAsync(
602                            image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
603        } else {
604            avatarThumbnailFuture =
605                    resizeAndStoreAvatarAsync(
606                            image,
607                            Config.AVATAR_THUMBNAIL_SIZE,
608                            ImageFormat.JPEG,
609                            Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
610        }
611
612        final var uploadManager = getManager(HttpUploadManager.class);
613
614        final var uploadService = uploadManager.getService();
615        if (uploadService == null || !uploadService.supportsPurpose(Profile.class)) {
616            Log.d(
617                    Config.LOGTAG,
618                    getAccount().getJid() + ": 'profile' upload purpose not supported");
619            return Futures.transform(
620                    avatarThumbnailFuture, ImmutableList::of, MoreExecutors.directExecutor());
621        }
622
623        final ListenableFuture<Info> avatarFuture;
624        if (hasAlphaChannel) {
625            avatarFuture =
626                    resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
627        } else {
628            final int autoAcceptFileSize =
629                    context.getResources().getInteger(R.integer.auto_accept_filesize);
630            avatarFuture =
631                    resizeAndStoreAvatarAsync(
632                            image, Config.AVATAR_FULL_SIZE, ImageFormat.JPEG, autoAcceptFileSize);
633
634            final var device = new Device(context);
635
636            if (Compatibility.twentyEight() && device.isPhysicalDevice()) {
637                final var avatarHeifFuture =
638                        resizeAndStoreAvatarAsync(
639                                image,
640                                Config.AVATAR_FULL_SIZE,
641                                ImageFormat.HEIF,
642                                autoAcceptFileSize);
643                final var avatarHeifWithUrlFuture =
644                        Futures.transformAsync(
645                                avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
646                avatarFutures.add(avatarHeifWithUrlFuture);
647            }
648            if (Compatibility.thirtyFour() && device.isPhysicalDevice()) {
649                final var avatarAvifFuture =
650                        resizeAndStoreAvatarAsync(
651                                image,
652                                Config.AVATAR_FULL_SIZE,
653                                ImageFormat.AVIF,
654                                autoAcceptFileSize);
655                final var avatarAvifWithUrlFuture =
656                        Futures.transformAsync(
657                                avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
658                final var caughtAvifWithUrlFuture =
659                        Futures.catching(
660                                avatarAvifWithUrlFuture,
661                                Exception.class,
662                                ex -> {
663                                    Log.d(Config.LOGTAG, "ignoring AVIF compression failure", ex);
664                                    return null;
665                                },
666                                MoreExecutors.directExecutor());
667                avatarFutures.add(caughtAvifWithUrlFuture);
668            }
669        }
670        avatarFutures.add(avatarThumbnailFuture);
671        final var avatarWithUrlFuture =
672                Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
673        avatarFutures.add(avatarWithUrlFuture);
674
675        final var all = Futures.allAsList(avatarFutures.build());
676        return Futures.transform(
677                all,
678                input -> Collections2.filter(input, Objects::nonNull),
679                MoreExecutors.directExecutor());
680    }
681
682    private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
683        return Futures.submit(
684                () -> {
685                    final var cropped =
686                            context.getFileBackend().cropCenterSquare(image, Config.AVATAR_FULL_SIZE);
687                    final var hasAlphaChannel = FileBackend.hasAlpha(cropped);
688                    cropped.recycle();
689                    return hasAlphaChannel;
690                },
691                AVATAR_COMPRESSION_EXECUTOR);
692    }
693
694    private ListenableFuture<Info> upload(final Info avatar) {
695        final var file = FileBackend.getAvatarFile(context, avatar.getId());
696        final var urlFuture =
697                getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
698        return Futures.transform(
699                urlFuture,
700                url -> {
701                    avatar.setUrl(url);
702                    return avatar;
703                },
704                MoreExecutors.directExecutor());
705    }
706
707    private ListenableFuture<Info> resizeAndStoreAvatarAsync(
708            final Uri image, final int size, final ImageFormat format) {
709        return resizeAndStoreAvatarAsync(image, size, format, null);
710    }
711
712    private ListenableFuture<Info> resizeAndStoreAvatarAsync(
713            final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
714        return Futures.submit(
715                () -> resizeAndStoreAvatar(image, size, format, charLimit),
716                AVATAR_COMPRESSION_EXECUTOR);
717    }
718
719    private ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
720        final Info mainAvatarInfo;
721        final byte[] mainAvatar;
722        try {
723            mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
724            mainAvatar =
725                    Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
726                            .read();
727        } catch (final IOException | NoSuchElementException e) {
728            return Futures.immediateFailedFuture(e);
729        }
730        final NodeConfiguration configuration =
731                open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
732        final var avatarData = new Data();
733        avatarData.setContent(mainAvatar);
734        final var future =
735                getManager(PepManager.class)
736                        .publish(avatarData, mainAvatarInfo.getId(), configuration);
737        return Futures.transformAsync(
738                future,
739                v -> {
740                    final var id = mainAvatarInfo.getId();
741                    final var metadata = new Metadata();
742                    metadata.addExtensions(avatars);
743                    return getManager(PepManager.class).publish(metadata, id, configuration);
744                },
745                MoreExecutors.directExecutor());
746    }
747
748    public ListenableFuture<Void> publishVCard(final Jid address, final Uri image) {
749
750        ListenableFuture<Info> avatarThumbnailFuture =
751                Futures.transformAsync(
752                        hasAlphaChannel(image),
753                        hasAlphaChannel -> {
754                            if (hasAlphaChannel) {
755                                return resizeAndStoreAvatarAsync(
756                                        image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
757                            } else {
758                                return resizeAndStoreAvatarAsync(
759                                        image,
760                                        Config.AVATAR_THUMBNAIL_SIZE,
761                                        ImageFormat.JPEG,
762                                        Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
763                            }
764                        },
765                        MoreExecutors.directExecutor());
766        return Futures.transformAsync(
767                avatarThumbnailFuture,
768                info -> {
769                    final var avatar =
770                            Files.asByteSource(FileBackend.getAvatarFile(context, info.getId()))
771                                    .read();
772                    return getManager(VCardManager.class)
773                            .publishPhoto(address, info.getType(), avatar);
774                },
775                AVATAR_COMPRESSION_EXECUTOR);
776    }
777
778    public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
779        final var infoFuture = uploadAvatar(image);
780        return Futures.transformAsync(
781                infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
782    }
783
784    public boolean hasPepToVCardConversion() {
785        return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
786    }
787
788    public ListenableFuture<Void> delete() {
789        final var pepManager = getManager(PepManager.class);
790        final var deleteMetaDataFuture = pepManager.delete(Namespace.AVATAR_METADATA);
791        final var deleteDataFuture = pepManager.delete(Namespace.AVATAR_DATA);
792        return Futures.transform(
793                Futures.allAsList(deleteDataFuture, deleteMetaDataFuture),
794                vs -> null,
795                MoreExecutors.directExecutor());
796    }
797
798    public ListenableFuture<Void> fetchAndStore(final Jid address) {
799        final var metaDataFuture =
800                getManager(PubSubManager.class).fetchItems(address, Metadata.class);
801        return Futures.transformAsync(
802                metaDataFuture,
803                metaData -> {
804                    final var entry = Iterables.getFirst(metaData.entrySet(), null);
805                    if (entry == null) {
806                        throw new IllegalStateException("Metadata item not found");
807                    }
808                    final var avatar = getPreferredFallback(entry);
809
810                    if (avatar == null) {
811                        throw new IllegalStateException("No avatar found");
812                    }
813
814                    final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
815
816                    if (cache.exists()) {
817                        Log.d(
818                                Config.LOGTAG,
819                                "fetchAndStore. file existed " + cache.getAbsolutePath());
820                        setAvatarInfo(address, avatar.preferred);
821                        return Futures.immediateVoidFuture();
822                    } else {
823                        final var future =
824                                this.fetchAndStoreWithFallback(
825                                        address, avatar.preferred, avatar.fallback);
826                        return Futures.transform(
827                                future,
828                                info -> {
829                                    setAvatarInfo(address, info);
830                                    return null;
831                                },
832                                MoreExecutors.directExecutor());
833                    }
834                },
835                MoreExecutors.directExecutor());
836    }
837
838    private static boolean moveAvatarIntoCache(final File randomFile, final File destination) {
839        synchronized (RENAME_LOCK) {
840            if (destination.exists()) {
841                return true;
842            }
843            final var directory = destination.getParentFile();
844            if (directory != null && directory.mkdirs()) {
845                Log.d(
846                        Config.LOGTAG,
847                        "create avatar cache directory: " + directory.getAbsolutePath());
848            }
849            return randomFile.renameTo(destination);
850        }
851    }
852
853    public ListenableFuture<Void> fetchAndStoreVCard(final Jid address, final String expectedHash) {
854        if (context.isBlockedMediaSha1(expectedHash)) {
855            final SettableFuture<Void> future = SettableFuture.create();
856            future.setException(new XmppConnectionService.BlockedMediaException());
857            return future;
858        }
859        final var future =
860                connection.getManager(VCardManager.class).retrievePhotoCacheException(address);
861        return Futures.transformAsync(
862                future,
863                photo -> {
864                    final var actualHash = Hashing.sha1().hashBytes(photo).toString();
865                    if (!actualHash.equals(expectedHash)) {
866                        return Futures.immediateFailedFuture(
867                                new IllegalStateException(
868                                        String.format(
869                                                "Hash in vCard update for %s did not match",
870                                                address)));
871                    }
872                    final var avatarFile = FileBackend.getAvatarFile(context, actualHash);
873                    if (avatarFile.exists()) {
874                        setAvatar(address, actualHash);
875                        return Futures.immediateVoidFuture();
876                    }
877                    final var writeFuture = write(avatarFile, photo);
878                    return Futures.transform(
879                            writeFuture,
880                            v -> {
881                                setAvatar(address, actualHash);
882                                return null;
883                            },
884                            MoreExecutors.directExecutor());
885                },
886                AVATAR_COMPRESSION_EXECUTOR);
887    }
888
889    private static final class ImageCompressionException extends IllegalStateException {
890        ImageCompressionException(final String message) {
891            super(message);
892        }
893    }
894
895    public enum ImageFormat {
896        PNG,
897        JPEG,
898        WEBP,
899        HEIF,
900        AVIF,
901        SVG;
902
903        public String toContentType() {
904            return switch (this) {
905                case WEBP -> "image/webp";
906                case PNG -> "image/png";
907                case JPEG -> "image/jpeg";
908                case AVIF -> "image/avif";
909                case HEIF -> "image/heif";
910                case SVG -> "image/svg+xml";
911            };
912        }
913
914        public static int formatPriority(final String type) {
915            final var format = ofContentType(type);
916            return format == null ? Integer.MIN_VALUE : format.ordinal();
917        }
918
919        private static ImageFormat ofContentType(final String type) {
920            return switch (type) {
921                case "image/png" -> PNG;
922                case "image/jpeg" -> JPEG;
923                case "image/webp" -> WEBP;
924                case "image/heif" -> HEIF;
925                case "image/avif" -> AVIF;
926                case "image/svg+xml" -> SVG;
927                default -> null;
928            };
929        }
930
931        public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
932            return switch (compressFormat) {
933                case PNG -> PNG;
934                case WEBP -> WEBP;
935                case JPEG -> JPEG;
936                default -> throw new AssertionError("Not implemented");
937            };
938        }
939    }
940
941    private static final class PreferredFallback {
942        private final Info preferred;
943        private final Info fallback;
944
945        private PreferredFallback(final Info fallback) {
946            this(fallback, fallback);
947        }
948
949        private PreferredFallback(Info preferred, Info fallback) {
950            this.preferred = preferred;
951            this.fallback = fallback;
952        }
953    }
954}