~singpolyma/cheogram-android

664b2f53fdcd5adf23d761b575c2dcf9d6337265 — Stephen Paul Weber 11 months ago d79ec4d + a8e57ce
Merge branch 'xhtml-im'

* xhtml-im:
  Fetch XEP-0231 inline images from trusted contacts
  Actually display images we already have inline in XHTML-IM
  Support for storing and displaying XHTML-IM
  Preserve Text Nodes in XML
27 files changed, 354 insertions(+), 132 deletions(-)

M src/cheogram/java/com/cheogram/android/BobTransfer.java
A src/cheogram/java/com/cheogram/android/GetThumbnailForCid.java
M src/main/java/eu/siacs/conversations/entities/Contact.java
M src/main/java/eu/siacs/conversations/entities/Conversation.java
M src/main/java/eu/siacs/conversations/entities/Conversational.java
M src/main/java/eu/siacs/conversations/entities/Message.java
M src/main/java/eu/siacs/conversations/entities/StubConversation.java
M src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
M src/main/java/eu/siacs/conversations/parser/MessageParser.java
M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java
M src/main/java/eu/siacs/conversations/persistance/FileBackend.java
M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java
M src/main/java/eu/siacs/conversations/utils/CryptoHelper.java
M src/main/java/eu/siacs/conversations/utils/UIHelper.java
M src/main/java/eu/siacs/conversations/xml/Element.java
M src/main/java/eu/siacs/conversations/xml/LocalizedContent.java
A src/main/java/eu/siacs/conversations/xml/Node.java
A src/main/java/eu/siacs/conversations/xml/TextNode.java
M src/main/java/eu/siacs/conversations/xml/XmlReader.java
M src/main/java/eu/siacs/conversations/xmpp/forms/Data.java
M src/main/java/eu/siacs/conversations/xmpp/forms/Field.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java
M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java
M src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java
M src/cheogram/java/com/cheogram/android/BobTransfer.java => src/cheogram/java/com/cheogram/android/BobTransfer.java +51 -22
@@ 15,6 15,7 @@ import io.ipfs.cid.Cid;

import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.Transferable;


@@ 24,15 25,18 @@ import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.utils.MimeUtils;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.stanzas.IqPacket;

public class BobTransfer implements Transferable {
	protected int status = Transferable.STATUS_OFFER;
	protected Message message;
	protected URI uri;
	protected Account account;
	protected Jid to;
	protected XmppConnectionService xmppConnectionService;

	public static Cid cid(URI uri) {
		if (!uri.getScheme().equals("cid")) return null;
		String bobCid = uri.getSchemeSpecificPart();
		if (!bobCid.contains("@") || !bobCid.contains("+")) return null;
		String[] cidParts = bobCid.split("@")[0].split("\\+");


@@ 43,10 47,15 @@ public class BobTransfer implements Transferable {
		}
	}

