AvatarManager.java

  1package eu.siacs.conversations.xmpp.manager;
  2
  3import android.graphics.Bitmap;
  4import android.net.Uri;
  5import android.util.Log;
  6import androidx.annotation.NonNull;
  7import androidx.heifwriter.AvifWriter;
  8import androidx.heifwriter.HeifWriter;
  9import com.google.common.collect.ImmutableList;
 10import com.google.common.collect.Iterables;
 11import com.google.common.hash.Hashing;
 12import com.google.common.hash.HashingOutputStream;
 13import com.google.common.io.BaseEncoding;
 14import com.google.common.io.Files;
 15import com.google.common.util.concurrent.FutureCallback;
 16import com.google.common.util.concurrent.Futures;
 17import com.google.common.util.concurrent.ListenableFuture;
 18import com.google.common.util.concurrent.MoreExecutors;
 19import eu.siacs.conversations.Config;
 20import eu.siacs.conversations.entities.Contact;
 21import eu.siacs.conversations.persistance.FileBackend;
 22import eu.siacs.conversations.services.XmppConnectionService;
 23import eu.siacs.conversations.utils.Compatibility;
 24import eu.siacs.conversations.utils.PhoneHelper;
 25import eu.siacs.conversations.xml.Namespace;
 26import eu.siacs.conversations.xmpp.Jid;
 27import eu.siacs.conversations.xmpp.XmppConnection;
 28import eu.siacs.conversations.xmpp.pep.Avatar;
 29import im.conversations.android.xmpp.NodeConfiguration;
 30import im.conversations.android.xmpp.model.ByteContent;
 31import im.conversations.android.xmpp.model.avatar.Data;
 32import im.conversations.android.xmpp.model.avatar.Info;
 33import im.conversations.android.xmpp.model.avatar.Metadata;
 34import im.conversations.android.xmpp.model.pubsub.Items;
 35import im.conversations.android.xmpp.model.upload.purpose.Profile;
 36import java.io.File;
 37import java.io.FileOutputStream;
 38import java.io.IOException;
 39import java.util.Collection;
 40import java.util.List;
 41import java.util.NoSuchElementException;
 42import java.util.Objects;
 43import java.util.UUID;
 44import java.util.concurrent.Executor;
 45import java.util.concurrent.Executors;
 46
 47public class AvatarManager extends AbstractManager {
 48
 49    private static final Executor AVATAR_COMPRESSION_EXECUTOR =
 50            MoreExecutors.newSequentialExecutor(Executors.newSingleThreadScheduledExecutor());
 51
 52    private final XmppConnectionService service;
 53
 54    public AvatarManager(final XmppConnectionService service, XmppConnection connection) {
 55        super(service.getApplicationContext(), connection);
 56        this.service = service;
 57    }
 58
 59    public ListenableFuture<byte[]> fetch(final Jid address, final String itemId) {
 60        final var future = getManager(PubSubManager.class).fetchItem(address, itemId, Data.class);
 61        return Futures.transform(future, ByteContent::asBytes, MoreExecutors.directExecutor());
 62    }
 63
 64    public ListenableFuture<Void> fetchAndStore(final Avatar avatar) {
 65        final var future = fetch(avatar.owner, avatar.sha1sum);
 66        return Futures.transform(
 67                future,
 68                data -> {
 69                    avatar.image = BaseEncoding.base64().encode(data);
 70                    if (service.getFileBackend().save(avatar)) {
 71                        setPepAvatar(avatar);
 72                        return null;
 73                    } else {
 74                        throw new IllegalStateException("Could not store avatar");
 75                    }
 76                },
 77                MoreExecutors.directExecutor());
 78    }
 79
 80    private void setPepAvatar(final Avatar avatar) {
 81        final var account = getAccount();
 82        if (account.getJid().asBareJid().equals(avatar.owner)) {
 83            if (account.setAvatar(avatar.getFilename())) {
 84                getDatabase().updateAccount(account);
 85            }
 86            this.service.getAvatarService().clear(account);
 87            this.service.updateConversationUi();
 88            this.service.updateAccountUi();
 89        } else {
 90            final Contact contact = account.getRoster().getContact(avatar.owner);
 91            contact.setAvatar(avatar);
 92            account.getXmppConnection().getManager(RosterManager.class).writeToDatabaseAsync();
 93            this.service.getAvatarService().clear(contact);
 94            this.service.updateConversationUi();
 95            this.service.updateRosterUi();
 96        }
 97    }
 98
 99    public void handleItems(final Jid from, final Items items) {
100        final var account = getAccount();
101        // TODO support retract
102        final var entry = items.getFirstItemWithId(Metadata.class);
103        final var avatar =
104                entry == null ? null : Avatar.parseMetadata(entry.getKey(), entry.getValue());
105        if (avatar == null) {
106            Log.d(Config.LOGTAG, "could not parse avatar metadata from " + from);
107            return;
108        }
109        avatar.owner = from.asBareJid();
110        if (service.getFileBackend().isAvatarCached(avatar)) {
111            if (account.getJid().asBareJid().equals(from)) {
112                if (account.setAvatar(avatar.getFilename())) {
113                    service.databaseBackend.updateAccount(account);
114                    service.notifyAccountAvatarHasChanged(account);
115                }
116                service.getAvatarService().clear(account);
117                service.updateConversationUi();
118                service.updateAccountUi();
119            } else {
120                final Contact contact = account.getRoster().getContact(from);
121                if (contact.setAvatar(avatar)) {
122                    connection.getManager(RosterManager.class).writeToDatabaseAsync();
123                    service.getAvatarService().clear(contact);
124                    service.updateConversationUi();
125                    service.updateRosterUi();
126                }
127            }
128        } else if (service.isDataSaverDisabled()) {
129            final var future = this.fetchAndStore(avatar);
130            Futures.addCallback(
131                    future,
132                    new FutureCallback<Void>() {
133                        @Override
134                        public void onSuccess(Void result) {
135                            Log.d(
136                                    Config.LOGTAG,
137                                    account.getJid().asBareJid()
138                                            + ": successfully fetched pep avatar for "
139                                            + avatar.owner);
140                        }
141
142                        @Override
143                        public void onFailure(@NonNull Throwable t) {
144                            Log.d(Config.LOGTAG, "could not fetch avatar", t);
145                        }
146                    },
147                    MoreExecutors.directExecutor());
148        }
149    }
150
151    public void handleDelete(final Jid from) {
152        final var account = getAccount();
153        final boolean isAccount = account.getJid().asBareJid().equals(from);
154        if (isAccount) {
155            account.setAvatar(null);
156            getDatabase().updateAccount(account);
157            service.getAvatarService().clear(account);
158            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted avatar metadata node");
159        }
160    }
161
162    private Info resizeAndStoreAvatar(
163            final Uri image, final int size, final ImageFormat format, final Integer charLimit)
164            throws Exception {
165        final var centerSquare = FileBackend.cropCenterSquare(context, image, size);
166        if (charLimit == null || format == ImageFormat.PNG) {
167            return resizeAndStoreAvatar(centerSquare, format, 90);
168        } else {
169            Info avatar = null;
170            for (int quality = 90; quality >= 50; quality = quality - 2) {
171                if (avatar != null) {
172                    FileBackend.getAvatarFile(context, avatar.getId()).delete();
173                }
174                Log.d(Config.LOGTAG, "trying to save thumbnail with quality " + quality);
175                avatar = resizeAndStoreAvatar(centerSquare, format, quality);
176                if (avatar.getBytes() <= charLimit) {
177                    return avatar;
178                }
179            }
180            return avatar;
181        }
182    }
183
184    private Info resizeAndStoreAvatar(final Bitmap image, ImageFormat format, final int quality)
185            throws Exception {
186        return switch (format) {
187            case PNG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.PNG, quality);
188            case JPEG -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.JPEG, quality);
189            case WEBP -> resizeAndStoreAvatar(image, Bitmap.CompressFormat.WEBP, quality);
190            case HEIF -> resizeAndStoreAvatarAsHeif(image, quality);
191            case AVIF -> resizeAndStoreAvatarAsAvif(image, quality);
192        };
193    }
194
195    private Info resizeAndStoreAvatar(
196            final Bitmap image, final Bitmap.CompressFormat format, final int quality)
197            throws IOException {
198        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
199        final var fileOutputStream = new FileOutputStream(randomFile);
200        final var hashingOutputStream = new HashingOutputStream(Hashing.sha1(), fileOutputStream);
201        image.compress(format, quality, hashingOutputStream);
202        hashingOutputStream.close();
203        final var sha1 = hashingOutputStream.hash().toString();
204        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
205        if (randomFile.renameTo(avatarFile)) {
206            return new Info(
207                    sha1,
208                    avatarFile.length(),
209                    ImageFormat.of(format).toContentType(),
210                    image.getHeight(),
211                    image.getWidth());
212        }
213        throw new IllegalStateException(
214                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
215    }
216
217    private Info resizeAndStoreAvatarAsHeif(final Bitmap image, final int quality)
218            throws Exception {
219        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
220        try (final var fileOutputStream = new FileOutputStream(randomFile);
221                final var heifWriter =
222                        new HeifWriter.Builder(
223                                        fileOutputStream.getFD(),
224                                        image.getWidth(),
225                                        image.getHeight(),
226                                        HeifWriter.INPUT_MODE_BITMAP)
227                                .setMaxImages(1)
228                                .setQuality(quality)
229                                .build()) {
230
231            heifWriter.start();
232            heifWriter.addBitmap(image);
233            heifWriter.stop(3_000);
234        }
235        return storeAsAvatar(randomFile, ImageFormat.HEIF, image.getHeight(), image.getWidth());
236    }
237
238    private Info resizeAndStoreAvatarAsAvif(final Bitmap image, final int quality)
239            throws Exception {
240        final var randomFile = new File(context.getCacheDir(), UUID.randomUUID().toString());
241        try (final var fileOutputStream = new FileOutputStream(randomFile);
242                final var avifWriter =
243                        new AvifWriter.Builder(
244                                        fileOutputStream.getFD(),
245                                        image.getWidth(),
246                                        image.getHeight(),
247                                        AvifWriter.INPUT_MODE_BITMAP)
248                                .setMaxImages(1)
249                                .setQuality(quality)
250                                .build()) {
251            avifWriter.start();
252            avifWriter.addBitmap(image);
253            avifWriter.stop(3_000);
254        }
255        return storeAsAvatar(randomFile, ImageFormat.AVIF, image.getHeight(), image.getWidth());
256    }
257
258    private Info storeAsAvatar(
259            final File randomFile, final ImageFormat type, final int height, final int width)
260            throws IOException {
261        final var sha1 = Files.asByteSource(randomFile).hash(Hashing.sha1()).toString();
262        final var avatarFile = FileBackend.getAvatarFile(context, sha1);
263        if (randomFile.renameTo(avatarFile)) {
264            return new Info(sha1, avatarFile.length(), type.toContentType(), height, width);
265        }
266        throw new IllegalStateException(
267                String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
268    }
269
270    public ListenableFuture<List<Info>> uploadAvatar(final Uri image, final int size) {
271        final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
272        final var avatarFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.JPEG);
273        final var avatarWithUrlFuture =
274                Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
275        avatarFutures.add(avatarWithUrlFuture);
276
277        if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
278            final var avatarHeifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.HEIF);
279            final var avatarHeifWithUrlFuture =
280                    Futures.transformAsync(
281                            avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
282            avatarFutures.add(avatarHeifWithUrlFuture);
283        }
284        if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
285            final var avatarAvifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.AVIF);
286            final var avatarAvifWithUrlFuture =
287                    Futures.transformAsync(
288                            avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
289            avatarFutures.add(avatarAvifWithUrlFuture);
290        }
291
292        final var avatarThumbnailFuture =
293                resizeAndStoreAvatarAsync(
294                        image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
295        avatarFutures.add(avatarThumbnailFuture);
296
297        return Futures.allAsList(avatarFutures.build());
298    }
299
300    private ListenableFuture<Info> upload(final Info avatar) {
301        final var file = FileBackend.getAvatarFile(context, avatar.getId());
302        final var urlFuture =
303                getManager(HttpUploadManager.class).upload(file, avatar.getType(), new Profile());
304        return Futures.transform(
305                urlFuture,
306                url -> {
307                    avatar.setUrl(url);
308                    return avatar;
309                },
310                MoreExecutors.directExecutor());
311    }
312
313    private ListenableFuture<Info> resizeAndStoreAvatarAsync(
314            final Uri image, final int size, final ImageFormat format) {
315        return resizeAndStoreAvatarAsync(image, size, format, null);
316    }
317
318    private ListenableFuture<Info> resizeAndStoreAvatarAsync(
319            final Uri image, final int size, final ImageFormat format, final Integer charLimit) {
320        return Futures.submit(
321                () -> resizeAndStoreAvatar(image, size, format, charLimit),
322                AVATAR_COMPRESSION_EXECUTOR);
323    }
324
325    public ListenableFuture<Void> publish(final Collection<Info> avatars, final boolean open) {
326        final Info mainAvatarInfo;
327        final byte[] mainAvatar;
328        try {
329            mainAvatarInfo = Iterables.find(avatars, a -> Objects.isNull(a.getUrl()));
330            mainAvatar =
331                    Files.asByteSource(FileBackend.getAvatarFile(context, mainAvatarInfo.getId()))
332                            .read();
333        } catch (final IOException | NoSuchElementException e) {
334            return Futures.immediateFailedFuture(e);
335        }
336        final NodeConfiguration configuration =
337                open ? NodeConfiguration.OPEN : NodeConfiguration.PRESENCE;
338        final var avatarData = new Data();
339        avatarData.setContent(mainAvatar);
340        final var future =
341                getManager(PepManager.class)
342                        .publish(avatarData, mainAvatarInfo.getId(), configuration);
343        return Futures.transformAsync(
344                future,
345                v -> {
346                    final var id = mainAvatarInfo.getId();
347                    final var metadata = new Metadata();
348                    metadata.addExtensions(avatars);
349                    return getManager(PepManager.class).publish(metadata, id, configuration);
350                },
351                MoreExecutors.directExecutor());
352    }
353
354    public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
355        final var infoFuture =
356                connection
357                        .getManager(AvatarManager.class)
358                        .uploadAvatar(image, Config.AVATAR_FULL_SIZE);
359        return Futures.transformAsync(
360                infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
361    }
362
363    public boolean hasPepToVCardConversion() {
364        return getManager(DiscoManager.class).hasAccountFeature(Namespace.AVATAR_CONVERSION);
365    }
366
367    public ListenableFuture<Void> delete() {
368        final var pepManager = getManager(PepManager.class);
369        final var deleteMetaDataFuture = pepManager.delete(Namespace.AVATAR_METADATA);
370        final var deleteDataFuture = pepManager.delete(Namespace.AVATAR_DATA);
371        return Futures.transform(
372                Futures.allAsList(deleteDataFuture, deleteMetaDataFuture),
373                vs -> null,
374                MoreExecutors.directExecutor());
375    }
376
377    private String asContentType(final ImageFormat format) {
378        return switch (format) {
379            case WEBP -> "image/webp";
380            case PNG -> "image/png";
381            case JPEG -> "image/jpeg";
382            case AVIF -> "image/avif";
383            case HEIF -> "image/heif";
384        };
385    }
386
387    public enum ImageFormat {
388        PNG,
389        JPEG,
390        WEBP,
391        HEIF,
392        AVIF;
393
394        public String toContentType() {
395            return switch (this) {
396                case WEBP -> "image/webp";
397                case PNG -> "image/png";
398                case JPEG -> "image/jpeg";
399                case AVIF -> "image/avif";
400                case HEIF -> "image/heif";
401            };
402        }
403
404        public static ImageFormat of(final Bitmap.CompressFormat compressFormat) {
405            return switch (compressFormat) {
406                case PNG -> PNG;
407                case WEBP -> WEBP;
408                case JPEG -> JPEG;
409                default -> throw new AssertionError("Not implemented");
410            };
411        }
412    }
413}