Initial WebXDC prototype

Stephen Paul Weber created

Change summary

src/cheogram/java/com/cheogram/android/ConversationPage.java             |  14 
src/cheogram/java/com/cheogram/android/WebxdcPage.java                   | 321 
src/cheogram/java/com/cheogram/android/WebxdcUpdate.java                 |  98 
src/cheogram/res/layout/webxdc_page.xml                                  |  28 
src/cheogram/res/raw/webxdc.js                                           |  40 
src/main/java/eu/siacs/conversations/entities/Conversation.java          |  67 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           |  25 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java    |  60 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |  14 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        |  10 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      |  22 
src/main/java/eu/siacs/conversations/utils/MimeUtils.java                |   4 
12 files changed, 688 insertions(+), 15 deletions(-)

Detailed changes

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

@@ -0,0 +1,14 @@
+package com.cheogram.android;
+
+import android.content.Context;
+import android.view.View;
+
+import eu.siacs.conversations.utils.Consumer;
+
+public interface ConversationPage {
+	public String getTitle();
+	public String getNode();
+	public View inflateUi(Context context, Consumer<ConversationPage> remover);
+	public View getView();
+	public void refresh();
+}

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

@@ -0,0 +1,321 @@
+// Based on GPLv3 code from deltachat-android
+// https://github.com/deltachat/deltachat-android/blob/master/src/org/thoughtcrime/securesms/WebViewActivity.java
+// https://github.com/deltachat/deltachat-android/blob/master/src/org/thoughtcrime/securesms/WebxdcActivity.java
+package com.cheogram.android;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.webkit.JavascriptInterface;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.TextView;
+
+import androidx.annotation.RequiresApi;
+import androidx.core.content.ContextCompat;
+import androidx.databinding.DataBindingUtil;
+
+import io.ipfs.cid.Cid;
+
+import java.lang.ref.WeakReference;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import org.json.JSONObject;
+import org.json.JSONException;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.WebxdcPageBinding;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.Consumer;
+import eu.siacs.conversations.utils.MimeUtils;
+import eu.siacs.conversations.utils.UIHelper;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class WebxdcPage implements ConversationPage {
+	protected XmppConnectionService xmppConnectionService;
+	protected WebxdcPageBinding binding = null;
+	protected ZipFile zip = null;
+	protected String baseUrl;
+	protected Message source;
+
+	public WebxdcPage(Cid cid, Message source, XmppConnectionService xmppConnectionService) {
+		this.xmppConnectionService = xmppConnectionService;
+		this.source = source;
+		File f = xmppConnectionService.getFileForCid(cid);
+		try {
+			if (f != null) zip = new ZipFile(xmppConnectionService.getFileForCid(cid));
+		} catch (final IOException e) {
+			Log.w(Config.LOGTAG, "WebxdcPage: " + e);
+		}
+
+		// ids in the subdomain makes sure, different apps using same files do not share the same cache entry
+		// (WebView may use a global cache shared across objects).
+		// (a random-id would also work, but would need maintenance and does not add benefits as we regard the file-part interceptRequest() only,
+		// also a random-id is not that useful for debugging)
+		baseUrl = "https://" + source.getUuid() + ".localhost";
+	}
+
+	public String getTitle() {
+		return "WebXDC";
+	}
+
+	public String getNode() {
+		return "webxdc\0" + source.getUuid();
+	}
+
+	public boolean openUri(Uri uri) {
+		Intent intent = new Intent(Intent.ACTION_VIEW, uri);
+		intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+		xmppConnectionService.startActivity(intent);
+		return true;
+	}
+
+	protected WebResourceResponse interceptRequest(String rawUrl) {
+		Log.i(Config.LOGTAG, "interceptRequest: " + rawUrl);
+		WebResourceResponse res = null;
+		try {
+			if (zip == null) {
+				throw new Exception("no zip found");
+			}
+			if (rawUrl == null) {
+				throw new Exception("no url specified");
+			}
+			String path = Uri.parse(rawUrl).getPath();
+			if (path.equalsIgnoreCase("/webxdc.js")) {
+				InputStream targetStream = xmppConnectionService.getResources().openRawResource(R.raw.webxdc);
+				res = new WebResourceResponse("text/javascript", "UTF-8", targetStream);
+			} else {
+				ZipEntry entry = zip.getEntry(path.substring(1));
+				if (entry == null) {
+					throw new Exception("\"" + path + "\" not found");
+				}
+				String mimeType = MimeUtils.guessFromPath(path);
+				String encoding = mimeType.startsWith("text/") ? "UTF-8" : null;
+				res = new WebResourceResponse(mimeType, encoding, zip.getInputStream(entry));
+			}
+		} catch (Exception e) {
+			e.printStackTrace();
+			InputStream targetStream = new ByteArrayInputStream(("Webxdc Request Error: " + e.getMessage()).getBytes());
+			res = new WebResourceResponse("text/plain", "UTF-8", targetStream);
+		}
+
+		if (res != null) {
+			Map<String, String> headers = new HashMap<>();
+			headers.put("Content-Security-Policy",
+					"default-src 'self'; "
+				+ "style-src 'self' 'unsafe-inline' blob: ; "
+				+ "font-src 'self' data: blob: ; "
+				+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ; "
+				+ "connect-src 'self' data: blob: ; "
+				+ "img-src 'self' data: blob: ; "
+				+ "webrtc 'block' ; "
+			);
+			headers.put("X-DNS-Prefetch-Control", "off");
+			res.setResponseHeaders(headers);
+		}
+		return res;
+	}
+
+	public View inflateUi(Context context, Consumer<ConversationPage> remover) {
+		if (binding != null) {
+			binding.webview.loadUrl("javascript:__webxdcUpdate();");
+			return getView();
+		}
+
+		binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.webxdc_page, null, false);
+		binding.webview.setWebViewClient(new WebViewClient() {
+			// `shouldOverrideUrlLoading()` is called when the user clicks a URL,
+			// returning `true` causes the WebView to abort loading the URL,
+			// returning `false` causes the WebView to continue loading the URL as usual.
+			// the method is not called for POST request nor for on-page-links.
+			//
+			// nb: from API 24, `shouldOverrideUrlLoading(String)` is deprecated and
+			// `shouldOverrideUrlLoading(WebResourceRequest)` shall be used.
+			// the new one has the same functionality, and the old one still exist,
+			// so, to support all systems, for now, using the old one seems to be the simplest way.
+			@Override
+			public boolean shouldOverrideUrlLoading(WebView view, String url) {
+				if (url != null) {
+					Uri uri = Uri.parse(url);
+					switch (uri.getScheme()) {
+						case "http":
+						case "https":
+						case "mailto":
+						case "xmpp":
+							return openUri(uri);
+					}
+				}
+				// by returning `true`, we also abort loading other URLs in our WebView;
+				// eg. that might be weird or internal protocols.
+				// if we come over really useful things, we should allow that explicitly.
+				return true;
+			}
+
+			@Override
+			@SuppressWarnings("deprecation")
+			public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
+				WebResourceResponse res = interceptRequest(url);
+				if (res!=null) {
+					return res;
+				}
+				return super.shouldInterceptRequest(view, url);
+			}
+
+			@Override
+			@RequiresApi(21)
+			public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
+				WebResourceResponse res = interceptRequest(request.getUrl().toString());
+				if (res!=null) {
+					return res;
+				}
+				return super.shouldInterceptRequest(view, request);
+			}
+		});
+
+		// disable "safe browsing" as this has privacy issues,
+		// eg. at least false positives are sent to the "Safe Browsing Lookup API".
+		// as all URLs opened in the WebView are local anyway,
+		// "safe browsing" will never be able to report issues, so it can be disabled.
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			binding.webview.getSettings().setSafeBrowsingEnabled(false);
+		}
+
+		WebSettings webSettings = binding.webview.getSettings();
+		webSettings.setJavaScriptEnabled(true);
+		webSettings.setAllowFileAccess(false);
+		webSettings.setBlockNetworkLoads(true);
+		webSettings.setAllowContentAccess(false);
+		webSettings.setGeolocationEnabled(false);
+		webSettings.setAllowFileAccessFromFileURLs(false);
+		webSettings.setAllowUniversalAccessFromFileURLs(false);
+		webSettings.setDatabaseEnabled(true);
+		webSettings.setDomStorageEnabled(true);
+		binding.webview.setNetworkAvailable(false); // this does not block network but sets `window.navigator.isOnline` in js land
+		binding.webview.addJavascriptInterface(new InternalJSApi(), "InternalJSApi");
+
+		binding.webview.loadUrl(baseUrl + "/index.html");
+
+		binding.actions.setAdapter(new ArrayAdapter<String>(context, R.layout.simple_list_item, new String[]{"Close"}) {
+			@Override
+			public View getView(int position, View convertView, ViewGroup parent) {
+				View v = super.getView(position, convertView, parent);
+				TextView tv = (TextView) v.findViewById(android.R.id.text1);
+				tv.setGravity(Gravity.CENTER);
+				tv.setTextColor(ContextCompat.getColor(context, R.color.white));
+				tv.setBackgroundColor(UIHelper.getColorForName(getItem(position)));
+				return v;
+			}
+		});
+		binding.actions.setOnItemClickListener((parent, v, pos, id) -> {
+			remover.accept(WebxdcPage.this);
+		});
+
+		return getView();
+	}
+
+	public View getView() {
+		if (binding == null) return null;
+		return binding.getRoot();
+	}
+
+	public void refresh() {
+		binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
+	}
+
+	protected Jid selfJid() {
+		Conversation conversation = (Conversation) source.getConversation();
+		if (conversation.getMode() == Conversation.MODE_MULTI && !conversation.getMucOptions().nonanonymous()) {
+			return conversation.getMucOptions().getSelf().getFullJid();
+		} else {
+			return source.getConversation().getAccount().getJid().asBareJid();
+		}
+	}
+
+	protected class InternalJSApi {
+		@JavascriptInterface
+		public String selfAddr() {
+			return "xmpp:" + Uri.encode(selfJid().toEscapedString(), "@/+");
+		}
+
+		@JavascriptInterface
+		public String selfName() {
+			return source.getConversation().getAccount().getDisplayName();
+		}
+
+		@JavascriptInterface
+		public boolean sendStatusUpdate(String paramS, String descr) {
+			JSONObject params = new JSONObject();
+			try {
+				params = new JSONObject(paramS);
+			} catch (final JSONException e) {
+				Log.w(Config.LOGTAG, "WebxdcPage sendStatusUpdate invalid JSON: " + e);
+			}
+			String payload = null;
+			Message message = new Message(source.getConversation(), descr, source.getEncryption());
+			message.addPayload(new Element("store", "urn:xmpp:hints"));
+			Element webxdc = new Element("x", "urn:xmpp:webxdc:0");
+			message.addPayload(webxdc);
+			if (params.has("payload")) {
+				payload = JSONObject.wrap(params.opt("payload")).toString();
+				webxdc.addChild("json", "urn:xmpp:json:0").setContent(payload);
+			}
+			if (params.has("document")) {
+				webxdc.addChild("document").setContent(params.optString("document", null));
+			}
+			if (params.has("summary")) {
+				webxdc.addChild("summary").setContent(params.optString("summary", null));
+			}
+			message.setBody(params.optString("info", null));
+			message.setThread(source.getThread());
+			if (source.isPrivateMessage()) {
+				Message.configurePrivateMessage(message, source.getCounterpart());
+			}
+			xmppConnectionService.sendMessage(message);
+			xmppConnectionService.insertWebxdcUpdate(new WebxdcUpdate(
+				(Conversation) message.getConversation(),
+				selfJid(),
+				message.getThread(),
+				params.optString("info", null),
+				params.optString("document", null),
+				params.optString("summary", null),
+				payload
+			));
+			binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
+			return true;
+		}
+
+		@JavascriptInterface
+		public String getStatusUpdates(long lastKnownSerial) {
+			StringBuilder builder = new StringBuilder("[");
+			String sep = "";
+			for (WebxdcUpdate update : xmppConnectionService.findWebxdcUpdates(source, lastKnownSerial)) {
+				builder.append(sep);
+				builder.append(update.toString());
+				sep = ",";
+			}
+			builder.append("]");
+			return builder.toString();
+		}
+	}
+}

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

