diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 73f4c7553..78bc56328 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -277,6 +277,11 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"> + + + diff --git a/jni/dc_wrapper.c b/jni/dc_wrapper.c index 7129ec1e3..90563b8c1 100644 --- a/jni/dc_wrapper.c +++ b/jni/dc_wrapper.c @@ -86,6 +86,19 @@ static jstring jstring_new__(JNIEnv* env, const char* a) #define CTIMESTAMP(a) (((jlong)a)/((jlong)1000)) +static jbyteArray ptr2jbyteArray(JNIEnv *env, const void* ptr, size_t len) { + if (ptr == NULL || len <= 0) { + return NULL; + } + jbyteArray ret = (*env)->NewByteArray(env, len); + if (ret == NULL) { + return NULL; + } + (*env)->SetByteArrayRegion(env, ret, 0, len, (const jbyte*)ptr); + return ret; +} + + static jintArray dc_array2jintArray_n_unref(JNIEnv *env, dc_array_t* ca) { /* takes a C-array of type dc_array_t and converts it it a Java-Array. @@ -704,6 +717,26 @@ JNIEXPORT jint Java_com_b44t_messenger_DcContext_sendVideochatInvitation(JNIEnv } +JNIEXPORT jboolean Java_com_b44t_messenger_DcContext_sendWebxdcStatusUpdate(JNIEnv *env, jobject obj, jint msg_id, jstring payload, jstring descr) +{ + CHAR_REF(payload); + CHAR_REF(descr); + jboolean ret = dc_send_webxdc_status_update(get_dc_context(env, obj), msg_id, payloadPtr, descrPtr) != 0; + CHAR_UNREF(descr); + CHAR_UNREF(payload); + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcContext_getWebxdcStatusUpdates(JNIEnv *env, jobject obj, jint msg_id, jint status_update_id) +{ + char* temp = dc_get_webxdc_status_updates(get_dc_context(env, obj), msg_id, status_update_id); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + JNIEXPORT jint Java_com_b44t_messenger_DcContext_addDeviceMsg(JNIEnv *env, jobject obj, jstring label, jobject msg) { CHAR_REF(label); @@ -1528,6 +1561,28 @@ JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getFilename(JNIEnv *env, jobject } +JNIEXPORT jbyteArray Java_com_b44t_messenger_DcMsg_getWebxdcBlob(JNIEnv *env, jobject obj, jstring filename) +{ + jbyteArray ret = NULL; + CHAR_REF(filename) + size_t ptrSize = 0; + char* ptr = dc_msg_get_webxdc_blob(get_dc_msg(env, obj), filenamePtr, &ptrSize); + ret = ptr2jbyteArray(env, ptr, ptrSize); + dc_str_unref(ptr); + CHAR_UNREF(filename) + return ret; +} + + +JNIEXPORT jstring Java_com_b44t_messenger_DcMsg_getWebxdcInfoJson(JNIEnv *env, jobject obj) +{ + char* temp = dc_msg_get_webxdc_info(get_dc_msg(env, obj)); + jstring ret = JSTRING_NEW(temp); + dc_str_unref(temp); + return ret; +} + + JNIEXPORT jboolean Java_com_b44t_messenger_DcMsg_isForwarded(JNIEnv *env, jobject obj) { return dc_msg_is_forwarded(get_dc_msg(env, obj))!=0; diff --git a/res/layout/conversation_activity_attachment_editor_stub.xml b/res/layout/conversation_activity_attachment_editor_stub.xml index 16b868210..40b60e5f6 100644 --- a/res/layout/conversation_activity_attachment_editor_stub.xml +++ b/res/layout/conversation_activity_attachment_editor_stub.xml @@ -58,6 +58,17 @@ android:padding="8dp" android:background="@drawable/message_bubble_background_sent_alone"/> + + diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index b645ef64e..0cf64875f 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -143,6 +143,16 @@ android:layout_marginLeft="@dimen/message_bubble_horizontal_padding" android:layout_marginRight="@dimen/message_bubble_horizontal_padding" /> + + + + + diff --git a/res/layout/webxdc_view.xml b/res/layout/webxdc_view.xml new file mode 100644 index 000000000..d7191df7e --- /dev/null +++ b/res/layout/webxdc_view.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + diff --git a/res/raw/webxdc.js b/res/raw/webxdc.js new file mode 100644 index 000000000..249d5b0dc --- /dev/null +++ b/res/raw/webxdc.js @@ -0,0 +1,26 @@ +window.webxdc = (() => { + var update_listener = () => {}; + + window.__webxdcUpdate = (updateId) => { + var updates = JSON.parse(InternalJSApi.getStatusUpdates(updateId)); + if (updates.length === 1) { + update_listener(updates[0]); + } + }; + + return { + selfAddr: () => InternalJSApi.selfAddr(), + + selfName: () => InternalJSApi.selfName(), + + setUpdateListener: (cb) => (update_listener = cb), + + getAllUpdates: () => { + return JSON.parse(InternalJSApi.getStatusUpdates(0)); + }, + + sendUpdate: (payload, descr) => { + InternalJSApi.sendStatusUpdate(JSON.stringify(payload), descr); + }, + }; +})(); diff --git a/src/com/b44t/messenger/DcContext.java b/src/com/b44t/messenger/DcContext.java index a74b2fb31..25303c190 100644 --- a/src/com/b44t/messenger/DcContext.java +++ b/src/com/b44t/messenger/DcContext.java @@ -27,6 +27,7 @@ public class DcContext { public final static int DC_EVENT_SECUREJOIN_JOINER_PROGRESS = 2061; public final static int DC_EVENT_CONNECTIVITY_CHANGED = 2100; public final static int DC_EVENT_SELFAVATAR_CHANGED = 2110; + public final static int DC_EVENT_WEBXDC_STATUS_UPDATE = 2120; public final static int DC_IMEX_EXPORT_SELF_KEYS = 1; public final static int DC_IMEX_IMPORT_SELF_KEYS = 2; @@ -195,6 +196,8 @@ public class DcContext { public native int sendMsg (int chat_id, DcMsg msg); public native int sendTextMsg (int chat_id, String text); public native int sendVideochatInvitation(int chat_id); + public native boolean sendWebxdcStatusUpdate(int msg_id, String payload, String descr); + public native String getWebxdcStatusUpdates(int msg_id, int status_update_id); public native int addDeviceMsg (String label, DcMsg msg); public native boolean wasDeviceMsgEverAdded(String label); public DcLot checkQr (String qr) { return new DcLot(checkQrCPtr(qr)); } diff --git a/src/com/b44t/messenger/DcMsg.java b/src/com/b44t/messenger/DcMsg.java index 54e853270..fbf12605d 100644 --- a/src/com/b44t/messenger/DcMsg.java +++ b/src/com/b44t/messenger/DcMsg.java @@ -1,5 +1,8 @@ package com.b44t.messenger; +import org.json.JSONException; +import org.json.JSONObject; + import java.io.File; import java.util.Set; @@ -15,12 +18,14 @@ public class DcMsg { public final static int DC_MSG_VIDEO = 50; public final static int DC_MSG_FILE = 60; public final static int DC_MSG_VIDEOCHAT_INVITATION = 70; + public final static int DC_MSG_WEBXDC = 80; public final static int DC_STATE_UNDEFINED = 0; public final static int DC_STATE_IN_FRESH = 10; public final static int DC_STATE_IN_NOTICED = 13; public final static int DC_STATE_IN_SEEN = 16; public final static int DC_STATE_OUT_PREPARING = 18; + public final static int DC_STATE_OUT_DRAFT = 19; public final static int DC_STATE_OUT_PENDING = 20; public final static int DC_STATE_OUT_ERROR = 24; public final static int DC_STATE_OUT_DELIVERED = 26; @@ -110,6 +115,15 @@ public class DcMsg { public native String getFilemime (); public native String getFilename (); public native long getFilebytes (); + public native byte[] getWebxdcBlob (String filename); + public JSONObject getWebxdcInfo () { + try { + return new JSONObject(getWebxdcInfoJson()); + } catch(Exception e) { + e.printStackTrace(); + return new JSONObject(); + } + } public native boolean isForwarded (); public native boolean isInfo (); public native boolean isSetupMessage (); @@ -206,4 +220,5 @@ public class DcMsg { private native long getSummaryCPtr (long chatCPtr); private native void setQuoteCPtr (long quoteCPtr); private native long getQuotedMsgCPtr (); + private native String getWebxdcInfoJson (); }; diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index 9443e0b28..65ab38397 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -46,6 +46,7 @@ import android.view.View.OnFocusChangeListener; import android.view.View.OnKeyListener; import android.view.WindowManager; import android.view.inputmethod.EditorInfo; +import android.webkit.MimeTypeMap; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; @@ -836,6 +837,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity case DcMsg.DC_MSG_VIDEO: setMedia(uri, MediaType.VIDEO).addListener(listener); break; + case DcMsg.DC_MSG_WEBXDC: + setMedia(draft, MediaType.DOCUMENT).addListener(listener); + break; default: setMedia(uri, MediaType.DOCUMENT).addListener(listener); break; @@ -1012,7 +1016,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return new SettableFuture<>(false); } - return attachmentManager.setMedia(glideRequests, uri, mediaType, 0, 0); + return attachmentManager.setMedia(glideRequests, uri, null, mediaType, 0, 0, chatId); + } + + private ListenableFuture setMedia(DcMsg msg, @NonNull MediaType mediaType) { + return attachmentManager.setMedia(glideRequests, Uri.fromFile(new File(msg.getFile())), msg, mediaType, 0, 0, chatId); } private void addAttachmentContactInfo(Intent data) { @@ -1029,7 +1037,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return dcChat.getVisibility() == DcChat.DC_CHAT_VISIBILITY_ARCHIVED; } - private String getRealPathFromAttachment(Attachment attachment) { + public static String getRealPathFromAttachment(Context context, Attachment attachment) { try { // get file in the blobdir as `/[-].` String filename = attachment.getFileName(); @@ -1045,11 +1053,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity filename = filename.substring(0, i); } } - String path = DcHelper.getBlobdirFile(dcContext, filename, ext); + String path = DcHelper.getBlobdirFile(DcHelper.getContext(context), filename, ext); // copy content to this file if(path!=null) { - InputStream inputStream = PartAuthority.getAttachmentStream(this, attachment.getDataUri()); + InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.getDataUri()); OutputStream outputStream = new FileOutputStream(path); Util.copy(inputStream, outputStream); } @@ -1095,29 +1103,30 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } try { - List attachments = slideDeck.asAttachments(); - for (Attachment attachment : attachments) { - String contentType = attachment.getContentType(); - if (MediaUtil.isImageType(contentType) && slideDeck.getDocumentSlide()==null) { - msg = new DcMsg(dcContext, - MediaUtil.isGif(contentType) ? DcMsg.DC_MSG_GIF : DcMsg.DC_MSG_IMAGE); - msg.setDimension(attachment.getWidth(), attachment.getHeight()); + if (slideDeck.getWebxdctDraftId() != 0) { + msg = dcContext.getDraft(chatId); + } else { + List attachments = slideDeck.asAttachments(); + for (Attachment attachment : attachments) { + String contentType = attachment.getContentType(); + if (MediaUtil.isImageType(contentType) && slideDeck.getDocumentSlide() == null) { + msg = new DcMsg(dcContext, + MediaUtil.isGif(contentType) ? DcMsg.DC_MSG_GIF : DcMsg.DC_MSG_IMAGE); + msg.setDimension(attachment.getWidth(), attachment.getHeight()); + } else if (MediaUtil.isAudioType(contentType)) { + msg = new DcMsg(dcContext, + attachment.isVoiceNote() ? DcMsg.DC_MSG_VOICE : DcMsg.DC_MSG_AUDIO); + } else if (MediaUtil.isVideoType(contentType) && slideDeck.getDocumentSlide() == null) { + msg = new DcMsg(dcContext, DcMsg.DC_MSG_VIDEO); + recompress = DcMsg.DC_MSG_VIDEO; + } else { + msg = new DcMsg(dcContext, DcMsg.DC_MSG_FILE); + } + String path = getRealPathFromAttachment(this, attachment); + msg.setFile(path, null); } - else if (MediaUtil.isAudioType(contentType)) { - msg = new DcMsg(dcContext, - attachment.isVoiceNote()? DcMsg.DC_MSG_VOICE : DcMsg.DC_MSG_AUDIO); - } - else if (MediaUtil.isVideoType(contentType) && slideDeck.getDocumentSlide()==null) { - msg = new DcMsg(dcContext, DcMsg.DC_MSG_VIDEO); - recompress = DcMsg.DC_MSG_VIDEO; - } - else { - msg = new DcMsg(dcContext, DcMsg.DC_MSG_FILE); - } - String path = getRealPathFromAttachment(attachment); - msg.setFile(path, null); - msg.setText(body); } + msg.setText(body); } catch(Exception e) { e.printStackTrace(); @@ -1140,7 +1149,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity DcMsg msg = (DcMsg)param[0]; Integer recompress = (Integer)param[1]; if (action==ACTION_SEND_OUT) { - dcContext.setDraft(dcChat.getId(), null); + + // for WEBXDC, drafts are just sent out as is. + // for preparations and other cases, cleanup draft soon. + if (msg.getType() != DcMsg.DC_MSG_WEBXDC) { + dcContext.setDraft(dcChat.getId(), null); + } if(msg!=null) { @@ -1369,7 +1383,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private void sendSticker(@NonNull Uri uri, String contentType) { Attachment attachment = new UriAttachment(uri, null, contentType, AttachmentDatabase.TRANSFER_PROGRESS_STARTED, 0, 0, 0, null, null, false); - String path = getRealPathFromAttachment(attachment); + String path = getRealPathFromAttachment(this, attachment); Optional quote = inputPanel.getQuote(); inputPanel.clearQuote(); diff --git a/src/org/thoughtcrime/securesms/ConversationItem.java b/src/org/thoughtcrime/securesms/ConversationItem.java index 8e83e134c..70a50d9ee 100644 --- a/src/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/ConversationItem.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.components.ConversationItemFooter; import org.thoughtcrime.securesms.components.ConversationItemThumbnail; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.QuoteView; +import org.thoughtcrime.securesms.components.WebxdcView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.mms.AudioSlide; @@ -107,6 +108,7 @@ public class ConversationItem extends BaseConversationItem private @NonNull Stub mediaThumbnailStub; private @NonNull Stub audioViewStub; private @NonNull Stub documentViewStub; + private @NonNull Stub webxdcViewStub; private Stub stickerStub; private @Nullable EventListener eventListener; @@ -139,6 +141,7 @@ public class ConversationItem extends BaseConversationItem this.mediaThumbnailStub = new Stub<>(findViewById(R.id.image_view_stub)); this.audioViewStub = new Stub<>(findViewById(R.id.audio_view_stub)); this.documentViewStub = new Stub<>(findViewById(R.id.document_view_stub)); + this.webxdcViewStub = new Stub<>(findViewById(R.id.webxdc_view_stub)); this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub)); this.groupSenderHolder = findViewById(R.id.group_sender_holder); this.quoteView = findViewById(R.id.quote_view); @@ -289,6 +292,11 @@ public class ConversationItem extends BaseConversationItem documentViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); documentViewStub.get().setClickable(batchSelected.isEmpty()); } + + if (webxdcViewStub.resolved()) { + webxdcViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty()); + webxdcViewStub.get().setClickable(batchSelected.isEmpty()); + } } private boolean hasAudio(DcMsg messageRecord) { @@ -313,9 +321,14 @@ public class ConversationItem extends BaseConversationItem return hasThumbnail(messageRecord) && !hasAudio(messageRecord) && !hasDocument(messageRecord) && + !hasWebxdc(messageRecord) && !hasSticker(messageRecord); } + private boolean hasWebxdc(DcMsg dcMsg) { + return dcMsg.getType()==DcMsg.DC_MSG_WEBXDC; + } + private boolean hasDocument(DcMsg dcMsg) { return dcMsg.getType()==DcMsg.DC_MSG_FILE && !dcMsg.isSetupMessage(); } @@ -361,6 +374,17 @@ public class ConversationItem extends BaseConversationItem passthroughClickListener.onClick(view); } }); + } else if (messageRecord.getType() == DcMsg.DC_MSG_WEBXDC) { + msgActionButton.setVisibility(View.VISIBLE); + msgActionButton.setEnabled(true); + msgActionButton.setText("Start…"); + msgActionButton.setOnClickListener(view -> { + if (batchSelected.isEmpty()) { + WebxdcActivity.openWebxdcActivity(getContext(), messageRecord); + } else { + passthroughClickListener.onClick(view); + } + }); } else if (messageRecord.hasHtml()) { msgActionButton.setVisibility(View.VISIBLE); @@ -401,6 +425,7 @@ public class ConversationItem extends BaseConversationItem audioViewStub.get().setVisibility(View.VISIBLE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); //noinspection ConstantConditions @@ -422,6 +447,7 @@ public class ConversationItem extends BaseConversationItem documentViewStub.get().setVisibility(View.VISIBLE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); //noinspection ConstantConditions @@ -433,10 +459,26 @@ public class ConversationItem extends BaseConversationItem ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); footer.setVisibility(VISIBLE); } + else if (hasWebxdc(messageRecord)) { + webxdcViewStub.get().setVisibility(View.VISIBLE); + if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); + if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); + if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); + + webxdcViewStub.get().setWebxdc(messageRecord); + webxdcViewStub.get().setWebxdcClickListener(new ThumbnailClickListener()); + webxdcViewStub.get().setOnLongClickListener(passthroughClickListener); + + ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + footer.setVisibility(VISIBLE); + } else if (hasThumbnail(messageRecord)) { mediaThumbnailStub.get().setVisibility(View.VISIBLE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE); Slide slide = MediaUtil.getSlideForMsg(context, messageRecord); @@ -473,6 +515,7 @@ public class ConversationItem extends BaseConversationItem stickerStub.get().setVisibility(View.VISIBLE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); bodyBubble.setBackgroundColor(Color.TRANSPARENT); @@ -492,6 +535,7 @@ public class ConversationItem extends BaseConversationItem if (mediaThumbnailStub.resolved()) mediaThumbnailStub.get().setVisibility(View.GONE); if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE); if (documentViewStub.resolved()) documentViewStub.get().setVisibility(View.GONE); + if (webxdcViewStub.resolved()) webxdcViewStub.get().setVisibility(View.GONE); ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -725,6 +769,8 @@ public class ConversationItem extends BaseConversationItem public void onClick(final View v, final Slide slide) { if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { performClick(); + } else if (messageRecord.getType() == DcMsg.DC_MSG_WEBXDC) { + WebxdcActivity.openWebxdcActivity(context, messageRecord); } else if (MediaPreviewActivity.isTypeSupported(slide) && slide.getUri() != null) { Intent intent = new Intent(context, MediaPreviewActivity.class); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); diff --git a/src/org/thoughtcrime/securesms/ProfileDocumentsFragment.java b/src/org/thoughtcrime/securesms/ProfileDocumentsFragment.java index 10c928de3..0ad7cc88f 100644 --- a/src/org/thoughtcrime/securesms/ProfileDocumentsFragment.java +++ b/src/org/thoughtcrime/securesms/ProfileDocumentsFragment.java @@ -103,7 +103,7 @@ public class ProfileDocumentsFragment @Override public Loader onCreateLoader(int i, Bundle bundle) { - return new BucketedThreadMediaLoader(getContext(), chatId, DcMsg.DC_MSG_FILE, DcMsg.DC_MSG_AUDIO, 0); + return new BucketedThreadMediaLoader(getContext(), chatId, DcMsg.DC_MSG_FILE, DcMsg.DC_MSG_AUDIO, DcMsg.DC_MSG_WEBXDC); } @Override diff --git a/src/org/thoughtcrime/securesms/WebViewActivity.java b/src/org/thoughtcrime/securesms/WebViewActivity.java index ebd9ddd52..29cfc1716 100644 --- a/src/org/thoughtcrime/securesms/WebViewActivity.java +++ b/src/org/thoughtcrime/securesms/WebViewActivity.java @@ -9,11 +9,14 @@ import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.ImageView; import android.widget.Toast; +import androidx.annotation.RequiresApi; import androidx.appcompat.widget.SearchView; import androidx.webkit.WebSettingsCompat; import androidx.webkit.WebViewFeature; @@ -75,6 +78,26 @@ public class WebViewActivity extends PassphraseRequiredActionBarActivity // 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); + } }); webView.setFindListener(this); @@ -245,6 +268,10 @@ public class WebViewActivity extends PassphraseRequiredActionBarActivity } } + protected WebResourceResponse interceptRequest(String url) { + return null; + } + @Override public void onActivityResult(int reqCode, int resultCode, final Intent data) { super.onActivityResult(reqCode, resultCode, data); diff --git a/src/org/thoughtcrime/securesms/WebxdcActivity.java b/src/org/thoughtcrime/securesms/WebxdcActivity.java new file mode 100644 index 000000000..1688d0f24 --- /dev/null +++ b/src/org/thoughtcrime/securesms/WebxdcActivity.java @@ -0,0 +1,172 @@ +package org.thoughtcrime.securesms; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.webkit.JavascriptInterface; +import android.webkit.MimeTypeMap; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; +import com.b44t.messenger.DcMsg; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcEventDelegate { + private static final String TAG = WebxdcActivity.class.getSimpleName(); + private static final String INTERNAL_SCHEMA = "webxdc"; + private static final String INTERNAL_DOMAIN = "local.app"; + private DcContext dcContext; + private DcMsg dcAppMsg; + + public static void openWebxdcActivity(Context context, DcMsg instance) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Intent intent =new Intent(context, WebxdcActivity.class); + intent.putExtra("appMessageId", instance.getId()); + context.startActivity(intent); + } else { + Toast.makeText(context, "At least Android 5.0 (Lollipop) required for Webxdc.", Toast.LENGTH_LONG).show(); + } + } + + @Override + protected void onCreate(Bundle state, boolean ready) { + super.onCreate(state, ready); + DcEventCenter eventCenter = DcHelper.getEventCenter(WebxdcActivity.this.getApplicationContext()); + eventCenter.addObserver(DcContext.DC_EVENT_WEBXDC_STATUS_UPDATE, this); + + Bundle b = getIntent().getExtras(); + int appMessageId = b.getInt("appMessageId"); + + this.dcContext = DcHelper.getContext(getApplicationContext()); + this.dcAppMsg = this.dcContext.getMsg(appMessageId); + + WebSettings webSettings = webView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setAllowFileAccess(false); + webSettings.setBlockNetworkLoads(true); + webSettings.setAllowContentAccess(false); + webSettings.setGeolocationEnabled(false); + webSettings.setAllowFileAccessFromFileURLs(false); + webSettings.setAllowUniversalAccessFromFileURLs(false); + webView.addJavascriptInterface(new InternalJSApi(), "InternalJSApi"); + + webView.loadUrl(INTERNAL_SCHEMA + "://" + INTERNAL_DOMAIN + "/index.html"); + + Util.runOnAnyBackgroundThread(() -> { + JSONObject info = this.dcAppMsg.getWebxdcInfo(); + Util.runOnMain(() -> { + try { + getSupportActionBar().setTitle(info.getString("name")); + } catch (Exception e) { + e.printStackTrace(); + } + }); + }); + } + + @Override + protected void onDestroy() { + DcHelper.getEventCenter(this.getApplicationContext()).removeObservers(this); + super.onDestroy(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + // do not call super.onPrepareOptionsMenu() as the default "Search" menu is not needed + return true; + } + + @Override + protected void openOnlineUrl(String url) { + Toast.makeText(this, "Please embed needed resources.", Toast.LENGTH_LONG).show(); + } + + @Override + protected WebResourceResponse interceptRequest(String rawUrl) { + Log.i(TAG, "interceptRequest: " + rawUrl); + try { + if (rawUrl == null) { + throw new Exception("no url specified"); + } + String path = Uri.parse(rawUrl).getPath(); + if (path.equalsIgnoreCase("/webxdc.js")) { + InputStream targetStream = getResources().openRawResource(R.raw.webxdc); + return new WebResourceResponse("text/javascript", "UTF-8", targetStream); + } else { + byte[] blob = this.dcAppMsg.getWebxdcBlob(path); + if (blob == null) { + throw new Exception("\"" + path + "\" not found"); + } + String ext = MimeTypeMap.getFileExtensionFromUrl(path); + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + if (mimeType == null) { + switch (ext) { + case "js": mimeType = "text/javascript"; break; + default: mimeType = "application/octet-stream"; Log.i(TAG, "unknown mime type for " + rawUrl); break; + } + } + String encoding = mimeType.startsWith("text/")? "UTF-8" : null; + InputStream targetStream = new ByteArrayInputStream(blob); + return new WebResourceResponse(mimeType, encoding, targetStream); + } + } catch (Exception e) { + e.printStackTrace(); + InputStream targetStream = new ByteArrayInputStream(("Webxdc Request Error: " + e.getMessage()).getBytes()); + return new WebResourceResponse("text/plain", "UTF-8", targetStream); + } + } + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + if ((eventId == DcContext.DC_EVENT_WEBXDC_STATUS_UPDATE && event.getData1Int() == dcAppMsg.getId())) { + Log.i(TAG, "handleEvent"); + webView.loadUrl("javascript:window.__webxdcUpdate(" + event.getData2Int() + ");"); + } + } + + class InternalJSApi { + @JavascriptInterface + public String selfAddr() { + return WebxdcActivity.this.dcContext.getConfig("addr"); + } + + @JavascriptInterface + public String selfName() { + String name = WebxdcActivity.this.dcContext.getConfig("displayname"); + if (TextUtils.isEmpty(name)) { + name = selfAddr(); + } + return name; + } + + @JavascriptInterface + public boolean sendStatusUpdate(String payload, String descr) { + Log.i(TAG, "sendStatusUpdate"); + return WebxdcActivity.this.dcContext.sendWebxdcStatusUpdate(WebxdcActivity.this.dcAppMsg.getId(), payload, descr); + } + + @JavascriptInterface + public String getStatusUpdates(int statusUpdateId) { + Log.i(TAG, "getStatusUpdates"); + return WebxdcActivity.this.dcContext.getWebxdcStatusUpdates(WebxdcActivity.this.dcAppMsg.getId(), statusUpdateId); + } + } +} diff --git a/src/org/thoughtcrime/securesms/components/WebxdcView.java b/src/org/thoughtcrime/securesms/components/WebxdcView.java new file mode 100644 index 000000000..67b181824 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/WebxdcView.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.components; + + +import android.content.Context; + +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatImageView; + +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.b44t.messenger.DcMsg; + +import org.json.JSONObject; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DocumentSlide; +import org.thoughtcrime.securesms.mms.SlideClickListener; +import org.thoughtcrime.securesms.util.Util; + +import java.io.ByteArrayInputStream; + +public class WebxdcView extends FrameLayout { + + private static final String TAG = WebxdcView.class.getSimpleName(); + + private final @NonNull AppCompatImageView icon; + private final @NonNull TextView appName; + private final @NonNull TextView appSubtitle; + + private @Nullable SlideClickListener viewListener; + + public WebxdcView(@NonNull Context context) { + this(context, null); + } + + public WebxdcView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public WebxdcView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + inflate(context, R.layout.webxdc_view, this); + + this.icon = findViewById(R.id.webxdc_icon); + this.appName = findViewById(R.id.webxdc_app_name); + this.appSubtitle = findViewById(R.id.webxdc_subtitle); + } + + public void setWebxdcClickListener(@Nullable SlideClickListener listener) { + this.viewListener = listener; + } + + public void setWebxdc(final @NonNull DcMsg dcMsg) + { + try { + JSONObject info = dcMsg.getWebxdcInfo(); + setOnClickListener(new OpenClickedListener(getContext(), dcMsg)); + + // icon + byte[] blob = dcMsg.getWebxdcBlob(info.getString("icon")); + if (blob == null) { + throw new Exception("webxdc icon not found"); + } + ByteArrayInputStream is = new ByteArrayInputStream(blob); + Drawable drawable = Drawable.createFromStream(is, "icon"); + icon.setImageDrawable(drawable); + + // name + appName.setText(info.getString("name")); + + // subtitle + String summary = info.optString("summary"); + if (summary.isEmpty()) { + summary = Util.getPrettyFileSize(dcMsg.getFilebytes()) + " Webxdc"; + } + appSubtitle.setText(summary); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private class OpenClickedListener implements View.OnClickListener { + private final @NonNull DocumentSlide slide; + + private OpenClickedListener(Context context, @NonNull DcMsg dcMsg) { + this.slide = new DocumentSlide(context, dcMsg); + } + + @Override + public void onClick(View v) { + if (viewListener != null) { + viewListener.onClick(v, slide); + } + } + } + +} diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index e45fee21b..7d127bc2b 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -38,17 +38,25 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcMsg; + import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.ConversationActivity; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.ShareLocationDialog; +import org.thoughtcrime.securesms.WebxdcActivity; import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; import org.thoughtcrime.securesms.audio.AudioSlidePlayer; import org.thoughtcrime.securesms.components.AudioView; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.RemovableEditableMediaView; import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.components.WebxdcView; import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.geolocation.DcLocationManager; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; @@ -84,6 +92,7 @@ public class AttachmentManager { private ThumbnailView thumbnail; private AudioView audioView; private DocumentView documentView; + private WebxdcView webxdcView; //private SignalMapView mapView; private @NonNull List garbage = new LinkedList<>(); @@ -104,6 +113,7 @@ public class AttachmentManager { this.thumbnail = ViewUtil.findById(root, R.id.attachment_thumbnail); this.audioView = ViewUtil.findById(root, R.id.attachment_audio); this.documentView = ViewUtil.findById(root, R.id.attachment_document); + this.webxdcView = ViewUtil.findById(root, R.id.attachment_webxdc); //this.mapView = ViewUtil.findById(root, R.id.attachment_location); this.removableMediaView = ViewUtil.findById(root, R.id.removable_media_view); @@ -113,6 +123,7 @@ public class AttachmentManager { int incomingBubbleColor = ThemeUtil.getThemedColor(context, R.attr.conversation_item_incoming_bubble_color); audioView.getBackground().setColorFilter(incomingBubbleColor, PorterDuff.Mode.MULTIPLY); documentView.getBackground().setColorFilter(incomingBubbleColor, PorterDuff.Mode.MULTIPLY); + webxdcView.getBackground().setColorFilter(incomingBubbleColor, PorterDuff.Mode.MULTIPLY); } } @@ -220,9 +231,11 @@ public class AttachmentManager { @SuppressLint("StaticFieldLeak") public ListenableFuture setMedia(@NonNull final GlideRequests glideRequests, @NonNull final Uri uri, + @Nullable final DcMsg msg, @NonNull final MediaType mediaType, final int width, - final int height) + final int height, + final int chatId) { inflateStub(); @@ -238,10 +251,13 @@ public class AttachmentManager { @Override protected @Nullable Slide doInBackground(Void... params) { try { - if (PartAuthority.isLocalUri(uri)) { + if (msg != null && msg.getType() == DcMsg.DC_MSG_WEBXDC) { + return new DocumentSlide(context, msg); + } + else if (PartAuthority.isLocalUri(uri)) { return getManuallyCalculatedSlideInfo(uri, width, height); } else { - Slide result = getContentResolverSlideInfo(uri, width, height); + Slide result = getContentResolverSlideInfo(uri, width, height, chatId); if (result == null) return getManuallyCalculatedSlideInfo(uri, width, height); else return result; @@ -291,8 +307,17 @@ public class AttachmentManager { removableMediaView.display(audioView, false); result.set(true); } else if (slide.hasDocument()) { - documentView.setDocument((DocumentSlide) slide); - removableMediaView.display(documentView, false); + if (slide.isWebxdcDocument()) { + DcMsg instance = msg != null ? msg : DcHelper.getContext(context).getMsg(slide.dcMsgId); + webxdcView.setWebxdc(instance); + webxdcView.setWebxdcClickListener((v, s) -> { + WebxdcActivity.openWebxdcActivity(context, instance); + }); + removableMediaView.display(webxdcView, false); + } else { + documentView.setDocument((DocumentSlide) slide); + removableMediaView.display(documentView, false); + } result.set(true); } else { Attachment attachment = slide.asAttachment(); @@ -304,7 +329,7 @@ public class AttachmentManager { } } - private @Nullable Slide getContentResolverSlideInfo(Uri uri, int width, int height) { + private @Nullable Slide getContentResolverSlideInfo(Uri uri, int width, int height, int chatId) { Cursor cursor = null; long start = System.currentTimeMillis(); @@ -323,7 +348,7 @@ public class AttachmentManager { } Log.w(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); - return mediaType.createSlide(context, uri, fileName, mimeType, fileSize, width, height); + return mediaType.createSlide(context, uri, fileName, mimeType, fileSize, width, height, chatId); } } finally { if (cursor != null) cursor.close(); @@ -367,7 +392,7 @@ public class AttachmentManager { } Log.w(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); - return mediaType.createSlide(context, uri, fileName, mimeType, mediaSize, width, height); + return mediaType.createSlide(context, uri, fileName, mimeType, mediaSize, width, height, chatId); } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); @@ -598,7 +623,8 @@ public class AttachmentManager { @Nullable String mimeType, long dataSize, int width, - int height) + int height, + int chatId) { if (mimeType == null) { mimeType = "application/octet-stream"; @@ -609,7 +635,20 @@ public class AttachmentManager { case GIF: return new GifSlide(context, uri, dataSize, width, height); case AUDIO: return new AudioSlide(context, uri, dataSize, false, fileName); case VIDEO: return new VideoSlide(context, uri, dataSize); - case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); + case DOCUMENT: + // We have to special-case Webxdc slides: The user can interact with them as soon as a draft + // is set. Therefore we need to create a DcMsg already now. + if (fileName != null && fileName.endsWith(".xdc")) { + DcContext dcContext = DcHelper.getContext(context); + DcMsg msg = new DcMsg(dcContext, DcMsg.DC_MSG_WEBXDC); + Attachment attachment = new UriAttachment(uri, null, MediaUtil.WEBXDC, AttachmentDatabase.TRANSFER_PROGRESS_STARTED, 0, 0, 0, fileName, null, false); + String path = ConversationActivity.getRealPathFromAttachment(context, attachment); + msg.setFile(path, MediaUtil.WEBXDC); + dcContext.setDraft(chatId, msg); + return new DocumentSlide(context, msg); + } else { + return new DocumentSlide(context, uri, mimeType, dataSize, fileName); + } default: throw new AssertionError("unrecognized enum"); } } diff --git a/src/org/thoughtcrime/securesms/mms/DocumentSlide.java b/src/org/thoughtcrime/securesms/mms/DocumentSlide.java index 6328ff745..6e3cc3686 100644 --- a/src/org/thoughtcrime/securesms/mms/DocumentSlide.java +++ b/src/org/thoughtcrime/securesms/mms/DocumentSlide.java @@ -13,10 +13,12 @@ import org.thoughtcrime.securesms.attachments.DcAttachment; import org.thoughtcrime.securesms.util.StorageUtil; public class DocumentSlide extends Slide { + private int dcMsgType = DcMsg.DC_MSG_UNDEFINED; public DocumentSlide(Context context, DcMsg dcMsg) { super(context, new DcAttachment(dcMsg)); dcMsgId = dcMsg.getId(); + dcMsgType = dcMsg.getType(); } public DocumentSlide(@NonNull Context context, @NonNull Uri uri, @@ -31,4 +33,8 @@ public class DocumentSlide extends Slide { return true; } + @Override + public boolean isWebxdcDocument() { + return dcMsgType == DcMsg.DC_MSG_WEBXDC; + } } diff --git a/src/org/thoughtcrime/securesms/mms/Slide.java b/src/org/thoughtcrime/securesms/mms/Slide.java index 66865a4c1..c8f32df9f 100644 --- a/src/org/thoughtcrime/securesms/mms/Slide.java +++ b/src/org/thoughtcrime/securesms/mms/Slide.java @@ -94,6 +94,10 @@ public abstract class Slide { return false; } + public boolean isWebxdcDocument() { + return false; + } + public boolean hasLocation() { return false; } diff --git a/src/org/thoughtcrime/securesms/mms/SlideDeck.java b/src/org/thoughtcrime/securesms/mms/SlideDeck.java index cff4708a1..7fe36e5f5 100644 --- a/src/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/src/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -19,6 +19,8 @@ package org.thoughtcrime.securesms.mms; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.b44t.messenger.DcMsg; + import org.thoughtcrime.securesms.attachments.Attachment; import java.util.LinkedList; @@ -72,4 +74,14 @@ public class SlideDeck { return null; } + + // Webxdc requires draft-ids to be used; this function returns the previously used draft-id, if any. + public int getWebxdctDraftId() { + for (Slide slide: slides) { + if (slide.isWebxdcDocument()) { + return slide.dcMsgId; + } + } + return 0; + } } diff --git a/src/org/thoughtcrime/securesms/util/MediaUtil.java b/src/org/thoughtcrime/securesms/util/MediaUtil.java index 756eb82a3..eaa36b035 100644 --- a/src/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/src/org/thoughtcrime/securesms/util/MediaUtil.java @@ -47,6 +47,7 @@ public class MediaUtil { public static final String AUDIO_UNSPECIFIED = "audio/*"; public static final String VIDEO_UNSPECIFIED = "video/*"; public static final String OCTET = "application/octet-stream"; + public static final String WEBXDC = "application/webxdc+zip"; public static Slide getSlideForMsg(Context context, DcMsg dcMsg) { @@ -62,7 +63,8 @@ public class MediaUtil { } else if (dcMsg.getType() == DcMsg.DC_MSG_AUDIO || dcMsg.getType() == DcMsg.DC_MSG_VOICE) { slide = new AudioSlide(context, dcMsg); - } else if (dcMsg.getType() == DcMsg.DC_MSG_FILE) { + } else if (dcMsg.getType() == DcMsg.DC_MSG_FILE + || dcMsg.getType() == DcMsg.DC_MSG_WEBXDC) { slide = new DocumentSlide(context, dcMsg); } @@ -259,6 +261,8 @@ public class MediaUtil { return "aac"; case IMAGE_WEBP: return "webp"; + case WEBXDC: + return "xdc"; } return null; }