Merge branch 'xhtml-im'

Stephen Paul Weber created

* 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

Change summary

src/cheogram/java/com/cheogram/android/BobTransfer.java                      | 73 
src/cheogram/java/com/cheogram/android/GetThumbnailForCid.java               |  9 
src/main/java/eu/siacs/conversations/entities/Contact.java                   |  4 
src/main/java/eu/siacs/conversations/entities/Conversation.java              |  6 
src/main/java/eu/siacs/conversations/entities/Conversational.java            |  2 
src/main/java/eu/siacs/conversations/entities/Message.java                   | 57 
src/main/java/eu/siacs/conversations/entities/StubConversation.java          |  6 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java        |  1 
src/main/java/eu/siacs/conversations/parser/MessageParser.java               | 14 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java        |  7 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java            | 60 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java     |  3 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java            |  2 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java          | 61 
src/main/java/eu/siacs/conversations/utils/CryptoHelper.java                 | 13 
src/main/java/eu/siacs/conversations/utils/UIHelper.java                     |  6 
src/main/java/eu/siacs/conversations/xml/Element.java                        | 60 
src/main/java/eu/siacs/conversations/xml/LocalizedContent.java               |  2 
src/main/java/eu/siacs/conversations/xml/Node.java                           |  5 
src/main/java/eu/siacs/conversations/xml/TextNode.java                       | 20 
src/main/java/eu/siacs/conversations/xml/XmlReader.java                      | 11 
src/main/java/eu/siacs/conversations/xmpp/forms/Data.java                    |  9 
src/main/java/eu/siacs/conversations/xmpp/forms/Field.java                   | 17 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java          |  2 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java        |  2 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java | 24 
src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java         | 10 
27 files changed, 354 insertions(+), 132 deletions(-)

Detailed changes

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

@@ -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);
+		}
 	}
 }

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

@@ -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)))

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

@@ -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

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

@@ -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;
 

src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java 🔗

@@ -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));
         }

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

@@ -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) {

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

@@ -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;

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

@@ -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();

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -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);
     }
 

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

@@ -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) {

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

@@ -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();
+        }
+    }
 }

src/main/java/eu/siacs/conversations/utils/CryptoHelper.java 🔗

@@ -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;

src/main/java/eu/siacs/conversations/utils/UIHelper.java 🔗

@@ -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);
                 }

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

@@ -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) {

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

@@ -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");

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

@@ -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);
+	}
+}

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

@@ -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);
 			}

src/main/java/eu/siacs/conversations/xmpp/forms/Data.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/xmpp/forms/Field.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java 🔗

@@ -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) {

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java 🔗

@@ -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)) {

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java 🔗

@@ -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);
-        }
-    }
 }

src/main/java/eu/siacs/conversations/xmpp/stanzas/MessagePacket.java 🔗

@@ -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) {