@@ -0,0 +1,98 @@
+package com.cheogram.android;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+
+import org.json.JSONObject;
+
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xmpp.Jid;
+
+public class WebxdcUpdate {
+	protected final Long serial;
+	protected final Long maxSerial;
+	protected final String conversationId;
+	protected final Jid sender;
+	protected final String thread;
+	protected final String threadParent;
+	protected final String info;
+	protected final String document;
+	protected final String summary;
+	protected final String payload;
+
+	public WebxdcUpdate(final Conversation conversation, final Jid sender, final Element thread, final String info, final String document, final String summary, final String payload) {
+		this.serial = null;
+		this.maxSerial = null;
+		this.conversationId = conversation.getUuid();
+		this.sender = sender;
+		this.thread = thread.getContent();
+		this.threadParent = thread.getAttribute("parent");
+		this.info = info;
+		this.document = document;
+		this.summary = summary;
+		this.payload = payload;
+	}
+
+	public WebxdcUpdate(final Cursor cursor, long maxSerial) {
+		this.maxSerial = maxSerial;
+		this.serial = cursor.getLong(cursor.getColumnIndex("serial"));
+		this.conversationId = cursor.getString(cursor.getColumnIndex(Message.CONVERSATION));
+		this.sender = Jid.of(cursor.getString(cursor.getColumnIndex("sender")));
+		this.thread = cursor.getString(cursor.getColumnIndex("thread"));
+		this.threadParent = cursor.getString(cursor.getColumnIndex("threadParent"));
+		this.info = cursor.getString(cursor.getColumnIndex("threadParent"));
+		this.document = cursor.getString(cursor.getColumnIndex("document"));
+		this.summary = cursor.getString(cursor.getColumnIndex("summary"));
+		this.payload = cursor.getString(cursor.getColumnIndex("payload"));
+	}
+
+	public String getSummary() {
+		return summary;
+	}
+
+	public ContentValues getContentValues() {
+		ContentValues cv = new ContentValues();
+		cv.put(Message.CONVERSATION, conversationId);
+		cv.put("sender", sender.toEscapedString());
+		cv.put("thread", thread);
+		cv.put("threadParent", threadParent);
+		if (info != null) cv.put("info", info);
+		if (document != null) cv.put("document", document);
+		if (summary != null) cv.put("summary", summary);
+		if (payload != null) cv.put("payload", payload);
+		return cv;
+	}
+
+	public String toString() {
+		StringBuilder body = new StringBuilder("{\"sender\":");
+		body.append(JSONObject.quote(sender.toEscapedString()));
+		if (serial != null) {
+			body.append(",\"serial\":");
+			body.append(serial.toString());
+		}
+		if (maxSerial != null) {
+			body.append(",\"max_serial\":");
+			body.append(maxSerial.toString());
+		}
+		if (info != null) {
+			body.append(",\"info\":");
+			body.append(JSONObject.quote(info));
+		}
+		if (document != null) {
+			body.append(",\"document\":");
+			body.append(JSONObject.quote(document));
+		}
+		if (summary != null) {
+			body.append(",\"summary\":");
+			body.append(JSONObject.quote(summary));
+		}
+		if (payload != null) {
+			body.append(",\"payload\":");
+			body.append(payload);
+		}
+		body.append("}");
+		return body.toString();
+	}
+}

