1// Based on GPLv3 code from deltachat-android
2// https://github.com/deltachat/deltachat-android/blob/master/src/org/thoughtcrime/securesms/WebViewActivity.java
3// https://github.com/deltachat/deltachat-android/blob/master/src/org/thoughtcrime/securesms/WebxdcActivity.java
4package com.cheogram.android;
5
6import android.content.Context;
7import android.content.Intent;
8import android.graphics.drawable.BitmapDrawable;
9import android.graphics.drawable.Drawable;
10import android.graphics.drawable.Icon;
11import android.graphics.ImageDecoder;
12import android.graphics.Rect;
13import android.net.Uri;
14import android.os.Build;
15import android.util.DisplayMetrics;
16import android.util.Log;
17import android.view.LayoutInflater;
18import android.view.Gravity;
19import android.view.View;
20import android.view.ViewGroup;
21import android.util.Base64;
22import android.webkit.JavascriptInterface;
23import android.webkit.ValueCallback;
24import android.webkit.WebChromeClient;
25import android.webkit.WebResourceRequest;
26import android.webkit.WebResourceResponse;
27import android.webkit.WebSettings;
28import android.webkit.WebView;
29import android.webkit.WebViewClient;
30import android.widget.ArrayAdapter;
31import android.widget.TextView;
32
33import androidx.annotation.RequiresApi;
34import androidx.core.content.ContextCompat;
35import androidx.core.content.pm.ShortcutInfoCompat;
36import androidx.core.content.pm.ShortcutManagerCompat;
37import androidx.core.graphics.drawable.IconCompat;
38import androidx.core.util.Consumer;
39import androidx.databinding.DataBindingUtil;
40
41import com.google.android.material.color.MaterialColors;
42import com.google.common.io.ByteStreams;
43
44import io.ipfs.cid.Cid;
45
46import java.lang.ref.WeakReference;
47import java.io.ByteArrayInputStream;
48import java.io.File;
49import java.io.IOException;
50import java.io.InputStream;
51import java.nio.ByteBuffer;
52import java.util.HashMap;
53import java.util.Map;
54import java.util.zip.ZipEntry;
55import java.util.zip.ZipFile;
56
57import org.json.JSONObject;
58import org.json.JSONException;
59
60import org.tomlj.Toml;
61import org.tomlj.TomlTable;
62
63import eu.siacs.conversations.Config;
64import eu.siacs.conversations.R;
65import eu.siacs.conversations.databinding.WebxdcPageBinding;
66import eu.siacs.conversations.entities.Conversation;
67import eu.siacs.conversations.entities.Message;
68import eu.siacs.conversations.persistance.FileBackend;
69import eu.siacs.conversations.services.XmppConnectionService;
70import eu.siacs.conversations.ui.ConversationsActivity;
71import eu.siacs.conversations.ui.XmppActivity;
72import eu.siacs.conversations.utils.MimeUtils;
73import eu.siacs.conversations.utils.UIHelper;
74import eu.siacs.conversations.xml.Element;
75import eu.siacs.conversations.xmpp.Jid;
76
77public class WebxdcPage implements ConversationPage {
78 protected XmppConnectionService xmppConnectionService;
79 protected WebxdcPageBinding binding = null;
80 protected ZipFile zip = null;
81 protected TomlTable manifest = null;
82 protected String baseUrl;
83 protected Message source;
84 protected WebxdcUpdate lastUpdate = null;
85 protected WeakReference<XmppActivity> activity;
86 protected WeakReference<Consumer<ConversationPage>> remover;
87
88 public WebxdcPage(final XmppActivity activity, File f, Message source) {
89 this.xmppConnectionService = activity.xmppConnectionService;
90 this.source = source;
91 this.activity = new WeakReference(activity);
92 try {
93 if (f != null) zip = new ZipFile(f);
94 final ZipEntry manifestEntry = zip == null ? null : zip.getEntry("manifest.toml");
95 if (manifestEntry != null) {
96 manifest = Toml.parse(zip.getInputStream(manifestEntry));
97 }
98 } catch (final IOException e) {
99 Log.w(Config.LOGTAG, "WebxdcPage: " + e);
100 }
101
102 // ids in the subdomain makes sure, different apps using same files do not share the same cache entry
103 // (WebView may use a global cache shared across objects).
104 // (a random-id would also work, but would need maintenance and does not add benefits as we regard the file-part interceptRequest() only,
105 // also a random-id is not that useful for debugging)
106 baseUrl = "https://" + source.getUuid() + ".localhost";
107 }
108
109 public WebxdcPage(final XmppActivity activity, Cid cid, Message source) {
110 this(activity, activity.xmppConnectionService.getFileForCid(cid), source);
111 }
112
113 public Drawable getIcon() {
114 return getIcon(288);
115 }
116
117 public Drawable getIcon(int dp) {
118 if (android.os.Build.VERSION.SDK_INT < 28) return null;
119 if (zip == null) return null;
120 ZipEntry entry = zip.getEntry("icon.webp");
121 if (entry == null) entry = zip.getEntry("icon.png");
122 if (entry == null) entry = zip.getEntry("icon.jpg");
123 if (entry == null) return null;
124
125 try {
126 DisplayMetrics metrics = xmppConnectionService.getResources().getDisplayMetrics();
127 ImageDecoder.Source source = ImageDecoder.createSource(ByteBuffer.wrap(ByteStreams.toByteArray(zip.getInputStream(entry))));
128 return ImageDecoder.decodeDrawable(source, (decoder, info, src) -> {
129 int w = info.getSize().getWidth();
130 int h = info.getSize().getHeight();
131 Rect r = FileBackend.rectForSize(w, h, (int)(metrics.density * dp));
132 decoder.setTargetSize(r.width(), r.height());
133 });
134 } catch (final IOException e) {
135 Log.w(Config.LOGTAG, "WebxdcPage.getIcon: " + e);
136 return null;
137 }
138 }
139
140 public String getName() {
141 String title = manifest == null ? null : manifest.getString("name");
142 return title == null ? "Widget" : title;
143 }
144
145 public String getTitle() {
146 String title = manifest == null ? null : manifest.getString("name");
147 if (lastUpdate != null && lastUpdate.getDocument() != null) {
148 if (title == null) {
149 title = lastUpdate.getDocument();
150 } else {
151 title += ": " + lastUpdate.getDocument();
152 }
153 }
154 return title == null ? "Widget" : title;
155 }
156
157 public String getNode() {
158 return "webxdc\0" + source.getUuid();
159 }
160
161 public boolean threadMatches(final Element thread) {
162 if (thread == null) return false;
163 if (thread.getContent() == null) return false;
164 if (source.getThread() == null) return false;
165 return thread.getContent().equals(source.getThread().getContent());
166 }
167
168 public boolean openUri(Uri uri) {
169 Intent intent = new Intent(Intent.ACTION_VIEW, uri);
170 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
171 xmppConnectionService.startActivity(intent);
172 return true;
173 }
174
175 protected WebResourceResponse interceptRequest(String rawUrl) {
176 Log.i(Config.LOGTAG, "interceptRequest: " + rawUrl);
177 WebResourceResponse res = null;
178 try {
179 if (zip == null) {
180 throw new Exception("no zip found");
181 }
182 if (rawUrl == null) {
183 throw new Exception("no url specified");
184 }
185 String path = Uri.parse(rawUrl).getPath();
186 if (path.equalsIgnoreCase("/webxdc.js")) {
187 InputStream targetStream = xmppConnectionService.getResources().openRawResource(R.raw.webxdc);
188 res = new WebResourceResponse("text/javascript", "UTF-8", targetStream);
189 } else {
190 ZipEntry entry = zip.getEntry(path.substring(1));
191 if (entry == null) {
192 throw new Exception("\"" + path + "\" not found");
193 }
194 String mimeType = MimeUtils.guessFromPath(path);
195 String encoding = mimeType.startsWith("text/") ? "UTF-8" : null;
196 res = new WebResourceResponse(mimeType, encoding, zip.getInputStream(entry));
197 }
198 } catch (Exception e) {
199 e.printStackTrace();
200 InputStream targetStream = new ByteArrayInputStream(("Webxdc Request Error: " + e.getMessage()).getBytes());
201 res = new WebResourceResponse("text/plain", "UTF-8", targetStream);
202 }
203
204 if (res != null) {
205 Map<String, String> headers = new HashMap<>();
206 headers.put("Content-Security-Policy",
207 "default-src 'self'; "
208 + "style-src 'self' 'unsafe-inline' blob: ; "
209 + "font-src 'self' data: blob: ; "
210 + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ; "
211 + "connect-src 'self' data: blob: ; "
212 + "img-src 'self' data: blob: ; "
213 + "media-src 'self' data: blob: ; "
214 + "webrtc 'block' ; "
215 );
216 headers.put("X-DNS-Prefetch-Control", "off");
217 res.setResponseHeaders(headers);
218 }
219 return res;
220 }
221
222 public View inflateUi(Context context, Consumer<ConversationPage> remover) {
223 this.remover = new WeakReference<>(remover);
224 if (binding != null) {
225 binding.webview.loadUrl("javascript:__webxdcUpdate();");
226 return getView();
227 }
228
229 binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.webxdc_page, null, false);
230 binding.webview.setWebViewClient(new WebViewClient() {
231 // `shouldOverrideUrlLoading()` is called when the user clicks a URL,
232 // returning `true` causes the WebView to abort loading the URL,
233 // returning `false` causes the WebView to continue loading the URL as usual.
234 // the method is not called for POST request nor for on-page-links.
235 //
236 // nb: from API 24, `shouldOverrideUrlLoading(String)` is deprecated and
237 // `shouldOverrideUrlLoading(WebResourceRequest)` shall be used.
238 // the new one has the same functionality, and the old one still exist,
239 // so, to support all systems, for now, using the old one seems to be the simplest way.
240 @Override
241 public boolean shouldOverrideUrlLoading(WebView view, String url) {
242 if (url.startsWith(baseUrl)) return false;
243
244 if (url != null) {
245 Uri uri = Uri.parse(url);
246 switch (uri.getScheme()) {
247 case "http":
248 case "https":
249 case "mailto":
250 case "xmpp":
251 return openUri(uri);
252 }
253 }
254 // by returning `true`, we also abort loading other URLs in our WebView;
255 // eg. that might be weird or internal protocols.
256 // if we come over really useful things, we should allow that explicitly.
257 return true;
258 }
259
260 @Override
261 @SuppressWarnings("deprecation")
262 public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
263 WebResourceResponse res = interceptRequest(url);
264 if (res!=null) {
265 return res;
266 }
267 return super.shouldInterceptRequest(view, url);
268 }
269
270 @Override
271 @RequiresApi(21)
272 public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
273 WebResourceResponse res = interceptRequest(request.getUrl().toString());
274 if (res!=null) {
275 return res;
276 }
277 return super.shouldInterceptRequest(view, request);
278 }
279 });
280
281 binding.webview.setWebChromeClient(new WebChromeClient() {
282 @Override
283 public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) {
284 // WebxdcActivity.this.filePathCallback = filePathCallback;
285 Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
286 intent.addCategory(Intent.CATEGORY_OPENABLE);
287 intent.setType("*/*");
288 intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE);
289 final XmppActivity activity = WebxdcPage.this.activity.get();
290 if (activity != null) activity.startActivityWithCallback(Intent.createChooser(intent, "Choose a file"), filePathCallback);
291 return activity != null;
292 }
293 });
294
295 // disable "safe browsing" as this has privacy issues,
296 // eg. at least false positives are sent to the "Safe Browsing Lookup API".
297 // as all URLs opened in the WebView are local anyway,
298 // "safe browsing" will never be able to report issues, so it can be disabled.
299 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
300 binding.webview.getSettings().setSafeBrowsingEnabled(false);
301 }
302
303 WebSettings webSettings = binding.webview.getSettings();
304 webSettings.setJavaScriptEnabled(true);
305 webSettings.setAllowFileAccess(false);
306 webSettings.setBlockNetworkLoads(true);
307 webSettings.setAllowContentAccess(false);
308 webSettings.setGeolocationEnabled(false);
309 webSettings.setAllowFileAccessFromFileURLs(false);
310 webSettings.setAllowUniversalAccessFromFileURLs(false);
311 webSettings.setDatabaseEnabled(true);
312 webSettings.setDomStorageEnabled(true);
313 binding.webview.setNetworkAvailable(false); // this does not block network but sets `window.navigator.isOnline` in js land
314 binding.webview.addJavascriptInterface(new InternalJSApi(), "InternalJSApi");
315
316 binding.webview.loadUrl(baseUrl + "/index.html");
317
318 final var actions =
319 source.getStatus() == Message.STATUS_DUMMY ?
320 new String[]{"Close"} :
321 new String[]{"Add to Home Screen", "Close"};
322
323 if (source.getStatus() == Message.STATUS_DUMMY) binding.actions.setNumColumns(1);
324
325 binding.actions.setAdapter(new ArrayAdapter<String>(context, R.layout.simple_list_item, actions) {
326 @Override
327 public View getView(int position, View convertView, ViewGroup parent) {
328 View v = super.getView(position, convertView, parent);
329 TextView tv = (TextView) v.findViewById(android.R.id.text1);
330 tv.setGravity(Gravity.CENTER);
331 tv.setTextColor(ContextCompat.getColor(context, R.color.white));
332 tv.setBackgroundColor(MaterialColors.harmonizeWithPrimary(activity.get(),UIHelper.getColorForName(getItem(position))));
333 return v;
334 }
335 });
336 binding.actions.setOnItemClickListener((parent, v, pos, id) -> {
337 if (pos == 0 && actions.length > 1) {
338 Intent intent = new Intent(xmppConnectionService, ConversationsActivity.class);
339 intent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
340 intent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, ((Conversation) source.getConversation()).getUuid());
341 intent.putExtra(ConversationsActivity.EXTRA_POST_INIT_ACTION, "webxdc");
342 intent.putExtra(ConversationsActivity.EXTRA_DOWNLOAD_UUID, source.getUuid());
343 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP|Intent.FLAG_ACTIVITY_SINGLE_TOP);
344
345 ShortcutInfoCompat.Builder builder = new ShortcutInfoCompat.Builder(xmppConnectionService, "webxdc:" + source.getUuid())
346 .setShortLabel(getTitle())
347 .setIntent(intent);
348 Drawable icon = getIcon();
349 if (icon != null && icon instanceof BitmapDrawable) {
350 builder = builder.setIcon(IconCompat.createFromIcon(Icon.createWithBitmap(((BitmapDrawable) icon).getBitmap())));
351 }
352 ShortcutManagerCompat.requestPinShortcut(xmppConnectionService, builder.build(), null);
353 } else {
354 binding.webview.loadUrl("about:blank");
355 remover.accept(WebxdcPage.this);
356 }
357 });
358
359 return getView();
360 }
361
362 public View getView() {
363 if (binding == null) return null;
364 return binding.getRoot();
365 }
366
367 public void refresh() {
368 if (source.getStatus() == Message.STATUS_DUMMY) return;
369 if (binding == null) return;
370
371 binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
372 }
373
374 public void realtimeData(String base64) {
375 if (source.getStatus() == Message.STATUS_DUMMY) return;
376 if (binding == null) return;
377
378 binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcRealtimeData('" + base64.replace("'", "").replace("\\", "").replace("+", "%2B") + "');"));
379 }
380
381 protected Jid selfJid() {
382 final Conversation conversation = (Conversation) source.getConversation();
383 if (conversation.getMode() == Conversation.MODE_MULTI && !conversation.getMucOptions().nonanonymous()) {
384 return conversation.getMucOptions().getSelf().getFullJid();
385 } else {
386 return source.getConversation().getAccount().getJid().asBareJid();
387 }
388 }
389
390 protected class InternalJSApi {
391 @JavascriptInterface
392 public String selfAddr() {
393 final Conversation conversation = (Conversation) source.getConversation();
394 if (conversation.getMode() == Conversation.MODE_MULTI && !conversation.getMucOptions().nonanonymous()) {
395 final var occupantId = conversation.getMucOptions().getSelf().getOccupantId();
396 if (occupantId != null) return occupantId;
397 }
398 return "xmpp:" + Uri.encode(selfJid().toString(), "@/+");
399 }
400
401 @JavascriptInterface
402 public String selfName() {
403 final Conversation conversation = (Conversation) source.getConversation();
404 if (conversation.getMode() == Conversation.MODE_MULTI) {
405 return conversation.getMucOptions().getActualNick();
406 } else {
407 final String displayName = conversation.getAccount().getDisplayName();
408 return displayName == null || "".equals(displayName) ? conversation.getAccount().getUsername() : displayName;
409 }
410 }
411
412 @JavascriptInterface
413 public boolean sendStatusUpdate(String paramS, String descr) {
414 if (source.getStatus() == Message.STATUS_DUMMY) return false;
415
416 JSONObject params = new JSONObject();
417 try {
418 params = new JSONObject(paramS);
419 } catch (final JSONException e) {
420 Log.w(Config.LOGTAG, "WebxdcPage sendStatusUpdate invalid JSON: " + e);
421 }
422 String payload = null;
423 int encryption = Message.ENCRYPTION_NONE;
424 if (!params.has("payload") && !params.has("document") && !params.has("summary")) {
425 if (source.getConversation() instanceof Conversation) {
426 encryption = ((Conversation) source.getConversation()).getNextEncryption();
427 } else {
428 encryption = source.getEncryption();
429 }
430 }
431 Message message = new Message(source.getConversation(), descr, encryption);
432 message.addPayload(new Element("store", "urn:xmpp:hints"));
433 Element webxdc = new Element("x", "urn:xmpp:webxdc:0");
434 message.addPayload(webxdc);
435 if (params.has("payload")) {
436 payload = JSONObject.wrap(params.opt("payload")).toString();
437 webxdc.addChild("json", "urn:xmpp:json:0").setContent(payload);
438 }
439 if (params.has("document")) {
440 webxdc.addChild("document").setContent(params.optString("document", null));
441 }
442 if (params.has("summary")) {
443 webxdc.addChild("summary").setContent(params.optString("summary", null));
444 }
445 message.setBody(params.optString("info", null));
446 message.setThread(source.getThread());
447 if (source.isPrivateMessage()) {
448 Message.configurePrivateMessage(message, source.getCounterpart());
449 }
450 xmppConnectionService.sendMessage(message);
451 xmppConnectionService.insertWebxdcUpdate(new WebxdcUpdate(
452 (Conversation) message.getConversation(),
453 message.getUuid(),
454 selfJid(),
455 message.getThread(),
456 params.optString("info", null),
457 params.optString("document", null),
458 params.optString("summary", null),
459 payload
460 ));
461 binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
462 return true;
463 }
464
465 @JavascriptInterface
466 public String getStatusUpdates(long lastKnownSerial) {
467 if (source.getStatus() == Message.STATUS_DUMMY) return "[]";
468
469 StringBuilder builder = new StringBuilder("[");
470 String sep = "";
471 for (WebxdcUpdate update : xmppConnectionService.findWebxdcUpdates(source, lastKnownSerial)) {
472 lastUpdate = update;
473 builder.append(sep);
474 builder.append(update.toString());
475 sep = ",";
476 }
477 builder.append("]");
478 return builder.toString();
479 }
480
481 @JavascriptInterface
482 public String sendToChat(String message) {
483 try {
484 JSONObject jsonObject = new JSONObject(message);
485
486 String text = null;
487 String data = null;
488 String name = null;
489 if (jsonObject.has("base64")) {
490 data = jsonObject.getString("base64");
491 }
492 if (jsonObject.has("name")) {
493 name = jsonObject.getString("name");
494 }
495 if (jsonObject.has("text")) {
496 text = jsonObject.getString("text");
497 }
498
499 Intent intent = new Intent(xmppConnectionService, ConversationsActivity.class);
500 intent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
501 intent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, ((Conversation) source.getConversation()).getUuid());
502 if (text != null) intent.putExtra(Intent.EXTRA_TEXT, text);
503 if (data != null) {
504 var mimeType = name == null ? null : MimeUtils.guessFromPath(name);
505 if (mimeType == null) mimeType = "application/octet-stream";
506 intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("data:" + mimeType + ";base64," + data));
507 }
508 activity.get().runOnUiThread(() -> {
509 if (source.getStatus() == Message.STATUS_DUMMY) {
510 binding.webview.loadUrl("about:blank");
511 final var remover = WebxdcPage.this.remover.get();
512 if (remover != null) remover.accept(WebxdcPage.this);
513 }
514 activity.get().startActivity(intent);
515 });
516 return null;
517 } catch (Exception e) {
518 e.printStackTrace();
519 return e.toString();
520 }
521 }
522
523 @JavascriptInterface
524 public void sendRealtime(byte[] data) {
525 if (source.getStatus() == Message.STATUS_DUMMY) return;
526
527 Message message = new Message(source.getConversation(), null, Message.ENCRYPTION_NONE);
528 message.addPayload(new Element("no-store", "urn:xmpp:hints"));
529 Element webxdc = new Element("x", "urn:xmpp:webxdc:0");
530 message.addPayload(webxdc);
531 webxdc.addChild("data").setContent(Base64.encodeToString(data, Base64.NO_WRAP));
532 message.setThread(source.getThread());
533 if (source.isPrivateMessage()) {
534 Message.configurePrivateMessage(message, source.getCounterpart());
535 }
536 message.setBody((String) null);
537 xmppConnectionService.sendMessage(message);
538 }
539 }
540}