~singpolyma/cheogram-android

a0b0da3be3860452bfc37397cf46f3eb7b4f1060 — Stephen Paul Weber 1 year, 8 months ago c835763 + 86dbeaa
Merge branch 'sims2'

* 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
M build.gradle => build.gradle +1 -0
@@ 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
}


M src/cheogram/java/com/cheogram/android/BobTransfer.java => src/cheogram/java/com/cheogram/android/BobTransfer.java +6 -0
@@ 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());

M src/main/java/eu/siacs/conversations/entities/Message.java => src/main/java/eu/siacs/conversations/entities/Message.java +124 -5
@@ 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) {

M src/main/java/eu/siacs/conversations/parser/MessageParser.java => src/main/java/eu/siacs/conversations/parser/MessageParser.java +33 -11
@@ 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);

M src/main/java/eu/siacs/conversations/persistance/FileBackend.java => src/main/java/eu/siacs/conversations/persistance/FileBackend.java +91 -2
@@ 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;
        }

M src/main/java/eu/siacs/conversations/ui/XmppActivity.java => src/main/java/eu/siacs/conversations/ui/XmppActivity.java +8 -2
@@ 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();

M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java => src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +64 -13
@@ 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) {

M src/main/java/eu/siacs/conversations/xml/Element.java => src/main/java/eu/siacs/conversations/xml/Element.java +5 -2
@@ 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;
	}