aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--android/app/build.gradle25
-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
-rw-r--r--build.js9
-rw-r--r--src/features/nwc.js31
-rw-r--r--static/coinos-unifiedpush.apkbin0 -> 38692 bytes
-rw-r--r--static/coinos.apkbin434639 -> 435555 bytes
-rw-r--r--tools/unifiedpush-test.js14
16 files changed, 471 insertions, 7 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();
+ }
+}
diff --git a/build.js b/build.js
index cdc54f5..ca0f651 100644
--- a/build.js
+++ b/build.js
@@ -469,8 +469,13 @@ if (import.meta.main) {
if (await Bun.file('static/.well-known/assetlinks.json').exists()) {
await Bun.write('dist/.well-known/assetlinks.json', Bun.file('static/.well-known/assetlinks.json'));
}
- if (await Bun.file('static/coinos.apk').exists()) {
- await Bun.write('dist/coinos.apk', Bun.file('static/coinos.apk'));
+ // The Android builds. coinos.apk is the TWA (Chrome renders the site);
+ // coinos-unifiedpush.apk runs the wallet in its own WebView so a phone
+ // without Google Play Services can be woken — see android/.
+ for (const apk of ['coinos.apk', 'coinos-unifiedpush.apk']) {
+ if (await Bun.file('static/' + apk).exists()) {
+ await Bun.write('dist/' + apk, Bun.file('static/' + apk));
+ }
}
// default avatars, self-hosted since coinos.io's copies are half-broken.
// punks-sm carries a 128px copy of each: the art is 240px because that's
diff --git a/src/features/nwc.js b/src/features/nwc.js
index dd0141d..cefeb31 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -242,6 +242,7 @@ export function nwcFeature(ctx) {
content,
}, sk);
await publish(NWC_RELAYS, evt);
+ noteAnswered(); // a woken background run can stop once this happens
} catch (e) {
console.warn('nwc: could not reply', e.message);
}
@@ -794,6 +795,34 @@ export function nwcFeature(ctx) {
});
}
+ // ---- woken by the Android wrapper --------------------------------------
+ // The WebView build starts the wallet off-screen when a push arrives, with
+ // ?wake=<payload> on the URL. There's no service worker in that picture —
+ // the page IS the background — so it does the normal thing (subscribe, let
+ // the request arrive, answer it) and then says it's finished, so the
+ // service can stop rather than sit there costing battery. A cap, because
+ // "finished" is not always reachable: the relay may have nothing for us.
+ function serveWake() {
+ let payload = null;
+ try { payload = new URLSearchParams(location.search).get('wake'); } catch {}
+ if (payload == null) return;
+ try { history.replaceState(null, '', location.pathname || '/'); } catch {}
+ const host = typeof window !== 'undefined' && window.CoinosHost;
+ listen(); // the request is on a relay; this is what hears it
+ reconcileBg(); // and make sure the pouch behind it is current
+ if (!host || typeof host.done !== 'function') return;
+ let settled = false;
+ const finish = () => { if (settled) return; settled = true; try { host.done(); } catch {} };
+ // an answer published is the signal we were woken for
+ const stop = onAnswered(() => setTimeout(finish, 1500));
+ setTimeout(() => { try { stop(); } catch {} finish(); }, 25_000);
+ }
+
+ // Callers that want to know when this wallet answered something.
+ const answeredWatchers = new Set();
+ function onAnswered(fn) { answeredWatchers.add(fn); return () => answeredWatchers.delete(fn); }
+ function noteAnswered() { for (const fn of [...answeredWatchers]) { try { fn(); } catch {} } }
+
// Watchdog: relay sockets can die in ways the pool's reconnect never heals
// (an errored socket sets skipReconnection and gives up), and a wallet
// service with a dead subscription is indistinguishable from one that's
@@ -913,7 +942,7 @@ export function nwcFeature(ctx) {
id: 'nwc',
nwcOfferPubkey() { return (hook('arkReady') && !wallet.watchOnly) ? offerKeys().pk : null; },
nwcOfferString() { return hook('arkReady') && !wallet.watchOnly ? offerString() : null; },
- init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); },
+ init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); serveWake(); },
stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } },
nostrSettingsCards() { return [nwcCard()]; },
notifySettingsCards() {
diff --git a/static/coinos-unifiedpush.apk b/static/coinos-unifiedpush.apk
new file mode 100644
index 0000000..8e6de0f
--- /dev/null
+++ b/static/coinos-unifiedpush.apk
Binary files differ
diff --git a/static/coinos.apk b/static/coinos.apk
index 6bef026..7a30775 100644
--- a/static/coinos.apk
+++ b/static/coinos.apk
Binary files differ
diff --git a/tools/unifiedpush-test.js b/tools/unifiedpush-test.js
index 71fbcde..2d656ba 100644
--- a/tools/unifiedpush-test.js
+++ b/tools/unifiedpush-test.js
@@ -78,6 +78,20 @@ try {
const txt = await page.evaluate(() => document.body.innerText);
check('the browser\'s own dead push is no longer the story', !/no push service/i.test(txt),
(txt.match(/[^\n]*push service[^\n]*/i) || [''])[0].slice(0, 50));
+
+ // The WebView build wakes the wallet off-screen with ?wake=<payload> and a
+ // CoinosHost bridge. The page has to notice, tidy the URL, and say when
+ // it's finished so the service can stop.
+ await page.evaluateOnNewDocument(() => {
+ window.__done = 0;
+ window.CoinosHost = { done: () => { window.__done++; }, notify: () => {} };
+ });
+ await page.goto('http://localhost:5263/?wake=' + encodeURIComponent(JSON.stringify({ type: 'nwc', servicePubkey: 'a'.repeat(64) })), { waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ check('a woken run takes the payload off the URL', !(await page.url()).includes('wake='), await page.url());
+ await sleep(27000); // the cap is 25s; a real answer ends it sooner
+ const done = await page.evaluate(() => window.__done);
+ check('...and tells the service when to stop', done > 0, done + ' call(s) to CoinosHost.done()');
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a de-Googled phone can be reached' : '\n❌ failed');
process.exit(ok ? 0 : 1);