aboutsummaryrefslogtreecommitdiffstats
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/main/java/io/coinos/app/PushReceiver.java11
-rw-r--r--android/app/src/main/res/values/strings.xml3
-rw-r--r--android/app/src/twa/java/io/coinos/app/IntentActivity.java (renamed from android/app/src/main/java/io/coinos/app/IntentActivity.java)0
-rw-r--r--android/app/src/twa/java/io/coinos/app/LauncherActivity.java (renamed from android/app/src/main/java/io/coinos/app/LauncherActivity.java)0
-rw-r--r--android/app/src/twa/java/io/coinos/app/Wake.java21
-rw-r--r--android/app/src/twa/res/values/strings.xml4
-rw-r--r--android/app/src/webview/AndroidManifest.xml50
-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
10 files changed, 396 insertions, 3 deletions
diff --git a/android/app/src/main/java/io/coinos/app/PushReceiver.java b/android/app/src/main/java/io/coinos/app/PushReceiver.java
index 07cabeb..faabfaf 100644
--- a/android/app/src/main/java/io/coinos/app/PushReceiver.java
+++ b/android/app/src/main/java/io/coinos/app/PushReceiver.java
@@ -44,7 +44,11 @@ public class PushReceiver extends BroadcastReceiver {
case Push.ACTION_MESSAGE: {
byte[] bytes = intent.getByteArrayExtra(Push.EXTRA_BYTES_MESSAGE);
String body = bytes != null ? new String(bytes) : intent.getStringExtra(Push.EXTRA_MESSAGE);
- notify(context, body);
+ // On the WebView build the wallet lives in THIS app's storage, so it
+ // can be woken and told to deal with whatever arrived — no tap. On
+ // the TWA build the wallet is Chrome's and out of reach, so the
+ // notification is the whole of what we can do.
+ if (!Wake.start(context, body)) notify(context, body);
Push.ack(context, intent.getStringExtra(Push.EXTRA_ID));
break;
}
@@ -84,7 +88,10 @@ public class PushReceiver extends BroadcastReceiver {
nm.createNotificationChannel(ch);
}
- Intent open = new Intent(c, LauncherActivity.class);
+ // whichever front door this build has — the TWA's launcher or the
+ // WebView's main activity; the receiver is shared by both
+ Intent open = c.getPackageManager().getLaunchIntentForPackage(c.getPackageName());
+ if (open == null) open = new Intent(Intent.ACTION_VIEW);
open.setAction(Intent.ACTION_VIEW);
open.setData(Uri.parse(deepLink != null && deepLink.startsWith("https://v3.coinos.io")
? deepLink : "https://v3.coinos.io/"));
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8eca245..8132a82 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
- <string name="app_name">Coinos</string>
<!-- UnifiedPush notifications: what a woken app says before you open it.
The payload names a category; the wallet itself has the detail. -->
<string name="push_generic">Something happened in your wallet</string>
@@ -8,6 +7,8 @@
<string name="push_dm">New message</string>
<string name="push_chat">New message in a group</string>
<string name="push_nwc">An app is asking to pay — open Coinos to approve</string>
+ <string name="push_nwc_answering">Answering a payment request…</string>
+ <string name="wake_working">Checking your wallet…</string>
<!-- must match the site's /.well-known/assetlinks.json for URL-bar-less TWA -->
<string name="asset_statements">
[{
diff --git a/android/app/src/main/java/io/coinos/app/IntentActivity.java b/android/app/src/twa/java/io/coinos/app/IntentActivity.java
index 3de1d62..3de1d62 100644
--- a/android/app/src/main/java/io/coinos/app/IntentActivity.java
+++ b/android/app/src/twa/java/io/coinos/app/IntentActivity.java
diff --git a/android/app/src/main/java/io/coinos/app/LauncherActivity.java b/android/app/src/twa/java/io/coinos/app/LauncherActivity.java
index 654265b..654265b 100644
--- a/android/app/src/main/java/io/coinos/app/LauncherActivity.java
+++ b/android/app/src/twa/java/io/coinos/app/LauncherActivity.java
diff --git a/android/app/src/twa/java/io/coinos/app/Wake.java b/android/app/src/twa/java/io/coinos/app/Wake.java
new file mode 100644
index 0000000..558a6f5
--- /dev/null
+++ b/android/app/src/twa/java/io/coinos/app/Wake.java
@@ -0,0 +1,21 @@
+package io.coinos.app;
+
+import android.content.Context;
+
+/**
+ * Whether this build can wake the wallet itself.
+ *
+ * <p>This is the TWA build's answer: no. The wallet lives in Chrome's storage
+ * for its own origin, and nothing in this app can reach into that, so a push
+ * can only raise a notification for someone to tap. The WebView build has its
+ * own version of this class (src/webview) that says yes. One class per
+ * flavor, so the shared receiver needs no flavor checks.
+ */
+final class Wake {
+ private Wake() {}
+
+ /** @return true when the wallet was woken and the notification isn't needed. */
+ static boolean start(Context context, String payload) {
+ return false;
+ }
+}
diff --git a/android/app/src/twa/res/values/strings.xml b/android/app/src/twa/res/values/strings.xml
new file mode 100644
index 0000000..1fc8cc7
--- /dev/null
+++ b/android/app/src/twa/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">Coinos</string>
+</resources>
diff --git a/android/app/src/webview/AndroidManifest.xml b/android/app/src/webview/AndroidManifest.xml
new file mode 100644
index 0000000..e88217b
--- /dev/null
+++ b/android/app/src/webview/AndroidManifest.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- The WebView build: the wallet runs in this app, so this app can wake it.
+ See MainActivity for why that trade exists. -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.CAMERA" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
+ <uses-feature android:name="android.hardware.camera" android:required="false" />
+ <application android:usesCleartextTraffic="false">
+ <activity
+ android:name="io.coinos.app.MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ <!-- the payment schemes the TWA build claims, claimed here too -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="lightning" />
+ <data android:scheme="bitcoin" />
+ <data android:scheme="nostr" />
+ <data android:scheme="lnurl" />
+ <data android:scheme="lnurlw" />
+ <data android:scheme="lnurlp" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="lightning" />
+ <data android:scheme="bitcoin" />
+ <data android:scheme="nostr" />
+ <data android:scheme="lnurl" />
+ <data android:scheme="lnurlw" />
+ <data android:scheme="lnurlp" />
+ </intent-filter>
+ </activity>
+ <!-- the off-screen wallet a push wakes -->
+ <service
+ android:name="io.coinos.app.WakeService"
+ android:exported="false"
+ android:foregroundServiceType="dataSync" />
+ </application>
+</manifest>
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();
+ }
+}