Merge branch 'sims2'

Stephen Paul Weber created

* sims2:
  If we already have a file by this hash, let's just use it
  Helper to get Cids for the <hash> elements
  Helper to get file element, where thumnails etc live
  Don't just start from blank if there are some FileParams we can update
  Support BoB thumbnails
  Support android Uri as well as java URI
  Support data-uri thumbnails
  Show thumbnail during download, if relevant
  Show fallback thumbnail if there is no image
  Helper to get thumbnails from SIMS element
  Store FileParams as SIMS in payloads
  Prevent accidental mutation of Elements
  Parse all SIMS and OOBs on a message

Change summary

build.gradle                                                        |   1 
src/cheogram/java/com/cheogram/android/BobTransfer.java             |   6 
src/main/java/eu/siacs/conversations/entities/Message.java          | 129 
src/main/java/eu/siacs/conversations/parser/MessageParser.java      |  44 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java   |  93 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java           |  10 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java |  77 
src/main/java/eu/siacs/conversations/xml/Element.java               |   7 
8 files changed, 332 insertions(+), 35 deletions(-)

Detailed changes

build.gradle 🔗

@@ -100,6 +100,7 @@ dependencies {
     implementation 'me.saket:better-link-movement-method:2.2.0'
     implementation 'com.github.singpolyma:android-identicons:master-SNAPSHOT'
     implementation 'org.snikket:webrtc-android:107.0.0'
+    implementation 'com.github.woltapp:blurhash:master'
     // INSERT
 }
 

src/cheogram/java/com/cheogram/android/BobTransfer.java 🔗

@@ -1,5 +1,6 @@
 package com.cheogram.android;
 
+import android.net.Uri;
 import android.util.Base64;
 import android.util.Log;
 
@@ -35,6 +36,11 @@ public class BobTransfer implements Transferable {
 	protected Jid to;
 	protected XmppConnectionService xmppConnectionService;
 
+	public static Cid cid(Uri uri) {
+		if (!uri.getScheme().equals("cid")) return null;
+		return cid(uri.getSchemeSpecificPart());
+	}
+
 	public static Cid cid(URI uri) {
 		if (!uri.getScheme().equals("cid")) return null;
 		return cid(uri.getSchemeSpecificPart());

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -7,6 +7,7 @@ import android.graphics.Color;
 import android.os.Build;
 import android.text.Html;
 import android.text.SpannableStringBuilder;
+import android.util.Base64;
 import android.util.Log;
 
 import com.cheogram.android.BobTransfer;
@@ -23,6 +24,8 @@ import java.lang.ref.WeakReference;
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.time.Duration;
+import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Iterator;
@@ -242,8 +245,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         this.bodyLanguage = bodyLanguage;
         this.timeReceived = timeReceived;
         this.subject = subject;
-        if (fileParams != null) this.fileParams = new FileParams(fileParams);
         if (payloads != null) this.payloads = payloads;
+        if (fileParams != null && getSims().isEmpty()) this.fileParams = new FileParams(fileParams);
     }
 
     public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException {
@@ -319,6 +322,14 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         values.put(UUID, uuid);
         values.put("subject", subject);
         values.put("fileParams", fileParams == null ? null : fileParams.toString());
+        if (fileParams != null) {
+            List<Element> sims = getSims();
+            if (sims.isEmpty()) {
+                addPayload(fileParams.toSims());
+            } else {
+                sims.get(0).replaceChildren(fileParams.toSims().getChildren());
+            }
+        }
         values.put("payloads", payloads.size() < 1 ? null : payloads.stream().map(Object::toString).collect(Collectors.joining()));
         return values;
     }
@@ -996,21 +1007,33 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return isGeoUri;
     }
 
+    protected List<Element> getSims() {
+        return payloads.stream().filter(el ->
+            el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0") &&
+            el.findChild("media-sharing", "urn:xmpp:sims:1") != null
+        ).collect(Collectors.toList());
+    }
+
     public synchronized void resetFileParams() {
         this.fileParams = null;
     }
 
     public synchronized void setFileParams(FileParams fileParams) {
+        if (this.fileParams != null && this.fileParams.sims != null && fileParams.sims == null) {
+            fileParams.sims = this.fileParams.sims;
+        }
         this.fileParams = fileParams;
     }
 
     public synchronized FileParams getFileParams() {
         if (fileParams == null) {
-            fileParams = new FileParams(oob ? this.body : "");
+            List<Element> sims = getSims();
+            fileParams = sims.isEmpty() ? new FileParams(oob ? this.body : "") : new FileParams(sims.get(0));
             if (this.transferable != null) {
                 fileParams.size = this.transferable.getFileSize();
             }
         }
+
         return fileParams;
     }
 
