Files
arcanechat-android/src/org/thoughtcrime/securesms/WebxdcActivity.java
T
2023-07-21 23:06:54 +02:00

424 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.webkit.JavascriptInterface;
import android.webkit.MimeTypeMap;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.core.app.TaskStackBuilder;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import com.b44t.messenger.DcChat;
import com.b44t.messenger.DcContext;
import com.b44t.messenger.DcEvent;
import com.b44t.messenger.DcMsg;
import org.json.JSONObject;
import org.thoughtcrime.securesms.connect.DcEventCenter;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Prefs;
import org.thoughtcrime.securesms.util.Util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcEventDelegate {
private static final String TAG = WebxdcActivity.class.getSimpleName();
private static final int REQUEST_CODE_FILE_PICKER = 51426;
private ValueCallback<Uri[]> filePathCallback;
private DcContext dcContext;
private DcMsg dcAppMsg;
private String baseURL;
private String sourceCodeUrl = "";
private boolean internetAccess = false;
public static void openWebxdcActivity(Context context, DcMsg instance) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (Prefs.isDeveloperModeEnabled(context)) {
WebView.setWebContentsDebuggingEnabled(true);
}
context.startActivity(getWebxdcIntent(context, instance.getId()));
} else {
Toast.makeText(context, "At least Android 5.0 (Lollipop) required for Webxdc.", Toast.LENGTH_LONG).show();
}
}
private static Intent getWebxdcIntent(Context context, int msgId) {
DcContext dcContext = DcHelper.getContext(context);
Intent intent = new Intent(context, WebxdcActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("accountId", dcContext.getAccountId());
intent.putExtra("appMessageId", msgId);
return intent;
}
private static Intent[] getWebxdcIntentWithParentStack(Context context, int msgId) {
DcContext dcContext = DcHelper.getContext(context);
final Intent chatIntent = new Intent(context, ConversationActivity.class)
.putExtra(ConversationActivity.CHAT_ID_EXTRA, dcContext.getMsg(msgId).getChatId())
.setAction(Intent.ACTION_VIEW);
final Intent webxdcIntent = getWebxdcIntent(context, msgId);
return TaskStackBuilder.create(context)
.addNextIntentWithParentStack(chatIntent)
.addNextIntent(webxdcIntent)
.getIntents();
}
@Override
protected void onCreate(Bundle state, boolean ready) {
super.onCreate(state, ready);
webView.setWebChromeClient(new WebChromeClient() {
@Override
@RequiresApi(21)
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) {
if (WebxdcActivity.this.filePathCallback != null) {
WebxdcActivity.this.filePathCallback.onReceiveValue(null);
}
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);
WebxdcActivity.this.startActivityForResult(Intent.createChooser(intent, getString(R.string.select)), REQUEST_CODE_FILE_PICKER);
return true;
}
});
DcEventCenter eventCenter = DcHelper.getEventCenter(WebxdcActivity.this.getApplicationContext());
eventCenter.addObserver(DcContext.DC_EVENT_WEBXDC_STATUS_UPDATE, this);
Bundle b = getIntent().getExtras();
int appMessageId = b.getInt("appMessageId");
this.dcContext = DcHelper.getContext(getApplicationContext());
if (dcContext.getAccountId() != b.getInt("accountId")) {
Toast.makeText(this, "Switch to belonging account first.", Toast.LENGTH_LONG).show();
finish();
return;
}
this.dcAppMsg = this.dcContext.getMsg(appMessageId);
if (!this.dcAppMsg.isOk()) {
Toast.makeText(this, "Webxdc does no longer exist.", Toast.LENGTH_LONG).show();
finish();
return;
}
// `msg_id` in the subdomain makes sure, different apps using same files do not share the same cache entry
// (WebView may use a global cache shared across objects).
// (a random-id would also work, but would need maintenance and does not add benefits as we regard the file-part interceptRequest() only,
// also a random-id is not that useful for debugging)
this.baseURL = "https://acc" + dcContext.getAccountId() + "-msg" + appMessageId + ".localhost";
final JSONObject info = this.dcAppMsg.getWebxdcInfo();
internetAccess = JsonUtils.optBoolean(info, "internet_access");
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setAllowFileAccess(false);
webSettings.setBlockNetworkLoads(!internetAccess);
webSettings.setAllowContentAccess(false);
webSettings.setGeolocationEnabled(false);
webSettings.setAllowFileAccessFromFileURLs(false);
webSettings.setAllowUniversalAccessFromFileURLs(false);
webSettings.setDatabaseEnabled(true);
webSettings.setDomStorageEnabled(true);
webView.setNetworkAvailable(internetAccess); // this does not block network but sets `window.navigator.isOnline` in js land
webView.addJavascriptInterface(new InternalJSApi(), "InternalJSApi");
webView.loadUrl(this.baseURL + "/index.html");
Util.runOnAnyBackgroundThread(() -> {
final DcChat chat = dcContext.getChat(dcAppMsg.getChatId());
Util.runOnMain(() -> {
updateTitleAndMenu(info, chat);
});
});
}
@Override
protected void onDestroy() {
DcHelper.getEventCenter(this.getApplicationContext()).removeObservers(this);
super.onDestroy();
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
// do not call super.onPrepareOptionsMenu() as the default "Search" menu is not needed
menu.clear();
this.getMenuInflater().inflate(R.menu.webxdc, menu);
menu.findItem(R.id.source_code).setVisible(!sourceCodeUrl.isEmpty());
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case R.id.menu_add_to_home_screen:
addToHomeScreen(this, dcAppMsg.getId());
return true;
case R.id.source_code:
openUrlInBrowser(this, sourceCodeUrl);
return true;
}
return false;
}
@Override
protected boolean openOnlineUrl(String url) {
if (url.startsWith(baseURL +"/")) {
// internal page, continue loading in the WebView
return false;
}
return super.openOnlineUrl(url);
}
@Override
protected WebResourceResponse interceptRequest(String rawUrl) {
Log.i(TAG, "interceptRequest: " + rawUrl);
WebResourceResponse res = null;
try {
if (rawUrl == null) {
throw new Exception("no url specified");
}
String path = Uri.parse(rawUrl).getPath();
if (path.equalsIgnoreCase("/webxdc.js")) {
InputStream targetStream = getResources().openRawResource(R.raw.webxdc);
res = new WebResourceResponse("text/javascript", "UTF-8", targetStream);
} else if (path.equalsIgnoreCase("/webxdc_bootstrap324567869.html")) {
InputStream targetStream = getResources().openRawResource(R.raw.webxdc_wrapper);
res = new WebResourceResponse("text/html", "UTF-8", targetStream);
} else if (path.equalsIgnoreCase("/sandboxed_iframe_rtcpeerconnection_check_5965668501706.html")) {
InputStream targetStream = getResources().openRawResource(R.raw.sandboxed_iframe_rtcpeerconnection_check);
res = new WebResourceResponse("text/html", "UTF-8", targetStream);
} else {
byte[] blob = this.dcAppMsg.getWebxdcBlob(path);
if (blob == null) {
if (internetAccess) {
return null; // do not intercept request
}
throw new Exception("\"" + path + "\" not found");
}
String ext = MediaUtil.getFileExtensionFromUrl(path);
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
if (mimeType == null) {
switch (ext) {
case "js": mimeType = "text/javascript"; break;
default: mimeType = "application/octet-stream"; Log.i(TAG, "unknown mime type for " + rawUrl); break;
}
}
String encoding = mimeType.startsWith("text/")? "UTF-8" : null;
InputStream targetStream = new ByteArrayInputStream(blob);
res = new WebResourceResponse(mimeType, encoding, targetStream);
}
} catch (Exception e) {
e.printStackTrace();
InputStream targetStream = new ByteArrayInputStream(("Webxdc Request Error: " + e.getMessage()).getBytes());
res = new WebResourceResponse("text/plain", "UTF-8", targetStream);
}
if (res != null) {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Security-Policy",
"default-src 'self'; "
+ "style-src 'self' 'unsafe-inline' blob: ; "
+ "font-src 'self' data: blob: ; "
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ; "
+ "connect-src 'self' data: blob: ; "
+ "img-src 'self' data: blob: ; "
+ "media-src 'self' data: blob: ;"
+ "webrtc 'block' ; "
);
headers.put("X-DNS-Prefetch-Control", "off");
res.setResponseHeaders(headers);
}
return res;
}
@Override
public void handleEvent(@NonNull DcEvent event) {
int eventId = event.getId();
if ((eventId == DcContext.DC_EVENT_WEBXDC_STATUS_UPDATE && event.getData1Int() == dcAppMsg.getId())) {
Log.i(TAG, "handleEvent");
webView.loadUrl("javascript:window.__webxdcUpdate();");
} else if ((eventId == DcContext.DC_EVENT_MSGS_CHANGED && event.getData2Int() == dcAppMsg.getId())) {
Util.runOnAnyBackgroundThread(() -> {
final JSONObject info = dcAppMsg.getWebxdcInfo();
final DcChat chat = dcContext.getChat(dcAppMsg.getChatId());
Util.runOnMain(() -> {
updateTitleAndMenu(info, chat);
});
});
}
}
private void updateTitleAndMenu(JSONObject info, DcChat chat) {
final String docName = JsonUtils.optString(info, "document");
final String xdcName = JsonUtils.optString(info, "name");
final String currSourceCodeUrl = JsonUtils.optString(info, "source_code_url");
getSupportActionBar().setTitle((docName.isEmpty() ? xdcName : docName) + " – " + chat.getName());
if (!sourceCodeUrl.equals(currSourceCodeUrl)) {
sourceCodeUrl = currSourceCodeUrl;
invalidateOptionsMenu();
}
}
public static void addToHomeScreen(Activity activity, int msgId) {
Context context = activity.getApplicationContext();
try {
DcContext dcContext = DcHelper.getContext(context);
DcMsg msg = dcContext.getMsg(msgId);
final JSONObject info = msg.getWebxdcInfo();
final String docName = JsonUtils.optString(info, "document");
final String xdcName = JsonUtils.optString(info, "name");
byte[] blob = msg.getWebxdcBlob(JsonUtils.optString(info, "icon"));
ByteArrayInputStream is = new ByteArrayInputStream(blob);
BitmapDrawable drawable = (BitmapDrawable) Drawable.createFromStream(is, "icon");
Bitmap bitmap = drawable.getBitmap();
ShortcutInfoCompat shortcutInfoCompat = new ShortcutInfoCompat.Builder(context, "xdc-" + dcContext.getAccountId() + "-" + msgId)
.setShortLabel(docName.isEmpty() ? xdcName : docName)
.setIcon(IconCompat.createWithBitmap(bitmap)) // createWithAdaptiveBitmap() removes decorations but cuts out a too small circle and defamiliarize the icon too much
.setIntents(getWebxdcIntentWithParentStack(context, msgId))
.build();
if (ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) {
Toast.makeText(context, R.string.done, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(context, "ErrAddToHomescreen: requestPinShortcut() failed", Toast.LENGTH_LONG).show();
}
} catch(Exception e) {
Toast.makeText(context, "ErrAddToHomescreen: " + e, Toast.LENGTH_LONG).show();
}
}
@Override
public void onActivityResult(int reqCode, int resultCode, final Intent data) {
if (reqCode == REQUEST_CODE_FILE_PICKER && filePathCallback != null) {
Uri[] dataUris = null;
if (resultCode == Activity.RESULT_OK && data != null) {
try {
if (data.getDataString() != null) {
dataUris = new Uri[]{Uri.parse(data.getDataString())};
} else if (data.getClipData() != null) {
final int numSelectedFiles = data.getClipData().getItemCount();
dataUris = new Uri[numSelectedFiles];
for (int i = 0; i < numSelectedFiles; i++) {
dataUris[i] = data.getClipData().getItemAt(i).getUri();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
filePathCallback.onReceiveValue(dataUris);
filePathCallback = null;
}
super.onActivityResult(reqCode, resultCode, data);
}
class InternalJSApi {
@JavascriptInterface
public String selfAddr() {
return WebxdcActivity.this.dcContext.getConfig("addr");
}
@JavascriptInterface
public String selfName() {
String name = WebxdcActivity.this.dcContext.getConfig("displayname");
if (TextUtils.isEmpty(name)) {
name = selfAddr();
}
return name;
}
@JavascriptInterface
public boolean sendStatusUpdate(String payload, String descr) {
Log.i(TAG, "sendStatusUpdate");
if (!WebxdcActivity.this.dcContext.sendWebxdcStatusUpdate(WebxdcActivity.this.dcAppMsg.getId(), payload, descr)) {
DcChat dcChat = WebxdcActivity.this.dcContext.getChat(WebxdcActivity.this.dcAppMsg.getChatId());
Toast.makeText(WebxdcActivity.this,
dcChat.isContactRequest() ?
WebxdcActivity.this.getString(R.string.accept_request_first) :
WebxdcActivity.this.dcContext.getLastError(),
Toast.LENGTH_LONG).show();
return false;
}
return true;
}
@JavascriptInterface
public String getStatusUpdates(int lastKnownSerial) {
Log.i(TAG, "getStatusUpdates");
return WebxdcActivity.this.dcContext.getWebxdcStatusUpdates(WebxdcActivity.this.dcAppMsg.getId(), lastKnownSerial );
}
@JavascriptInterface
public String sendToChat(String message) {
Log.i(TAG, "sendToChat");
try {
JSONObject jsonObject = new JSONObject(message);
String text = null;
byte[] data = null;
String name = null;
String type = null;
if (jsonObject.has("base64")) {
data = Base64.decode(jsonObject.getString("base64"), Base64.NO_WRAP | Base64.NO_PADDING);
name = jsonObject.getString("name");
}
if (jsonObject.has("type")) {
type = jsonObject.getString("type");
}
if (jsonObject.has("text")) {
text = jsonObject.getString("text");
}
if (TextUtils.isEmpty(text) && TextUtils.isEmpty(name)) {
return "provided file is invalid, you need to set both name and base64 content";
}
DcHelper.sendToChat(WebxdcActivity.this, data, type, name, text);
return null;
} catch (Exception e) {
e.printStackTrace();
return e.toString();
}
}
}
}