Initial WebXDC prototype (Cheogram)

This commit is contained in:
Arne 2023-05-24 17:45:07 +02:00
parent 3896c221d1
commit ac0558c5da
13 changed files with 710 additions and 14 deletions

View file

@ -170,8 +170,8 @@ android {
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_9
targetCompatibility JavaVersion.VERSION_1_9
}
flavorDimensions("distribution")

View file

@ -0,0 +1,14 @@
package de.monocles.chat;
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();
}

View file

@ -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 de.monocles.chat;
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();
}
}
}

View file

@ -0,0 +1,98 @@
package de.monocles.chat;
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();
}
}

View file

@ -54,6 +54,13 @@ import com.caverock.androidsvg.SVG;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.textfield.TextInputLayout;
import com.google.common.base.Optional;
import de.monocles.chat.ConversationPage;
import de.monocles.chat.WebxdcPage;
import eu.siacs.conversations.utils.Consumer;
import io.ipfs.cid.Cid;
import java.util.Timer;
import java.util.TimerTask;
import java.util.stream.Collectors;
@ -297,6 +304,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return messagesLeftOnServer;
}
public boolean switchToSession(final String node) {
return pagerAdapter.switchToSession(node);
}
public void setHasMessagesLeftOnServer(boolean value) {
this.messagesLeftOnServer = value;
}
@ -1589,6 +1600,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);
}
@ -1642,7 +1661,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;
@ -1691,6 +1710,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);
@ -1712,11 +1746,26 @@ 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();
}
public boolean switchToSession(final String node) {
if (sessions == null) return false;
int i = 0;
for (ConversationPage session : sessions) {
if (session.getNode().equals(node)) {
if (mPager != null) mPager.setCurrentItem(i + 2);
return true;
}
i++;
}
return false;
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
@ -1729,10 +1778,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;
}
@ -1742,7 +1789,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
container.removeView((View) o);
return;
}
container.removeView(((CommandSession) o).getView());
container.removeView(((ConversationPage) o).getView());
}
@Override
@ -1776,8 +1823,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;
@ -1792,13 +1839,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;
@ -2728,6 +2775,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return mTitle;
}
public String getNode() {
return mNode;
}
public void updateWithResponse(IqPacket iq) {
this.loadingTimer.cancel();
this.loadingTimer = new Timer();
@ -3110,6 +3161,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return false;
}
public void refresh() { }
protected void loading() {
View v = getView();
loadingTimer.schedule(new TimerTask() {
@ -3157,7 +3210,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() {
@ -3206,6 +3259,12 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
actionsAdapter.notifyDataSetChanged();
}
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++) {

View file

@ -6,6 +6,7 @@ import android.util.Pair;
import de.monocles.chat.BobTransfer;
import de.monocles.chat.WebxdcUpdate;
import java.io.File;
import java.net.URISyntaxException;
@ -617,6 +618,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);

View file

@ -15,6 +15,8 @@ import android.util.Log;
import com.google.common.base.Stopwatch;
import de.monocles.chat.WebxdcUpdate;
import org.json.JSONException;
import org.json.JSONObject;
import org.whispersystems.libsignal.IdentityKey;
@ -301,6 +303,25 @@ public class DatabaseBackend extends SQLiteOpenHelper {
}
if(monoclesVersion < 8) {
db.execSQL(
"CREATE TABLE monocles.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 monocles.webxdc_index ON webxdc_updates (" + Message.CONVERSATION + ", thread)");
db.execSQL("PRAGMA monocles.user_version = 8");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
@ -873,6 +894,46 @@ public class DatabaseBackend extends SQLiteOpenHelper {
db.execSQL("DELETE FROM monocles.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());

View file

@ -88,6 +88,8 @@ import com.google.common.base.Objects;
import com.google.common.base.Strings;
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy;
import de.monocles.chat.WebxdcUpdate;
import org.conscrypt.Conscrypt;
import org.openintents.openpgp.IOpenPgpService2;
import org.openintents.openpgp.util.OpenPgpApi;
@ -612,6 +614,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;
}

View file

@ -828,6 +828,7 @@ public class ConversationFragment extends XmppFragment
if (conversation == null) {
return;
}
if (type == "application/xdc+zip") newSubThread();
final Toast prepareFileToast = ToastCompat.makeText(getActivity(), getText(R.string.preparing_file), ToastCompat.LENGTH_SHORT);
prepareFileToast.show();
activity.delegateUriPermissionsToService(uri);
@ -2556,6 +2557,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());
@ -3554,6 +3563,7 @@ public class ConversationFragment extends XmppFragment
updateEditablity();
}
}
conversation.refreshSessions();
}
protected void messageSent() {

View file

@ -88,6 +88,8 @@ import java.io.IOException;
import java.net.URISyntaxException;
import java.security.NoSuchAlgorithmException;
import de.monocles.chat.WebxdcUpdate;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.Roster;
@ -837,6 +839,26 @@ 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.download_button.setVisibility(View.VISIBLE);
@ -1472,6 +1494,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
displayMediaPreviewMessage(viewHolder, message, darkBackground, type);
} else if (message.getFileParams().runtime > 0 && (message.getFileParams().width == 0 && message.getFileParams().height == 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);
}

View file

@ -234,6 +234,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");
@ -338,6 +339,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");

View file

@ -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/primary_monocles"
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>

View file

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