WebxdcPage.java

  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}