Allow publishing an animated avatar

Stephen Paul Weber created

This requires not compressing it or cropping it unless we want to do *a lot*
more code, so file must be under 100k or we freeze it and no promises what your
non-square avatars will look like generally.

Change summary

src/main/java/eu/siacs/conversations/Config.java                                    |  2 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java                   | 77 
src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java | 24 
src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java          | 22 
4 files changed, 110 insertions(+), 15 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/Config.java 🔗

@@ -86,7 +86,7 @@ public final class Config {
 
     public static final int AVATAR_SIZE = 192;
     public static final Bitmap.CompressFormat AVATAR_FORMAT = Bitmap.CompressFormat.JPEG;
-    public static final int AVATAR_CHAR_LIMIT = 9400;
+    public static final int AVATAR_CHAR_LIMIT = 100000;
 
     public static final int IMAGE_SIZE = 1920;
     public static final Bitmap.CompressFormat IMAGE_FORMAT = Bitmap.CompressFormat.JPEG;

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

@@ -2,6 +2,7 @@ package eu.siacs.conversations.persistance;
 
 import android.content.ContentResolver;
 import android.content.Context;
+import android.content.res.AssetFileDescriptor;
 import android.content.res.Resources;
 import android.database.Cursor;
 import android.graphics.Bitmap;
@@ -1504,21 +1505,60 @@ public class FileBackend {
     }
 
     private Avatar getUncompressedAvatar(Uri uri) {
-        Bitmap bitmap = null;
         try {
-            bitmap =
+            if (android.os.Build.VERSION.SDK_INT >= 28) {
+                ImageDecoder.Source source = ImageDecoder.createSource(mXmppConnectionService.getContentResolver(), uri);
+                int[] size = new int[] { 0, 0 };
+                boolean[] animated = new boolean[] { false };
+                String[] mimeType = new String[] { null };
+                Drawable drawable = ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
+                    mimeType[0] = info.getMimeType();
+                    animated[0] = info.isAnimated();
+                    size[0] = info.getSize().getWidth();
+                    size[1] = info.getSize().getHeight();
+                });
+
+                if (animated[0]) {
+                    Avatar avatar = getPepAvatar(uri, size[0], size[1], mimeType[0]);
+                    if (avatar != null) return avatar;
+                }
+
+                return getPepAvatar(drawDrawable(drawable), Bitmap.CompressFormat.PNG, 100);
+            } else {
+                Bitmap bitmap =
                     BitmapFactory.decodeStream(
                             mXmppConnectionService.getContentResolver().openInputStream(uri));
-            return getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100);
+                return getPepAvatar(bitmap, Bitmap.CompressFormat.PNG, 100);
+            }
         } catch (Exception e) {
             return null;
-        } finally {
-            if (bitmap != null) {
-                bitmap.recycle();
-            }
         }
     }
 
+    private Avatar getPepAvatar(Uri uri, int width, int height, final String mimeType) throws IOException, NoSuchAlgorithmException {
+        AssetFileDescriptor fd = mXmppConnectionService.getContentResolver().openAssetFileDescriptor(uri, "r");
+        if (fd.getLength() > Config.AVATAR_CHAR_LIMIT) return null; // Too big to use raw file
+
+        ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
+        Base64OutputStream mBase64OutputStream =
+                new Base64OutputStream(mByteArrayOutputStream, Base64.DEFAULT);
+        MessageDigest digest = MessageDigest.getInstance("SHA-1");
+        DigestOutputStream mDigestOutputStream =
+                new DigestOutputStream(mBase64OutputStream, digest);
+
+        ByteStreams.copy(fd.createInputStream(), mDigestOutputStream);
+        mDigestOutputStream.flush();
+        mDigestOutputStream.close();
+
+        final Avatar avatar = new Avatar();
+        avatar.sha1sum = CryptoHelper.bytesToHex(digest.digest());
+        avatar.image = new String(mByteArrayOutputStream.toByteArray());
+        avatar.type = mimeType;
+        avatar.width = width;
+        avatar.height = height;
+        return avatar;
+    }
+
     private Avatar getPepAvatar(Bitmap bitmap, Bitmap.CompressFormat format, int quality) {
         try {
             ByteArrayOutputStream mByteArrayOutputStream = new ByteArrayOutputStream();
@@ -1696,6 +1736,29 @@ public class FileBackend {
         return Uri.fromFile(getAvatarFile(avatar));
     }
 
+    public Drawable cropCenterSquareDrawable(Uri image, int size) {
+        if (android.os.Build.VERSION.SDK_INT >= 28) {
+            try {
+                ImageDecoder.Source source = ImageDecoder.createSource(mXmppConnectionService.getContentResolver(), image);
+                return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
+                    int w = info.getSize().getWidth();
+                    int h = info.getSize().getHeight();
+                    Rect r = rectForSize(w, h, size);
+                    decoder.setTargetSize(r.width(), r.height());
+
+                    int newSize = Math.min(r.width(), r.height());
+                    int left = (r.width() - newSize) / 2;
+                    int top = (r.height() - newSize) / 2;
+                    decoder.setCrop(new Rect(left, top, left + newSize, top + newSize));
+                });
+            } catch (final IOException e) {
+                return null;
+            }
+        } else {
+            return new BitmapDrawable(cropCenterSquare(image, size));
+        }
+    }
+
     public Bitmap cropCenterSquare(Uri image, int size) {
         if (image == null) {
             return null;

src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java 🔗

@@ -31,9 +31,11 @@ package eu.siacs.conversations.ui;
 
 import android.content.Intent;
 import android.graphics.Bitmap;
+import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 import android.util.Log;
 import android.view.View;
@@ -83,10 +85,13 @@ public class PublishGroupChatProfilePictureActivity extends XmppActivity impleme
             bitmap = xmppConnectionService.getAvatarService().get(conversation, size);
         } else {
             Log.d(Config.LOGTAG, "loading " + uri.toString() + " into preview");
-            bitmap = new BitmapDrawable(xmppConnectionService.getFileBackend().cropCenterSquare(uri, size));
+            bitmap = xmppConnectionService.getFileBackend().cropCenterSquareDrawable(uri, size);
         }
         this.binding.accountImage.setImageDrawable(bitmap);
         this.binding.publishButton.setEnabled(uri != null);
+        if (Build.VERSION.SDK_INT >= 28 && bitmap instanceof AnimatedImageDrawable) {
+            ((AnimatedImageDrawable) bitmap).start();
+        }
     }
 
     @Override
