aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 10:05:10 -0700
committerAdam Soltys <[email protected]>2026-09-12 10:05:10 -0700
commita846211262f37238c001e5210938749294f8db6a (patch)
treeeb2f1c313051a739ef18a32d39bc4988a63e44fd
parentcab447766734cb99821c36988ebc8d27a0bce228 (diff)
downloadcoinosv3-a846211262f37238c001e5210938749294f8db6a.tar.gz
coinosv3-a846211262f37238c001e5210938749294f8db6a.zip
UnifiedPush: reach a phone that has no Google in it.
Web push on Android is Firebase's, so a phone without Google Play Services — GrapheneOS, CalyxOS, any de-Googled Android — cannot be woken by a web app at all. UnifiedPush is the way that doesn't involve Google: the user runs a distributor (ntfy and friends), apps ask it for an endpoint, and whoever wants to reach the device POSTs to that endpoint. THE WRAPPER speaks the protocol directly — four broadcasts each way, so it pulls in no library; an APK this small has no business growing a Kotlin runtime for eight intents. It asks the distributor on every launch (the call is idempotent, and it's how a distributor installed later gets noticed), raises a notification when a message arrives, and acknowledges it so the distributor stops resending. THE ENDPOINT reaches the web app on the launch URL as ?up=, the same road payment intents already travel — because the distributor talks to the app and only the web app knows which pubkeys are worth watching. It's registered with the notifier in place of a browser subscription, for notifications and for NWC alike, and needs neither permission nor push service. THE NOTIFIER sends to either kind: one deliver() that POSTs the payload to a UnifiedPush endpoint, or hands it to web-push as before. Same payloads, same 404/410 pruning. The endpoint is the only secret there, so it's never logged in full. Verified: a POST to a live ntfy endpoint delivers the payload byte for byte, so the app parses exactly what the notifier sent; the APK builds, carries the receiver, and is signed with the certificate assetlinks already pins (v1.6). tools/unifiedpush-test.js drives a browser with no push service and checks the endpoint is taken off the URL, kept, and registered. Honest about the limit: a UnifiedPush message wakes the ANDROID app, not Chrome's service worker, and the wallet's keys live in the browser's storage where nothing here can reach them. So a zap request arrives as a notification you tap, and the wallet answers when it opens — one tap instead of none. Automatic answering while closed still needs a browser with a push service. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--android/app/build.gradle4
-rw-r--r--android/app/src/main/AndroidManifest.xml22
-rw-r--r--android/app/src/main/java/io/coinos/app/LauncherActivity.java39
-rw-r--r--android/app/src/main/java/io/coinos/app/Push.java149
-rw-r--r--android/app/src/main/java/io/coinos/app/PushReceiver.java110
-rw-r--r--android/app/src/main/res/values/strings.xml7
-rw-r--r--nwcpush/server.js35
-rw-r--r--src/features/messages.js30
-rw-r--r--src/features/nwc.js40
-rw-r--r--static/coinos.apkbin429707 -> 434639 bytes
-rw-r--r--tools/unifiedpush-test.js83
11 files changed, 504 insertions, 15 deletions
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2a9bfc8..e11ce56 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId 'io.coinos.app'
minSdk 24
targetSdk 35
- versionCode 6
- versionName '1.5'
+ versionCode 7
+ versionName '1.6'
}
signingConfigs {
release {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e2f4e16..36f9d1e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- NFC is a way in (bolt cards, NDEF tags), never a requirement -->
<uses-feature android:name="android.hardware.nfc" android:required="false" />
+ <!-- our own notifications, raised from UnifiedPush messages (Android 13+) -->
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+ <!-- Which apps can act as a UnifiedPush distributor: Android 11+ hides the
+ package list unless we say what we're looking for. -->
+ <queries>
+ <intent>
+ <action android:name="org.unifiedpush.android.distributor.REGISTER" />
+ </intent>
+ </queries>
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
@@ -86,5 +95,18 @@
<data android:scheme="https" android:host="v3.coinos.io" />
</intent-filter>
</activity>
+ <!-- UnifiedPush: the distributor broadcasts here. Web push needs Google
+ Play Services on Android, so on a de-Googled phone this is the only
+ way the wallet can be reached at all while it isn't open. -->
+ <receiver
+ android:name="io.coinos.app.PushReceiver"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
+ <action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
+ <action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
+ <action android:name="org.unifiedpush.android.connector.MESSAGE" />
+ </intent-filter>
+ </receiver>
</application>
</manifest>
diff --git a/android/app/src/main/java/io/coinos/app/LauncherActivity.java b/android/app/src/main/java/io/coinos/app/LauncherActivity.java
index c3950ac..654265b 100644
--- a/android/app/src/main/java/io/coinos/app/LauncherActivity.java
+++ b/android/app/src/main/java/io/coinos/app/LauncherActivity.java
@@ -1,19 +1,54 @@
package io.coinos.app;
+import android.Manifest;
+import android.content.pm.PackageManager;
import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+
+import java.net.URLEncoder;
/**
* The TWA launcher. Scheme/NFC intents arrive via IntentActivity (the
* trampoline that survives the running-TWA case); this activity only needs
* to honor a full URL handed to it, falling back to the site root.
+ *
+ * <p>It also carries the UnifiedPush endpoint across: the Android side is
+ * what a distributor talks to, and the web app is what knows which pubkeys
+ * to watch, so the endpoint rides into the web app as ?up= exactly the way
+ * payment intents ride in as ?u=.
*/
public class LauncherActivity
extends com.google.androidbrowserhelper.trusted.LauncherActivity {
@Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ // Ask the distributor on every launch: it's idempotent, and it's how a
+ // distributor installed after us ever gets noticed.
+ Push.register(this);
+ // Android 13+ won't show our notifications without this, and the TWA's
+ // own permission covers the browser's, not ours.
+ if (Build.VERSION.SDK_INT >= 33
+ && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ && Push.distributor(this) != null) {
+ try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, 1); } catch (Exception ignored) {}
+ }
+ }
+
+ @Override
protected Uri getLaunchingUrl() {
Uri data = getIntent().getData();
- if (data != null && "v3.coinos.io".equals(data.getHost())) return data;
- return super.getLaunchingUrl();
+ Uri base = (data != null && "v3.coinos.io".equals(data.getHost())) ? data : super.getLaunchingUrl();
+ String endpoint = Push.endpoint(this);
+ if (endpoint == null || Push.endpointDelivered(this)) return base;
+ try {
+ String sep = base.toString().contains("?") ? "&" : "?";
+ Uri withEndpoint = Uri.parse(base + sep + "up=" + URLEncoder.encode(endpoint, "UTF-8"));
+ Push.markEndpointDelivered(this);
+ return withEndpoint;
+ } catch (Exception e) {
+ return base;
+ }
}
}
diff --git a/android/app/src/main/java/io/coinos/app/Push.java b/android/app/src/main/java/io/coinos/app/Push.java
new file mode 100644
index 0000000..1c4bee5
--- /dev/null
+++ b/android/app/src/main/java/io/coinos/app/Push.java
@@ -0,0 +1,149 @@
+package io.coinos.app;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * UnifiedPush, spoken directly.
+ *
+ * <p>Web push on Android goes through Firebase, so a phone without Google
+ * Play Services — GrapheneOS, CalyxOS, any de-Googled Android — cannot be
+ * woken by a web app at all. UnifiedPush is the answer that doesn't involve
+ * Google: the user installs a distributor (ntfy and friends), apps ask it for
+ * an endpoint, and whoever wants to reach the device POSTs to that endpoint.
+ *
+ * <p>The protocol is four broadcasts each way, so this talks it directly
+ * rather than pulling in the connector library — a wrapper this small has no
+ * business growing a Kotlin runtime for eight intents.
+ *
+ * <p>The endpoint is handed to the web app (which is the only part that knows
+ * WHAT to watch for) on the next launch, as ?up= on the TWA's URL — the same
+ * road payment intents already travel.
+ */
+public final class Push {
+ private Push() {}
+
+ // spoken to the distributor
+ static final String ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER";
+ static final String ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER";
+ static final String ACTION_MESSAGE_ACK = "org.unifiedpush.android.distributor.MESSAGE_ACK";
+ // heard from it
+ static final String ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT";
+ static final String ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED";
+ static final String ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED";
+ static final String ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE";
+
+ static final String EXTRA_TOKEN = "token";
+ static final String EXTRA_APPLICATION = "application";
+ static final String EXTRA_ENDPOINT = "endpoint";
+ static final String EXTRA_MESSAGE = "message";
+ static final String EXTRA_BYTES_MESSAGE = "bytesMessage";
+ static final String EXTRA_ID = "id";
+
+ private static final String PREFS = "unifiedpush";
+ private static final String KEY_TOKEN = "token";
+ private static final String KEY_DIST = "distributor";
+ private static final String KEY_ENDPOINT = "endpoint";
+ private static final String KEY_SENT = "endpointSent";
+
+ static SharedPreferences prefs(Context c) {
+ return c.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ /** Our registration token — one per install, and the distributor's name for us. */
+ static String token(Context c) {
+ SharedPreferences p = prefs(c);
+ String t = p.getString(KEY_TOKEN, null);
+ if (t == null) {
+ t = UUID.randomUUID().toString();
+ p.edit().putString(KEY_TOKEN, t).apply();
+ }
+ return t;
+ }
+
+ /** Every app on the phone that can act as a distributor. */
+ static List<String> distributors(Context c) {
+ List<String> out = new ArrayList<>();
+ Intent probe = new Intent(ACTION_REGISTER);
+ for (ResolveInfo ri : c.getPackageManager().queryBroadcastReceivers(probe, 0)) {
+ if (ri.activityInfo != null && ri.activityInfo.packageName != null
+ && !out.contains(ri.activityInfo.packageName)) {
+ out.add(ri.activityInfo.packageName);
+ }
+ }
+ return out;
+ }
+
+ static String distributor(Context c) {
+ String saved = prefs(c).getString(KEY_DIST, null);
+ List<String> all = distributors(c);
+ // a distributor that's been uninstalled is not a distributor
+ if (saved != null && all.contains(saved)) return saved;
+ if (all.isEmpty()) return null;
+ String pick = all.get(0);
+ prefs(c).edit().putString(KEY_DIST, pick).apply();
+ return pick;
+ }
+
+ static String endpoint(Context c) {
+ return prefs(c).getString(KEY_ENDPOINT, null);
+ }
+
+ static void setEndpoint(Context c, String endpoint) {
+ // a NEW endpoint has to reach the web app again, so the "already handed
+ // over" mark is cleared with it
+ prefs(c).edit().putString(KEY_ENDPOINT, endpoint).putBoolean(KEY_SENT, false).apply();
+ }
+
+ /** Whether the web app has already been handed this endpoint. */
+ static boolean endpointDelivered(Context c) {
+ return prefs(c).getBoolean(KEY_SENT, false);
+ }
+
+ static void markEndpointDelivered(Context c) {
+ prefs(c).edit().putBoolean(KEY_SENT, true).apply();
+ }
+
+ /**
+ * Ask the distributor for an endpoint. Cheap and idempotent: a distributor
+ * that already knows this token answers with the endpoint it already gave,
+ * so this can run on every launch without churn.
+ */
+ static void register(Context c) {
+ String dist = distributor(c);
+ if (dist == null) return; // nothing installed to ask
+ Intent i = new Intent(ACTION_REGISTER);
+ i.setPackage(dist);
+ i.putExtra(EXTRA_TOKEN, token(c));
+ i.putExtra(EXTRA_APPLICATION, c.getPackageName());
+ c.sendBroadcast(i);
+ }
+
+ static void unregister(Context c) {
+ String dist = distributor(c);
+ if (dist == null) return;
+ Intent i = new Intent(ACTION_UNREGISTER);
+ i.setPackage(dist);
+ i.putExtra(EXTRA_TOKEN, token(c));
+ c.sendBroadcast(i);
+ prefs(c).edit().remove(KEY_ENDPOINT).putBoolean(KEY_SENT, false).apply();
+ }
+
+ /** Tell the distributor a message arrived, so it stops resending it. */
+ static void ack(Context c, String id) {
+ String dist = distributor(c);
+ if (dist == null || id == null) return;
+ Intent i = new Intent(ACTION_MESSAGE_ACK);
+ i.setPackage(dist);
+ i.putExtra(EXTRA_TOKEN, token(c));
+ i.putExtra(EXTRA_ID, id);
+ c.sendBroadcast(i);
+ }
+}
diff --git a/android/app/src/main/java/io/coinos/app/PushReceiver.java b/android/app/src/main/java/io/coinos/app/PushReceiver.java
new file mode 100644
index 0000000..07cabeb
--- /dev/null
+++ b/android/app/src/main/java/io/coinos/app/PushReceiver.java
@@ -0,0 +1,110 @@
+package io.coinos.app;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+
+import org.json.JSONObject;
+
+/**
+ * What the distributor says, and what we do about it.
+ *
+ * <p>A UnifiedPush message wakes THIS app, not the browser — so it can raise
+ * a notification, and tapping that opens the wallet, which then answers
+ * whatever was waiting on the relay. It cannot answer while still closed the
+ * way a service worker does: the wallet's keys live in the browser's storage
+ * for its own origin, and nothing here can reach into that. One tap instead
+ * of none is the honest version of this on a phone without Play Services.
+ */
+public class PushReceiver extends BroadcastReceiver {
+
+ private static final String CHANNEL = "coinos";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action == null) return;
+ switch (action) {
+ case Push.ACTION_NEW_ENDPOINT: {
+ String endpoint = intent.getStringExtra(Push.EXTRA_ENDPOINT);
+ if (endpoint != null && !endpoint.isEmpty()) Push.setEndpoint(context, endpoint);
+ break;
+ }
+ case Push.ACTION_REGISTRATION_FAILED:
+ case Push.ACTION_UNREGISTERED: {
+ Push.prefs(context).edit().remove("endpoint").putBoolean("endpointSent", false).apply();
+ break;
+ }
+ 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);
+ Push.ack(context, intent.getStringExtra(Push.EXTRA_ID));
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ /**
+ * The payload is the same JSON the web push path sends. We show what it
+ * describes; anything we don't recognise still gets a notification, because
+ * silence is the one answer that's always wrong here.
+ */
+ private void notify(Context c, String payload) {
+ String title = c.getString(R.string.app_name);
+ String text = c.getString(R.string.push_generic);
+ String deepLink = null;
+ try {
+ JSONObject j = new JSONObject(payload == null ? "{}" : payload);
+ String reason = j.optString("reason", j.optString("type", ""));
+ if (j.has("title")) title = j.optString("title", title);
+ if (j.has("body")) text = j.optString("body", text);
+ else if ("payment".equals(reason)) text = c.getString(R.string.push_payment);
+ else if ("dm".equals(reason)) text = c.getString(R.string.push_dm);
+ else if ("chat".equals(reason)) text = c.getString(R.string.push_chat);
+ else if ("nwc".equals(reason) || "request".equals(reason)) text = c.getString(R.string.push_nwc);
+ if (j.has("url")) deepLink = j.optString("url", null);
+ } catch (Exception ignored) {
+ // a payload we can't parse is still a reason to tell the user something
+ }
+
+ NotificationManager nm = (NotificationManager) c.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (nm == null) return;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel ch = new NotificationChannel(CHANNEL, c.getString(R.string.app_name),
+ NotificationManager.IMPORTANCE_DEFAULT);
+ nm.createNotificationChannel(ch);
+ }
+
+ Intent open = new Intent(c, LauncherActivity.class);
+ open.setAction(Intent.ACTION_VIEW);
+ open.setData(Uri.parse(deepLink != null && deepLink.startsWith("https://v3.coinos.io")
+ ? deepLink : "https://v3.coinos.io/"));
+ open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
+ PendingIntent pi = PendingIntent.getActivity(c, 0, open, flags);
+
+ Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ ? new Notification.Builder(c, CHANNEL)
+ : new Notification.Builder(c);
+ b.setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setAutoCancel(true)
+ .setContentIntent(pi);
+ try {
+ nm.notify((int) (System.currentTimeMillis() % 100000), b.build());
+ } catch (SecurityException e) {
+ // POST_NOTIFICATIONS not granted — nothing to be done from a receiver
+ }
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8a4876a..8eca245 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,6 +1,13 @@
<?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>
+ <string name="push_payment">You were paid</string>
+ <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>
<!-- must match the site's /.well-known/assetlinks.json for URL-bar-less TWA -->
<string name="asset_statements">
[{
diff --git a/nwcpush/server.js b/nwcpush/server.js
index cd7d28f..d441bdd 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -48,6 +48,35 @@ const REQ_KIND = 23194;
const OFFER_KIND = 21001;
webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey);
+
+// Two ways to reach a device.
+//
+// Web Push is the browser's, and on Android it is Firebase's — a phone
+// without Google Play Services (GrapheneOS and friends) can't subscribe at
+// all. UnifiedPush is the other way: the user runs a distributor, the app
+// gets an endpoint, and anyone wanting to reach the device POSTs the payload
+// to it. No encryption envelope and no VAPID — the endpoint is the secret,
+// which is why we never log one in full.
+//
+// A subscription marked `unifiedpush` is the second kind; everything else is
+// a browser's. One function, so every send path serves both.
+const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string');
+async function deliver(sub, payload, opts = {}) {
+ if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts);
+ const res = await fetch(sub.endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: payload,
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) {
+ // shaped like web-push's errors so the callers' 404/410 pruning works
+ const err = new Error(`unifiedpush ${res.status}`);
+ err.statusCode = res.status;
+ throw err;
+ }
+ return res;
+}
const log = (...a) => console.log(new Date().toISOString(), ...a);
// ---------------------------------------------------------------------------
@@ -158,14 +187,14 @@ async function pushNotify(id, r, reason, extra) {
const payload = JSON.stringify({ type: 'notify', reason, ...extra });
try {
try {
- await webpush.sendNotification(r.sub, payload, { TTL: 3600, urgency: 'normal' });
+ await deliver(r.sub, payload, { TTL: 3600, urgency: 'normal' });
} catch (e) {
// A payload the push service won't take (413, or the library refusing
// the size) must not cost the notification itself — send the bare nudge
// and let the device show its generic message.
if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e;
log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`);
- await webpush.sendNotification(r.sub, JSON.stringify({ type: 'notify', reason }), { TTL: 3600, urgency: 'normal' });
+ await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), { TTL: 3600, urgency: 'normal' });
}
log(`notify(${reason}) -> device ${id}`);
} catch (e) {
@@ -261,7 +290,7 @@ async function wake(servicePk, eventId, ev) {
let ok = 0;
for (const r of targets) {
try {
- await webpush.sendNotification(r.sub, payload, { TTL: 60, urgency: 'high' });
+ await deliver(r.sub, payload, { TTL: 60, urgency: 'high' });
ok++;
} catch (e) {
// 404/410 mean the browser dropped the subscription: forget it.
diff --git a/src/features/messages.js b/src/features/messages.js
index b12efa7..5ff1492 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1267,8 +1267,38 @@ export function messagesFeature(ctx) {
} catch { inboxAt = 0; }
}
+ // The Android wrapper hands us a UnifiedPush endpoint on its launch URL
+ // (?up=), because the distributor talks to the app and only the web app
+ // knows which pubkeys are worth watching. On a phone without Google Play
+ // Services this is the ONLY way to be reached while closed, so it takes
+ // precedence over the browser's own push — which there doesn't work at all.
+ const UP_KEY = 'coinos-unifiedpush';
+ const upEndpoint = () => { try { return localStorage.getItem(UP_KEY) || null; } catch { return null; } };
+ (() => {
+ try {
+ const u = new URLSearchParams(location.search).get('up');
+ if (!u) return;
+ localStorage.setItem(UP_KEY, u);
+ // tidy the URL the way the payment intent does
+ history.replaceState(null, '', location.pathname || '/');
+ } catch {}
+ })();
+
async function registerPush({ interactive = false } = {}) {
try {
+ // A UnifiedPush endpoint needs no permission and no push service: it's
+ // a URL the distributor gave the wrapper, and the notifier POSTs to it.
+ const up = upEndpoint();
+ if (up) {
+ const r = await fetch(`${NOTIFIER}/register`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ subscription: { endpoint: up, unifiedpush: true }, notify: pushWatch() }),
+ });
+ if (!r.ok) return false;
+ const s2 = st();
+ if (!s2.push || s2.noPushService) { s2.push = true; delete s2.noPushService; save(s2); }
+ return true;
+ }
if (typeof Notification === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window)) return false;
if (Notification.permission !== 'granted') {
if (!interactive) return false;
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 077b53e..dd0141d 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -595,7 +595,28 @@ export function nwcFeature(ctx) {
return /push service|Registration failed|AbortError|NotSupportedError|permission denied/i.test(m);
};
+ // The Android wrapper's UnifiedPush endpoint, if it handed us one (see
+ // messages.js, which reads it off the launch URL). It needs no permission
+ // and no push service, and on a phone without Google Play Services it is
+ // the only way to be reached at all.
+ const upEndpoint = () => { try { return localStorage.getItem('coinos-unifiedpush') || null; } catch { return null; } };
+
async function enableBackground() {
+ const up = upEndpoint();
+ if (up) {
+ const pksUp = conns().map((c) => c.servicePk);
+ if (load().offer) pksUp.push(load().offer.pk);
+ if (pksUp.length) {
+ const r = await fetch(`${NOTIFIER}/register`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ subscription: { endpoint: up, unifiedpush: true }, servicePubkeys: pksUp }),
+ });
+ if (!r.ok) throw new Error(`notifier refused: ${r.status}`);
+ }
+ const stUp = load(); stUp.background = true; delete stUp.pushUnavailable; save(stUp);
+ writeBg();
+ return true;
+ }
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error(t('nwcNoPushHere'));
}
@@ -643,11 +664,13 @@ export function nwcFeature(ctx) {
async function ensureBackground(interactive) {
const st = load();
if (st.background || st.backgroundOff) return;
- if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)
- || typeof window === 'undefined' || !('PushManager' in window)
- || typeof Notification === 'undefined') return;
if (!conns().length) return;
- if (!interactive && Notification.permission !== 'granted') return;
+ if (!upEndpoint()) {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)
+ || typeof window === 'undefined' || !('PushManager' in window)
+ || typeof Notification === 'undefined') return;
+ if (!interactive && Notification.permission !== 'granted') return;
+ }
try {
await enableBackground();
await reconcileBg();
@@ -691,15 +714,16 @@ export function nwcFeature(ctx) {
clearTimeout(regTimer);
regTimer = setTimeout(async () => {
try {
- const reg = await navigator.serviceWorker.ready;
- const sub = await reg.pushManager.getSubscription();
- if (!sub) return;
const pks = conns().map((c) => c.servicePk);
if (load().offer) pks.push(load().offer.pk);
if (!pks.length) return;
+ const up2 = upEndpoint();
+ const sub = up2 ? { endpoint: up2, unifiedpush: true }
+ : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON());
+ if (!sub) return;
await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: pks }),
+ body: JSON.stringify({ subscription: sub, servicePubkeys: pks }),
});
} catch (e) { console.warn('nwc: notifier re-register failed', e.message); }
}, 1500);
diff --git a/static/coinos.apk b/static/coinos.apk
index c677fb3..6bef026 100644
--- a/static/coinos.apk
+++ b/static/coinos.apk
Binary files differ
diff --git a/tools/unifiedpush-test.js b/tools/unifiedpush-test.js
new file mode 100644
index 0000000..71fbcde
--- /dev/null
+++ b/tools/unifiedpush-test.js
@@ -0,0 +1,83 @@
+// UnifiedPush: the way a phone without Google Play Services can be reached.
+//
+// The Android wrapper asks a distributor (ntfy and friends) for an endpoint
+// and hands it to the web app on the launch URL as ?up=. The web app is what
+// knows which pubkeys to watch, so it does the registering; the notifier then
+// POSTs payloads straight to that endpoint instead of through Firebase.
+//
+// This checks the web half: the endpoint is picked up, kept, registered with
+// the notifier, and used in place of a browser subscription that could never
+// have worked on such a phone.
+//
+// Run: bun tools/unifiedpush-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// a phone with no push service at all, which is the whole point of the exercise
+await page.evaluateOnNewDocument(() => {
+ const reg = {
+ pushManager: {
+ getSubscription: () => Promise.resolve(null),
+ subscribe: () => Promise.reject(new DOMException('Registration failed - push service error', 'AbortError')),
+ },
+ showNotification: () => Promise.resolve(),
+ };
+ Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve(reg) });
+ Notification.requestPermission = () => Promise.resolve('granted');
+ Object.defineProperty(Notification, 'permission', { get: () => 'granted' });
+});
+// watch what it tries to tell the notifier
+const registrations = [];
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ if (r.url().includes('nwcpush.coinos.io/register') && r.method() === 'POST') {
+ try { registrations.push(JSON.parse(r.postData() || '{}')); } catch {}
+ return r.respond({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
+ }
+ if (r.url().includes('nwcpush.coinos.io/vapid')) return r.respond({ status: 200, contentType: 'application/json', body: '{"publicKey":"BKd0FOtD3Y9gAB3ZFDkCPlBtm0IhEbiKUeYUCE7Tuh9nWDOAhBKiZ0dPYjYQkHhVYyPNPUn0E-gLRPPqzYyAWmA"}' });
+ return r.continue();
+});
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms/250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const ENDPOINT = 'https://ntfy.sh/upTESTendpoint123';
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('get started'); await sleep(500);
+ await click('import existing'); await sleep(400);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', generateMnemonic(wordlist));
+ await click('open wallet');
+ await waitText('receive', 20000);
+
+ // the wrapper launches the web app with the endpoint on the URL
+ await page.goto('http://localhost:5263/?up=' + encodeURIComponent(ENDPOINT), { waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(2500);
+
+ const kept = await page.evaluate(() => localStorage.getItem('coinos-unifiedpush'));
+ check('the endpoint is taken off the launch URL and kept', kept === ENDPOINT, kept || 'nothing stored');
+ check('...and the URL is tidied afterwards', !(await page.url()).includes('up='), await page.url());
+
+ const up = registrations.find((r) => r.subscription && r.subscription.unifiedpush);
+ check('the notifier is told to use it', !!up, JSON.stringify(registrations.map((r) => Object.keys(r))) || 'no registration');
+ check('...with the endpoint, marked as UnifiedPush', up && up.subscription.endpoint === ENDPOINT, up ? up.subscription.endpoint : '');
+ check('...and the watch list rides along', !!(up && up.notify), up ? Object.keys(up.notify || {}).join(',') : '');
+
+ 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));
+} 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);