use PNG for avatars with alpha channels

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/persistance/FileBackend.java    | 203 
src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java | 107 
2 files changed, 81 insertions(+), 229 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/persistance/FileBackend.java 🔗

@@ -21,8 +21,6 @@ import android.provider.MediaStore;
 import android.provider.OpenableColumns;
 import android.system.Os;
 import android.system.StructStat;
-import android.util.Base64;
-import android.util.Base64OutputStream;
 import android.util.DisplayMetrics;
 import android.util.Log;
 import android.util.LruCache;
@@ -41,12 +39,9 @@ import eu.siacs.conversations.services.AttachFileToConversationRunnable;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.adapter.MediaAdapter;
 import eu.siacs.conversations.ui.util.Attachment;
-import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.utils.FileUtils;
 import eu.siacs.conversations.utils.FileWriterException;
 import eu.siacs.conversations.utils.MimeUtils;
-import eu.siacs.conversations.xmpp.pep.Avatar;
-import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.File;
 import java.io.FileDescriptor;
@@ -58,15 +53,11 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.ServerSocket;
 import java.net.Socket;
-import java.security.DigestOutputStream;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
-import java.util.UUID;
 
 public class FileBackend {
 
@@ -227,7 +218,7 @@ public class FileBackend {
         return context.getPackageName() + FILE_PROVIDER;
     }
 
-    private static boolean hasAlpha(final Bitmap bitmap) {
+    public static boolean hasAlpha(final Bitmap bitmap) {
         final int w = bitmap.getWidth();
         final int h = bitmap.getHeight();
         final int yStep = Math.max(1, w / 100);
@@ -1170,198 +1161,6 @@ public class FileBackend {
         return getUriForFile(mXmppConnectionService, file);
     }
 
-    public Avatar getPepAvatar(
-            final Uri image, final int size, final Bitmap.CompressFormat format) {
-
-        final Avatar uncompressAvatar = getUncompressedAvatar(image);
-        if (uncompressAvatar != null
-                && uncompressAvatar.image.length() <= Config.AVATAR_CHAR_LIMIT) {
-            return uncompressAvatar;
-        }
-        if (uncompressAvatar != null) {
-            Log.d(
-                    Config.LOGTAG,
-                    "uncompressed avatar exceeded char limit by "
-                            + (uncompressAvatar.image.length() - Config.AVATAR_CHAR_LIMIT));
-        }
-
-        Bitmap bm = cropCenterSquare(image, size);
-        if (bm == null) {
-            return null;
-        }
-        if (hasAlpha(bm)) {
-            Log.d(Config.LOGTAG, "alpha in avatar detected; uploading as PNG");
-            bm.recycle();
-            bm = cropCenterSquare(image, 96);
-            return getPepAvatar(bm, Bitmap.CompressFormat.PNG, 100);
-        }
-        return getPepAvatar(bm, format, 100);
-    }
-
-    private Avatar getUncompressedAvatar(Uri uri) {
-        Bitmap bitmap = null;
-        try {
-            bitmap =
-                    BitmapFactory.decodeStream(
-                            mXmppConnectionService.getContentResolver().openInputStream(uri));
-            return getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100);
-        } catch (Exception e) {
-            return null;
-        } finally {
-            if (bitmap != null) {
-                bitmap.recycle();
-            }
-        }
-    }
-
-    private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
-        try {
-            ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
-            Base64OutputStream mBase64OutputStream =
-                    new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT | Base64.NO_WRAP);
-            MessageDigest digest = MessageDigest.getInstance("SHA-1");
-            DigestOutputStream mDigestOutputStream =
-                    new DigestOutputStream(mBase64OutputStream, digest);
-            if (!bitmap.compress(format, quality, mDigestOutputStream)) {
-                return null;
-            }
-            mDigestOutputStream.flush();
-            mDigestOutputStream.close();
-            long chars = mByteArrayOutputStream.size();
-            if (format != Bitmap.CompressFormat.PNG
-                    && quality >= 50
-                    && chars >= Config.AVATAR_CHAR_LIMIT) {
-                int q = quality - 2;
-                Log.d(
-                        Config.LOGTAG,
-                        "avatar char length was " + chars + " reducing quality to " + q);
-                return getPepAvatar(bitmap, format, q);
-            }
-            Log.d(Config.LOGTAG, "settled on char length " + chars + " with quality=" + quality);
-            final Avatar avatar = new Avatar();
-            avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
-            avatar.image = mByteArrayOutputStream.toString();
-            if (format.equals(Bitmap.CompressFormat.WEBP)) {
-                avatar.type = "image/webp";
-            } else if (format.equals(Bitmap.CompressFormat.JPEG)) {
-                avatar.type = "image/jpeg";
-            } else if (format.equals(Bitmap.CompressFormat.PNG)) {
-                avatar.type = "image/png";
-            }
-            avatar.width = bitmap.getWidth();
-            avatar.height = bitmap.getHeight();
-            return avatar;
-        } catch (OutOfMemoryError e) {
-            Log.d(Config.LOGTAG, "unable to convert avatar to base64 due to low memory");
-            return null;
-        } catch (Exception e) {
-            return null;
-        }
-    }
-
-    // this was used by republishAvatarIfNeeded()
-    public Avatar getStoredPepAvatar(String hash) {
-        if (hash == null) {
-            return null;
-        }
-        Avatar avatar = new Avatar();
-        final File file = getAvatarFile(hash);
-        FileInputStream is = null;
-        try {
-            avatar.size = file.length();
-            BitmapFactory.Options options = new BitmapFactory.Options();
-            options.inJustDecodeBounds = true;
-            BitmapFactory.decodeFile(file.getAbsolutePath(), options);
-            is = new FileInputStream(file);
-            ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
-            Base64OutputStream mBase64OutputStream =
-                    new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
-            MessageDigest digest = MessageDigest.getInstance("SHA-1");
-            DigestOutputStream os = new DigestOutputStream(mBase64OutputStream, digest);
-            byte[] buffer = new byte[4096];
-            int length;
-            while ((length = is.read(buffer)) > 0) {
-                os.write(buffer, 0, length);
-            }
-            os.flush();
-            os.close();
-            avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
-            avatar.image = mByteArrayOutputStream.toString();
-            avatar.height = options.outHeight;
-            avatar.width = options.outWidth;
-            avatar.type = options.outMimeType;
-            return avatar;
-        } catch (NoSuchAlgorithmException | IOException e) {
-            return null;
-        } finally {
-            close(is);
-        }
-    }
-
-    public boolean isAvatarCached(Avatar avatar) {
-        final File file = getAvatarFile(avatar.getFilename());
-        return file.exists();
-    }
-
-    public boolean save(final Avatar avatar) {
-        File file;
-        if (isAvatarCached(avatar)) {
-            file = getAvatarFile(avatar.getFilename());
-            avatar.size = file.length();
-        } else {
-            file =
-                    new File(
-                            mXmppConnectionService.getCacheDir().getAbsolutePath()
-                                    + "/"
-                                    + UUID.randomUUID().toString());
-            if (file.getParentFile().mkdirs()) {
-                Log.d(Config.LOGTAG, "created cache directory");
-            }
-            OutputStream os = null;
-            try {
-                if (!file.createNewFile()) {
-                    Log.d(
-                            Config.LOGTAG,
-                            "unable to create temporary file " + file.getAbsolutePath());
-                }
-                os = new FileOutputStream(file);
-                MessageDigest digest = MessageDigest.getInstance("SHA-1");
-                digest.reset();
-                DigestOutputStream mDigestOutputStream = new DigestOutputStream(os, digest);
-                final byte[] bytes = avatar.getImageAsBytes();
-                mDigestOutputStream.write(bytes);
-                mDigestOutputStream.flush();
-                mDigestOutputStream.close();
-                String sha1sum = CryptoHelper.bytesToHex(digest.digest());
-                if (sha1sum.equals(avatar.sha1sum)) {
-                    final File outputFile = getAvatarFile(avatar.getFilename());
-                    if (outputFile.getParentFile().mkdirs()) {
-                        Log.d(Config.LOGTAG, "created avatar directory");
-                    }
-                    final File avatarFile = getAvatarFile(avatar.getFilename());
-                    if (!file.renameTo(avatarFile)) {
-                        Log.d(
-                                Config.LOGTAG,
-                                "unable to rename " + file.getAbsolutePath() + " to " + outputFile);
-                        return false;
-                    }
-                } else {
-                    Log.d(Config.LOGTAG, "sha1sum mismatch for " + avatar.owner);
-                    if (!file.delete()) {
-                        Log.d(Config.LOGTAG, "unable to delete temporary file");
-                    }
-                    return false;
-                }
-                avatar.size = bytes.length;
-            } catch (IllegalArgumentException | IOException | NoSuchAlgorithmException e) {
-                return false;
-            } finally {
-                close(os);
-            }
-        }
-        return true;
-    }
-
     public void deleteHistoricAvatarPath() {
         delete(getHistoricAvatarPath());
     }

