From a846211262f37238c001e5210938749294f8db6a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 12 Sep 2026 10:05:10 -0700 Subject: UnifiedPush: reach a phone that has no Google in it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- .../main/java/io/coinos/app/LauncherActivity.java | 39 +++++- android/app/src/main/java/io/coinos/app/Push.java | 149 +++++++++++++++++++++ .../src/main/java/io/coinos/app/PushReceiver.java | 110 +++++++++++++++ 3 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/io/coinos/app/Push.java create mode 100644 android/app/src/main/java/io/coinos/app/PushReceiver.java (limited to 'android/app/src/main/java') 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. + * + *

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. + * + *

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. + * + *

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. + * + *

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 distributors(Context c) { + List 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 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. + * + *

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 + } + } +} -- cgit v1.2.3