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