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