AvatarManager.java

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