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