src/cheogram/res/layout/webxdc_page.xml 🔗

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <RelativeLayout
+        android:layout_width="fill_parent"
+        android:layout_height="fill_parent">
+
+        <WebView
+            android:id="@+id/webview"
+            android:paddingTop="8dp"
+            android:layout_above="@+id/actions"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+
+        <GridView
+            android:id="@+id/actions"
+            android:background="@color/perpy"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_alignParentStart="true"
+            android:layout_alignParentLeft="true"
+            android:layout_alignParentBottom="true"
+            android:horizontalSpacing="0dp"
+            android:verticalSpacing="0dp"
+            android:numColumns="1" />
+
+    </RelativeLayout>
+</layout>

src/cheogram/res/raw/webxdc.js 🔗

@@ -0,0 +1,40 @@
+// Based on GPLv3 code from deltachat-android
+// https://github.com/deltachat/deltachat-android/blob/master/res/raw/webxdc.js
+
+window.webxdc = (() => {
+	let setUpdateListenerPromise = null
+	var update_listener = () => {};
+	var last_serial = 0;
+
+	window.__webxdcUpdate = () => {
+		var updates = JSON.parse(InternalJSApi.getStatusUpdates(last_serial));
+		updates.forEach((update) => {
+				update_listener(update);
+				last_serial = update.serial;
+		});
+		if (setUpdateListenerPromise) {
+			setUpdateListenerPromise();
+			setUpdateListenerPromise = null;
+		}
+	};
+
+	return {
+		selfAddr: InternalJSApi.selfAddr(),
+
+		selfName: InternalJSApi.selfName(),
+
+		setUpdateListener: (cb, serial) => {
+				last_serial = typeof serial === "undefined" ? 0 : parseInt(serial);
+				update_listener = cb;
+				var promise = new Promise((res, _rej) => {
+					setUpdateListenerPromise = res;
+				});
+				window.__webxdcUpdate();
+				return promise;
+		},
+
+		sendUpdate: (payload, descr) => {
+			InternalJSApi.sendStatusUpdate(JSON.stringify(payload), descr);
+		},
+	};
+})();

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

