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}