~singpolyma/cheogram-android

4bb8f0ae94d40dfb45755f3a68258b64ac2666e8 — Stephen Paul Weber 11 months ago 3ec4ee9
Initial WebXDC prototype
A src/cheogram/java/com/cheogram/android/ConversationPage.java => src/cheogram/java/com/cheogram/android/ConversationPage.java +14 -0
@@ 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();
}

A src/cheogram/java/com/cheogram/android/WebxdcPage.java => src/cheogram/java/com/cheogram/android/WebxdcPage.java +321 -0
@@ 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();
		}
	}
}

A src/cheogram/java/com/cheogram/android/WebxdcUpdate.java => src/cheogram/java/com/cheogram/android/WebxdcUpdate.java +98 -0
@@ 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();
	}
}

A src/cheogram/res/layout/webxdc_page.xml => src/cheogram/res/layout/webxdc_page.xml +28 -0
@@ 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>

A src/cheogram/res/raw/webxdc.js => src/cheogram/res/raw/webxdc.js +40 -0
@@ 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);
		},
	};
})();

M src/main/java/eu/siacs/conversations/entities/Conversation.java => src/main/java/eu/siacs/conversations/entities/Conversation.java +53 -14
@@ 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++) {

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

M src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java => src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +60 -0
@@ 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());

M src/main/java/eu/siacs/conversations/services/XmppConnectionService.java => src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +14 -0
@@ 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;
    }

M src/main/java/eu/siacs/conversations/ui/ConversationFragment.java => src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +10 -0
@@ 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() {

M src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java => src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +22 -0
@@ 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);
            }

M src/main/java/eu/siacs/conversations/utils/MimeUtils.java => src/main/java/eu/siacs/conversations/utils/MimeUtils.java +3 -1
@@ 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));