@@ -57,12 +57,17 @@ import androidx.viewpager.widget.ViewPager;
 
 import com.caverock.androidsvg.SVG;
 
+import com.cheogram.android.ConversationPage;
+import com.cheogram.android.WebxdcPage;
+
 import com.google.android.material.tabs.TabLayout;
 import com.google.android.material.textfield.TextInputLayout;
 import com.google.common.base.Optional;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.Lists;
 
+import io.ipfs.cid.Cid;
+
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -107,6 +112,7 @@ import eu.siacs.conversations.ui.UriHandlerActivity;
 import eu.siacs.conversations.ui.text.FixedURLSpan;
 import eu.siacs.conversations.ui.util.ShareUtil;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
+import eu.siacs.conversations.utils.Consumer;
 import eu.siacs.conversations.utils.JidHelper;
 import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.UIHelper;
@@ -1299,6 +1305,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return 1;
     }
 
+    public void refreshSessions() {
+        pagerAdapter.refreshSessions();
+    }
+
+    public void startWebxdc(Cid cid, Message message, XmppConnectionService xmppConnectionService) {
+        pagerAdapter.startWebxdc(cid, message, xmppConnectionService);
+    }
+
     public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
         pagerAdapter.startCommand(command, xmppConnectionService);
     }
@@ -1344,7 +1358,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     public class ConversationPagerAdapter extends PagerAdapter {
         protected ViewPager mPager = null;
         protected TabLayout mTabs = null;
-        ArrayList<CommandSession> sessions = null;
+        ArrayList<ConversationPage> sessions = null;
         protected View page1 = null;
         protected View page2 = null;
         protected boolean mOnboarding = false;
@@ -1391,6 +1405,21 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             notifyDataSetChanged();
         }
 