@@ -1054,6 +1077,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         public int width = 0;
         public int height = 0;
         public int runtime = 0;
+        public Element sims = null;
 
         public FileParams() { }
 
@@ -1062,6 +1086,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 this.url = el.findChildContent("url", Namespace.OOB);
             }
             if (el.getName().equals("reference") && el.getNamespace().equals("urn:xmpp:reference:0")) {
+                sims = el;
                 final String refUri = el.getAttribute("uri");
                 if (refUri != null) url = refUri;
                 final Element mediaSharing = el.findChild("media-sharing", "urn:xmpp:sims:1");
@@ -1070,10 +1095,14 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                     if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
                     if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
                     if (file != null) {
-                        String sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:5");
-                        if (sizeS == null) sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:4");
-                        if (sizeS == null) sizeS = file.findChildContent("size", "urn:xmpp:jingle:apps:file-transfer:3");
+                        String sizeS = file.findChildContent("size", file.getNamespace());
                         if (sizeS != null) size = new Long(sizeS);
+                        String widthS = file.findChildContent("width", "https://schema.org/");
+                        if (widthS != null) width = parseInt(widthS);
+                        String heightS = file.findChildContent("height", "https://schema.org/");
+                        if (heightS != null) height = parseInt(heightS);
+                        String durationS = file.findChildContent("duration", "https://schema.org/");
+                        if (durationS != null) runtime = (int)(Duration.parse(durationS).toMillis() / 1000L);
                     }
 
                     final Element sources = mediaSharing.findChild("sources", "urn:xmpp:sims:1");
@@ -1116,6 +1145,86 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             return size == null ? 0 : size;
         }
 
+        public Element toSims() {
+            if (sims == null) sims = new Element("reference", "urn:xmpp:reference:0");
+            sims.setAttribute("type", "data");
+            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
+            if (mediaSharing == null) mediaSharing = sims.addChild("media-sharing", "urn:xmpp:sims:1");
+
+            Element file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
+            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
+            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
+            if (file == null) file = mediaSharing.addChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
+
+            file.removeChild(file.findChild("size", file.getNamespace()));
+            if (size != null) file.addChild("size", file.getNamespace()).setContent(size.toString());
+
+            file.removeChild(file.findChild("width", "https://schema.org/"));
+            if (width > 0) file.addChild("width", "https://schema.org/").setContent(String.valueOf(width));
+
+            file.removeChild(file.findChild("height", "https://schema.org/"));
+            if (height > 0) file.addChild("height", "https://schema.org/").setContent(String.valueOf(height));
+
+            file.removeChild(file.findChild("duration", "https://schema.org/"));
+            if (runtime > 0) file.addChild("duration", "https://schema.org/").setContent("PT" + runtime + "S");
+
+            if (url != null) {
+                Element sources = mediaSharing.findChild("sources", mediaSharing.getNamespace());
+                if (sources == null) sources = mediaSharing.addChild("sources", mediaSharing.getNamespace());
+
+                Element source = sources.findChild("reference", "urn:xmpp:reference:0");
+                if (source == null) source = sources.addChild("reference", "urn:xmpp:reference:0");
+                source.setAttribute("type", "data");
+                source.setAttribute("uri", url);
+            }
+
+            return sims;
+        }
+
+        protected Element getFileElement() {
+            Element file = null;
+            if (sims == null) return file;
+
+            Element mediaSharing = sims.findChild("media-sharing", "urn:xmpp:sims:1");
+            if (mediaSharing == null) return file;
+            file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:5");
+            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:4");
+            if (file == null) file = mediaSharing.findChild("file", "urn:xmpp:jingle:apps:file-transfer:3");
+            return file;
+        }
+
+        public List<Cid> getCids() {
+            List<Cid> cids = new ArrayList<>();
+            Element file = getFileElement();
+            if (file == null) return cids;
+
+            for (Element child : file.getChildren()) {
+                if (child.getName().equals("hash") && child.getNamespace().equals("urn:xmpp:hashes:2")) {
+                    try {
+                        cids.add(CryptoHelper.cid(Base64.decode(child.getContent(), Base64.DEFAULT), child.getAttribute("algo")));
+                    } catch (final NoSuchAlgorithmException | IllegalStateException e) { }
+                }
+            }
+
+            cids.sort((x, y) -> y.getType().compareTo(x.getType()));
+
+            return cids;
+        }
+
+        public List<Element> getThumbnails() {
+            List<Element> thumbs = new ArrayList<>();
+            Element file = getFileElement();
+            if (file == null) return thumbs;
+
+            for (Element child : file.getChildren()) {
+                if (child.getName().equals("thumbnail") && child.getNamespace().equals("urn:xmpp:thumbs:1")) {
+                    thumbs.add(child);
+                }
+            }
+
+            return thumbs;
+        }
+
         public String toString() {
             final StringBuilder builder = new StringBuilder();
             if (url != null) builder.append(url);
@@ -1125,6 +1234,16 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             if (runtime > 0) builder.append('|').append(runtime);
             return builder.toString();
         }
