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 contact = getManager(RosterManager.class).getContactFromContactList(from);
316 final ListenableFuture<Info> future;
317 if (contact != null && contact.showInContactList()) {
318 future = this.fetchAndStoreWithFallback(from, avatar.preferred, avatar.fallback);
319 } else {
320 future = fetchAndStoreInBand(from, avatar.fallback);
321 }
322 Futures.addCallback(
323 future,
324 new FutureCallback<>() {
325 @Override
326 public void onSuccess(Info result) {
327 setAvatarInfo(from, result);
328 Log.d(
329 Config.LOGTAG,
330 account.getJid().asBareJid()
331 + ": successfully fetched pep avatar for "
332 + from);
333 }
334
335 @Override
336 public void onFailure(@NonNull Throwable t) {
337 Log.d(Config.LOGTAG, "could not fetch avatar", t);
338 }
339 },
340 MoreExecutors.directExecutor());
341 }
342 }
343
344 public void handleVCardUpdate(final Jid address, final VCardUpdate vCardUpdate) {
345 final var hash = vCardUpdate.getHash();
346 if (hash == null) {
347 return;
348 }
349 handleVCardUpdate(address, hash);
350 }
351
352 public void handleVCardUpdate(final Jid address, final String hash) {
353 Preconditions.checkArgument(VCardUpdate.isValidSHA1(hash));
354 final var avatarFile = FileBackend.getAvatarFile(context, hash);
355 if (avatarFile.exists()) {
356 setAvatar(address, hash);
357 } else if (service.isDataSaverDisabled()) {
358 final var future = this.fetchAndStoreVCard(address, hash);
359 Futures.addCallback(
360 future,
361 new FutureCallback<Void>() {
362 @Override
363 public void onSuccess(Void result) {
364 Log.d(Config.LOGTAG, "successfully fetch vCard avatar for " + address);
365 }
366
367 @Override
368 public void onFailure(@NonNull Throwable t) {
369 Log.d(Config.LOGTAG, "could not fetch avatar for " + address, t);
370 }
371 },
372 MoreExecutors.directExecutor());
373 }
374 }
375
376 private PreferredFallback getPreferredFallback(final Map.Entry<String, Metadata> entry) {
377 final var mainItemId = entry.getKey();
378 final var infos = entry.getValue().getInfos();
379
380 final var inBandAvatar = Iterables.find(infos, i -> mainItemId.equals(i.getId()), null);
381
382 if (inBandAvatar == null || inBandAvatar.getUrl() != null) {
383 return null;
384 }
385
386 final var optionalAutoAcceptSize = new AppSettings(context).getAutoAcceptFileSize();
387 if (optionalAutoAcceptSize.isEmpty()) {
388 return new PreferredFallback(inBandAvatar);
389 } else {
390
391 final var supported =
392 Collections2.filter(
393 infos,
394 i ->
395 Objects.nonNull(i.getId())
396 && i.getBytes() > 0
397 && i.getHeight() > 0
398 && i.getWidth() > 0
399 && SUPPORTED_CONTENT_TYPES.contains(i.getType()));
400
401 final var autoAcceptSize = optionalAutoAcceptSize.get();
402
403 final var supportedBelowLimit =
404 Collections2.filter(supported, i -> i.getBytes() <= autoAcceptSize);
405
406 if (supportedBelowLimit.isEmpty()) {
407 return new PreferredFallback(inBandAvatar);
408 } else {
409 final var preferred =
410 Iterables.getFirst(AVATAR_ORDERING.sortedCopy(supportedBelowLimit), null);
411 return new PreferredFallback(preferred, inBandAvatar);
412 }
413 }
414 }
415
416 public void handleDelete(final Jid from) {
417 Preconditions.checkArgument(
418 from.isBareJid(), "node deletion can only be triggered from bare JIDs");
419 setAvatar(from, null);
420 }
421
422 private Info resizeAndStoreAvatar(
423 final Uri image, final int size, final ImageFormat format, final Integer charLimit)
424 throws Exception {
425 final var centerSquare = FileBackend.cropCenterSquare(context, image, size);
426 final var info = resizeAndStoreAvatar(centerSquare, format, charLimit);
427 centerSquare.recycle();
428 return info;
429 }
430
431 private Info resizeAndStoreAvatar(
432 final Bitmap centerSquare, final ImageFormat format, final Integer charLimit)
433 throws Exception {
434 if (charLimit == null || format == ImageFormat.PNG) {
435 return resizeAndStoreAvatar(centerSquare, format, 90);
436 } else {
437 Info avatar = null;
438 for (int quality = 90; quality >= 50; quality = quality - 2) {
439 if (avatar != null) {
440 FileBackend.getAvatarFile(context, avatar.getId()).delete();
441 }
442 Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
443 avatar = resizeAndStoreAvatar(centerSquare, format, quality);
444 if (avatar.getBytes() <= charLimit) {
445 return avatar;
446 }
447 }
448 return avatar;
449 }
450 }
451
452 private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
453 throws Exception {
454 return switch (format) {
455 case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
456 case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
457 case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
458 case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
459 case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
460 };
461 }
462
463 private Info resizeAndStoreAvatar(
464 final Bitmap image, final Bitmap.CompressFormat format, final int quality)
465 throws IOException {
466 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
467 final var fileOutputStream = new FileOutputStream(randomFile);
468 final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
469 image.compress(format, quality, hashingOutputStream);
470 hashingOutputStream.close();
471 final var sha1 = hashingOutputStream.hash().toString();
472 final var avatarFile = FileBackend.getAvatarFile(context, sha1);
473 if (moveAvatarIntoCache(randomFile, avatarFile)) {
474 return new Info(
475 sha1,
476 avatarFile.length(),
477 ImageFormat.of(format).toContentType(),
478 image.getHeight(),
479 image.getWidth());
480 }
481 throw new IllegalStateException(
482 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
483 }
484
485 private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
486 throws Exception {
487 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
488 try (final var fileOutputStream = new FileOutputStream(randomFile);
489 final var heifWriter =
490 new HeifWriter.Builder(
491 fileOutputStream.getFD(),
492 image.getWidth(),
493 image.getHeight(),
494 HeifWriter.INPUT_MODE_BITMAP)
495 .setMaxImages(1)
496 .setQuality(quality)
497 .build()) {
498
499 heifWriter.start();
500 heifWriter.addBitmap(image);
501 heifWriter.stop(3_000);
502 }
503 return storeAsAvatar(randomFile, ImageFormat.HEIF, image.getHeight(), image.getWidth());
504 }
505
506 private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
507 throws Exception {
508 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
509 try (final var fileOutputStream = new FileOutputStream(randomFile);
510 final var avifWriter =
511 new AvifWriter.Builder(
512 fileOutputStream.getFD(),
513 image.getWidth(),
514 image.getHeight(),
515 AvifWriter.INPUT_MODE_BITMAP)
516 .setMaxImages(1)
517 .setQuality(quality)
518 .build()) {
519 avifWriter.start();
520 avifWriter.addBitmap(image);
521 avifWriter.stop(3_000);
522 }
523 return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
524 }
525
526 private Info storeAsAvatar(
527 final File randomFile, final ImageFormat type, final int height, final int width)
528 throws IOException {
529 final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
530 final var avatarFile = FileBackend.getAvatarFile(context, sha1);
531 if (moveAvatarIntoCache(randomFile, avatarFile)) {
532 return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
533 }
534 throw new IllegalStateException(
535 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
536 }
537
538 private ListenableFuture<List<Info>> uploadAvatar(final Uri image) {
539 return Futures.transformAsync(
540 hasAlphaChannel(image),
541 hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
542 MoreExecutors.directExecutor());
543 }
544
545 private ListenableFuture<List<Info>> uploadAvatar(
546 final Uri image, final boolean hasAlphaChannel) {
547 final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
548
549 final ListenableFuture<Info> avatarThumbnailFuture;
550 final ListenableFuture<Info> avatarFuture;
551 if (hasAlphaChannel) {
552 avatarThumbnailFuture =
553 resizeAndStoreAvatarAsync(
554 image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
555 avatarFuture =
556 resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
557 } else {
558 final int autoAcceptFileSize =
559 context.getResources().getInteger(R.integer.auto_accept_filesize);
560 avatarThumbnailFuture =
561 resizeAndStoreAvatarAsync(
562 image,
563 Config.AVATAR_THUMBNAIL_SIZE,
564 ImageFormat.JPEG,
565 Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
566 avatarFuture =
567 resizeAndStoreAvatarAsync(
568 image, Config.AVATAR_FULL_SIZE, ImageFormat.JPEG, autoAcceptFileSize);
569
570 if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
571 final var avatarHeifFuture =
572 resizeAndStoreAvatarAsync(
573 image,
574 Config.AVATAR_FULL_SIZE,
575 ImageFormat.HEIF,
576 autoAcceptFileSize);
577 final var avatarHeifWithUrlFuture =
578 Futures.transformAsync(
579 avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
580 avatarFutures.add(avatarHeifWithUrlFuture);
581 }
582 if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
583 final var avatarAvifFuture =
584 resizeAndStoreAvatarAsync(
585 image,
586 Config.AVATAR_FULL_SIZE,
587 ImageFormat.AVIF,
588 autoAcceptFileSize);
589 final var avatarAvifWithUrlFuture =
590 Futures.transformAsync(
591 avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
592 avatarFutures.add(avatarAvifWithUrlFuture);
593 }
594 }
595 avatarFutures.add(avatarThumbnailFuture);
596 final var avatarWithUrlFuture =
597 Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
598 avatarFutures.add(avatarWithUrlFuture);
599
600 return Futures.allAsList(avatarFutures.build());
601 }
602
603 private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
604 return Futures.submit(
605 () -> {
606 final var cropped =
607 FileBackend.cropCenterSquare(context, image, Config.AVATAR_FULL_SIZE);
608 final var hasAlphaChannel = FileBackend.hasAlpha(cropped);
609 cropped.recycle();
610 return hasAlphaChannel;
611 },
612 AVATAR_COMPRESSION_EXECUTOR);
613 }
614
615 private ListenableFuture<Info> upload(final Info avatar) {
616 final var file = FileBackend.getAvatarFile(context, avatar.getId());
617 final var urlFuture =
618 getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
619 return Futures.transform(
620 urlFuture,
621 url -> {
622 avatar.setUrl(url);
623 return avatar;
624 },
625 MoreExecutors.directExecutor());
626 }
627
628 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
629 final Uri image, final int size, final ImageFormat format) {
630 return resizeAndStoreAvatarAsync(image, size, format, null);
631 }
632
633 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
634 final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
635 return Futures.submit(
636 () -> resizeAndStoreAvatar(image, size, format, charLimit),
637 AVATAR_COMPRESSION_EXECUTOR);
638 }
639
640 private ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
641 final Info mainAvatarInfo;
642 final byte[] mainAvatar;
643 try {
644 mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
645 mainAvatar =
646 Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
647 .read();
648 } catch (final IOException | NoSuchElementException e) {
649 return Futures.immediateFailedFuture(e);
650 }
651 final NodeConfiguration configuration =
652 open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
653 final var avatarData = new Data();
654 avatarData.setContent(mainAvatar);
655 final var future =
656 getManager(PepManager.class)
657 .publish(avatarData, mainAvatarInfo.getId(), configuration);
658 return Futures.transformAsync(
659 future,
660 v -> {
661 final var id = mainAvatarInfo.getId();
662 final var metadata = new Metadata();
663 metadata.addExtensions(avatars);
664 return getManager(PepManager.class).publish(metadata, id, configuration);
665 },
666 MoreExecutors.directExecutor());
667 }
668
669 public ListenableFuture<Void> publishVCard(final Jid address, final Uri image) {
670
671 ListenableFuture<Info> avatarThumbnailFuture =
672 Futures.transformAsync(
673 hasAlphaChannel(image),
674 hasAlphaChannel -> {
675 if (hasAlphaChannel) {
676 return resizeAndStoreAvatarAsync(
677 image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
678 } else {
679 return resizeAndStoreAvatarAsync(
680 image,
681 Config.AVATAR_THUMBNAIL_SIZE,
682 ImageFormat.JPEG,
683 Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
684 }
685 },
686 MoreExecutors.directExecutor());
687 return Futures.transformAsync(
688 avatarThumbnailFuture,
689 info -> {
690 final var avatar =
691 Files.asByteSource(FileBackend.getAvatarFile(context, info.getId()))
692 .read();
693 return getManager(VCardManager.class)
694 .publishPhoto(address, info.getType(), avatar);
695 },
696 AVATAR_COMPRESSION_EXECUTOR);
697 }
698
699 public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
700 final var infoFuture = uploadAvatar(image);
701 return Futures.transformAsync(
702 infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
703 }
704
705 public boolean hasPepToVCardConversion() {
706 return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
707 }
708
709 public ListenableFuture<Void> delete() {
710 final var pepManager = getManager(PepManager.class);
711 final var deleteMetaDataFuture = pepManager.delete(Namespace.AVATAR_METADATA);
712 final var deleteDataFuture = pepManager.delete(Namespace.AVATAR_DATA);
713 return Futures.transform(
714 Futures.allAsList(deleteDataFuture, deleteMetaDataFuture),
715 vs -> null,
716 MoreExecutors.directExecutor());
717 }
718
719 public ListenableFuture<Void> fetchAndStore(final Jid address) {
720 final var metaDataFuture =
721 getManager(PubSubManager.class).fetchItems(address, Metadata.class);
722 return Futures.transformAsync(
723 metaDataFuture,
724 metaData -> {
725 final var entry = Iterables.getFirst(metaData.entrySet(), null);
726 if (entry == null) {
727 throw new IllegalStateException("Metadata item not found");
728 }
729 final var avatar = getPreferredFallback(entry);
730
731 if (avatar == null) {
732 throw new IllegalStateException("No avatar found");
733 }
734
735 final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
736
737 if (cache.exists()) {
738 Log.d(
739 Config.LOGTAG,
740 "fetchAndStore. file existed " + cache.getAbsolutePath());
741 setAvatarInfo(address, avatar.preferred);
742 return Futures.immediateVoidFuture();
743 } else {
744 final var future =
745 this.fetchAndStoreWithFallback(
746 address, avatar.preferred, avatar.fallback);
747 return Futures.transform(
748 future,
749 info -> {
750 setAvatarInfo(address, info);
751 return null;
752 },
753 MoreExecutors.directExecutor());
754 }
755 },
756 MoreExecutors.directExecutor());
757 }
758
759 private static boolean moveAvatarIntoCache(final File randomFile, final File destination) {
760 synchronized (RENAME_LOCK) {
761 if (destination.exists()) {
762 return true;
763 }
764 final var directory = destination.getParentFile();
765 if (directory != null && directory.mkdirs()) {
766 Log.d(
767 Config.LOGTAG,
768 "create avatar cache directory: " + directory.getAbsolutePath());
769 }
770 return randomFile.renameTo(destination);
771 }
772 }
773
774 public ListenableFuture<Void> fetchAndStoreVCard(final Jid address, final String expectedHash) {
775 final var future = connection.getManager(VCardManager.class).retrievePhoto(address);
776 return Futures.transformAsync(
777 future,
778 photo -> {
779 final var actualHash = Hashing.sha1().hashBytes(photo).toString();
780 if (!actualHash.equals(expectedHash)) {
781 return Futures.immediateFailedFuture(
782 new IllegalStateException(
783 String.format(
784 "Hash in vCard update for %s did not match",
785 address)));
786 }
787 final var avatarFile = FileBackend.getAvatarFile(context, actualHash);
788 if (avatarFile.exists()) {
789 setAvatar(address, actualHash);
790 return Futures.immediateVoidFuture();
791 }
792 final var writeFuture = write(avatarFile, photo);
793 return Futures.transform(
794 writeFuture,
795 v -> {
796 setAvatar(address, actualHash);
797 return null;
798 },
799 MoreExecutors.directExecutor());
800 },
801 AVATAR_COMPRESSION_EXECUTOR);
802 }
803
804 public enum ImageFormat {
805 PNG,
806 JPEG,
807 WEBP,
808 HEIF,
809 AVIF;
810
811 public String toContentType() {
812 return switch (this) {
813 case WEBP -> "image/webp";
814 case PNG -> "image/png";
815 case JPEG -> "image/jpeg";
816 case AVIF -> "image/avif";
817 case HEIF -> "image/heif";
818 };
819 }
820
821 public static int formatPriority(final String type) {
822 final var format = ofContentType(type);
823 return format == null ? Integer.MIN_VALUE : format.ordinal();
824 }
825
826 private static ImageFormat ofContentType(final String type) {
827 return switch (type) {
828 case "image/png" -> PNG;
829 case "image/jpeg" -> JPEG;
830 case "image/webp" -> WEBP;
831 case "image/heif" -> HEIF;
832 case "image/avif" -> AVIF;
833 default -> null;
834 };
835 }
836
837 public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
838 return switch (compressFormat) {
839 case PNG -> PNG;
840 case WEBP -> WEBP;
841 case JPEG -> JPEG;
842 default -> throw new AssertionError("Not implemented");
843 };
844 }
845 }
846
847 private static final class PreferredFallback {
848 private final Info preferred;
849 private final Info fallback;
850
851 private PreferredFallback(final Info fallback) {
852 this(fallback, fallback);
853 }
854
855 private PreferredFallback(Info preferred, Info fallback) {
856 this.preferred = preferred;
857 this.fallback = fallback;
858 }
859 }
860}