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, ImageFormat.SVG);
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, 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(XmppConnectionService.UpdateRosterReason.AVATAR);
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 = context.getFileBackend().cropCenterSquare(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 case SVG -> throw new RuntimeException("SVG cannot be a Bitmap?");
481 };
482 }
483
484 private Info resizeAndStoreAvatar(
485 final Bitmap image, final Bitmap.CompressFormat format, final int quality)
486 throws IOException {
487 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
488 final var fileOutputStream = new FileOutputStream(randomFile);
489 final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
490 image.compress(format, quality, hashingOutputStream);
491 hashingOutputStream.close();
492 final var sha1 = hashingOutputStream.hash().toString();
493 final var avatarFile = FileBackend.getAvatarFile(context, sha1);
494 if (moveAvatarIntoCache(randomFile, avatarFile)) {
495 return new Info(
496 sha1,
497 avatarFile.length(),
498 ImageFormat.of(format).toContentType(),
499 image.getWidth(),
500 image.getHeight());
501 }
502 throw new IllegalStateException(
503 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
504 }
505
506 private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
507 throws Exception {
508 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
509 try (final var fileOutputStream = new FileOutputStream(randomFile);
510 final var heifWriter =
511 new HeifWriter.Builder(
512 fileOutputStream.getFD(),
513 image.getWidth(),
514 image.getHeight(),
515 HeifWriter.INPUT_MODE_BITMAP)
516 .setMaxImages(1)
517 .setQuality(quality)
518 .build()) {
519
520 heifWriter.start();
521 heifWriter.addBitmap(image);
522 heifWriter.stop(3_000);
523 }
524 final var width = image.getWidth();
525 final var height = image.getHeight();
526 checkDecoding(randomFile, ImageFormat.HEIF, width, height);
527 return storeAsAvatar(randomFile, ImageFormat.HEIF, width, height);
528 }
529
530 private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
531 throws Exception {
532 final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
533 try (final var fileOutputStream = new FileOutputStream(randomFile);
534 final var avifWriter =
535 new AvifWriter.Builder(
536 fileOutputStream.getFD(),
537 image.getWidth(),
538 image.getHeight(),
539 AvifWriter.INPUT_MODE_BITMAP)
540 .setMaxImages(1)
541 .setQuality(quality)
542 .build()) {
543 avifWriter.start();
544 avifWriter.addBitmap(image);
545 avifWriter.stop(3_000);
546 }
547 final var width = image.getWidth();
548 final var height = image.getHeight();
549 checkDecoding(randomFile, ImageFormat.AVIF, width, height);
550 return storeAsAvatar(randomFile, ImageFormat.AVIF, width, height);
551 }
552
553 private void checkDecoding(
554 final File randomFile, final ImageFormat format, final int width, final int height) {
555 var readCheck = BitmapFactory.decodeFile(randomFile.getAbsolutePath());
556 if (readCheck == null) {
557 throw new ImageCompressionException(
558 String.format("%s image was null after trying to decode", format));
559 }
560 if (readCheck.getWidth() != width || readCheck.getHeight() != height) {
561 readCheck.recycle();
562 throw new ImageCompressionException(String.format("%s had wrong image bounds", format));
563 }
564 readCheck.recycle();
565 }
566
567 private Info storeAsAvatar(
568 final File randomFile, final ImageFormat type, final int width, final int height)
569 throws IOException {
570 final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
571 final var avatarFile = FileBackend.getAvatarFile(context, sha1);
572 if (moveAvatarIntoCache(randomFile, avatarFile)) {
573 return new Info(sha1, avatarFile.length(), type.toContentType(), width, height);
574 }
575 throw new IllegalStateException(
576 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
577 }
578
579 private ListenableFuture<Collection<Info>> uploadAvatar(final Uri image) {
580 return Futures.transformAsync(
581 hasAlphaChannel(image),
582 hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
583 MoreExecutors.directExecutor());
584 }
585
586 private ListenableFuture<Collection<Info>> uploadAvatar(
587 final Uri image, final boolean hasAlphaChannel) {
588 final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
589
590 final ListenableFuture<Info> avatarThumbnailFuture;
591 if (hasAlphaChannel) {
592 avatarThumbnailFuture =
593 resizeAndStoreAvatarAsync(
594 image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
595 } else {
596 avatarThumbnailFuture =
597 resizeAndStoreAvatarAsync(
598 image,
599 Config.AVATAR_THUMBNAIL_SIZE,
600 ImageFormat.JPEG,
601 Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
602 }
603
604 final var uploadManager = getManager(HttpUploadManager.class);
605
606 final var uploadService = uploadManager.getService();
607 if (uploadService == null || !uploadService.supportsPurpose(Profile.class)) {
608 Log.d(
609 Config.LOGTAG,
610 getAccount().getJid() + ": 'profile' upload purpose not supported");
611 return Futures.transform(
612 avatarThumbnailFuture, ImmutableList::of, MoreExecutors.directExecutor());
613 }
614
615 final ListenableFuture<Info> avatarFuture;
616 if (hasAlphaChannel) {
617 avatarFuture =
618 resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
619 } else {
620 final int autoAcceptFileSize =
621 context.getResources().getInteger(R.integer.auto_accept_filesize);
622 avatarFuture =
623 resizeAndStoreAvatarAsync(
624 image, Config.AVATAR_FULL_SIZE, ImageFormat.JPEG, autoAcceptFileSize);
625
626 final var device = new Device(context);
627
628 if (Compatibility.twentyEight() && device.isPhysicalDevice()) {
629 final var avatarHeifFuture =
630 resizeAndStoreAvatarAsync(
631 image,
632 Config.AVATAR_FULL_SIZE,
633 ImageFormat.HEIF,
634 autoAcceptFileSize);
635 final var avatarHeifWithUrlFuture =
636 Futures.transformAsync(
637 avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
638 avatarFutures.add(avatarHeifWithUrlFuture);
639 }
640 if (Compatibility.thirtyFour() && device.isPhysicalDevice()) {
641 final var avatarAvifFuture =
642 resizeAndStoreAvatarAsync(
643 image,
644 Config.AVATAR_FULL_SIZE,
645 ImageFormat.AVIF,
646 autoAcceptFileSize);
647 final var avatarAvifWithUrlFuture =
648 Futures.transformAsync(
649 avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
650 final var caughtAvifWithUrlFuture =
651 Futures.catching(
652 avatarAvifWithUrlFuture,
653 Exception.class,
654 ex -> {
655 Log.d(Config.LOGTAG, "ignoring AVIF compression failure", ex);
656 return null;
657 },
658 MoreExecutors.directExecutor());
659 avatarFutures.add(caughtAvifWithUrlFuture);
660 }
661 }
662 avatarFutures.add(avatarThumbnailFuture);
663 final var avatarWithUrlFuture =
664 Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
665 avatarFutures.add(avatarWithUrlFuture);
666
667 final var all = Futures.allAsList(avatarFutures.build());
668 return Futures.transform(
669 all,
670 input -> Collections2.filter(input, Objects::nonNull),
671 MoreExecutors.directExecutor());
672 }
673
674 private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
675 return Futures.submit(
676 () -> {
677 final var cropped =
678 context.getFileBackend().cropCenterSquare(image, Config.AVATAR_FULL_SIZE);
679 final var hasAlphaChannel = FileBackend.hasAlpha(cropped);
680 cropped.recycle();
681 return hasAlphaChannel;
682 },
683 AVATAR_COMPRESSION_EXECUTOR);
684 }
685
686 private ListenableFuture<Info> upload(final Info avatar) {
687 final var file = FileBackend.getAvatarFile(context, avatar.getId());
688 final var urlFuture =
689 getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
690 return Futures.transform(
691 urlFuture,
692 url -> {
693 avatar.setUrl(url);
694 return avatar;
695 },
696 MoreExecutors.directExecutor());
697 }
698
699 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
700 final Uri image, final int size, final ImageFormat format) {
701 return resizeAndStoreAvatarAsync(image, size, format, null);
702 }
703
704 private ListenableFuture<Info> resizeAndStoreAvatarAsync(
705 final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
706 return Futures.submit(
707 () -> resizeAndStoreAvatar(image, size, format, charLimit),
708 AVATAR_COMPRESSION_EXECUTOR);
709 }
710
711 private ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
712 final Info mainAvatarInfo;
713 final byte[] mainAvatar;
714 try {
715 mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
716 mainAvatar =
717 Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
718 .read();
719 } catch (final IOException | NoSuchElementException e) {
720 return Futures.immediateFailedFuture(e);
721 }
722 final NodeConfiguration configuration =
723 open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
724 final var avatarData = new Data();
725 avatarData.setContent(mainAvatar);
726 final var future =
727 getManager(PepManager.class)
728 .publish(avatarData, mainAvatarInfo.getId(), configuration);
729 return Futures.transformAsync(
730 future,
731 v -> {
732 final var id = mainAvatarInfo.getId();
733 final var metadata = new Metadata();
734 metadata.addExtensions(avatars);
735 return getManager(PepManager.class).publish(metadata, id, configuration);
736 },
737 MoreExecutors.directExecutor());
738 }
739
740 public ListenableFuture<Void> publishVCard(final Jid address, final Uri image) {
741
742 ListenableFuture<Info> avatarThumbnailFuture =
743 Futures.transformAsync(
744 hasAlphaChannel(image),
745 hasAlphaChannel -> {
746 if (hasAlphaChannel) {
747 return resizeAndStoreAvatarAsync(
748 image, Config.AVATAR_THUMBNAIL_SIZE / 2, ImageFormat.PNG);
749 } else {
750 return resizeAndStoreAvatarAsync(
751 image,
752 Config.AVATAR_THUMBNAIL_SIZE,
753 ImageFormat.JPEG,
754 Config.AVATAR_THUMBNAIL_CHAR_LIMIT);
755 }
756 },
757 MoreExecutors.directExecutor());
758 return Futures.transformAsync(
759 avatarThumbnailFuture,
760 info -> {
761 final var avatar =
762 Files.asByteSource(FileBackend.getAvatarFile(context, info.getId()))
763 .read();
764 return getManager(VCardManager.class)
765 .publishPhoto(address, info.getType(), avatar);
766 },
767 AVATAR_COMPRESSION_EXECUTOR);
768 }
769
770 public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
771 final var infoFuture = uploadAvatar(image);
772 return Futures.transformAsync(
773 infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
774 }
775
776 public boolean hasPepToVCardConversion() {
777 return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
778 }
779
780 public ListenableFuture<Void> delete() {
781 final var pepManager = getManager(PepManager.class);
782 final var deleteMetaDataFuture = pepManager.delete(Namespace.AVATAR_METADATA);
783 final var deleteDataFuture = pepManager.delete(Namespace.AVATAR_DATA);
784 return Futures.transform(
785 Futures.allAsList(deleteDataFuture, deleteMetaDataFuture),
786 vs -> null,
787 MoreExecutors.directExecutor());
788 }
789
790 public ListenableFuture<Void> fetchAndStore(final Jid address) {
791 final var metaDataFuture =
792 getManager(PubSubManager.class).fetchItems(address, Metadata.class);
793 return Futures.transformAsync(
794 metaDataFuture,
795 metaData -> {
796 final var entry = Iterables.getFirst(metaData.entrySet(), null);
797 if (entry == null) {
798 throw new IllegalStateException("Metadata item not found");
799 }
800 final var avatar = getPreferredFallback(entry);
801
802 if (avatar == null) {
803 throw new IllegalStateException("No avatar found");
804 }
805
806 final var cache = FileBackend.getAvatarFile(context, avatar.preferred.getId());
807
808 if (cache.exists()) {
809 Log.d(
810 Config.LOGTAG,
811 "fetchAndStore. file existed " + cache.getAbsolutePath());
812 setAvatarInfo(address, avatar.preferred);
813 return Futures.immediateVoidFuture();
814 } else {
815 final var future =
816 this.fetchAndStoreWithFallback(
817 address, avatar.preferred, avatar.fallback);
818 return Futures.transform(
819 future,
820 info -> {
821 setAvatarInfo(address, info);
822 return null;
823 },
824 MoreExecutors.directExecutor());
825 }
826 },
827 MoreExecutors.directExecutor());
828 }
829
830 private static boolean moveAvatarIntoCache(final File randomFile, final File destination) {
831 synchronized (RENAME_LOCK) {
832 if (destination.exists()) {
833 return true;
834 }
835 final var directory = destination.getParentFile();
836 if (directory != null && directory.mkdirs()) {
837 Log.d(
838 Config.LOGTAG,
839 "create avatar cache directory: " + directory.getAbsolutePath());
840 }
841 return randomFile.renameTo(destination);
842 }
843 }
844
845 public ListenableFuture<Void> fetchAndStoreVCard(final Jid address, final String expectedHash) {
846 final var future =
847 connection.getManager(VCardManager.class).retrievePhotoCacheException(address);
848 return Futures.transformAsync(
849 future,
850 photo -> {
851 final var actualHash = Hashing.sha1().hashBytes(photo).toString();
852 if (!actualHash.equals(expectedHash)) {
853 return Futures.immediateFailedFuture(
854 new IllegalStateException(
855 String.format(
856 "Hash in vCard update for %s did not match",
857 address)));
858 }
859 final var avatarFile = FileBackend.getAvatarFile(context, actualHash);
860 if (avatarFile.exists()) {
861 setAvatar(address, actualHash);
862 return Futures.immediateVoidFuture();
863 }
864 final var writeFuture = write(avatarFile, photo);
865 return Futures.transform(
866 writeFuture,
867 v -> {
868 setAvatar(address, actualHash);
869 return null;
870 },
871 MoreExecutors.directExecutor());
872 },
873 AVATAR_COMPRESSION_EXECUTOR);
874 }
875
876 private static final class ImageCompressionException extends IllegalStateException {
877 ImageCompressionException(final String message) {
878 super(message);
879 }
880 }
881
882 public enum ImageFormat {
883 PNG,
884 JPEG,
885 WEBP,
886 HEIF,
887 AVIF,
888 SVG;
889
890 public String toContentType() {
891 return switch (this) {
892 case WEBP -> "image/webp";
893 case PNG -> "image/png";
894 case JPEG -> "image/jpeg";
895 case AVIF -> "image/avif";
896 case HEIF -> "image/heif";
897 case SVG -> "image/svg+xml";
898 };
899 }
900
901 public static int formatPriority(final String type) {
902 final var format = ofContentType(type);
903 return format == null ? Integer.MIN_VALUE : format.ordinal();
904 }
905
906 private static ImageFormat ofContentType(final String type) {
907 return switch (type) {
908 case "image/png" -> PNG;
909 case "image/jpeg" -> JPEG;
910 case "image/webp" -> WEBP;
911 case "image/heif" -> HEIF;
912 case "image/avif" -> AVIF;
913 case "image/svg+xml" -> SVG;
914 default -> null;
915 };
916 }
917
918 public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
919 return switch (compressFormat) {
920 case PNG -> PNG;
921 case WEBP -> WEBP;
922 case JPEG -> JPEG;
923 default -> throw new AssertionError("Not implemented");
924 };
925 }
926 }
927
928 private static final class PreferredFallback {
929 private final Info preferred;
930 private final Info fallback;
931
932 private PreferredFallback(final Info fallback) {
933 this(fallback, fallback);
934 }
935
936 private PreferredFallback(Info preferred, Info fallback) {
937 this.preferred = preferred;
938 this.fallback = fallback;
939 }
940 }
941}