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