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