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}