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}