src/main/java/eu/siacs/conversations/xmpp/manager/AvatarManager.java 🔗

@@ -417,7 +417,14 @@ public class AvatarManager extends AbstractManager {
             final Uri image, final int size, final ImageFormat format, final Integer charLimit)
             throws Exception {
         final var centerSquare = FileBackend.cropCenterSquare(context, image, size);
-        // TODO do an alpha check. if alpha and format JPEG half size and use PNG
+        final var info = resizeAndStoreAvatar(centerSquare, format, charLimit);
+        centerSquare.recycle();
+        return info;
+    }
+
+    private Info resizeAndStoreAvatar(
+            final Bitmap centerSquare, final ImageFormat format, final Integer charLimit)
+            throws Exception {
         if (charLimit == null || format == ImageFormat.PNG) {
             return resizeAndStoreAvatar(centerSquare, format, 90);
         } else {
@@ -522,36 +529,68 @@ public class AvatarManager extends AbstractManager {
                 String.format("Could not move file to %s", avatarFile.getAbsolutePath()));
     }
 
-    private ListenableFuture<List<Info>> uploadAvatar(final Uri image, final int size) {
+    private ListenableFuture<List<Info>> uploadAvatar(final Uri image) {
+        return Futures.transformAsync(
+                hasAlphaChannel(image),
+                hasAlphaChannel -> uploadAvatar(image, hasAlphaChannel),
+                MoreExecutors.directExecutor());
+    }
+
+    private ListenableFuture<List<Info>> uploadAvatar(
+            final Uri image, final boolean hasAlphaChannel) {
         final var avatarFutures = new ImmutableList.Builder<ListenableFuture<Info>>();
-        final var avatarFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.JPEG);
-        final var avatarWithUrlFuture =
-                Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
-        avatarFutures.add(avatarWithUrlFuture);
 
-        if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
-            final var avatarHeifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.HEIF);
-            final var avatarHeifWithUrlFuture =
-                    Futures.transformAsync(
-                            avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
-            avatarFutures.add(avatarHeifWithUrlFuture);
-        }
-        if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
-            final var avatarAvifFuture = resizeAndStoreAvatarAsync(image, size, ImageFormat.AVIF);
-            final var avatarAvifWithUrlFuture =
-                    Futures.transformAsync(
-                            avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
-            avatarFutures.add(avatarAvifWithUrlFuture);
+        final ListenableFuture<Info> avatarThumbnailFuture;
+        final ListenableFuture<Info> avatarFuture;
+        if (hasAlphaChannel) {
+            avatarThumbnailFuture =
+                    resizeAndStoreAvatarAsync(image, Config.AVATAR_SIZE / 2, ImageFormat.PNG);
+            avatarFuture =
+                    resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE / 2, ImageFormat.PNG);
+        } else {
+            avatarThumbnailFuture =
+                    resizeAndStoreAvatarAsync(
+                            image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
+            avatarFuture =
+                    resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE, ImageFormat.JPEG);
+
+            if (Compatibility.twentyEight() && !PhoneHelper.isEmulator()) {
+                final var avatarHeifFuture =
+                        resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE, ImageFormat.HEIF);
+                final var avatarHeifWithUrlFuture =
+                        Futures.transformAsync(
+                                avatarHeifFuture, this::upload, MoreExecutors.directExecutor());
+                avatarFutures.add(avatarHeifWithUrlFuture);
+            }
+            if (Compatibility.thirtyFour() && !PhoneHelper.isEmulator()) {
+                final var avatarAvifFuture =
+                        resizeAndStoreAvatarAsync(image, Config.AVATAR_FULL_SIZE, ImageFormat.AVIF);
+                final var avatarAvifWithUrlFuture =
+                        Futures.transformAsync(
+                                avatarAvifFuture, this::upload, MoreExecutors.directExecutor());
+                avatarFutures.add(avatarAvifWithUrlFuture);
+            }
         }
