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