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.android.Device;
32import eu.siacs.conversations.entities.Contact;
33import eu.siacs.conversations.entities.Conversation;
34import eu.siacs.conversations.entities.Conversational;
35import eu.siacs.conversations.persistance.FileBackend;
36import eu.siacs.conversations.services.XmppConnectionService;
37import eu.siacs.conversations.utils.Compatibility;
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 final var device = new Device(context);
626
627 if (Compatibility.twentyEight() && device.isPhysicalDevice()) {
628 final var avatarHeifFuture =
629 resizeAndStoreAvatarAsync(
630 image,
631 Config.AVATAR_FULL_SIZE,
632 ImageFormat.HEIF,
633 autoAcceptFileSize);
634 final var avatarHeifWithUrlFuture =
635 Futures.transformAsync(
636 avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
637 avatarFutures.add(avatarHeifWithUrlFuture);
638 }
639 if (Compatibility.thirtyFour() && device.isPhysicalDevice()) {
640 final var avatarAvifFuture =
641 resizeAndStoreAvatarAsync(
642 image,
643 Config.AVATAR_FULL_SIZE,
644 ImageFormat.AVIF,
645 autoAcceptFileSize);
646 final var avatarAvifWithUrlFuture =
647 Futures.transformAsync(
648 avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
649 final var caughtAvifWithUrlFuture =
650 Futures.catching(
651 avatarAvifWithUrlFuture,
652 Exception.class,
653 ex -> {
654 Log.d(Config.LOGTAG, "ignoring AVIF compression failure", ex);
655 return null;
656 },
657 MoreExecutors.directExecutor());
658 avatarFutures.add(caughtAvifWithUrlFuture);
659 }
660 }
661 avatarFutures.add(avatarThumbnailFuture);
662 final var avatarWithUrlFuture =
663 Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
664 avatarFutures.add(avatarWithUrlFuture);
665
666 final var all = Futures.allAsList(avatarFutures.build());
667 return Futures.transform(
668 all,
669 input -> Collections2.filter(input, Objects::nonNull),
670 MoreExecutors.directExecutor());
671 }
672
673 private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
674 return Futures.submit(
675 () -> {
676 final var cropped =
677 FileBackend.cropCenterSquare(context, image, Config.AVATAR_FULL_SIZE);
678 final var hasAlphaChannel = FileBackend.hasAlpha(cropped);
679 cropped.recycle();
680 return hasAlphaChannel;
681 },
682 AVATAR_COMPRESSION_EXECUTOR);
683 }
684
685 private ListenableFuture<Info> upload(final Info avatar) {
686 final var file = FileBackend.getAvatarFile(context, avatar.getId());
687 final var urlFuture =
688 getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
689 return Futures.transform(
690 urlFuture,
691 url -> {
692 avatar.setUrl(url);
693 return avatar;
694 },
695 MoreExecutors.directExecutor());
696 }
697
698 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
699 final Uri image, final int size, final ImageFormat format) {
700 return resizeAndStoreAvatarAsync(image, size, format, null);
701 }
702
703 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
704 final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
705 return Futures.submit(
706 () -> resizeAndStoreAvatar(image, size, format, charLimit),
707 AVATAR_COMPRESSION_EXECUTOR);
708 }
709
710 private ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
711 final Info mainAvatarInfo;
712 final byte[] mainAvatar;
713 try {
714 mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
715 mainAvatar =
716 Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
717 .read();
718 } catch (final IOException | NoSuchElementException e) {
719 return Futures.immediateFailedFuture(e);
720 }
721 final NodeConfiguration configuration =
722 open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
723 final var avatarData = new Data();
724 avatarData.setContent(mainAvatar);
725 final var future =
726 getManager(PepManager.class)
727 .publish(avatarData, mainAvatarInfo.getId(), configuration);
728 return Futures.transformAsync(
729 future,
730 v -> {
731 final var id = mainAvatarInfo.getId();
732 final var metadata = new Metadata();
733 metadata.addExtensions(avatars);
734 return getManager(PepManager.class).publish(metadata, id, configuration);
735 },
736 MoreExecutors.directExecutor());
737 }
738
739 public ListenableFuture<Void> publishVCard(final Jid address, final Uri image) {
740
741 ListenableFuture<Info> avatarThumbnailFuture =
742 Futures.transformAsync(
743 hasAlphaChannel(image),
744 hasAlphaChannel -> {
745 if (hasAlphaChannel) {
746 return resizeAndStoreAvatarAsync(
747 image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
748 } else {
749 return resizeAndStoreAvatarAsync(
750 image,
751 Config.AVATAR_THUMBNAIL_SIZE,
752 ImageFormat.JPEG,
753 Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
754 }
755 },
756 MoreExecutors.directExecutor());
757 return Futures.transformAsync(
758 avatarThumbnailFuture,
759 info -> {
760 final var avatar =
761 Files.asByteSource(FileBackend.getAvatarFile(context, info.getId()))
762 .read();
763 return getManager(VCardManager.class)
764 .publishPhoto(address, info.getType(), avatar);
765 },
766 AVATAR_COMPRESSION_EXECUTOR);
767 }
768
769 public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
770 final var infoFuture = uploadAvatar(image);
771 return Futures.transformAsync(
772 infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
773 }
774
775 public boolean hasPepToVCardConversion() {
776 return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
777 }
778
779 public ListenableFuture<Void> delete() {
780 final var pepManager = getManager(PepManager.class);
781 final var deleteMetaDataFuture = pepManager.delete(Namespace.AVATAR_METADATA);
782 final var deleteDataFuture = pepManager.delete(Namespace.AVATAR_DATA);
783 return Futures.transform(
784 Futures.allAsList(deleteDataFuture, deleteMetaDataFuture),
785 vs -> null,
786 MoreExecutors.directExecutor());
787 }
788
789 public ListenableFuture<Void> fetchAndStore(final Jid address) {
790 final var metaDataFuture =
791 getManager(PubSubManager.class).fetchItems(address, Metadata.class);
792 return Futures.transformAsync(
793 metaDataFuture,
794 metaData -> {
795 final var entry = Iterables.getFirst(metaData.entrySet(), null);
796 if (entry == null) {
797 throw new IllegalStateException("Metadata item not found");
798 }
799 final var avatar = getPreferredFallback(entry);
800
801 if (avatar == null) {
802 throw new IllegalStateException("No avatar found");
803 }
804
805 final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
806
807 if (cache.exists()) {
808 Log.d(
809 Config.LOGTAG,
810 "fetchAndStore. file existed " + cache.getAbsolutePath());
811 setAvatarInfo(address, avatar.preferred);
812 return Futures.immediateVoidFuture();
813 } else {
814 final var future =
815 this.fetchAndStoreWithFallback(
816 address, avatar.preferred, avatar.fallback);
817 return Futures.transform(
818 future,
819 info -> {
820 setAvatarInfo(address, info);
821 return null;
822 },
823 MoreExecutors.directExecutor());
824 }
825 },
826 MoreExecutors.directExecutor());
827 }
828
829 private static boolean moveAvatarIntoCache(final File randomFile, final File destination) {
830 synchronized (RENAME_LOCK) {
831 if (destination.exists()) {
832 return true;
833 }
834 final var directory = destination.getParentFile();
835 if (directory != null && directory.mkdirs()) {
836 Log.d(
837 Config.LOGTAG,
838 "create avatar cache directory: " + directory.getAbsolutePath());
839 }
840 return randomFile.renameTo(destination);
841 }
842 }
843
844 public ListenableFuture<Void> fetchAndStoreVCard(final Jid address, final String expectedHash) {
845 final var future =
846 connection.getManager(VCardManager.class).retrievePhotoCacheException(address);
847 return Futures.transformAsync(
848 future,
849 photo -> {
850 final var actualHash = Hashing.sha1().hashBytes(photo).toString();
851 if (!actualHash.equals(expectedHash)) {
852 return Futures.immediateFailedFuture(
853 new IllegalStateException(
854 String.format(
855 "Hash in vCard update for %s did not match",
856 address)));
857 }
858 final var avatarFile = FileBackend.getAvatarFile(context, actualHash);
859 if (avatarFile.exists()) {
860 setAvatar(address, actualHash);
861 return Futures.immediateVoidFuture();
862 }
863 final var writeFuture = write(avatarFile, photo);
864 return Futures.transform(
865 writeFuture,
866 v -> {
867 setAvatar(address, actualHash);
868 return null;
869 },
870 MoreExecutors.directExecutor());
871 },
872 AVATAR_COMPRESSION_EXECUTOR);
873 }
874
875 private static final class ImageCompressionException extends IllegalStateException {
876 ImageCompressionException(final String message) {
877 super(message);
878 }
879 }
880
881 public enum ImageFormat {
882 PNG,
883 JPEG,
884 WEBP,
885 HEIF,
886 AVIF;
887
888 public String toContentType() {
889 return switch (this) {
890 case WEBP -> "image/webp";
891 case PNG -> "image/png";
892 case JPEG -> "image/jpeg";
893 case AVIF -> "image/avif";
894 case HEIF -> "image/heif";
895 };
896 }
897
898 public static int formatPriority(final String type) {
899 final var format = ofContentType(type);
900 return format == null ? Integer.MIN_VALUE : format.ordinal();
901 }
902
903 private static ImageFormat ofContentType(final String type) {
904 return switch (type) {
905 case "image/png" -> PNG;
906 case "image/jpeg" -> JPEG;
907 case "image/webp" -> WEBP;
908 case "image/heif" -> HEIF;
909 case "image/avif" -> AVIF;
910 default -> null;
911 };
912 }
913
914 public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
915 return switch (compressFormat) {
916 case PNG -> PNG;
917 case WEBP -> WEBP;
918 case JPEG -> JPEG;
919 default -> throw new AssertionError("Not implemented");
920 };
921 }
922 }
923
924 private static final class PreferredFallback {
925 private final Info preferred;
926 private final Info fallback;
927
928 private PreferredFallback(final Info fallback) {
929 this(fallback, fallback);
930 }
931
932 private PreferredFallback(Info preferred, Info fallback) {
933 this.preferred = preferred;
934 this.fallback = fallback;
935 }
936 }
937}