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