aboutsummaryrefslogtreecommitdiffstats
path: root/android/app/src/main/java/io
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 /android/app/src/main/java/io
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
Diffstat (limited to 'android/app/src/main/java/io')
-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
3 files changed, 296 insertions, 2 deletions
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
+ }
+ }
+}