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