+
+        public boolean equals(Object o) {
+            if (!(o instanceof FileParams)) return false;
+
+            return url.equals(((FileParams) o).url);
+        }
+
+        public int hashCode() {
+            return url.hashCode();
+        }
     }
 
     public void setFingerprint(String fingerprint) {

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -5,18 +5,22 @@ import android.util.Pair;
 
 import com.cheogram.android.BobTransfer;
 
+import java.io.File;
 import java.net.URISyntaxException;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 
+import io.ipfs.cid.Cid;
+
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
@@ -410,13 +414,19 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
         final Element mucUserElement = packet.findChild("x", Namespace.MUC_USER);
         final String pgpEncrypted = packet.findChildContent("x", "jabber:x:encrypted");
         final Element replaceElement = packet.findChild("replace", "urn:xmpp:message-correct:0");
-        Element oob = packet.findChild("x", Namespace.OOB);
-        if (oob != null && oob.findChildContent("url") == null) {
-            oob = null;
+        Set<Message.FileParams> attachments = new LinkedHashSet<>();
+        for (Element child : packet.getChildren()) {
+            // SIMS first so they get preference in the set
+            if (child.getName().equals("reference") && child.getNamespace().equals("urn:xmpp:reference:0")) {
+                if (child.findChild("media-sharing", "urn:xmpp:sims:1") != null) {
+                    attachments.add(new Message.FileParams(child));
+                }
+            }
         }
-        final Element reference = packet.findChild("reference", "urn:xmpp:reference:0");
-        if (reference != null && reference.findChild("media-sharing", "urn:xmpp:sims:1") != null) {
-            oob = reference;
+        for (Element child : packet.getChildren()) {
+            if (child.getName().equals("x") && child.getNamespace().equals(Namespace.OOB)) {
+                attachments.add(new Message.FileParams(child));
+            }
         }
         String replacementId = replaceElement == null ? null : replaceElement.getAttribute("id");
         if (replacementId == null) {
@@ -485,7 +495,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             }
         }
 
-        if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || oob != null || html != null) && !isMucStatusMessage) {
+        if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || !attachments.isEmpty() || html != null) && !isMucStatusMessage) {
             final boolean conversationIsProbablyMuc = isTypeGroupChat || mucUserElement != null || account.getXmppConnection().getMucServersWithholdAccount().contains(counterpart.getDomain().toEscapedString());
             final Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, counterpart.asBareJid(), conversationIsProbablyMuc, false, query, false);
             final boolean conversationMultiMode = conversation.getMode() == Conversation.MODE_MULTI;
@@ -583,7 +593,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                 if (conversationMultiMode) {
                     message.setTrueCounterpart(origin);
                 }