+        public void refreshSessions() {
+            if (sessions == null) return;
+
+            for (ConversationPage session : sessions) {
+                session.refresh();
+            }
+        }
+
+        public void startWebxdc(Cid cid, Message message, XmppConnectionService xmppConnectionService) {
+            show();
+            sessions.add(new WebxdcPage(cid, message, xmppConnectionService));
+            notifyDataSetChanged();
+            if (mPager != null) mPager.setCurrentItem(getCount() - 1);
+        }
+
         public void startCommand(Element command, XmppConnectionService xmppConnectionService) {
             show();
             CommandSession session = new CommandSession(command.getAttribute("name"), command.getAttribute("node"), xmppConnectionService);
@@ -1432,7 +1461,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             if (mPager != null) mPager.setCurrentItem(getCount() - 1);
         }
 
-        public void removeSession(CommandSession session) {
+        public void removeSession(ConversationPage session) {
             sessions.remove(session);
             notifyDataSetChanged();
         }
@@ -1441,8 +1470,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             if (sessions == null) return false;
 
             int i = 0;
-            for (CommandSession session : sessions) {
-                if (session.mNode.equals(node)) {
+            for (ConversationPage session : sessions) {
+                if (session.getNode().equals(node)) {
                     if (mPager != null) mPager.setCurrentItem(i + 2);
                     return true;
                 }
@@ -1464,10 +1493,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 return page2;
             }
 
-            CommandSession session = sessions.get(position-2);
-            CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_page, container, false);
-            container.addView(binding.getRoot());
-            session.setBinding(binding);
+            ConversationPage session = sessions.get(position-2);
+            container.addView(session.inflateUi(container.getContext(), (s) -> removeSession(s)));
             return session;
         }
 
@@ -1478,7 +1505,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 return;
             }
 
