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.net.Uri;
  9import android.os.Build;
 10import android.util.Log;
 11import android.view.LayoutInflater;
 12import android.view.Gravity;
 13import android.view.View;
 14import android.view.ViewGroup;
 15import android.widget.ArrayAdapter;
 16import android.webkit.JavascriptInterface;
 17import android.webkit.WebResourceRequest;
 18import android.webkit.WebResourceResponse;
 19import android.webkit.WebSettings;
 20import android.webkit.WebView;
 21import android.webkit.WebViewClient;
 22import android.widget.TextView;
 23
 24import androidx.annotation.RequiresApi;
 25import androidx.core.content.ContextCompat;
 26import androidx.databinding.DataBindingUtil;
 27
 28import io.ipfs.cid.Cid;
 29
 30import java.lang.ref.WeakReference;
 31import java.io.ByteArrayInputStream;
 32import java.io.File;
 33import java.io.IOException;
 34import java.io.InputStream;
 35import java.util.HashMap;
 36import java.util.Map;
 37import java.util.zip.ZipEntry;
 38import java.util.zip.ZipFile;
 39
 40import org.json.JSONObject;
 41import org.json.JSONException;
 42
 43import eu.siacs.conversations.Config;
 44import eu.siacs.conversations.R;
 45import eu.siacs.conversations.databinding.WebxdcPageBinding;
 46import eu.siacs.conversations.entities.Conversation;
 47import eu.siacs.conversations.entities.Message;
 48import eu.siacs.conversations.services.XmppConnectionService;
 49import eu.siacs.conversations.utils.Consumer;
 50import eu.siacs.conversations.utils.MimeUtils;
 51import eu.siacs.conversations.utils.UIHelper;
 52import eu.siacs.conversations.xml.Element;
 53import eu.siacs.conversations.xmpp.Jid;
 54
 55public class WebxdcPage implements ConversationPage {
 56	protected XmppConnectionService xmppConnectionService;
 57	protected WebxdcPageBinding binding = null;
 58	protected ZipFile zip = null;
 59	protected String baseUrl;
 60	protected Message source;
 61
 62	public WebxdcPage(Cid cid, Message source, XmppConnectionService xmppConnectionService) {
 63		this.xmppConnectionService = xmppConnectionService;
 64		this.source = source;
 65		File f = xmppConnectionService.getFileForCid(cid);
 66		try {
 67			if (f != null) zip = new ZipFile(xmppConnectionService.getFileForCid(cid));
 68		} catch (final IOException e) {
 69			Log.w(Config.LOGTAG, "WebxdcPage: " + e);
 70		}
 71
 72		// ids in the subdomain makes sure, different apps using same files do not share the same cache entry
 73		// (WebView may use a global cache shared across objects).
 74		// (a random-id would also work, but would need maintenance and does not add benefits as we regard the file-part interceptRequest() only,
 75		// also a random-id is not that useful for debugging)
 76		baseUrl = "https://" + source.getUuid() + ".localhost";
 77	}
 78
 79	public String getTitle() {
 80		return "WebXDC";
 81	}
 82
 83	public String getNode() {
 84		return "webxdc\0" + source.getUuid();
 85	}
 86
 87	public boolean openUri(Uri uri) {
 88		Intent intent = new Intent(Intent.ACTION_VIEW, uri);
 89		intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 90		xmppConnectionService.startActivity(intent);
 91		return true;
 92	}
 93
 94	protected WebResourceResponse interceptRequest(String rawUrl) {
 95		Log.i(Config.LOGTAG, "interceptRequest: " + rawUrl);
 96		WebResourceResponse res = null;
 97		try {
 98			if (zip == null) {
 99				throw new Exception("no zip found");
100			}
101			if (rawUrl == null) {
102				throw new Exception("no url specified");
103			}
104			String path = Uri.parse(rawUrl).getPath();
105			if (path.equalsIgnoreCase("/webxdc.js")) {
106				InputStream targetStream = xmppConnectionService.getResources().openRawResource(R.raw.webxdc);
107				res = new WebResourceResponse("text/javascript", "UTF-8", targetStream);
108			} else {
109				ZipEntry entry = zip.getEntry(path.substring(1));
110				if (entry == null) {
111					throw new Exception("\"" + path + "\" not found");
112				}
113				String mimeType = MimeUtils.guessFromPath(path);
114				String encoding = mimeType.startsWith("text/") ? "UTF-8" : null;
115				res = new WebResourceResponse(mimeType, encoding, zip.getInputStream(entry));
116			}
117		} catch (Exception e) {
118			e.printStackTrace();
119			InputStream targetStream = new ByteArrayInputStream(("Webxdc Request Error: " + e.getMessage()).getBytes());
120			res = new WebResourceResponse("text/plain", "UTF-8", targetStream);
121		}
122
123		if (res != null) {
124			Map<String, String> headers = new HashMap<>();
125			headers.put("Content-Security-Policy",
126					"default-src 'self'; "
127				+ "style-src 'self' 'unsafe-inline' blob: ; "
128				+ "font-src 'self' data: blob: ; "
129				+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ; "
130				+ "connect-src 'self' data: blob: ; "
131				+ "img-src 'self' data: blob: ; "
132				+ "webrtc 'block' ; "
133			);
134			headers.put("X-DNS-Prefetch-Control", "off");
135			res.setResponseHeaders(headers);
136		}
137		return res;
138	}
139
140	public View inflateUi(Context context, Consumer<ConversationPage> remover) {
141		if (binding != null) {
142			binding.webview.loadUrl("javascript:__webxdcUpdate();");
143			return getView();
144		}
145
146		binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.webxdc_page, null, false);
147		binding.webview.setWebViewClient(new WebViewClient() {
148			// `shouldOverrideUrlLoading()` is called when the user clicks a URL,
149			// returning `true` causes the WebView to abort loading the URL,
150			// returning `false` causes the WebView to continue loading the URL as usual.
151			// the method is not called for POST request nor for on-page-links.
152			//
153			// nb: from API 24, `shouldOverrideUrlLoading(String)` is deprecated and
154			// `shouldOverrideUrlLoading(WebResourceRequest)` shall be used.
155			// the new one has the same functionality, and the old one still exist,
156			// so, to support all systems, for now, using the old one seems to be the simplest way.
157			@Override
158			public boolean shouldOverrideUrlLoading(WebView view, String url) {
159				if (url != null) {
160					Uri uri = Uri.parse(url);
161					switch (uri.getScheme()) {
162						case "http":
163						case "https":
164						case "mailto":
165						case "xmpp":
166							return openUri(uri);
167					}
168				}
169				// by returning `true`, we also abort loading other URLs in our WebView;
170				// eg. that might be weird or internal protocols.
171				// if we come over really useful things, we should allow that explicitly.
172				return true;
173			}
174
175			@Override
176			@SuppressWarnings("deprecation")
177			public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
178				WebResourceResponse res = interceptRequest(url);
179				if (res!=null) {
180					return res;
181				}
182				return super.shouldInterceptRequest(view, url);
183			}
184
185			@Override
186			@RequiresApi(21)
187			public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
188				WebResourceResponse res = interceptRequest(request.getUrl().toString());
189				if (res!=null) {
190					return res;
191				}
192				return super.shouldInterceptRequest(view, request);
193			}
194		});
195
196		// disable "safe browsing" as this has privacy issues,
197		// eg. at least false positives are sent to the "Safe Browsing Lookup API".
198		// as all URLs opened in the WebView are local anyway,
199		// "safe browsing" will never be able to report issues, so it can be disabled.
200		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
201			binding.webview.getSettings().setSafeBrowsingEnabled(false);
202		}
203
204		WebSettings webSettings = binding.webview.getSettings();
205		webSettings.setJavaScriptEnabled(true);
206		webSettings.setAllowFileAccess(false);
207		webSettings.setBlockNetworkLoads(true);
208		webSettings.setAllowContentAccess(false);
209		webSettings.setGeolocationEnabled(false);
210		webSettings.setAllowFileAccessFromFileURLs(false);
211		webSettings.setAllowUniversalAccessFromFileURLs(false);
212		webSettings.setDatabaseEnabled(true);
213		webSettings.setDomStorageEnabled(true);
214		binding.webview.setNetworkAvailable(false); // this does not block network but sets `window.navigator.isOnline` in js land
215		binding.webview.addJavascriptInterface(new InternalJSApi(), "InternalJSApi");
216
217		binding.webview.loadUrl(baseUrl + "/index.html");
218
219		binding.actions.setAdapter(new ArrayAdapter<String>(context, R.layout.simple_list_item, new String[]{"Close"}) {
220			@Override
221			public View getView(int position, View convertView, ViewGroup parent) {
222				View v = super.getView(position, convertView, parent);
223				TextView tv = (TextView) v.findViewById(android.R.id.text1);
224				tv.setGravity(Gravity.CENTER);
225				tv.setTextColor(ContextCompat.getColor(context, R.color.white));
226				tv.setBackgroundColor(UIHelper.getColorForName(getItem(position)));
227				return v;
228			}
229		});
230		binding.actions.setOnItemClickListener((parent, v, pos, id) -> {
231			remover.accept(WebxdcPage.this);
232		});
233
234		return getView();
235	}
236
237	public View getView() {
238		if (binding == null) return null;
239		return binding.getRoot();
240	}
241
242	public void refresh() {
243		binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
244	}
245
246	protected Jid selfJid() {
247		Conversation conversation = (Conversation) source.getConversation();
248		if (conversation.getMode() == Conversation.MODE_MULTI && !conversation.getMucOptions().nonanonymous()) {
249			return conversation.getMucOptions().getSelf().getFullJid();
250		} else {
251			return source.getConversation().getAccount().getJid().asBareJid();
252		}
253	}
254
255	protected class InternalJSApi {
256		@JavascriptInterface
257		public String selfAddr() {
258			return "xmpp:" + Uri.encode(selfJid().toEscapedString(), "@/+");
259		}
260
261		@JavascriptInterface
262		public String selfName() {
263			return source.getConversation().getAccount().getDisplayName();
264		}
265
266		@JavascriptInterface
267		public boolean sendStatusUpdate(String paramS, String descr) {
268			JSONObject params = new JSONObject();
269			try {
270				params = new JSONObject(paramS);
271			} catch (final JSONException e) {
272				Log.w(Config.LOGTAG, "WebxdcPage sendStatusUpdate invalid JSON: " + e);
273			}
274			String payload = null;
275			Message message = new Message(source.getConversation(), descr, source.getEncryption());
276			message.addPayload(new Element("store", "urn:xmpp:hints"));
277			Element webxdc = new Element("x", "urn:xmpp:webxdc:0");
278			message.addPayload(webxdc);
279			if (params.has("payload")) {
280				payload = JSONObject.wrap(params.opt("payload")).toString();
281				webxdc.addChild("json", "urn:xmpp:json:0").setContent(payload);
282			}
283			if (params.has("document")) {
284				webxdc.addChild("document").setContent(params.optString("document", null));
285			}
286			if (params.has("summary")) {
287				webxdc.addChild("summary").setContent(params.optString("summary", null));
288			}
289			message.setBody(params.optString("info", null));
290			message.setThread(source.getThread());
291			if (source.isPrivateMessage()) {
292				Message.configurePrivateMessage(message, source.getCounterpart());
293			}
294			xmppConnectionService.sendMessage(message);
295			xmppConnectionService.insertWebxdcUpdate(new WebxdcUpdate(
296				(Conversation) message.getConversation(),
297				selfJid(),
298				message.getThread(),
299				params.optString("info", null),
300				params.optString("document", null),
301				params.optString("summary", null),
302				payload
303			));
304			binding.webview.post(() -> binding.webview.loadUrl("javascript:__webxdcUpdate();"));
305			return true;
306		}
307
308		@JavascriptInterface
309		public String getStatusUpdates(long lastKnownSerial) {
310			StringBuilder builder = new StringBuilder("[");
311			String sep = "";
312			for (WebxdcUpdate update : xmppConnectionService.findWebxdcUpdates(source, lastKnownSerial)) {
313				builder.append(sep);
314				builder.append(update.toString());
315				sep = ",";
316			}
317			builder.append("]");
318			return builder.toString();
319		}
320	}
321}