aboutsummaryrefslogtreecommitdiffstats
path: root/android/app/src/webview/java/io
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/webview/java/io')
-rw-r--r--android/app/src/webview/java/io/coinos/app/MainActivity.java150
-rw-r--r--android/app/src/webview/java/io/coinos/app/Wake.java28
-rw-r--r--android/app/src/webview/java/io/coinos/app/WakeService.java132
3 files changed, 310 insertions, 0 deletions
diff --git a/android/app/src/webview/java/io/coinos/app/MainActivity.java b/android/app/src/webview/java/io/coinos/app/MainActivity.java
new file mode 100644
index 0000000..166a1b7
--- /dev/null
+++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java
@@ -0,0 +1,150 @@
+package io.coinos.app;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.ViewGroup;
+import android.webkit.CookieManager;
+import android.webkit.PermissionRequest;
+import android.webkit.ValueCallback;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+/**
+ * The wallet, in a WebView this app owns.
+ *
+ * <p>The other build is a TWA — Chrome renders the site, and the wallet's
+ * keys live in Chrome's storage. That's the better browser, but it means
+ * nothing outside Chrome can wake the wallet, and on a phone without Google
+ * Play Services nothing inside Chrome can either. This build trades the
+ * browser for reach: the wallet lives in THIS app's storage, so a
+ * UnifiedPush message can start it headlessly and let it answer (see
+ * WakeService).
+ *
+ * <p>The trade is real and worth naming: a separate app means a separate
+ * wallet. Signing in here is signing in again.
+ */
+public class MainActivity extends Activity {
+
+ static final String BASE = "https://v3.coinos.io/";
+ private WebView web;
+ private ValueCallback<Uri[]> filePicker;
+ private static final int REQ_FILE = 10, REQ_PERMS = 11;
+
+ @Override
+ protected void onCreate(Bundle saved) {
+ super.onCreate(saved);
+ web = new WebView(this);
+ setContentView(web, new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ WebSettings s = web.getSettings();
+ s.setJavaScriptEnabled(true);
+ s.setDomStorageEnabled(true); // localStorage + IndexedDB: the wallet
+ s.setDatabaseEnabled(true);
+ s.setMediaPlaybackRequiresUserGesture(false);
+ s.setSupportMultipleWindows(false);
+ s.setLoadWithOverviewMode(true);
+ s.setUseWideViewPort(true);
+ CookieManager.getInstance().setAcceptCookie(true);
+
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest r) {
+ Uri u = r.getUrl();
+ String host = u.getHost();
+ // our own pages stay in here; everything else is the world's, and
+ // belongs in a real browser
+ if (host != null && (host.equals("v3.coinos.io") || host.endsWith(".coinos.io"))) return false;
+ try { startActivity(new Intent(Intent.ACTION_VIEW, u)); } catch (Exception ignored) {}
+ return true;
+ }
+ });
+
+ web.setWebChromeClient(new WebChromeClient() {
+ @Override
+ public void onPermissionRequest(final PermissionRequest request) {
+ // the QR scanner asks for the camera; grant it only once Android has
+ // granted it to us
+ runOnUiThread(() -> {
+ if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ request.grant(request.getResources());
+ } else {
+ request.deny();
+ requestPermissions(new String[] { Manifest.permission.CAMERA }, REQ_PERMS);
+ }
+ });
+ }
+
+ @Override
+ public boolean onShowFileChooser(WebView v, ValueCallback<Uri[]> cb, FileChooserParams params) {
+ // attaching a picture to a post
+ if (filePicker != null) filePicker.onReceiveValue(null);
+ filePicker = cb;
+ try {
+ startActivityForResult(params.createIntent(), REQ_FILE);
+ return true;
+ } catch (Exception e) {
+ filePicker = null;
+ return false;
+ }
+ }
+ });
+
+ if (Build.VERSION.SDK_INT >= 33
+ && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQ_PERMS); } catch (Exception ignored) {}
+ }
+ Push.register(this);
+ web.loadUrl(urlFor(getIntent()));
+ }
+
+ /** A scheme intent (lightning:, bitcoin:, nostr:) rides in as ?u=, like the TWA's. */
+ private String urlFor(Intent intent) {
+ StringBuilder url = new StringBuilder(BASE);
+ Uri data = intent == null ? null : intent.getData();
+ if (data != null) {
+ String scheme = data.getScheme();
+ if (scheme != null && !scheme.startsWith("http")) url.append("?u=").append(Uri.encode(data.toString()));
+ else if ("v3.coinos.io".equals(data.getHost())) { url.setLength(0); url.append(data.toString()); }
+ }
+ // the UnifiedPush endpoint, on its way to the part of the app that knows
+ // what to watch for
+ String endpoint = Push.endpoint(this);
+ if (endpoint != null && !Push.endpointDelivered(this)) {
+ url.append(url.indexOf("?") >= 0 ? "&" : "?").append("up=").append(Uri.encode(endpoint));
+ Push.markEndpointDelivered(this);
+ }
+ return url.toString();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ if (web != null) web.loadUrl(urlFor(intent));
+ }
+
+ @Override
+ protected void onActivityResult(int req, int res, Intent data) {
+ if (req == REQ_FILE && filePicker != null) {
+ filePicker.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res, data));
+ filePicker = null;
+ return;
+ }
+ super.onActivityResult(req, res, data);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (web != null && web.canGoBack()) web.goBack();
+ else super.onBackPressed();
+ }
+}
diff --git a/android/app/src/webview/java/io/coinos/app/Wake.java b/android/app/src/webview/java/io/coinos/app/Wake.java
new file mode 100644
index 0000000..883ec49
--- /dev/null
+++ b/android/app/src/webview/java/io/coinos/app/Wake.java
@@ -0,0 +1,28 @@
+package io.coinos.app;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+
+/**
+ * This build CAN wake the wallet: it lives in this app's own storage, so a
+ * push starts WakeService, which loads it off-screen and lets it answer.
+ * (The TWA build's version of this class returns false — see src/main.)
+ */
+final class Wake {
+ private Wake() {}
+
+ static boolean start(Context context, String payload) {
+ try {
+ Intent i = new Intent(context, WakeService.class);
+ i.putExtra("payload", payload);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i);
+ else context.startService(i);
+ return true;
+ } catch (Exception e) {
+ // Android refuses background starts in some states; fall back to the
+ // notification, which is better than nothing happening at all
+ return false;
+ }
+ }
+}
diff --git a/android/app/src/webview/java/io/coinos/app/WakeService.java b/android/app/src/webview/java/io/coinos/app/WakeService.java
new file mode 100644
index 0000000..cd33547
--- /dev/null
+++ b/android/app/src/webview/java/io/coinos/app/WakeService.java
@@ -0,0 +1,132 @@
+package io.coinos.app;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.webkit.JavascriptInterface;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+
+/**
+ * The wallet, woken without being opened.
+ *
+ * <p>A UnifiedPush message starts this service; it loads the wallet into an
+ * off-screen WebView — the same storage the visible app uses, because it's
+ * the same app — with ?wake= naming what arrived. The page does whatever the
+ * payload calls for (answering an NWC request, checking for a payment) and
+ * calls back through the CoinosHost bridge when it's finished, or the service
+ * gives up after half a minute. Either way it stops: nothing about this is
+ * allowed to sit in the background burning battery.
+ *
+ * <p>A foreground service, because Android will not let a background one
+ * touch the network reliably. Its notification IS the point rather than an
+ * apology for it — something arrived, and this says so while it's dealt with.
+ */
+public class WakeService extends Service {
+
+ private static final String CHANNEL = "coinos-wake";
+ private static final int NOTIF_ID = 42;
+ private static final long GIVE_UP_MS = 30_000;
+
+ private WebView web;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable giveUp;
+
+ @Override
+ public IBinder onBind(Intent intent) { return null; }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ String payload = intent == null ? null : intent.getStringExtra("payload");
+ startForeground(NOTIF_ID, notification(payload));
+
+ if (web == null) {
+ web = new WebView(this);
+ WebSettings s = web.getSettings();
+ s.setJavaScriptEnabled(true);
+ s.setDomStorageEnabled(true);
+ s.setDatabaseEnabled(true);
+ web.addJavascriptInterface(new Host(), "CoinosHost");
+ }
+ // The payload rides in as a parameter the page can read; it carries the
+ // event that woke us, so the wallet needn't hunt for it on a relay.
+ String url = MainActivity.BASE + "?wake=" + Uri.encode(payload == null ? "1" : payload);
+ handler.post(() -> web.loadUrl(url));
+
+ if (giveUp != null) handler.removeCallbacks(giveUp);
+ giveUp = this::done;
+ handler.postDelayed(giveUp, GIVE_UP_MS);
+ return START_NOT_STICKY;
+ }
+
+ /** What the page calls when it has finished. */
+ private class Host {
+ @JavascriptInterface
+ public void done() { handler.post(WakeService.this::done); }
+ /** Lets the page replace our placeholder notification with the real news. */
+ @JavascriptInterface
+ public void notify(String title, String body) {
+ handler.post(() -> {
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return;
+ nm.notify(NOTIF_ID + 1, build(title, body, false));
+ });
+ }
+ }
+
+ private void done() {
+ if (giveUp != null) handler.removeCallbacks(giveUp);
+ if (web != null) { web.destroy(); web = null; }
+ stopForeground(true);
+ stopSelf();
+ }
+
+ @Override
+ public void onDestroy() {
+ if (web != null) { web.destroy(); web = null; }
+ super.onDestroy();
+ }
+
+ private Notification notification(String payload) {
+ String body = getString(R.string.wake_working);
+ try {
+ org.json.JSONObject j = new org.json.JSONObject(payload == null ? "{}" : payload);
+ String reason = j.optString("reason", j.optString("type", ""));
+ if ("nwc".equals(reason)) body = getString(R.string.push_nwc_answering);
+ else if ("payment".equals(reason)) body = getString(R.string.push_payment);
+ else if ("dm".equals(reason)) body = getString(R.string.push_dm);
+ else if ("chat".equals(reason)) body = getString(R.string.push_chat);
+ } catch (Exception ignored) {}
+ return build(getString(R.string.app_name), body, true);
+ }
+
+ private Notification build(String title, String body, boolean quiet) {
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm != null) {
+ NotificationChannel ch = new NotificationChannel(CHANNEL, getString(R.string.app_name),
+ quiet ? NotificationManager.IMPORTANCE_LOW : NotificationManager.IMPORTANCE_DEFAULT);
+ nm.createNotificationChannel(ch);
+ }
+ Intent open = new Intent(this, MainActivity.class);
+ open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ int flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? android.app.PendingIntent.FLAG_IMMUTABLE : 0);
+ Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ ? new Notification.Builder(this, CHANNEL)
+ : new Notification.Builder(this);
+ return b.setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setAutoCancel(true)
+ .setContentIntent(android.app.PendingIntent.getActivity(this, 0, open, flags))
+ .build();
+ }
+}