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        settableFuture.setException(new RuntimeException("HTTP avatars disabled"));
206        if (true) return settableFuture;
207
208        final OkHttpClient client =
209                service.getHttpConnectionManager().buildHttpClient(url, getAccount(), 30, false);
210        final var request = new Request.Builder().url(url).get().build();
211        client.newCall(request)
212                .enqueue(
213                        new Callback() {
214                            @Override
215                            public void onFailure(@NonNull Call call, @NonNull IOException e) {
216                                settableFuture.setException(e);
217                            }
218
219                            @Override
220                            public void onResponse(@NonNull Call call, @NonNull Response response) {
221                                if (response.isSuccessful()) {
222                                    try {
223                                        write(avatar, response);
224                                    } catch (final Exception e) {
225                                        settableFuture.setException(e);
226                                        return;
227                                    }
228                                    settableFuture.set(avatar);
229                                } else {
230                                    settableFuture.setException(
231                                            new IOException("HTTP call was not successful"));
232                                }
233                            }
234                        });
235        return settableFuture;
236    }
237
238    private void write(final Info avatar, Response response) throws IOException {
239        final var body = response.body();
240        if (body == null) {
241            throw new IOException("Body was null");
242        }
243        final long bytes = avatar.getBytes();
244        final long actualBytes;
245        final var inputStream = ByteStreams.limit(body.byteStream(), avatar.getBytes());
246        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
247        final String actualHash;
248        try (final var fileOutputStream = new FileOutputStream(randomFile);
249                var hashingOutputStream =
250                        new HashingOutputStream(Hashing.sha1(), fileOutputStream)) {
251            actualBytes = ByteStreams.copy(inputStream, hashingOutputStream);
252            actualHash = hashingOutputStream.hash().toString();
253        }
254        if (actualBytes != bytes) {
255            throw new IllegalStateException("File size did not meet expected size");
256        }
257        if (!actualHash.equals(avatar.getId())) {
258            throw new IllegalStateException("File hash did not match");
259        }
260        final var avatarFile = FileBackend.getAvatarFile(context, avatar.getId());
261        if (moveAvatarIntoCache(randomFile, avatarFile)) {
262            return;
263        }
264        throw new IOException("Could not move avatar to avatar location");
265    }
266
267    private void setAvatarInfo(final Jid address, @NonNull final Info info) {
268        setAvatar(address, info.getId());
269    }
270
271    private void setAvatar(final Jid from, @Nullable final String id) {
272        if (context.isBlockedMediaSha1(id)) {
273            return;
274        }
275        Log.d(Config.LOGTAG, "setting avatar for " + from + " to " + id);
276        if (from.isBareJid()) {
277            setAvatarContact(from, id);
278        } else {
279            setAvatarMucUser(from, id);
280        }
281    }
282
283    private void setAvatarContact(final Jid from, @Nullable final String id) {
284        final var account = getAccount();
285        if (account.getJid().asBareJid().equals(from)) {
286            if (account.setAvatar(id)) {
287                getDatabase().updateAccount(account);
288                service.notifyAccountAvatarHasChanged(account);
289            }
290            service.getAvatarService().clear(account);
291            service.updateConversationUi();
292            service.updateAccountUi();
293        } else {
294            final Contact contact = account.getRoster().getContact(from);
295            if (contact.setAvatar(id)) {
296                connection.getManager(RosterManager.class).writeToDatabaseAsync();
297                service.getAvatarService().clear(contact);
298
299                final var conversation = service.find(account, from);
300                if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI) {
301                    service.getAvatarService().clear(conversation.getMucOptions());
302                }
303
304                service.updateConversationUi();
305                service.updateRosterUi(XmppConnectionService.UpdateRosterReason.AVATAR);
306            }
307        }
308    }
309
310    private void setAvatarMucUser(final Jid from, final String id) {
311        final var account = getAccount();
312        final Conversation conversation = service.find(account, from.asBareJid());
313        if (conversation == null || conversation.getMode() != Conversation.MODE_MULTI) {
314            return;
315        }
316        final var user = conversation.getMucOptions().findUserByFullJid(from);
317        if (user == null) {
318            return;
319        }
320        if (user.setAvatar(id)) {
321            service.getAvatarService().clear(user);
322            service.updateConversationUi();
323            service.updateMucRosterUi();
324        }
325    }
326
327    public void handleItems(final Jid from, final Items items) {
328        final var account = getAccount();
329        // TODO support retract
330        final var entry = items.getFirstItemWithId(Metadata.class);
331        if (entry == null) {
332            return;
333        }
334        final var avatar = getPreferredFallback(entry);
335        if (avatar == null) {
336            return;
337        }
338
339        Log.d(Config.LOGTAG, "picked avatar from " + from + ": " + avatar.preferred);
340
341        final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
342
343        if (cache.exists()) {
344            setAvatarInfo(from, avatar.preferred);
345        } else if (service.isDataSaverDisabled()) {
346            final var contact = getManager(RosterManager.class).getContactFromContactList(from);
347            final ListenableFuture<Info> future;
348            if (false) {
349                future = this.fetchAndStoreWithFallback(from, avatar.preferred, avatar.fallback);
350            } else {
351                future = fetchAndStoreInBand(from, avatar.fallback);
352            }
353            Futures.addCallback(
354                    future,
355                    new FutureCallback<>() {
356                        @Override
357                        public void onSuccess(Info result) {
358                            setAvatarInfo(from, result);
359                            Log.d(
360                                    Config.LOGTAG,
361                                    account.getJid().asBareJid()
362                                            + ": successfully fetched pep avatar for "
363                                            + from);
364                        }
365
366                        @Override
367                        public void onFailure(@NonNull Throwable t) {
368                            Log.d(Config.LOGTAG, "could not fetch avatar", t);
369                        }
370                    },
371                    MoreExecutors.directExecutor());
372        }
373    }
374
375    public void handleVCardUpdate(final Jid address, final VCardUpdate vCardUpdate) {
376        final var hash = vCardUpdate.getHash();
377        if (hash == null) {
378            return;
379        }
380        handleVCardUpdate(address, hash);
381    }
382
383    public void handleVCardUpdate(final Jid address, final String hash) {
384        Preconditions.checkArgument(VCardUpdate.isValidSHA1(hash));
385        final var avatarFile = FileBackend.getAvatarFile(context, hash);
386        if (avatarFile.exists()) {
387            setAvatar(address, hash);
388        } else if (service.isDataSaverDisabled()) {
389            final var future = this.fetchAndStoreVCard(address, hash);
390            Futures.addCallback(
391                    future,
392                    new FutureCallback<Void>() {
393                        @Override
394                        public void onSuccess(Void result) {
395                            Log.d(Config.LOGTAG, "successfully fetch vCard avatar for " + address);
396                        }
397
398                        @Override
399                        public void onFailure(@NonNull Throwable t) {
400                            Log.d(Config.LOGTAG, "could not fetch avatar for " + address, t);
401                        }
402                    },
403                    MoreExecutors.directExecutor());
404        }
405    }
406
407    private PreferredFallback getPreferredFallback(final Map.Entry<String, Metadata> entry) {
408        final var mainItemId = entry.getKey();
409        final var infos = entry.getValue().getInfos();
410
411        final var inBandAvatar = Iterables.find(infos, i -> mainItemId.equals(i.getId()), null);
412
413        if (inBandAvatar == null || inBandAvatar.getUrl() != null) {
414            return null;
415        }
416
417        final var optionalAutoAcceptSize = new AppSettings(context).getAutoAcceptFileSize();
418        if (optionalAutoAcceptSize.isEmpty()) {
419            return new PreferredFallback(inBandAvatar);
420        } else {
421
422            final var supported =
423                    Collections2.filter(
424                            infos,
425                            i ->
426                                    Objects.nonNull(i.getId()) && !context.isBlockedMediaSha1(i.getId())
427                                            && i.getBytes() > 0
428                                            && i.getHeight() > 0
429                                            && i.getWidth() > 0
430                                            && SUPPORTED_CONTENT_TYPES.contains(i.getType()));
431
432            final var autoAcceptSize = optionalAutoAcceptSize.get();
433
434            final var supportedBelowLimit =
435                    Collections2.filter(supported, i -> i.getBytes() <= autoAcceptSize);
436
437            if (true) {
438                return new PreferredFallback(inBandAvatar);
439            } else {
440                final var preferred =
441                        Iterables.getFirst(AVATAR_ORDERING.sortedCopy(supportedBelowLimit), null);
442                return new PreferredFallback(preferred, inBandAvatar);
443            }
444        }
445    }
446
447    public void handleDelete(final Jid from) {
448        Preconditions.checkArgument(
449                from.isBareJid(), "node deletion can only be triggered from bare JIDs");
450        setAvatar(from, null);
451    }
452
453    private Info resizeAndStoreAvatar(
454            final Uri image, final int size, final ImageFormat format, final Integer charLimit)
455            throws Exception {
456        final var centerSquare = context.getFileBackend().cropCenterSquare(image, size);
457        final var info = resizeAndStoreAvatar(centerSquare, format, charLimit);
458        centerSquare.recycle();
459        return info;
460    }
461
462    private Info resizeAndStoreAvatar(
463            final Bitmap centerSquare, final ImageFormat format, final Integer charLimit)
464            throws Exception {
465        if (charLimit == null || format == ImageFormat.PNG) {
466            return resizeAndStoreAvatar(centerSquare, format, 90);
467        } else {
468            Info avatar = null;
469            for (int quality = 90; quality >= 50; quality = quality - 2) {
470                if (avatar != null) {
471                    FileBackend.getAvatarFile(context, avatar.getId()).delete();
472                }
473                Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
474                avatar = resizeAndStoreAvatar(centerSquare, format, quality);
475                if (avatar.getBytes() <= charLimit) {
476                    return avatar;
477                }
478            }
479            return avatar;
480        }
481    }
482
483    private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
484            throws Exception {
485        return switch (format) {
486            case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
487            case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
488            case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
489            case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
490            case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
491            case SVG -> throw new RuntimeException("SVG cannot be a Bitmap?");
492        };
493    }
494
495    private Info resizeAndStoreAvatar(
496            final Bitmap image, final Bitmap.CompressFormat format, final int quality)
497            throws IOException {
498        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
499        final var fileOutputStream = new FileOutputStream(randomFile);
500        final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
501        image.compress(format, quality, hashingOutputStream);
502        hashingOutputStream.close();
503        final var sha1 = hashingOutputStream.hash().toString();
504        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
505        if (moveAvatarIntoCache(randomFile, avatarFile)) {
506            return new Info(
507                    sha1,
508                    avatarFile.length(),
509                    ImageFormat.of(format).toContentType(),
510                    image.getWidth(),
511                    image.getHeight());
512        }
513        throw new IllegalStateException(
514                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
515    }
516
517    private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
518            throws Exception {
519        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
520        try (final var fileOutputStream = new FileOutputStream(randomFile);
521                final var heifWriter =
522                        new HeifWriter.Builder(
523                                        fileOutputStream.getFD(),
524                                        image.getWidth(),
525                                        image.getHeight(),
526                                        HeifWriter.INPUT_MODE_BITMAP)
527                                .setMaxImages(1)
528                                .setQuality(quality)
529                                .build()) {
530
531            heifWriter.start();
532            heifWriter.addBitmap(image);
533            heifWriter.stop(3_000);
534        }
535        final var width = image.getWidth();
536        final var height = image.getHeight();
537        checkDecoding(randomFile, ImageFormat.HEIF, width, height);
538        return storeAsAvatar(randomFile, ImageFormat.HEIF, width, height);
539    }
540
541    private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
542            throws Exception {
543        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
544        try (final var fileOutputStream = new FileOutputStream(randomFile);
545                final var avifWriter =
546                        new AvifWriter.Builder(
547                                        fileOutputStream.getFD(),
548                                        image.getWidth(),
549                                        image.getHeight(),
550                                        AvifWriter.INPUT_MODE_BITMAP)
551                                .setMaxImages(1)
552                                .setQuality(quality)
553                                .build()) {
554            avifWriter.start();
555            avifWriter.addBitmap(image);
556            avifWriter.stop(3_000);
557        }
558        final var width = image.getWidth();
559        final var height = image.getHeight();
560        checkDecoding(randomFile, ImageFormat.AVIF, width, height);
561        return storeAsAvatar(randomFile, ImageFormat.AVIF, width, height);
562    }
563
564    private void checkDecoding(
565            final File randomFile, final ImageFormat format, final int width, final int height) {
566        var readCheck = BitmapFactory.decodeFile(randomFile.getAbsolutePath());
567        if (readCheck == null) {
568            throw new ImageCompressionException(
569                    String.format("%s image was null after trying to decode", format));
570        }
571        if (readCheck.getWidth() != width || readCheck.getHeight() != height) {
572            readCheck.recycle();
573            throw new ImageCompressionException(String.format("%s had wrong image bounds", format));
574        }
575        readCheck.recycle();
576    }
577
578    private Info storeAsAvatar(
579            final File randomFile, final ImageFormat type, final int width, final int height)
580            throws IOException {
581        final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
582        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
583        if (moveAvatarIntoCache(randomFile, avatarFile)) {
584            return new Info(sha1, avatarFile.length(), type.toContentType(), width, height);
585        }
586        throw new IllegalStateException(
587                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
588    }
589
590    private ListenableFuture<Collection<Info>> uploadAvatar(final Uri image) {
591        return Futures.transformAsync(
592                hasAlphaChannel(image),
593                hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
594                MoreExecutors.directExecutor());
595    }
596
597    private ListenableFuture<Collection<Info>> uploadAvatar(
598            final Uri image, final boolean hasAlphaChannel) {
599        final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
600
601        final ListenableFuture<Info> avatarThumbnailFuture;
602        if (hasAlphaChannel) {
603            avatarThumbnailFuture =
604                    resizeAndStoreAvatarAsync(
605                            image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
606        } else {
607            avatarThumbnailFuture =
608                    resizeAndStoreAvatarAsync(
609                            image,
610                            Config.AVATAR_THUMBNAIL_SIZE,
611                            ImageFormat.JPEG,
612                            Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
613        }
614
615        final var uploadManager = getManager(HttpUploadManager.class);
616
617        final var uploadService = uploadManager.getService();
618        if (true) {
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}