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 final var info = resizeAndStoreAvatar(centerSquare, format, charLimit);
421 centerSquare.recycle();
422 return info;
423 }
424
425 private Info resizeAndStoreAvatar(
426 final Bitmap centerSquare, final ImageFormat format, final Integer charLimit)
427 throws Exception {
428 if (charLimit == null || format == ImageFormat.PNG) {
429 return resizeAndStoreAvatar(centerSquare, format, 90);
430 } else {
431 Info avatar = null;
432 for (int quality = 90; quality >= 50; quality = quality - 2) {
433 if (avatar != null) {
434 FileBackend.getAvatarFile(context, avatar.getId()).delete();
435 }
436 Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
437 avatar = resizeAndStoreAvatar(centerSquare, format, quality);
438 if (avatar.getBytes() <= charLimit) {
439 return avatar;
440 }
441 }
442 return avatar;
443 }
444 }
445
446 private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
447 throws Exception {
448 return switch (format) {
449 case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
450 case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
451 case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
452 case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
453 case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
454 };
455 }
456
457 private Info resizeAndStoreAvatar(
458 final Bitmap image, final Bitmap.CompressFormat format, final int quality)
459 throws IOException {
460 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
461 final var fileOutputStream = new FileOutputStream(randomFile);
462 final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
463 image.compress(format, quality, hashingOutputStream);
464 hashingOutputStream.close();
465 final var sha1 = hashingOutputStream.hash().toString();
466 final var avatarFile = FileBackend.getAvatarFile(context, sha1);
467 if (moveAvatarIntoCache(randomFile, avatarFile)) {
468 return new Info(
469 sha1,
470 avatarFile.length(),
471 ImageFormat.of(format).toContentType(),
472 image.getHeight(),
473 image.getWidth());
474 }
475 throw new IllegalStateException(
476 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
477 }
478
479 private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
480 throws Exception {
481 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
482 try (final var fileOutputStream = new FileOutputStream(randomFile);
483 final var heifWriter =
484 new HeifWriter.Builder(
485 fileOutputStream.getFD(),
486 image.getWidth(),
487 image.getHeight(),
488 HeifWriter.INPUT_MODE_BITMAP)
489 .setMaxImages(1)
490 .setQuality(quality)
491 .build()) {
492
493 heifWriter.start();
494 heifWriter.addBitmap(image);
495 heifWriter.stop(3_000);
496 }
497 return storeAsAvatar(randomFile, ImageFormat.HEIF, image.getHeight(), image.getWidth());
498 }
499
500 private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
501 throws Exception {
502 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
503 try (final var fileOutputStream = new FileOutputStream(randomFile);
504 final var avifWriter =
505 new AvifWriter.Builder(
506 fileOutputStream.getFD(),
507 image.getWidth(),
508 image.getHeight(),
509 AvifWriter.INPUT_MODE_BITMAP)
510 .setMaxImages(1)
511 .setQuality(quality)
512 .build()) {
513 avifWriter.start();
514 avifWriter.addBitmap(image);
515 avifWriter.stop(3_000);
516 }
517 return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
518 }
519
520 private Info storeAsAvatar(
521 final File randomFile, final ImageFormat type, final int height, final int width)
522 throws IOException {
523 final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
524 final var avatarFile = FileBackend.getAvatarFile(context, sha1);
525 if (moveAvatarIntoCache(randomFile, avatarFile)) {
526 return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
527 }
528 throw new IllegalStateException(
529 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
530 }
531
532 private ListenableFuture<List<Info>> uploadAvatar(final Uri image) {
533 return Futures.transformAsync(
534 hasAlphaChannel(image),
535 hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
536 MoreExecutors.directExecutor());
537 }
538
539 private ListenableFuture<List<Info>> uploadAvatar(
540 final Uri image, final boolean hasAlphaChannel) {
541 final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
542
543 final ListenableFuture<Info> avatarThumbnailFuture;
544 final ListenableFuture<Info> avatarFuture;
545 if (hasAlphaChannel) {
546 avatarThumbnailFuture =
547 resizeAndStoreAvatarAsync(image, Config.AVATAR_SIZE / 2, ImageFormat.PNG);
548 avatarFuture =
549 resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
550 } else {
551 avatarThumbnailFuture =
552 resizeAndStoreAvatarAsync(
553 image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
554 avatarFuture =
555 resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE, ImageFormat.JPEG);
556
557 if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
558 final var avatarHeifFuture =
559 resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE, ImageFormat.HEIF);
560 final var avatarHeifWithUrlFuture =
561 Futures.transformAsync(
562 avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
563 avatarFutures.add(avatarHeifWithUrlFuture);
564 }
565 if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
566 final var avatarAvifFuture =
567 resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE, ImageFormat.AVIF);
568 final var avatarAvifWithUrlFuture =
569 Futures.transformAsync(
570 avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
571 avatarFutures.add(avatarAvifWithUrlFuture);
572 }
573 }
574 avatarFutures.add(avatarThumbnailFuture);
575 final var avatarWithUrlFuture =
576 Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
577 avatarFutures.add(avatarWithUrlFuture);
578
579 return Futures.allAsList(avatarFutures.build());
580 }
581
582 private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
583 return Futures.submit(
584 () -> {
585 final var cropped =
586 FileBackend.cropCenterSquare(context, image, Config.AVATAR_FULL_SIZE);
587 final var hasAlphaChannel = FileBackend.hasAlpha(cropped);
588 cropped.recycle();
589 return hasAlphaChannel;
590 },
591 AVATAR_COMPRESSION_EXECUTOR);
592 }
593
594 private ListenableFuture<Info> upload(final Info avatar) {
595 final var file = FileBackend.getAvatarFile(context, avatar.getId());
596 final var urlFuture =
597 getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
598 return Futures.transform(
599 urlFuture,
600 url -> {
601 avatar.setUrl(url);
602 return avatar;
603 },
604 MoreExecutors.directExecutor());
605 }
606
607 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
608 final Uri image, final int size, final ImageFormat format) {
609 return resizeAndStoreAvatarAsync(image, size, format, null);
610 }
611
612 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
613 final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
614 return Futures.submit(
615 () -> resizeAndStoreAvatar(image, size, format, charLimit),
616 AVATAR_COMPRESSION_EXECUTOR);
617 }
618
619 private ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
620 final Info mainAvatarInfo;
621 final byte[] mainAvatar;
622 try {
623 mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
624 mainAvatar =
625 Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
626 .read();
627 } catch (final IOException | NoSuchElementException e) {
628 return Futures.immediateFailedFuture(e);
629 }
630 final NodeConfiguration configuration =
631 open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
632 final var avatarData = new Data();
633 avatarData.setContent(mainAvatar);
634 final var future =
635 getManager(PepManager.class)
636 .publish(avatarData, mainAvatarInfo.getId(), configuration);
637 return Futures.transformAsync(
638 future,
639 v -> {
640 final var id = mainAvatarInfo.getId();
641 final var metadata = new Metadata();
642 metadata.addExtensions(avatars);
643 return getManager(PepManager.class).publish(metadata, id, configuration);
644 },
645 MoreExecutors.directExecutor());
646 }
647
648 public ListenableFuture<Void> publishVCard(final Jid address, final Uri image) {
649
650 ListenableFuture<Info> avatarThumbnailFuture =
651 Futures.transformAsync(
652 hasAlphaChannel(image),
653 hasAlphaChannel -> {
654 if (hasAlphaChannel) {
655 return resizeAndStoreAvatarAsync(
656 image, Config.AVATAR_SIZE / 2, ImageFormat.PNG);
657 } else {
658 return resizeAndStoreAvatarAsync(
659 image,
660 Config.AVATAR_SIZE,
661 ImageFormat.JPEG,
662 Config.AVATAR_CHAR_LIMIT);
663 }
664 },
665 MoreExecutors.directExecutor());
666 return Futures.transformAsync(
667 avatarThumbnailFuture,
668 info -> {
669 final var avatar =
670 Files.asByteSource(FileBackend.getAvatarFile(context, info.getId()))
671 .read();
672 return getManager(VCardManager.class)
673 .publishPhoto(address, info.getType(), avatar);
674 },
675 AVATAR_COMPRESSION_EXECUTOR);
676 }
677
678 public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
679 final var infoFuture = uploadAvatar(image);
680 return Futures.transformAsync(
681 infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
682 }
683
684 public boolean hasPepToVCardConversion() {
685 return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
686 }
687
688 public ListenableFuture<Void> delete() {
689 final var pepManager = getManager(PepManager.class);
690 final var deleteMetaDataFuture = pepManager.delete(Namespace.AVATAR_METADATA);
691 final var deleteDataFuture = pepManager.delete(Namespace.AVATAR_DATA);
692 return Futures.transform(
693 Futures.allAsList(deleteDataFuture, deleteMetaDataFuture),
694 vs -> null,
695 MoreExecutors.directExecutor());
696 }
697
698 public ListenableFuture<Void> fetchAndStore(final Jid address) {
699 final var metaDataFuture =
700 getManager(PubSubManager.class).fetchItems(address, Metadata.class);
701 return Futures.transformAsync(
702 metaDataFuture,
703 metaData -> {
704 final var entry = Iterables.getFirst(metaData.entrySet(), null);
705 if (entry == null) {
706 throw new IllegalStateException("Metadata item not found");
707 }
708 final var avatar = getPreferredFallback(entry);
709
710 if (avatar == null) {
711 throw new IllegalStateException("No avatar found");
712 }
713
714 final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
715
716 if (cache.exists()) {
717 Log.d(
718 Config.LOGTAG,
719 "fetchAndStore. file existed " + cache.getAbsolutePath());
720 setAvatarInfo(address, avatar.preferred);
721 return Futures.immediateVoidFuture();
722 } else {
723 final var future =
724 this.fetchAndStoreWithFallback(
725 address, avatar.preferred, avatar.fallback);
726 return Futures.transform(
727 future,
728 info -> {
729 setAvatarInfo(address, info);
730 return null;
731 },
732 MoreExecutors.directExecutor());
733 }
734 },
735 MoreExecutors.directExecutor());
736 }
737
738 private static boolean moveAvatarIntoCache(final File randomFile, final File destination) {
739 synchronized (RENAME_LOCK) {
740 if (destination.exists()) {
741 return true;
742 }
743 final var directory = destination.getParentFile();
744 if (directory != null && directory.mkdirs()) {
745 Log.d(
746 Config.LOGTAG,
747 "create avatar cache directory: " + directory.getAbsolutePath());
748 }
749 return randomFile.renameTo(destination);
750 }
751 }
752
753 public ListenableFuture<Void> fetchAndStoreVCard(final Jid address, final String expectedHash) {
754 final var future = connection.getManager(VCardManager.class).retrievePhoto(address);
755 return Futures.transformAsync(
756 future,
757 photo -> {
758 final var actualHash = Hashing.sha1().hashBytes(photo).toString();
759 if (!actualHash.equals(expectedHash)) {
760 return Futures.immediateFailedFuture(
761 new IllegalStateException(
762 String.format(
763 "Hash in vCard update for %s did not match",
764 address)));
765 }
766 final var avatarFile = FileBackend.getAvatarFile(context, actualHash);
767 if (avatarFile.exists()) {
768 setAvatar(address, actualHash);
769 return Futures.immediateVoidFuture();
770 }
771 final var writeFuture = write(avatarFile, photo);
772 return Futures.transform(
773 writeFuture,
774 v -> {
775 setAvatar(address, actualHash);
776 return null;
777 },
778 MoreExecutors.directExecutor());
779 },
780 AVATAR_COMPRESSION_EXECUTOR);
781 }
782
783 public enum ImageFormat {
784 PNG,
785 JPEG,
786 WEBP,
787 HEIF,
788 AVIF;
789
790 public String toContentType() {
791 return switch (this) {
792 case WEBP -> "image/webp";
793 case PNG -> "image/png";
794 case JPEG -> "image/jpeg";
795 case AVIF -> "image/avif";
796 case HEIF -> "image/heif";
797 };
798 }
799
800 public static int formatPriority(final String type) {
801 final var format = ofContentType(type);
802 return format == null ? Integer.MIN_VALUE : format.ordinal();
803 }
804
805 private static ImageFormat ofContentType(final String type) {
806 return switch (type) {
807 case "image/png" -> PNG;
808 case "image/jpeg" -> JPEG;
809 case "image/webp" -> WEBP;
810 case "image/heif" -> HEIF;
811 case "image/avif" -> AVIF;
812 default -> null;
813 };
814 }
815
816 public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
817 return switch (compressFormat) {
818 case PNG -> PNG;
819 case WEBP -> WEBP;
820 case JPEG -> JPEG;
821 default -> throw new AssertionError("Not implemented");
822 };
823 }
824 }
825
826 private static final class PreferredFallback {
827 private final Info preferred;
828 private final Info fallback;
829
830 private PreferredFallback(final Info fallback) {
831 this(fallback, fallback);
832 }
833
834 private PreferredFallback(Info preferred, Info fallback) {
835 this.preferred = preferred;
836 this.fallback = fallback;
837 }
838 }
839}