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