-            container.removeView(((CommandSession) o).getView());
+            container.removeView(((ConversationPage) o).getView());
         }
 
         @Override
@@ -1512,8 +1539,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
             if (view == o) return true;
 
-            if (o instanceof CommandSession) {
-                return ((CommandSession) o).getView() == view;
+            if (o instanceof ConversationPage) {
+                return ((ConversationPage) o).getView() == view;
             }
 
             return false;
@@ -1528,13 +1555,13 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 case 1:
                     return "Commands";
                 default:
-                    CommandSession session = sessions.get(position-2);
+                    ConversationPage session = sessions.get(position-2);
                     if (session == null) return super.getPageTitle(position);
                     return session.getTitle();
             }
         }
 
-        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> {
+        class CommandSession extends RecyclerView.Adapter<CommandSession.ViewHolder> implements ConversationPage {
             abstract class ViewHolder<T extends ViewDataBinding> extends RecyclerView.ViewHolder {
                 protected T binding;
 
@@ -2434,6 +2461,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 return mTitle;
             }
 
+            public String getNode() {
+                return mNode;
+            }
+
             public void updateWithResponse(final IqPacket iq) {
                 if (getView() != null && getView().isAttachedToWindow()) {
                     getView().post(() -> updateWithResponseUiThread(iq));
@@ -2853,6 +2884,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 return false;
             }
 
+            public void refresh() { }
+
             protected void loading() {
                 View v = getView();
                 loadingTimer.schedule(new TimerTask() {
@@ -2898,7 +2931,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 return layoutManager;
             }
 
-            public void setBinding(CommandPageBinding b) {
+            protected void setBinding(CommandPageBinding b) {
                 mBinding = b;
                 // https://stackoverflow.com/a/32350474/8611
                 mBinding.form.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
@@ -2951,6 +2984,12 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 }
             }
 
+            public View inflateUi(Context context, Consumer<ConversationPage> remover) {
+                CommandPageBinding binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.command_page, null, false);
+                setBinding(binding);
+                return binding.getRoot();
+            }
+
             // https://stackoverflow.com/a/36037991/8611
             private View findViewAt(ViewGroup viewGroup, float x, float y) {
                 for(int i = 0; i < viewGroup.getChildCount(); i++) {

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

@@ -4,6 +4,7 @@ import android.util.Log;
 import android.util.Pair;
 
 import com.cheogram.android.BobTransfer;
+import com.cheogram.android.WebxdcUpdate;
 
 import java.io.File;
 import java.net.URISyntaxException;
@@ -521,6 +522,30 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
             }
         }
 
+        final Element webxdc = packet.findChild("x", "urn:xmpp:webxdc:0");
+        if (webxdc != null) {
+            final Conversation conversation = mXmppConnectionService.find(account, counterpart.asBareJid());
+            Jid webxdcSender = counterpart.asBareJid();
+            if (conversation.getMode() == Conversation.MODE_MULTI) {
+                if(conversation.getMucOptions().nonanonymous()) {
+                    webxdcSender = conversation.getMucOptions().getTrueCounterpart(counterpart);
+                } else {
+                    webxdcSender = counterpart;
+                }
+            }
+            mXmppConnectionService.insertWebxdcUpdate(new WebxdcUpdate(
+                conversation,
+                counterpart,
+                packet.findChild("thread"),
+                body == null ? null : body.content,
+                webxdc.findChildContent("document", "urn:xmpp:webxdc:0"),
+                webxdc.findChildContent("summary", "urn:xmpp:webxdc:0"),
+                webxdc.findChildContent("json", "urn:xmpp:json:0")
+            ));
+
+            mXmppConnectionService.updateConversationUi();
+        }
+
         if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || !attachments.isEmpty() || html != null) && !isMucStatusMessage) {
             final boolean conversationIsProbablyMuc = isTypeGroupChat || mucUserElement != null || account.getXmppConnection().getMucServersWithholdAccount().contains(counterpart.getDomain().toEscapedString());
             final Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, counterpart.asBareJid(), conversationIsProbablyMuc, false, query, false);

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

@@ -11,6 +11,8 @@ import android.os.SystemClock;
 import android.util.Base64;
 import android.util.Log;
 
+import com.cheogram.android.WebxdcUpdate;
+
 import com.google.common.base.Stopwatch;
 
 import org.json.JSONException;
@@ -291,6 +293,24 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 db.execSQL("PRAGMA cheogram.user_version = 7");
             }
 
+            if(cheogramVersion < 8) {
+                db.execSQL(
+                    "CREATE TABLE cheogram.webxdc_updates (" +
+                    "serial INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                    Message.CONVERSATION + " TEXT NOT NULL, " +
+                    "sender TEXT NOT NULL, " +
+                    "thread TEXT NOT NULL, " +
+                    "threadParent TEXT, " +
+                    "info TEXT, " +
+                    "document TEXT, " +
+                    "summary TEXT, " +
+                    "payload TEXT" +
+                    ")"
+                );
+                db.execSQL("CREATE INDEX cheogram.webxdc_index ON webxdc_updates (" + Message.CONVERSATION + ", thread)");
+                db.execSQL("PRAGMA cheogram.user_version = 8");
+				}
+
             db.setTransactionSuccessful();
         } finally {
             db.endTransaction();
@@ -832,6 +852,46 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         db.execSQL("DELETE FROM cheogram.blocked_media");
     }
 
