diff --git a/src/cheogram/java/com/cheogram/android/ConversationPage.java b/src/cheogram/java/com/cheogram/android/ConversationPage.java new file mode 100644 index 0000000000000000000000000000000000000000..e37ae1e1bc2da1479c5c956e0d0afa8e1e94be34 --- /dev/null +++ b/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 remover); + public View getView(); + public void refresh(); +} diff --git a/src/cheogram/java/com/cheogram/android/WebxdcPage.java b/src/cheogram/java/com/cheogram/android/WebxdcPage.java new file mode 100644 index 0000000000000000000000000000000000000000..6174de0ad9f995ef31318adb455ad025a5bb21ff --- /dev/null +++ b/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 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 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(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(); + } + } +} diff --git a/src/cheogram/java/com/cheogram/android/WebxdcUpdate.java b/src/cheogram/java/com/cheogram/android/WebxdcUpdate.java new file mode 100644 index 0000000000000000000000000000000000000000..4e8168f1ed70df52971d3d502c44c9e90347b61f --- /dev/null +++ b/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(); + } +} diff --git a/src/cheogram/res/layout/webxdc_page.xml b/src/cheogram/res/layout/webxdc_page.xml new file mode 100644 index 0000000000000000000000000000000000000000..87def9cef099eda1a99711c4d448a8dd5f1c1507 --- /dev/null +++ b/src/cheogram/res/layout/webxdc_page.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/src/cheogram/res/raw/webxdc.js b/src/cheogram/res/raw/webxdc.js new file mode 100644 index 0000000000000000000000000000000000000000..ef789d1bdb7c294d1d8dc6594d62d2896f89f3e3 --- /dev/null +++ b/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); + }, + }; +})(); diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 5672e91c991de63d3a4c18fa4c2d577bcab79b33..5f79edab0e0ce6f23b6642799ac55613137e4270 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/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 sessions = null; + ArrayList 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 { + class CommandSession extends RecyclerView.Adapter implements ConversationPage { abstract class ViewHolder 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 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++) { diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index d42ff49be9da15ff229d1b30ea8e546bcf09e80b..df12190f8b0add19a2c45c4ecd961d1090a2391f 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/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); diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index 5af757bcbe2fb46a344802ef6c6938461cdf60aa..fbe560fa71dfcc5d8d220b4d1b7260a6b1f4ac89 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/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 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 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()); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 15dbbb284fa78c712c468e87f861c3ad9ba5f5cd..4a266598140716a51bf7c57c6bc43420fe6edb3c 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/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 findWebxdcUpdates(Message message, long serial) { + return this.databaseBackend.findWebxdcUpdates(message, serial); + } + public AvatarService getAvatarService() { return this.mAvatarService; } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 2ba1a423d5bb76d7435a45b8b01377b6b235625f..54b67886be823d9f0f61d40338c927458c3a63ea 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/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() { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java index 0369c35184da08f00cd2fc7e72f677e6551a43e8..1ad527cce616b64c094ee3a67a217729ac934d76 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/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 { 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 { 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); } diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java index bcaa9cb4b06a4b3beaf87dc344f58537af726f94..92a0fa6868ae20aa11e3dae0fdead23d57674e1e 100644 --- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java +++ b/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));