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