+    public void insertWebxdcUpdate(final WebxdcUpdate update) {
+        SQLiteDatabase db = this.getWritableDatabase();
+        db.insert("cheogram.webxdc_updates", null, update.getContentValues());
+    }
+
+    public WebxdcUpdate findLastWebxdcUpdate(Message message) {
+        SQLiteDatabase db = this.getReadableDatabase();
+        String[] selectionArgs = {message.getConversation().getUuid(), message.getThread().getContent()};
+        Cursor cursor = db.query("cheogram.webxdc_updates", null,
+                Message.CONVERSATION + "=? AND thread=?",
+                selectionArgs, null, null, null);
+        WebxdcUpdate update = null;
+        if (cursor.moveToLast()) {
+            update = new WebxdcUpdate(cursor, cursor.getLong(cursor.getColumnIndex("serial")));
+        }
+        cursor.close();
+        return update;
+    }
+
+    public List<WebxdcUpdate> findWebxdcUpdates(Message message, long serial) {
+        SQLiteDatabase db = this.getReadableDatabase();
+        String[] selectionArgs = {message.getConversation().getUuid(), message.getThread().getContent(), String.valueOf(serial)};
+        Cursor cursor = db.query("cheogram.webxdc_updates", null,
+                Message.CONVERSATION + "=? AND thread=? AND serial>?",
+                selectionArgs, null, null, null);
+        long maxSerial = 0;
+        if (cursor.moveToLast()) {
+            maxSerial = cursor.getLong(cursor.getColumnIndex("serial"));
+        }
+        cursor.moveToFirst();
+        cursor.moveToPrevious();
+
+        List<WebxdcUpdate> updates = new ArrayList<>();
+        while (cursor.moveToNext()) {
+            updates.add(new WebxdcUpdate(cursor, maxSerial));
+        }
+        cursor.close();
+        return updates;
+    }
+
     public void createConversation(Conversation conversation) {
         SQLiteDatabase db = this.getWritableDatabase();
         db.insert(Conversation.TABLENAME, null, conversation.getContentValues());

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

@@ -54,6 +54,8 @@ import androidx.annotation.NonNull;
 import androidx.core.app.RemoteInput;
 import androidx.core.content.ContextCompat;
 
+import com.cheogram.android.WebxdcUpdate;
+
 import com.google.common.base.Objects;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
@@ -596,6 +598,18 @@ public class XmppConnectionService extends Service {
         this.databaseBackend.clearBlockedMedia();
     }
 
+    public void insertWebxdcUpdate(final WebxdcUpdate update) {
+        this.databaseBackend.insertWebxdcUpdate(update);
+    }
+
+    public WebxdcUpdate findLastWebxdcUpdate(Message message) {
+        return this.databaseBackend.findLastWebxdcUpdate(message);
+    }
+
+    public List<WebxdcUpdate> findWebxdcUpdates(Message message, long serial) {
+        return this.databaseBackend.findWebxdcUpdates(message, serial);
+    }
+
     public AvatarService getAvatarService() {
         return this.mAvatarService;
     }

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

@@ -790,6 +790,7 @@ public class ConversationFragment extends XmppFragment
         if (conversation == null) {
             return;
         }
+        if (type == "application/xdc+zip") newSubThread();
         final Toast prepareFileToast =
                 Toast.makeText(getActivity(), getText(R.string.preparing_file), Toast.LENGTH_LONG);
         prepareFileToast.show();
@@ -2345,6 +2346,14 @@ public class ConversationFragment extends XmppFragment
         }
     }
 
