diff options
| author | Adam Soltys <[email protected]> | 2026-09-12 10:32:01 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-12 10:32:01 -0700 |
| commit | 60e755286302647d93bc82070c61fcf93ddbc3b9 (patch) | |
| tree | 5cbe3c780010e975d61193138a29870f295427af /android | |
| parent | a846211262f37238c001e5210938749294f8db6a (diff) | |
| download | coinosv3-60e755286302647d93bc82070c61fcf93ddbc3b9.tar.gz coinosv3-60e755286302647d93bc82070c61fcf93ddbc3b9.zip | |
A second Android build that can wake the wallet, because it holds it.
The TWA can't. Chrome renders the site, so the wallet's keys live in Chrome's
storage for its own origin, and nothing outside Chrome can reach them — a
UnifiedPush message could raise a notification and no more. On a phone with
Google Play Services none of this matters; without it, background answering
was simply unavailable.
So: a second flavour, io.coinos.webview, "Coinos (UnifiedPush)". The wallet
runs in a WebView this app owns, which means a push can start it off-screen
and let it answer for itself. WakeService loads it with ?wake=<payload> into
the same storage the visible app uses, the page subscribes and answers
whatever the payload was about, then calls CoinosHost.done() so the service
stops — capped at 25 seconds from the page and 30 from the service, because
nothing here is allowed to sit in the background costing battery. Its
foreground notification says what it's doing rather than apologising for
existing.
The WebView carries what the wallet actually needs: DOM storage and
IndexedDB, the camera for the QR scanner (granted only once Android has
granted it to us), a file chooser for attaching pictures, and links to
anywhere but coinos.io handed to a real browser.
The trade is named in the code and worth naming here: a separate app is a
separate wallet. Its storage is its own, so signing in there is signing in
again. That's why this is a second build rather than a replacement — the TWA
is untouched, still the better browser for anyone whose phone has push.
The shared push receiver now asks the system for whatever front door this
build has, and asks a per-flavour Wake class whether the wallet can be woken
at all — no flavour checks in the middle of it. Both APKs build and ship:
coinos.apk as before, coinos-unifiedpush.apk beside it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'android')
| -rw-r--r-- | android/app/build.gradle | 25 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/PushReceiver.java | 11 | ||||
| -rw-r--r-- | android/app/src/main/res/values/strings.xml | 3 | ||||
| -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.java | 21 | ||||
| -rw-r--r-- | android/app/src/twa/res/values/strings.xml | 4 | ||||
| -rw-r--r-- | android/app/src/webview/AndroidManifest.xml | 50 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/MainActivity.java | 150 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/Wake.java | 28 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/WakeService.java | 132 |
11 files changed, 420 insertions, 4 deletions
diff --git a/android/app/build.gradle b/android/app/build.gradle index e11ce56..532db83 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,6 +14,28 @@ android { versionCode 7 versionName '1.6' } + // Two builds of the same wallet. + // + // twa — Chrome renders the site (the better browser, and the one + // people already have installed). Its storage is Chrome's, so + // nothing outside Chrome can wake the wallet, and on a phone + // without Google Play Services nothing inside it can either. + // webview — the wallet runs in the app, so a UnifiedPush message can + // start it off-screen and let it answer. A separate app id, + // because it is a separate wallet: its storage is its own. + flavorDimensions += 'shell' + productFlavors { + twa { + dimension 'shell' + isDefault = true + } + webview { + dimension 'shell' + applicationId 'io.coinos.webview' + versionNameSuffix '-webview' + resValue 'string', 'app_name', 'Coinos (UnifiedPush)' + } + } signingConfigs { release { if (keystoreFile.exists()) { @@ -32,5 +54,6 @@ android { } } dependencies { - implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' + // only the TWA shell needs Chrome's helper; the WebView build carries none + twaImplementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' } 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(); + } +} |