-
-        final var avatarThumbnailFuture =
-                resizeAndStoreAvatarAsync(
-                        image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
         avatarFutures.add(avatarThumbnailFuture);
+        final var avatarWithUrlFuture =
+                Futures.transformAsync(avatarFuture, this::upload, MoreExecutors.directExecutor());
+        avatarFutures.add(avatarWithUrlFuture);
 
         return Futures.allAsList(avatarFutures.build());
     }
 
+    private ListenableFuture<Boolean> hasAlphaChannel(final Uri image) {
+        return Futures.submit(
+                () -> {
+                    final var cropped =
+                            FileBackend.cropCenterSquare(context, image, Config.AVATAR_FULL_SIZE);
+                    final var hasAlphaChannel = FileBackend.hasAlpha(cropped);
+                    cropped.recycle();
+                    return hasAlphaChannel;
+                },
+                AVATAR_COMPRESSION_EXECUTOR);
+    }
+
     private ListenableFuture<Info> upload(final Info avatar) {
         final var file = FileBackend.getAvatarFile(context, avatar.getId());
         final var urlFuture =
@@ -607,9 +646,23 @@ public class AvatarManager extends AbstractManager {
     }
 
     public ListenableFuture<Void> publishVCard(final Jid address, final Uri image) {
-        final var avatarThumbnailFuture =
-                resizeAndStoreAvatarAsync(
-                        image, Config.AVATAR_SIZE, ImageFormat.JPEG, Config.AVATAR_CHAR_LIMIT);
+
+        ListenableFuture<Info> avatarThumbnailFuture =
+                Futures.transformAsync(
+                        hasAlphaChannel(image),
+                        hasAlphaChannel -> {
+                            if (hasAlphaChannel) {
+                                return resizeAndStoreAvatarAsync(
+                                        image, Config.AVATAR_SIZE / 2, ImageFormat.PNG);
+                            } else {
+                                return resizeAndStoreAvatarAsync(
+                                        image,
+                                        Config.AVATAR_SIZE,
+                                        ImageFormat.JPEG,
+                                        Config.AVATAR_CHAR_LIMIT);
+                            }
+                        },
+                        MoreExecutors.directExecutor());
         return Futures.transformAsync(
                 avatarThumbnailFuture,
                 info -> {
@@ -623,7 +676,7 @@ public class AvatarManager extends AbstractManager {
     }
 
     public ListenableFuture<Void> uploadAndPublish(final Uri image, final boolean open) {
-        final var infoFuture = uploadAvatar(image, Config.AVATAR_FULL_SIZE);
+        final var infoFuture = uploadAvatar(image);
         return Futures.transformAsync(
                 infoFuture, avatars -> publish(avatars, open), MoreExecutors.directExecutor());
     }