+    private void newSubThread() {
+        Element oldThread = conversation.getThread();
+        Element thread = new Element("thread", "jabber:client");
+        thread.setContent(UUID.randomUUID().toString());
+        if (oldThread != null) thread.setAttribute("parent", oldThread.getContent());
+        setThread(thread);
+    }
+
     private void newThread() {
         Element thread = new Element("thread", "jabber:client");
         thread.setContent(UUID.randomUUID().toString());
@@ -3259,6 +3268,7 @@ public class ConversationFragment extends XmppFragment
                 updateEditablity();
             }
         }
+        conversation.refreshSessions();
     }
 
     protected void messageSent() {

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

@@ -42,6 +42,7 @@ import androidx.core.content.res.ResourcesCompat;
 
 import com.cheogram.android.BobTransfer;
 import com.cheogram.android.SwipeDetector;
+import com.cheogram.android.WebxdcUpdate;
 
 import com.google.common.base.Strings;
 
@@ -673,6 +674,25 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.download_button.setOnClickListener(v -> ConversationFragment.downloadFile(activity, message));
     }
 
+    private void displayWebxdcMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground, final int type) {
+        displayTextMessage(viewHolder, message, darkBackground, type);
+        viewHolder.image.setVisibility(View.GONE);
+        viewHolder.audioPlayer.setVisibility(View.GONE);
+        viewHolder.download_button.setVisibility(View.VISIBLE);
+        viewHolder.download_button.setText("Open ChatApp");
+        viewHolder.download_button.setOnClickListener(v -> {
+            Conversation conversation = (Conversation) message.getConversation();
+            if (!conversation.switchToSession("webxdc\0" + message.getUuid())) {
+                conversation.startWebxdc(message.getFileParams().getCids().get(0), message, activity.xmppConnectionService);
+            }
+        });
+        WebxdcUpdate lastUpdate = activity.xmppConnectionService.findLastWebxdcUpdate(message);
+        if (lastUpdate != null && lastUpdate.getSummary() != null) {
+            viewHolder.messageBody.setVisibility(View.VISIBLE);
+            viewHolder.messageBody.setText(lastUpdate.getSummary());
+        }
+    }
+
     private void displayOpenableMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground, final int type) {
         displayTextMessage(viewHolder, message, darkBackground, type);
         viewHolder.image.setVisibility(View.GONE);
@@ -982,6 +1002,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 displayMediaPreviewMessage(viewHolder, message, darkBackground, type);
             } else if (message.getFileParams().runtime > 0) {
                 displayAudioMessage(viewHolder, message, darkBackground, type);
+            } else if ("application/xdc+zip".equals(message.getFileParams().getMediaType()) && message.getConversation() instanceof Conversation) {
+                displayWebxdcMessage(viewHolder, message, darkBackground, type);
             } else {
                 displayOpenableMessage(viewHolder, message, darkBackground, type);
             }

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

@@ -233,6 +233,7 @@ public final class MimeUtils {
         add("application/x-x509-server-cert", "crt");
         add("application/x-xcf", "xcf");
         add("application/x-xfig", "fig");
+        add("application/xdc+zip", "xdc");
         add("application/xhtml+xml", "xhtml");
         add("video/3gpp", "3gpp");
         add("video/3gpp", "3gp");
@@ -337,6 +338,7 @@ public final class MimeUtils {
         add("text/html", "html");
         add("text/h323", "323");
         add("text/iuls", "uls");
+        add("text/javascript", "js");
         add("text/mathml", "mml");
         // add ".txt" first so it will be the default for guessExtensionFromMimeType
         add("text/plain", "txt");
@@ -589,7 +591,7 @@ public final class MimeUtils {
         return null;
     }
 
-    private static String guessFromPath(final String path) {
+    public static String guessFromPath(final String path) {
         final int start = path.lastIndexOf('.') + 1;
         if (start < path.length()) {
             return MimeUtils.guessMimeTypeFromExtension(path.substring(start));