Support for storing and displaying XHTML-IM

Stephen Paul Weber created

Only supports images with XEP-0231 compatible cid: URIs, otherwise images render
as a fallback image.  This commit doesn't yet support fetching the images at
all, but can ask a passed-in thumbnailer to figure out getting the image for a
certain Cid.

Change summary

src/cheogram/java/com/cheogram/android/BobTransfer.java               |  1 
src/cheogram/java/com/cheogram/android/GetThumbnailForCid.java        |  9 
src/main/java/eu/siacs/conversations/entities/Message.java            | 57 
src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java |  1 
src/main/java/eu/siacs/conversations/parser/MessageParser.java        | 12 
5 files changed, 75 insertions(+), 5 deletions(-)

Detailed changes

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

@@ -33,6 +33,7 @@ public class BobTransfer implements Transferable {
 	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("\\+");

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