diff --git a/src/cheogram/java/com/cheogram/android/WebxdcPage.java b/src/cheogram/java/com/cheogram/android/WebxdcPage.java index 747021e038b900ebbb46b8a49a221569e1ac3810..88808523c83fd1bfbaa05c6ac286264ddf38ab98 100644 --- a/src/cheogram/java/com/cheogram/android/WebxdcPage.java +++ b/src/cheogram/java/com/cheogram/android/WebxdcPage.java @@ -18,13 +18,15 @@ 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.ValueCallback; +import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; +import android.widget.ArrayAdapter; import android.widget.TextView; import androidx.annotation.RequiresApi; @@ -64,6 +66,7 @@ import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.ConversationsActivity; +import eu.siacs.conversations.ui.XmppActivity; import eu.siacs.conversations.utils.MimeUtils; import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.xml.Element; @@ -77,10 +80,12 @@ public class WebxdcPage implements ConversationPage { protected String baseUrl; protected Message source; protected WebxdcUpdate lastUpdate = null; + protected WeakReference activity; - public WebxdcPage(Cid cid, Message source, XmppConnectionService xmppConnectionService) { + public WebxdcPage(final XmppActivity activity, Cid cid, Message source, XmppConnectionService xmppConnectionService) { this.xmppConnectionService = xmppConnectionService; this.source = source; + this.activity = new WeakReference(activity); File f = xmppConnectionService.getFileForCid(cid); try { if (f != null) zip = new ZipFile(xmppConnectionService.getFileForCid(cid)); @@ -252,6 +257,20 @@ public class WebxdcPage implements ConversationPage { } }); + binding.webview.setWebChromeClient(new WebChromeClient() { + @Override + public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) { + // WebxdcActivity.this.filePathCallback = filePathCallback; + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE); + final XmppActivity activity = WebxdcPage.this.activity.get(); + if (activity != null) activity.startActivityWithCallback(Intent.createChooser(intent, "Choose a file"), filePathCallback); + return activity != null; + } + }); + // 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, @@ -402,5 +421,36 @@ public class WebxdcPage implements ConversationPage { builder.append("]"); return builder.toString(); } + + @JavascriptInterface + public String sendToChat(String message) { + try { + JSONObject jsonObject = new JSONObject(message); + + String text = null; + String data = null; + String name = null; + if (jsonObject.has("base64")) { + data = jsonObject.getString("base64"); + } + if (jsonObject.has("name")) { + name = jsonObject.getString("name"); + } + if (jsonObject.has("text")) { + text = jsonObject.getString("text"); + } + + Intent intent = new Intent(xmppConnectionService, ConversationsActivity.class); + intent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION); + intent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, ((Conversation) source.getConversation()).getUuid()); + if (text != null) intent.putExtra(Intent.EXTRA_TEXT, text); + if (data != null) intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("data:application/octet-stream;base64," + data)); + activity.get().startActivity(intent); + return null; + } catch (Exception e) { + e.printStackTrace(); + return e.toString(); + } + } } } diff --git a/src/cheogram/res/raw/webxdc.js b/src/cheogram/res/raw/webxdc.js index ef789d1bdb7c294d1d8dc6594d62d2896f89f3e3..31404e1ae71491ab7587cc579f77dbffefa98582 100644 --- a/src/cheogram/res/raw/webxdc.js +++ b/src/cheogram/res/raw/webxdc.js @@ -36,5 +36,82 @@ window.webxdc = (() => { sendUpdate: (payload, descr) => { InternalJSApi.sendStatusUpdate(JSON.stringify(payload), descr); }, + + importFiles: (filters) => { + var element = document.createElement("input"); + element.type = "file"; + element.accept = [ + ...(filters.extensions || []), + ...(filters.mimeTypes || []), + ].join(","); + element.multiple = filters.multiple || false; + const promise = new Promise((resolve, _reject) => { + element.onchange = (_ev) => { + const files = Array.from(element.files || []); + document.body.removeChild(element); + resolve(files); + }; + }); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + return promise; + }, + + sendToChat: async (message) => { + const data = {}; + if (!message.file && !message.text) { + return Promise.reject("sendToChat() error: file or text missing"); + } + const blobToBase64 = (file) => { + const dataStart = ";base64,"; + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + let data = reader.result; + resolve(data.slice(data.indexOf(dataStart) + dataStart.length)); + }; + reader.onerror = () => reject(reader.error); + }); + }; + if (message.text) { + data.text = message.text; + } + + if (message.file) { + let base64content; + if (!message.file.name) { + return Promise.reject("sendToChat() error: file name missing"); + } + if ( + Object.keys(message.file).filter((key) => + ["blob", "base64", "plainText"].includes(key) + ).length > 1 + ) { + return Promise.reject("sendToChat() error: only one of blob, base64 or plainText allowed"); + } + + if (message.file.blob instanceof Blob) { + base64content = await blobToBase64(message.file.blob); + } else if (typeof message.file.base64 === "string") { + base64content = message.file.base64; + } else if (typeof message.file.plainText === "string") { + base64content = await blobToBase64( + new Blob([message.file.plainText]) + ); + } else { + return Promise.reject("sendToChat() error: none of blob, base64 or plainText set correctly"); + } + data.base64 = base64content; + data.name = message.file.name; + } + + const errorMsg = InternalJSApi.sendToChat(JSON.stringify(data)); + if (errorMsg) { + return Promise.reject(errorMsg); + } + }, + }; })(); diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 434a5ed569664f014d458bef481f99cf4b264209..4a1f9c39a464fa4dd5464c3b2a09ec84b23e3903 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -53,6 +53,7 @@ import com.madebyevan.thumbhash.ThumbHash; import com.wolt.blurhashkt.BlurHashDecoder; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.File; @@ -713,6 +714,26 @@ public class FileBackend { } } + private InputStream openInputStream(Uri uri) throws IOException { + if (uri != null && "data".equals(uri.getScheme())) { + String[] parts = uri.getSchemeSpecificPart().split(",", 2); + byte[] data; + if (Arrays.asList(parts[0].split(";")).contains("base64")) { + String[] parts2 = parts[0].split(";", 2); + parts[0] = parts2[0]; + data = Base64.decode(parts[1], 0); + } else { + try { + data = parts[1].getBytes("UTF-8"); + } catch (final IOException e) { + data = new byte[0]; + } + } + return new ByteArrayInputStream(data); + } + return mXmppConnectionService.getContentResolver().openInputStream(uri); + } + private void copyFileToPrivateStorage(File file, Uri uri) throws FileCopyException { Log.d( Config.LOGTAG, @@ -724,8 +745,7 @@ public class FileBackend { throw new FileCopyException(R.string.error_unable_to_create_temporary_file); } try (final OutputStream os = new FileOutputStream(file); - final InputStream is = - mXmppConnectionService.getContentResolver().openInputStream(uri)) { + final InputStream is = openInputStream(uri)) { if (is == null) { throw new FileCopyException(R.string.error_file_not_found); } @@ -951,7 +971,7 @@ public class FileBackend { public void setupRelativeFilePath(final Message message, final Uri uri, final String extension) throws FileCopyException, XmppConnectionService.BlockedMediaException { try { - setupRelativeFilePath(message, mXmppConnectionService.getContentResolver().openInputStream(uri), extension); + setupRelativeFilePath(message, openInputStream(uri), extension); } catch (final FileNotFoundException e) { throw new FileCopyException(R.string.error_file_not_found); } catch (final IOException e) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 2fca12451b761935abdb91360c87896d15239a40..19d5b9ab660d8a6f5ff08fd5c78d34cab6a07d74 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -3214,7 +3214,7 @@ public class ConversationFragment extends XmppFragment if (message == null) return; Cid webxdcCid = message.getFileParams().getCids().get(0); - WebxdcPage webxdc = new WebxdcPage(webxdcCid, message, activity.xmppConnectionService); + WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService); Conversation conversation = (Conversation) message.getConversation(); if (!conversation.switchToSession("webxdc\0" + message.getUuid())) { conversation.startWebxdc(webxdc); diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index 72b9fb293319ac572d5b544acd7de27b96504bc3..b6a9ae30b5854423f8e85ed6eebcff4f06e31ac7 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -41,9 +41,11 @@ import android.text.Html; import android.text.InputType; import android.util.DisplayMetrics; import android.util.Log; +import android.util.Pair; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.webkit.ValueCallback; import android.widget.Button; import android.widget.CheckBox; import android.widget.ImageView; @@ -65,6 +67,7 @@ import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.PriorityQueue; import java.util.concurrent.RejectedExecutionException; import eu.siacs.conversations.Config; @@ -117,6 +120,7 @@ public abstract class XmppActivity extends ActionBarActivity { protected Toast mToast; public Runnable onOpenPGPKeyPublished = () -> Toast.makeText(XmppActivity.this, R.string.openpgp_has_been_published, Toast.LENGTH_SHORT).show(); protected ConferenceInvite mPendingConferenceInvite = null; + protected PriorityQueue>> activityCallbacks = new PriorityQueue<>((x, y) -> y.first.compareTo(x.first)); protected ServiceConnection mConnection = new ServiceConnection() { @Override @@ -854,6 +858,13 @@ public abstract class XmppActivity extends ActionBarActivity { } } + public synchronized void startActivityWithCallback(Intent intent, ValueCallback cb) { + Pair> peek = activityCallbacks.peek(); + int index = peek == null ? 1 : peek.first + 1; + activityCallbacks.add(new Pair<>(index, cb)); + startActivityForResult(intent, index); + } + protected void onActivityResult(int requestCode, int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_INVITE_TO_CONVERSATION && resultCode == RESULT_OK) { @@ -865,6 +876,21 @@ public abstract class XmppActivity extends ActionBarActivity { } mPendingConferenceInvite = null; } + } else if (resultCode == RESULT_OK) { + for (Pair> cb : new ArrayList<>(activityCallbacks)) { + if (cb.first == requestCode) { + activityCallbacks.remove(cb); + ArrayList dataUris = new ArrayList<>(); + if (data.getDataString() != null) { + dataUris.add(Uri.parse(data.getDataString())); + } else if (data.getClipData() != null) { + for (int i = 0; i < data.getClipData().getItemCount(); i++) { + dataUris.add(data.getClipData().getItemAt(i).getUri()); + } + } + cb.second.onReceiveValue(dataUris.toArray(new Uri[0])); + } + } } } 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 ac4252464c3ba56b6521053ca46681c1101b91d8..479acd590c5600b4e5a238b5076e9d9ceda474fb 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -689,7 +689,7 @@ public class MessageAdapter extends ArrayAdapter { private void displayWebxdcMessage(ViewHolder viewHolder, final Message message, final boolean darkBackground, final int type) { Cid webxdcCid = message.getFileParams().getCids().get(0); - WebxdcPage webxdc = new WebxdcPage(webxdcCid, message, activity.xmppConnectionService); + WebxdcPage webxdc = new WebxdcPage(activity, webxdcCid, message, activity.xmppConnectionService); displayTextMessage(viewHolder, message, darkBackground, type); viewHolder.image.setVisibility(View.GONE); viewHolder.audioPlayer.setVisibility(View.GONE); diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java index 102dc28e293dde72eb738efbb86a5d0a7774f976..06ba3906573deff3d105906a1e130f069be4c214 100644 --- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java @@ -572,7 +572,9 @@ public final class MimeUtils { } // sometimes this works (as with the commit content api) if (mimeType == null) { - mimeType = uri.getQueryParameter("mimeType"); + try { + mimeType = uri.getQueryParameter("mimeType"); + } catch (final Throwable throwable) { } } return mimeType; }