	public BobTransfer(Message message, XmppConnectionService xmppConnectionService) throws URISyntaxException {
		this.message = message;
	public static URI uri(Cid cid) throws NoSuchAlgorithmException, URISyntaxException {
		return new URI("cid", CryptoHelper.multihashAlgo(cid.getType()) + "+" + CryptoHelper.bytesToHex(cid.getHash()) + "@bob.xmpp.org", null);
	}

	public BobTransfer(URI uri, Account account, Jid to, XmppConnectionService xmppConnectionService) {
		this.xmppConnectionService = xmppConnectionService;
		this.uri = new URI(message.getFileParams().url);
		this.uri = uri;
		this.to = to;
		this.account = account;
	}

	@Override


@@ 55,10 64,7 @@ public class BobTransfer implements Transferable {
		File f = xmppConnectionService.getFileForCid(cid(uri));

		if (f != null && f.canRead()) {
			message.setRelativeFilePath(f.getAbsolutePath());
			finish();
			message.setTransferable(null);
			xmppConnectionService.updateConversationUi();
			finish(f);
			return true;
		}



@@ 66,13 72,14 @@ public class BobTransfer implements Transferable {
			changeStatus(Transferable.STATUS_DOWNLOADING);

			IqPacket request = new IqPacket(IqPacket.TYPE.GET);
			request.setTo(message.getCounterpart());
			request.setTo(to);
			final Element dataq = request.addChild("data", "urn:xmpp:bob");
			dataq.setAttribute("cid", uri.getSchemeSpecificPart());
			xmppConnectionService.sendIqPacket(message.getConversation().getAccount(), request, (acct, packet) -> {
			xmppConnectionService.sendIqPacket(account, request, (acct, packet) -> {
				final Element data = packet.findChild("data", "urn:xmpp:bob");
				if (packet.getType() == IqPacket.TYPE.ERROR || data == null) {
					Log.d(Config.LOGTAG, "BobTransfer failed: " + packet);
					finish(null);
					xmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
				} else {
					final String contentType = data.getAttribute("type");


@@ 84,25 91,23 @@ public class BobTransfer implements Transferable {
					try {
						final byte[] bytes = Base64.decode(data.getContent(), Base64.DEFAULT);

						xmppConnectionService.getFileBackend().setupRelativeFilePath(message, new ByteArrayInputStream(bytes), fileExtension);
						DownloadableFile file = xmppConnectionService.getFileBackend().getFile(message);
						File file = xmppConnectionService.getFileBackend().getStorageLocation(new ByteArrayInputStream(bytes), fileExtension);
						file.getParentFile().mkdirs();
						if (!file.exists() && !file.createNewFile()) {
							throw new IOException(file.getAbsolutePath());
						}

						final OutputStream outputStream = AbstractConnectionManager.createOutputStream(file, false, false);
						final OutputStream outputStream = AbstractConnectionManager.createOutputStream(new DownloadableFile(file.getAbsolutePath()), false, false);
						outputStream.write(bytes);
						outputStream.flush();
						outputStream.close();

						finish();
						finish(file);
					} catch (IOException e) {
						finish(null);
						xmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_write_file);
					}
				}
				message.setTransferable(null);
				xmppConnectionService.updateConversationUi();
			});
			return true;
		} else {


@@ 129,7 134,6 @@ public class BobTransfer implements Transferable {
	public void cancel() {
		// No real way to cancel an iq in process...
		changeStatus(Transferable.STATUS_CANCELLED);
		message.setTransferable(null);
	}

	protected void changeStatus(int newStatus) {


@@ 137,10 141,35 @@ public class BobTransfer implements Transferable {
		xmppConnectionService.updateConversationUi();
	}

	protected void finish() {
		final boolean privateMessage = message.isPrivateMessage();
		message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
		xmppConnectionService.getFileBackend().updateFileParams(message, uri.toString(), false);
		xmppConnectionService.updateMessage(message);
	protected void finish(File f) {
		if (f != null) xmppConnectionService.updateConversationUi();
	}

	public static class ForMessage extends BobTransfer {
		protected Message message;

		public ForMessage(Message message, XmppConnectionService xmppConnectionService) throws URISyntaxException {
			super(new URI(message.getFileParams().url), message.getConversation().getAccount(), message.getCounterpart(), xmppConnectionService);
			this.message = message;
		}

		@Override
		public void cancel() {
			super.cancel();
			message.setTransferable(null);
		}

		@Override
		protected void finish(File f) {
			if (f != null) {
				message.setRelativeFilePath(f.getAbsolutePath());
				final boolean privateMessage = message.isPrivateMessage();
				message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
				xmppConnectionService.getFileBackend().updateFileParams(message, uri.toString(), false);
				xmppConnectionService.updateMessage(message);
			}
			message.setTransferable(null);
			super.finish(f);
		}
	}
}

A src/cheogram/java/com/cheogram/android/GetThumbnailForCid.java => src/cheogram/java/com/cheogram/android/GetThumbnailForCid.java +9 -0
@@ 0,0 1,9 @@
package com.cheogram.android;

import android.graphics.drawable.Drawable;

import io.ipfs.cid.Cid;

public interface GetThumbnailForCid {
	public Drawable getThumbnail(Cid cid);
}

M src/main/java/eu/siacs/conversations/entities/Contact.java => src/main/java/eu/siacs/conversations/entities/Contact.java +4 -0
@@ 414,6 414,10 @@ public class Contact implements ListItem, Blockable {
        return ((this.subscription & (1 << option)) != 0);
    }

    public boolean canInferPresence() {
        return showInContactList() || isSelf();
    }

    public boolean showInRoster() {
        return (this.getOption(Contact.Options.IN_ROSTER) && (!this
                .getOption(Contact.Options.DIRTY_DELETE)))

M src/main/java/eu/siacs/conversations/entities/Conversation.java => src/main/java/eu/siacs/conversations/entities/Conversation.java +6 -0
@@ 1130,6 1130,12 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
        return count;
    }

    public boolean canInferPresence() {
        final Contact contact = getContact();
        if (contact != null && contact.canInferPresence()) return true;
        return sentMessagesCount() > 0;
    }

    public boolean isWithStranger() {
        final Contact contact = getContact();
        return mode == MODE_SINGLE

M src/main/java/eu/siacs/conversations/entities/Conversational.java => src/main/java/eu/siacs/conversations/entities/Conversational.java +2 -0
@@ 45,4 45,6 @@ public interface Conversational {
	int getMode();

	String getUuid();

	boolean canInferPresence();
}

M src/main/java/eu/siacs/conversations/entities/Message.java => src/main/java/eu/siacs/conversations/entities/Message.java +55 -2
@@ 2,10 2,15 @@ package eu.siacs.conversations.entities;

import android.content.ContentValues;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
import android.graphics.Color;
import android.text.Html;
import android.text.SpannableStringBuilder;
import android.util.Log;

import com.cheogram.android.BobTransfer;
import com.cheogram.android.GetThumbnailForCid;

import com.google.common.io.ByteSource;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;


@@ 25,6 30,8 @@ import java.util.Set;
import java.util.stream.Collectors;
import java.util.concurrent.CopyOnWriteArraySet;

import io.ipfs.cid.Cid;

import eu.siacs.conversations.Config;
import eu.siacs.conversations.crypto.axolotl.AxolotlService;
import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;


@@ 762,8 769,42 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
    public static class MergeSeparator {
    }

    public SpannableStringBuilder getSpannableBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
        final Element html = getHtml();
        if (html == null) {
            return new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
        } else {
            SpannableStringBuilder spannable = new SpannableStringBuilder(Html.fromHtml(
                MessageUtils.filterLtrRtl(html.toString()).trim(),
                Html.FROM_HTML_MODE_COMPACT,
                (source) -> {
                   try {
                       if (thumbnailer == null) return fallbackImg;
                       Cid cid = BobTransfer.cid(new URI(source));
                       if (cid == null) return fallbackImg;
                       Drawable thumbnail = thumbnailer.getThumbnail(cid);
                       if (thumbnail == null) return fallbackImg;
                       return thumbnail;
                   } catch (final URISyntaxException e) {
                       return fallbackImg;
                   }
                },
                (opening, tag, output, xmlReader) -> {}
            ));

            // https://stackoverflow.com/a/10187511/8611
            int i = spannable.length();
            while(--i >= 0 && Character.isWhitespace(spannable.charAt(i))) { }
            return (SpannableStringBuilder) spannable.subSequence(0, i+1);
        }
    }

    public SpannableStringBuilder getMergedBody() {
        SpannableStringBuilder body = new SpannableStringBuilder(MessageUtils.filterLtrRtl(getBody()).trim());
        return getMergedBody(null, null);
    }

    public SpannableStringBuilder getMergedBody(GetThumbnailForCid thumbnailer, Drawable fallbackImg) {
        SpannableStringBuilder body = getSpannableBody(thumbnailer, fallbackImg);
        Message current = this;
        while (current.mergeable(current.next())) {
            current = current.next();


@@ 773,7 814,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
            body.append("\n\n");
            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
            body.append(MessageUtils.filterLtrRtl(current.getBody()).trim());
            body.append(current.getSpannableBody(thumbnailer, fallbackImg));
        }
        return body;
    }


@@ 868,6 909,18 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable 
        this.payloads.add(el);
    }

    public Element getHtml() {
        if (this.payloads == null) return null;

        for (Element el : this.payloads) {
            if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
                return el.getChildren().get(0);
            }
        }

        return null;
    }

    public List<Element> getCommands() {
        if (this.payloads == null) return null;


M src/main/java/eu/siacs/conversations/entities/StubConversation.java => src/main/java/eu/siacs/conversations/entities/StubConversation.java +6 -0
@@ 70,4 70,10 @@ public class StubConversation implements Conversational {
	public String getUuid() {
		return uuid;
	}

	@Override
	public boolean canInferPresence() {
		final Contact contact = getContact();
		return contact != null && contact.canInferPresence();
	}
}

M src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java => src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +1 -0
@@ 112,6 112,7 @@ public abstract class AbstractGenerator {
    public List<String> getFeatures(Account account) {
        final XmppConnection connection = account.getXmppConnection();
        final ArrayList<String> features = new ArrayList<>(Arrays.asList(FEATURES));
        features.add("http://jabber.org/protocol/xhtml-im");
        if (mXmppConnectionService.confirmMessages()) {
            features.addAll(Arrays.asList(MESSAGE_CONFIRMATION_FEATURES));
        }

M src/main/java/eu/siacs/conversations/parser/MessageParser.java => src/main/java/eu/siacs/conversations/parser/MessageParser.java +10 -4
@@ 434,6 434,11 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
        }
        boolean notify = false;

        Element html = original.findChild("html", "http://jabber.org/protocol/xhtml-im");
        if (html != null && html.findChild("body", "http://www.w3.org/1999/xhtml") == null) {
            html = null;
        }

        if (from == null || !InvalidJid.isValid(from) || !InvalidJid.isValid(to)) {
            Log.e(Config.LOGTAG, "encountered invalid message from='" + from + "' to='" + to + "'");
            return;


@@ 472,7 477,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
            }
        }

        if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || oobUrl != null) && !isMucStatusMessage) {
        if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || oobUrl != null || 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;


@@ 577,12 582,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                    message.setEncryption(Message.ENCRYPTION_DECRYPTED);
                }
            } else {
                message = new Message(conversation, body.content, Message.ENCRYPTION_NONE, status);
                if (body.count > 1) {
                message = new Message(conversation, body == null ? "HTML-only message" : body.content, Message.ENCRYPTION_NONE, status);
                if (body != null && body.count > 1) {
                    message.setBodyLanguage(body.language);
                }
            }

            if (html != null) message.addPayload(html);
            message.setSubject(original.findChildContent("subject"));
            message.setCounterpart(counterpart);
            message.setRemoteMsgId(remoteMsgId);


@@ 759,7 765,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
            if (message.trusted() && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) {
                if (message.getOob() != null && message.getOob().getScheme().equalsIgnoreCase("cid")) {
                    try {
                        BobTransfer transfer = new BobTransfer(message, mXmppConnectionService);
                        BobTransfer transfer = new BobTransfer.ForMessage(message, mXmppConnectionService);
                        message.setTransferable(transfer);
                        transfer.start();
                    } catch (URISyntaxException e) {

M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java => src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +4 -3
@@ 48,6 48,7 @@ import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.PresenceTemplate;
import eu.siacs.conversations.entities.Roster;


@@ 733,12 734,12 @@ public class DatabaseBackend extends SQLiteOpenHelper {
        cursor.close();
    }

    public File getFileForCid(Cid cid) {
    public DownloadableFile getFileForCid(Cid cid) {
        SQLiteDatabase db = this.getReadableDatabase();
        Cursor cursor = db.query("cheogram.cids", new String[]{"path"}, "cid=?", new String[]{cid.toString()}, null, null, null);
        File f = null;
        DownloadableFile f = null;
        if (cursor.moveToNext()) {
            f = new File(cursor.getString(0));
            f = new DownloadableFile(cursor.getString(0));
        }
        cursor.close();
        return f;

M src/main/java/eu/siacs/conversations/persistance/FileBackend.java => src/main/java/eu/siacs/conversations/persistance/FileBackend.java +38 -22
@@ 13,6 13,7 @@ import android.graphics.drawable.Drawable;
import android.graphics.ImageDecoder;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.pdf.PdfRenderer;
import android.media.MediaMetadataRetriever;


@@ 886,13 887,7 @@ public class FileBackend {
    }

    public void setupRelativeFilePath(final Message message, final InputStream is, final String extension) throws IOException {
        Cid[] cids = calculateCids(is);

        setupRelativeFilePath(message, String.format("%s.%s", cids[0], extension));
        File file = getFile(message);
        for (int i = 0; i < cids.length; i++) {
            mXmppConnectionService.saveCid(cids[i], file);
        }
        message.setRelativeFilePath(getStorageLocation(is, extension).getAbsolutePath());
    }

    public void setupRelativeFilePath(final Message message, final String filename) {


@@ 901,6 896,17 @@ public class FileBackend {
        setupRelativeFilePath(message, filename, mime);
    }

    public File getStorageLocation(final InputStream is, final String extension) throws IOException {
        final String mime = MimeUtils.guessMimeTypeFromExtension(extension);
        Cid[] cids = calculateCids(is);

        File file = getStorageLocation(String.format("%s.%s", cids[0], extension), mime);
        for (int i = 0; i < cids.length; i++) {
            mXmppConnectionService.saveCid(cids[i], file);
        }
        return file;
    }

    public File getStorageLocation(final String filename, final String mime) {
        final File parentDirectory;
        if (Strings.isNullOrEmpty(mime)) {


@@ 995,16 1001,18 @@ public class FileBackend {
    }

    public Drawable getThumbnail(Message message, Resources res, int size, boolean cacheOnly) throws IOException {
        final String uuid = message.getUuid();
        return getThumbnail(getFile(message), res, size, cacheOnly);
    }

    public Drawable getThumbnail(DownloadableFile file, Resources res, int size, boolean cacheOnly) throws IOException {
        final LruCache<String, Drawable> cache = mXmppConnectionService.getDrawableCache();
        Drawable thumbnail = cache.get(uuid);
        Drawable thumbnail = cache.get(file.getAbsolutePath());
        if ((thumbnail == null) && (!cacheOnly)) {
            synchronized (THUMBNAIL_LOCK) {
                thumbnail = cache.get(uuid);
                thumbnail = cache.get(file.getAbsolutePath());
                if (thumbnail != null) {
                    return thumbnail;
                }
                DownloadableFile file = getFile(message);
                final String mime = file.getMimeType();
                if ("application/pdf".equals(mime)) {
                    thumbnail = new BitmapDrawable(res, getPdfDocumentPreview(file, size));


@@ 1016,7 1024,7 @@ public class FileBackend {
                        throw new FileNotFoundException();
                    }
                }
                cache.put(uuid, thumbnail);
                cache.put(file.getAbsolutePath(), thumbnail);
            }
        }
        return thumbnail;


@@ 1028,22 1036,30 @@ public class FileBackend {
          return drawDrawable(drawable);
    }

    public static Rect rectForSize(int w, int h, int size) {
        int scalledW;
        int scalledH;
        if (w <= h) {
            scalledW = Math.max((int) (w / ((double) h / size)), 1);
            scalledH = size;
        } else {
            scalledW = size;
            scalledH = Math.max((int) (h / ((double) w / size)), 1);
        }

        if (scalledW > w || scalledH > h) return new Rect(0, 0, w, h);

        return new Rect(0, 0, scalledW, scalledH);
    }

    private Drawable getImagePreview(File file, Resources res, int size, final String mime) throws IOException {
        if (android.os.Build.VERSION.SDK_INT >= 28) {
            ImageDecoder.Source source = ImageDecoder.createSource(file);
            return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
                int w = info.getSize().getWidth();
                int h = info.getSize().getHeight();
                int scalledW;
                int scalledH;
                if (w <= h) {
                    scalledW = Math.max((int) (w / ((double) h / size)), 1);
                    scalledH = size;
                } else {
                    scalledW = size;
                    scalledH = Math.max((int) (h / ((double) w / size)), 1);
                }
                decoder.setTargetSize(scalledW, scalledH);
                Rect r = rectForSize(w, h, size);
                decoder.setTargetSize(r.width(), r.height());
            });
        } else {
            BitmapFactory.Options options = new BitmapFactory.Options();

M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java => src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +2 -1
@@ 103,6 103,7 @@ import eu.siacs.conversations.entities.Bookmark;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.entities.DownloadableFile;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.MucOptions.OnRenameListener;


@@ 549,7 550,7 @@ public class XmppConnectionService extends Service {
        return this.fileBackend;
    }

    public File getFileForCid(Cid cid) {
    public DownloadableFile getFileForCid(Cid cid) {
        return this.databaseBackend.getFileForCid(cid);
    }


M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java => src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +1 -1
@@ 1946,7 1946,7 @@ public class ConversationFragment extends XmppFragment
        }
        if (message.getOob() != null && message.getOob().getScheme().equalsIgnoreCase("cid")) {
            try {
                BobTransfer transfer = new BobTransfer(message, activity.xmppConnectionService);
                BobTransfer transfer = new BobTransfer.ForMessage(message, activity.xmppConnectionService);
                message.setTransferable(transfer);
                transfer.start();
            } catch (URISyntaxException e) {

M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java => src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +60 -1
@@ 6,8 6,10 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.AsyncTask;
import android.preference.PreferenceManager;
import android.text.Spannable;
import android.text.SpannableString;


@@ 32,15 34,23 @@ import android.widget.Toast;

import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.res.ResourcesCompat;

import com.cheogram.android.BobTransfer;

import com.google.common.base.Strings;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import io.ipfs.cid.Cid;

import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;


@@ 439,7 449,32 @@ public class MessageAdapter extends ArrayAdapter<Message> {
        if (message.getBody() != null && !message.getBody().equals("")) {
            viewHolder.messageBody.setVisibility(View.VISIBLE);
            final String nick = UIHelper.getMessageDisplayName(message);
            SpannableStringBuilder body = message.getMergedBody();
            Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), activity.getThemeResource(R.attr.ic_attach_photo, R.drawable.ic_attach_photo), null);
            fallbackImg.setBounds(FileBackend.rectForSize(fallbackImg.getIntrinsicWidth(), fallbackImg.getIntrinsicHeight(), (int) (metrics.density * 32)));
            SpannableStringBuilder body = message.getMergedBody((cid) -> {
                try {
                    DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
                    if (f == null || !f.canRead()) {
                        if (!message.trusted() && !message.getConversation().canInferPresence()) return null;

                        try {
                            new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
                        } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
                        return null;
                    }

                    Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
                    if (d == null) {
                        new ThumbnailTask().execute(f);
                    } else {
                        d = d.getConstantState().newDrawable();
                        d.setBounds(FileBackend.rectForSize(d.getIntrinsicWidth(), d.getIntrinsicHeight(), (int) (metrics.density * 32)));
                    }
                    return d;
                } catch (final IOException e) {
                    return fallbackImg;
                }
            }, fallbackImg);
            boolean hasMeCommand = message.hasMeCommand();
            if (hasMeCommand) {
                body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");


@@ 959,4 994,28 @@ public class MessageAdapter extends ArrayAdapter<Message> {
        protected TextView encryption;
        protected ListView commands_list;
    }

    class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {
        @Override
        protected Drawable[] doInBackground(DownloadableFile... params) {
            if (isCancelled()) return null;

            Drawable[] d = new Drawable[params.length];
            for (int i = 0; i < params.length; i++) {
                try {
                    d[i] = activity.xmppConnectionService.getFileBackend().getThumbnail(params[i], activity.getResources(), (int) (metrics.density * 288), false);
                } catch (final IOException e) {
                    d[i] = null;
                }
            }

            return d;
        }

        @Override
        protected void onPostExecute(final Drawable[] d) {
            if (isCancelled()) return;
            activity.xmppConnectionService.updateConversationUi();
        }
    }
}

M src/main/java/eu/siacs/conversations/utils/CryptoHelper.java => src/main/java/eu/siacs/conversations/utils/CryptoHelper.java +13 -0
@@ 288,6 288,19 @@ public final class CryptoHelper {
        return !u.contains(" ") && (u.startsWith("https://") || u.startsWith("http://") || u.startsWith("p1s3://")) && u.endsWith(".pgp");
    }

    public static String multihashAlgo(Multihash.Type type) throws NoSuchAlgorithmException {
        switch(type) {
        case sha1:
            return "sha1";
        case sha2_256:
            return "sha-256";
        case sha2_512:
            return "sha-512";
        default:
            throw new NoSuchAlgorithmException("" + type);
        }
    }

    public static Multihash.Type multihashType(String algo) throws NoSuchAlgorithmException {
        if (algo.equals("SHA-1") || algo.equals("sha-1") || algo.equals("sha1")) {
            return Multihash.Type.sha1;

M src/main/java/eu/siacs/conversations/utils/UIHelper.java => src/main/java/eu/siacs/conversations/utils/UIHelper.java +5 -1
@@ 1,12 1,14 @@
package eu.siacs.conversations.utils;

import android.content.Context;
import android.graphics.drawable.Drawable;
import android.text.SpannableStringBuilder;
import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.util.Pair;

import androidx.annotation.ColorInt;
import androidx.core.content.res.ResourcesCompat;

import com.google.common.base.Strings;



@@ 319,7 321,9 @@ public class UIHelper {
                return new Pair<>(context.getString(R.string.x_file_offered_for_download,
                        getFileDescriptionString(context, message)), true);
            } else {
                SpannableStringBuilder styledBody = new SpannableStringBuilder(body);
                Drawable fallbackImg = ResourcesCompat.getDrawable(context.getResources(), R.drawable.ic_attach_photo, null);
                fallbackImg.setBounds(0, 0, fallbackImg.getIntrinsicWidth(), fallbackImg.getIntrinsicHeight());
                SpannableStringBuilder styledBody = message.getSpannableBody(null, fallbackImg);
                if (textColor != 0) {
                    StylingHelper.format(styledBody, 0, styledBody.length() - 1, textColor);
                }

M src/main/java/eu/siacs/conversations/xml/Element.java => src/main/java/eu/siacs/conversations/xml/Element.java +41 -19
@@ 3,19 3,21 @@ package eu.siacs.conversations.xml;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Hashtable;
import java.util.List;
import java.util.stream.Collectors;

import eu.siacs.conversations.utils.XmlHelper;
import eu.siacs.conversations.xmpp.InvalidJid;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;

public class Element {
public class Element implements Node {
	private final String name;
	private Hashtable<String, String> attributes = new Hashtable<>();
	private String content;
	protected List<Element> children = new ArrayList<>();
	private List<Element> children = new ArrayList<>();
	private List<Node> childNodes = new ArrayList<>();

	public Element(String name) {
		this.name = name;


@@ 26,30 28,52 @@ public class Element {
		this.setAttribute("xmlns", xmlns);
	}

	public Element addChild(Element child) {
		this.content = null;
		children.add(child);
	public Node prependChild(Node child) {
		childNodes.add(0, child);
		if (child instanceof Element) children.add(0, (Element) child);
		return child;
	}

	public Node addChild(Node child) {
		childNodes.add(child);
		if (child instanceof Element) children.add((Element) child);
		return child;
	}

	public Element addChild(String name) {
		this.content = null;
		Element child = new Element(name);
		childNodes.add(child);
		children.add(child);
		return child;
	}

	public Element addChild(String name, String xmlns) {
		this.content = null;
		Element child = new Element(name);
		child.setAttribute("xmlns", xmlns);
		childNodes.add(child);
		children.add(child);
		return child;
	}

	public void addChildren(final Collection<? extends Node> children) {
		if (children == null) return;

		this.childNodes.addAll(children);
		for (Node node : children) {
			if (node instanceof Element) {
				this.children.add((Element) node);
			}
		}
	}

	public void removeChild(Node child) {
		this.childNodes.remove(child);
		if (child instanceof Element) this.children.remove(child);
	}

	public Element setContent(String content) {
		this.content = content;
		this.children.clear();
		clearChildren();
		if (content != null) this.childNodes.add(new TextNode(content));
		return this;
	}



@@ 106,17 130,18 @@ public class Element {
		return findChild(name, xmlns) != null;
	}

	public List<Element> getChildren() {
	public final List<Element> getChildren() {
		return this.children;
	}

	public Element setChildren(List<Element> children) {
		this.childNodes = new ArrayList(children);
		this.children = children;
		return this;
	}

	public final String getContent() {
		return content;
		return this.childNodes.stream().map(Node::getContent).filter(c -> c != null).collect(Collectors.joining());
	}

	public Element setAttribute(String name, String value) {


@@ 170,7 195,7 @@ public class Element {
	@NotNull
	public String toString() {
		final StringBuilder elementOutput = new StringBuilder();
		if ((content == null) && (children.size() == 0)) {
		if (childNodes.size() == 0) {
			Tag emptyTag = Tag.empty(name);
			emptyTag.setAtttributes(this.attributes);
			elementOutput.append(emptyTag.toString());


@@ 178,12 203,8 @@ public class Element {
			Tag startTag = Tag.start(name);
			startTag.setAtttributes(this.attributes);
			elementOutput.append(startTag);
			if (content != null) {
				elementOutput.append(XmlHelper.encodeEntities(content));
			} else {
				for (Element child : children) {
					elementOutput.append(child.toString());
				}
			for (Node child : childNodes) {
				elementOutput.append(child.toString());
			}
			Tag endTag = Tag.end(name);
			elementOutput.append(endTag);


@@ 197,6 218,7 @@ public class Element {

	public void clearChildren() {
		this.children.clear();
		this.childNodes.clear();
	}

	public void setAttribute(String name, long value) {

M src/main/java/eu/siacs/conversations/xml/LocalizedContent.java => src/main/java/eu/siacs/conversations/xml/LocalizedContent.java +1 -1
@@ 23,7 23,7 @@ public class LocalizedContent {
    public static LocalizedContent get(final Element element, String name) {
        final HashMap<String, String> contents = new HashMap<>();
        final String parentLanguage = element.getAttribute("xml:lang");
        for(Element child : element.children) {
        for(Element child : element.getChildren()) {
            if (name.equals(child.getName())) {
                final String namespace = child.getNamespace();
                final String childLanguage = child.getAttribute("xml:lang");

A src/main/java/eu/siacs/conversations/xml/Node.java => src/main/java/eu/siacs/conversations/xml/Node.java +5 -0
@@ 0,0 1,5 @@
package eu.siacs.conversations.xml;

public interface Node {
	public String getContent();
}

A src/main/java/eu/siacs/conversations/xml/TextNode.java => src/main/java/eu/siacs/conversations/xml/TextNode.java +20 -0
@@ 0,0 1,20 @@
package eu.siacs.conversations.xml;

import eu.siacs.conversations.utils.XmlHelper;

public class TextNode implements Node {
	protected String content;

	public TextNode(final String content) {
		if (content == null) throw new IllegalArgumentException("null TextNode is not allowed");
		this.content = content;
	}

	public String getContent() {
		return content;
	}

	public String toString() {
		return XmlHelper.encodeEntities(content);
	}
}

M src/main/java/eu/siacs/conversations/xml/XmlReader.java => src/main/java/eu/siacs/conversations/xml/XmlReader.java +3 -8
@@ 94,15 94,10 @@ public class XmlReader implements Closeable {
		if (nextTag == null) {
			throw new IOException("interrupted mid tag");
		}
		if (nextTag.isNo()) {
			element.setContent(nextTag.getName());
			nextTag = this.readTag();
			if (nextTag == null) {
				throw new IOException("interrupted mid tag");
			}
		}
		while (!nextTag.isEnd(element.getName())) {
			if (!nextTag.isNo()) {
			if (nextTag.isNo()) {
				element.addChild(new TextNode(nextTag.getName()));
			} else {
				Element child = this.readElement(nextTag);
				element.addChild(child);
			}

M src/main/java/eu/siacs/conversations/xmpp/forms/Data.java => src/main/java/eu/siacs/conversations/xmpp/forms/Data.java +2 -7
@@ 4,8 4,8 @@ import android.os.Bundle;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;


@@ 77,12 77,7 @@ public class Data extends Element {
	}

	private void removeUnnecessaryChildren() {
		for(Iterator<Element> iterator = this.children.iterator(); iterator.hasNext();) {
			Element element = iterator.next();
			if (!element.getName().equals("field") && !element.getName().equals("title")) {
				iterator.remove();
			}
		}
		setChildren(getChildren().stream().filter(element -> element.getName().equals("field") || element.getName().equals("title")).collect(Collectors.toList()));
	}

	public static Data parse(Element element) {

M src/main/java/eu/siacs/conversations/xmpp/forms/Field.java => src/main/java/eu/siacs/conversations/xmpp/forms/Field.java +4 -13
@@ 2,8 2,8 @@ package eu.siacs.conversations.xmpp.forms;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

import eu.siacs.conversations.xml.Element;



@@ 23,24 23,15 @@ public class Field extends Element {
	}

	public void setValue(String value) {
		this.children.clear();
		this.addChild("value").setContent(value);
		setChildren(List.of(new Element("value").setContent(value)));
	}

	public void setValues(Collection<String> values) {
		this.children.clear();
		for(String value : values) {
			this.addChild("value").setContent(value);
		}
		setChildren(values.stream().map(val -> new Element("value").setContent(val)).collect(Collectors.toList()));
	}

	public void removeNonValueChildren() {
		for(Iterator<Element> iterator = this.children.iterator(); iterator.hasNext();) {
			Element element = iterator.next();
			if (!element.getName().equals("value")) {
				iterator.remove();
			}
		}
		setChildren(getChildren().stream().filter(element -> element.getName().equals("value")).collect(Collectors.toList()));
	}

	public static Field parse(Element element) {

M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java => src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java +1 -1
@@ 29,7 29,7 @@ public class Group extends Element {

    public List<String> getIdentificationTags() {
        final ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
        for (final Element child : this.children) {
        for (final Element child : getChildren()) {
            if ("content".equals(child.getName())) {
                final String name = child.getAttribute("name");
                if (name != null) {

M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java => src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java +1 -1
@@ 15,7 15,7 @@ public class Propose extends Element {

    public List<GenericDescription> getDescriptions() {
        final ImmutableList.Builder<GenericDescription> builder = new ImmutableList.Builder<>();
        for (final Element child : this.children) {
        for (final Element child : getChildren()) {
            if ("description".equals(child.getName())) {
                final String namespace = child.getNamespace();
                if (FileTransferDescription.NAMESPACES.contains(namespace)) {

M src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java => src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java +5 -19
@@ 66,7 66,7 @@ public class RtpDescription extends GenericDescription {

    public List<Source> getSources() {
        final ImmutableList.Builder<Source> builder = new ImmutableList.Builder<>();
        for (final Element child : this.children) {
        for (final Element child : getChildren()) {
            if ("source".equals(child.getName()) && Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(child.getNamespace())) {
                builder.add(Source.upgrade(child));
            }


@@ 76,7 76,7 @@ public class RtpDescription extends GenericDescription {

    public List<SourceGroup> getSourceGroups() {
        final ImmutableList.Builder<SourceGroup> builder = new ImmutableList.Builder<>();
        for (final Element child : this.children) {
        for (final Element child : getChildren()) {
            if ("ssrc-group".equals(child.getName()) && Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES.equals(child.getNamespace())) {
                builder.add(SourceGroup.upgrade(child));
            }


@@ 326,16 326,8 @@ public class RtpDescription extends GenericDescription {
            return null;
        }

        public void addChildren(final List<Element> children) {
            if (children != null) {
                this.children.addAll(children);
            }
        }

        public void addParameters(List<Parameter> parameters) {
            if (parameters != null) {
                this.children.addAll(parameters);
            }
            addChildren(parameters);
        }
    }



@@ 442,7 434,7 @@ public class RtpDescription extends GenericDescription {

        public List<Parameter> getParameters() {
            ImmutableList.Builder<Parameter> builder = new ImmutableList.Builder<>();
            for (Element child : this.children) {
            for (Element child : getChildren()) {
                if ("parameter".equals(child.getName())) {
                    builder.add(Parameter.upgrade(child));
                }


@@ 512,7 504,7 @@ public class RtpDescription extends GenericDescription {

        public List<String> getSsrcs() {
            ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
            for (Element child : this.children) {
            for (Element child : getChildren()) {
                if ("source".equals(child.getName())) {
                    final String ssrc = child.getAttribute("ssrc");
                    if (ssrc != null) {


@@ 610,10 602,4 @@ public class RtpDescription extends GenericDescription {
        }
        return rtpDescription;
    }

    private void addChildren(List<Element> elements) {
        if (elements != null) {
            this.children.addAll(elements);
        }
    }
}

M src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java => src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java +4 -6
@@ 22,15 22,13 @@ public class MessagePacket extends AbstractAcknowledgeableStanza {
	}

	public void setBody(String text) {
		this.children.remove(findChild("body"));
		Element body = new Element("body");
		body.setContent(text);
		this.children.add(0, body);
		removeChild(findChild("body"));
		prependChild(new Element("body").setContent(text));
	}

	public void setAxolotlMessage(Element axolotlMessage) {
		this.children.remove(findChild("body"));
		this.children.add(0, axolotlMessage);
		removeChild(findChild("body"));
		prependChild(axolotlMessage);
	}

	public void setType(int type) {