AvatarManager.java

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