diff options
| author | Adam Soltys <[email protected]> | 2026-09-12 10:05:10 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-12 10:05:10 -0700 |
| commit | a846211262f37238c001e5210938749294f8db6a (patch) | |
| tree | eb2f1c313051a739ef18a32d39bc4988a63e44fd | |
| parent | cab447766734cb99821c36988ebc8d27a0bce228 (diff) | |
| download | coinosv3-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.gradle | 4 | ||||
| -rw-r--r-- | android/app/src/main/AndroidManifest.xml | 22 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/LauncherActivity.java | 39 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/Push.java | 149 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/PushReceiver.java | 110 | ||||
| -rw-r--r-- | android/app/src/main/res/values/strings.xml | 7 | ||||
| -rw-r--r-- | nwcpush/server.js | 35 | ||||
| -rw-r--r-- | src/features/messages.js | 30 | ||||
| -rw-r--r-- | src/features/nwc.js | 40 | ||||
| -rw-r--r-- | static/coinos.apk | bin | 429707 -> 434639 bytes | |||
| -rw-r--r-- | tools/unifiedpush-test.js | 83 |
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 Binary files differindex c677fb3..6bef026 100644 --- a/static/coinos.apk +++ b/static/coinos.apk 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); |