-            } else if (body == null && oob != null) {
+            } else if (body == null && !attachments.isEmpty()) {
                 message = new Message(conversation, "", Message.ENCRYPTION_NONE, status);
             } else {
                 message = new Message(conversation, body == null ? "HTML-only message" : body.content, Message.ENCRYPTION_NONE, status);
@@ -599,8 +609,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             message.setServerMsgId(serverMsgId);
             message.setCarbon(isCarbon);
             message.setTime(timestamp);
-            if (oob != null) {
-                message.setFileParams(new Message.FileParams(oob));
+            if (!attachments.isEmpty()) {
+                message.setFileParams(attachments.iterator().next());
                 if (CryptoHelper.isPgpEncryptedUrl(message.getFileParams().url)) {
                     message.setEncryption(Message.ENCRYPTION_DECRYPTED);
                 }
@@ -768,9 +778,21 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                 processMessageReceipts(account, packet, remoteMsgId, query);
             }
 
+            if (message.getFileParams() != null) {
+                for (Cid cid : message.getFileParams().getCids()) {
+                    File f = mXmppConnectionService.getFileForCid(cid);
+                    if (f != null && f.canRead()) {
+                        message.setRelativeFilePath(f.getAbsolutePath());
+                        mXmppConnectionService.getFileBackend().updateFileParams(message, null, false);
+                        break;
+                    }
+                }
+            }
+
             mXmppConnectionService.databaseBackend.createMessage(message);
+
             final HttpConnectionManager manager = this.mXmppConnectionService.getHttpConnectionManager();
-            if (message.trusted() && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) {
+            if (message.getRelativeFilePath() == null && message.trusted() && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) {
                 if (message.getOob() != null && message.getOob().getScheme().equalsIgnoreCase("cid")) {
                     try {
                         BobTransfer transfer = new BobTransfer.ForMessage(message, mXmppConnectionService);

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

@@ -37,10 +37,14 @@ import androidx.annotation.StringRes;
 import androidx.core.content.FileProvider;
 import androidx.exifinterface.media.ExifInterface;
 
+import com.cheogram.android.BobTransfer;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.ByteStreams;
 
+import com.wolt.blurhashkt.BlurHashDecoder;
+
 import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.File;
@@ -53,11 +57,13 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.ServerSocket;
 import java.net.Socket;
+import java.nio.ByteBuffer;
 import java.security.DigestOutputStream;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
@@ -78,6 +84,7 @@ 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 eu.siacs.conversations.xml.Element;
 
 public class FileBackend {
 
@@ -1000,8 +1007,89 @@ public class FileBackend {
         }
     }
 
+    public BitmapDrawable getFallbackThumbnail(final Message message, int size) {
+        List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
+        if (thumbs != null && !thumbs.isEmpty()) {
+            for (Element thumb : thumbs) {
+                Uri uri = Uri.parse(thumb.getAttribute("uri"));
+                if (uri.getScheme().equals("data")) {
+                    String[] parts = uri.getSchemeSpecificPart().split(",", 2);
+                    if (parts[0].equals("image/blurhash")) {
+                        final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
+                        BitmapDrawable cached = (BitmapDrawable) cache.get(parts[1]);
+                        if (cached != null) return cached;
+
+                        int width = message.getFileParams().width;
+                        if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
+                        if (width < 1) width = 1920;
+
+                        int height = message.getFileParams().height;
+                        if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
+                        if (height < 1) height = 1080;
+                        Rect r = rectForSize(width, height, size);
+
+                        Bitmap blurhash = BlurHashDecoder.INSTANCE.decode(parts[1], r.width(), r.height(), 1.0f, false);
+                        if (blurhash != null) {
+                            cached = new BitmapDrawable(blurhash);
+                            cache.put(parts[1], cached);
+                            return cached;
+                        }
+                    }
+                }
+            }
+         }
+
+        return null;
+    }
+
     public Drawable getThumbnail(Message message, Resources res, int size, boolean cacheOnly) throws IOException {
-        return getThumbnail(getFile(message), res, size, cacheOnly);
+        final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
+        DownloadableFile file = getFile(message);
+        Drawable thumbnail = cache.get(file.getAbsolutePath());
+        if (thumbnail != null) return thumbnail;
+
+        if ((thumbnail == null) && (!cacheOnly)) {
+            synchronized (THUMBNAIL_LOCK) {
+                List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
+                if (thumbs != null && !thumbs.isEmpty()) {
+                    for (Element thumb : thumbs) {
+                        Uri uri = Uri.parse(thumb.getAttribute("uri"));
+                        if (uri.getScheme().equals("data")) {
+                            if (android.os.Build.VERSION.SDK_INT < 28) continue;
+                            String[] parts = uri.getSchemeSpecificPart().split(",", 2);
+                            byte[] data;
+                            if (Arrays.asList(parts[0].split(";")).contains("base64")) {
+                                data = Base64.decode(parts[1], 0);
+                            } else {
+                                data = parts[1].getBytes("UTF-8");
+                            }
+
+                            ImageDecoder.Source source = ImageDecoder.createSource(ByteBuffer.wrap(data));
+                            thumbnail = 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());
+                            });
+
+                            if (thumbnail != null) {
+                                cache.put(file.getAbsolutePath(), thumbnail);
+                                return thumbnail;
+                            }
+                        } else if (uri.getScheme().equals("cid")) {
+                            Cid cid = BobTransfer.cid(uri);
+                            if (cid == null) continue;
+                            DownloadableFile f = mXmppConnectionService.getFileForCid(cid);
+                            if (f != null && f.canRead()) {
+                                return getThumbnail(f, res, size, cacheOnly);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        return getThumbnail(file, res, size, cacheOnly);
     }
 
     public Drawable getThumbnail(DownloadableFile file, Resources res, int size, boolean cacheOnly) throws IOException {
@@ -1605,7 +1693,8 @@ public class FileBackend {
         final boolean image =
                 message.getType() == Message.TYPE_IMAGE
                         || (mime != null && mime.startsWith("image/"));
-        Message.FileParams fileParams = new Message.FileParams();
+        Message.FileParams fileParams = message.getFileParams();
+        if (fileParams == null) fileParams = new Message.FileParams();
         if (url != null) {
             fileParams.url = url;
         }

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

@@ -916,8 +916,9 @@ public abstract class XmppActivity extends ActionBarActivity {
                 imageView.setBackgroundColor(0xff333333);
                 imageView.setImageDrawable(null);
                 final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
+                final BitmapDrawable fallbackThumb = xmppConnectionService.getFileBackend().getFallbackThumbnail(message, (int) (metrics.density * 288));
                 final AsyncDrawable asyncDrawable = new AsyncDrawable(
-                        getResources(), null, task);
+                        getResources(), fallbackThumb != null ? fallbackThumb.getBitmap() : null, task);
                 imageView.setImageDrawable(asyncDrawable);
                 try {
                     task.execute(message);
@@ -995,7 +996,12 @@ public abstract class XmppActivity extends ActionBarActivity {
             if (!isCancelled()) {
                 final ImageView imageView = imageViewReference.get();
                 if (imageView != null) {
-                    imageView.setImageDrawable(drawable);
+                    Drawable old = imageView.getDrawable();
+                    if (drawable == null && old instanceof AsyncDrawable) {
+                        imageView.setImageDrawable(new BitmapDrawable(((AsyncDrawable) old).getBitmap()));
+                    } else {
+                        imageView.setImageDrawable(drawable);
+                    }
                     imageView.setBackgroundColor(drawable == null ? 0xff333333 : 0x00000000);
                     if (Build.VERSION.SDK_INT >= 28 && drawable instanceof AnimatedImageDrawable) {
                         ((AnimatedImageDrawable) drawable).start();

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -341,6 +341,13 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         }
     }
 
+    private void displayInfoMessage(ViewHolder viewHolder, CharSequence text, boolean darkBackground, final Message message, int type) {
+        displayDownloadableMessage(viewHolder, message, "", darkBackground, type);
+        int imageVisibility = viewHolder.image.getVisibility();
+        displayInfoMessage(viewHolder, text, darkBackground);
+        viewHolder.image.setVisibility(imageVisibility);
+    }
+
     private void displayInfoMessage(ViewHolder viewHolder, CharSequence text, boolean darkBackground) {
         viewHolder.download_button.setVisibility(View.GONE);
         viewHolder.audioPlayer.setVisibility(View.GONE);
@@ -586,6 +593,46 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     private void displayDownloadableMessage(ViewHolder viewHolder, final Message message, String text, final boolean darkBackground, final int type) {
         displayTextMessage(viewHolder, message, darkBackground, type);
         viewHolder.image.setVisibility(View.GONE);
+        List<Element> thumbs = message.getFileParams() != null ? message.getFileParams().getThumbnails() : null;
+        if (thumbs != null && !thumbs.isEmpty()) {
+            for (Element thumb : thumbs) {
+                Uri uri = Uri.parse(thumb.getAttribute("uri"));
+                if (uri.getScheme().equals("data")) {
+                    String[] parts = uri.getSchemeSpecificPart().split(",", 2);
+                    parts = parts[0].split(";");
+                    if (!parts[0].equals("image/blurhash") && !parts[0].equals("image/jpeg") && !parts[0].equals("image/png") && !parts[0].equals("image/webp") && !parts[0].equals("image/gif")) continue;
+                } else if (uri.getScheme().equals("cid")) {
+                    Cid cid = BobTransfer.cid(uri);
+                    if (cid == null) continue;
+                    DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
+                    if (f == null || !f.canRead()) {
+                        if (!message.trusted() && !message.getConversation().canInferPresence()) continue;
+
+                        try {
+                            new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
+                        } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
+                        continue;
+                    }
+                } else {
+                    continue;
+                }
+
+                int width = message.getFileParams().width;
+                if (width < 1 && thumb.getAttribute("width") != null) width = Integer.parseInt(thumb.getAttribute("width"));
+                if (width < 1) width = 1920;
+
+                int height = message.getFileParams().height;
+                if (height < 1 && thumb.getAttribute("height") != null) height = Integer.parseInt(thumb.getAttribute("height"));
+                if (height < 1) height = 1080;
+
+                viewHolder.image.setVisibility(View.VISIBLE);
+                imagePreviewLayout(width, height, viewHolder.image);
+                activity.loadBitmap(message, viewHolder.image);
+                viewHolder.image.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
+
+                break;
+            }
+        }
         viewHolder.audioPlayer.setVisibility(View.GONE);
         viewHolder.download_button.setVisibility(View.VISIBLE);
         viewHolder.download_button.setText(text);
@@ -626,27 +673,31 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.audioPlayer.setVisibility(View.GONE);
         viewHolder.image.setVisibility(View.VISIBLE);
         final FileParams params = message.getFileParams();
+        imagePreviewLayout(params.width, params.height, viewHolder.image);
+        activity.loadBitmap(message, viewHolder.image);
+        viewHolder.image.setOnClickListener(v -> openDownloadable(message));
+    }
+
+    private void imagePreviewLayout(int w, int h, ImageView image) {
         final float target = activity.getResources().getDimension(R.dimen.image_preview_width);
         final int scaledW;
         final int scaledH;
-        if (Math.max(params.height, params.width) * metrics.density <= target) {
-            scaledW = (int) (params.width * metrics.density);
-            scaledH = (int) (params.height * metrics.density);
-        } else if (Math.max(params.height, params.width) <= target) {
-            scaledW = params.width;
-            scaledH = params.height;
-        } else if (params.width <= params.height) {
-            scaledW = (int) (params.width / ((double) params.height / target));
+        if (Math.max(h, w) * metrics.density <= target) {
+            scaledW = (int) (w * metrics.density);
+            scaledH = (int) (h * metrics.density);
+        } else if (Math.max(h, w) <= target) {
+            scaledW = w;
+            scaledH = h;
+        } else if (w <= h) {
+            scaledW = (int) (w / ((double) h / target));
             scaledH = (int) target;
         } else {
             scaledW = (int) target;
-            scaledH = (int) (params.height / ((double) params.width / target));
+            scaledH = (int) (h / ((double) w / target));
         }
         final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(scaledW, scaledH);
         layoutParams.setMargins(0, (int) (metrics.density * 4), 0, (int) (metrics.density * 4));
-        viewHolder.image.setLayoutParams(layoutParams);
-        activity.loadBitmap(message, viewHolder.image);
-        viewHolder.image.setOnClickListener(v -> openDownloadable(message));
+        image.setLayoutParams(layoutParams);
     }
 
     private void toggleWhisperInfo(ViewHolder viewHolder, final Message message, final boolean darkBackground) {
@@ -881,7 +932,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             } else if (transferable != null && transferable.getStatus() == Transferable.STATUS_OFFER_CHECK_FILESIZE) {
                 displayDownloadableMessage(viewHolder, message, activity.getString(R.string.check_x_filesize, UIHelper.getFileDescriptionString(activity, message)), darkBackground, type);
             } else {
-                displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity, message).first, darkBackground);
+                displayInfoMessage(viewHolder, UIHelper.getMessagePreview(activity, message).first, darkBackground, message, type);
             }
         } else if (message.isFileOrImage() && message.getEncryption() != Message.ENCRYPTION_PGP && message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED) {
             if (message.getFileParams().width > 0 && message.getFileParams().height > 0) {

src/main/java/eu/siacs/conversations/xml/Element.java 🔗

@@ -1,6 +1,7 @@
 package eu.siacs.conversations.xml;
 
 import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
 
 import org.jetbrains.annotations.NotNull;
@@ -70,6 +71,8 @@ public class Element implements Node {
 	}
 
 	public void removeChild(Node child) {
+		if (child == null) return;
+
 		this.childNodes.remove(child);
 		if (child instanceof Element) this.children.remove(child);
 	}
@@ -134,13 +137,13 @@ public class Element implements Node {
 	}
 
 	public final List<Element> getChildren() {
-		return this.children;
+		return ImmutableList.copyOf(this.children);
 	}
 
 	// Deprecated: you probably want bindTo or replaceChildren
 	public Element setChildren(List<Element> children) {
 		this.childNodes = new ArrayList(children);
-		this.children = children;
+		this.children = new ArrayList(children);
 		return this;
 	}