@@ -131,9 +136,24 @@ public class PublishGroupChatProfilePictureActivity extends XmppActivity impleme
             }
         } else if (requestCode == REQUEST_CHOOSE_PICTURE) {
             if (resultCode == RESULT_OK) {
-                PublishProfilePictureActivity.cropUri(this, data.getData());
+                cropUri(data.getData());
+            }
+        }
+    }
+
+    public void cropUri(final Uri uri) {
+        if (Build.VERSION.SDK_INT >= 28) {
+            this.uri = uri;
+            reloadAvatar();
+            if (this.binding.accountImage.getDrawable() instanceof AnimatedImageDrawable) {
+                return;
             }
         }
+
+        CropImage.activity(uri).setOutputCompressFormat(Bitmap.CompressFormat.PNG)
+                .setAspectRatio(1, 1)
+                .setMinCropResultSize(Config.AVATAR_SIZE, Config.AVATAR_SIZE)
+                .start(this);
     }
 
     @Override

src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java 🔗

@@ -2,6 +2,7 @@ package eu.siacs.conversations.ui;
 
 import android.app.Activity;
 import android.content.Intent;
+import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.graphics.Bitmap;
@@ -174,7 +175,7 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
             }
         } else if (requestCode == REQUEST_CHOOSE_PICTURE) {
             if (resultCode == RESULT_OK) {
-                cropUri(this, data.getData());
+                cropUri(data.getData());
             }
         }
     }
@@ -227,7 +228,7 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
         final Uri uri = intent != null ? intent.getData() : null;
 
         if (uri != null && handledExternalUri.compareAndSet(false,true)) {
-            cropUri(this, uri);
+            cropUri(uri);
             return;
         }
 
@@ -237,11 +238,19 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
         configureActionBar(getSupportActionBar(), !this.mInitialAccountSetup && !handledExternalUri.get());
     }
 
-    public static void cropUri(final Activity activity, final Uri uri) {
+    public void cropUri(final Uri uri) {
+        if (Build.VERSION.SDK_INT >= 28) {
+            loadImageIntoPreview(uri);
+            if (this.avatar.getDrawable() instanceof AnimatedImageDrawable) {
+                this.avatarUri = uri;
+                return;
+            }
+        }
+
         CropImage.activity(uri).setOutputCompressFormat(Bitmap.CompressFormat.PNG)
                 .setAspectRatio(1, 1)
                 .setMinCropResultSize(Config.AVATAR_SIZE, Config.AVATAR_SIZE)
-                .start(activity);
+                .start(this);
     }
 
     protected void loadImageIntoPreview(Uri uri) {
@@ -251,7 +260,7 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
             bm = avatarService().get(account, (int) getResources().getDimension(R.dimen.publish_avatar_size));
         } else {
             try {
-                bm = new BitmapDrawable(xmppConnectionService.getFileBackend().cropCenterSquare(uri, (int) getResources().getDimension(R.dimen.publish_avatar_size)));
+                bm = xmppConnectionService.getFileBackend().cropCenterSquareDrawable(uri, (int) getResources().getDimension(R.dimen.publish_avatar_size));
             } catch (Exception e) {
                 Log.d(Config.LOGTAG, "unable to load bitmap into image view", e);
             }
@@ -265,6 +274,9 @@ public class PublishProfilePictureActivity extends XmppActivity implements XmppC
             return;
         }
         this.avatar.setImageDrawable(bm);
+        if (Build.VERSION.SDK_INT >= 28 && bm instanceof AnimatedImageDrawable) {
+            ((AnimatedImageDrawable) bm).start();
+        }
         if (support) {
             togglePublishButton(uri != null, R.string.publish);
             this.hintOrWarning.setVisibility(View.INVISIBLE);