aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-04 20:48:27 -0700
committerAdam Soltys <[email protected]>2026-09-04 20:48:27 -0700
commit8d27a5d2c59e0f1824753d59a7326b6b76887bf7 (patch)
tree5894488403d2f72b994255ba8ff6859121ead1d4
parent175600238fb3e9cf60bd65b0164ced476d18f445 (diff)
downloadcoinosv3-8d27a5d2c59e0f1824753d59a7326b6b76887bf7.tar.gz
coinosv3-8d27a5d2c59e0f1824753d59a7326b6b76887bf7.zip
Android wrapper app (android/): a minimal TWA around v3.coinos.io whose whole reason to exist is intent registration — lightning:/bitcoin:/nostr:/lnurl:/lnurlw:/lnurlp: VIEW intents and the same schemes via NFC NDEF dispatch (bolt cards, address stickers; NFC declared required=false), plus verified https links to the site. LauncherActivity forwards a scheme URI into the web app as ?u=<encoded>; the web app reads it BEFORE features construct — a nostr npub/nprofile rewrites itself onto the existing /npub profile deep link, payment URIs stash and dispatch through handleScanned (the QR path) once a wallet is open, and the param is stripped from the URL either way. assetlinks.json (static/.well-known/, copied by build.js) vouches for the signing cert so the TWA runs URL-bar-less; the built APK is served at /coinos.apk for sideloading. Signing keystore lives in android/ GITIGNORED (keystore.properties beside it) — the assetlinks fingerprint pins it, so losing it means re-shipping assetlinks too. Verified: headless ?u= routing (lightning → Send with invoice filled + URL cleaned; nostr → profile page), APK installed on a real phone via adb, schemes registered (chooser appears where other wallets also claim lightning:)
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
-rw-r--r--android/.gitignore7
-rw-r--r--android/app/build.gradle36
-rw-r--r--android/app/src/main/AndroidManifest.xml69
-rw-r--r--android/app/src/main/java/io/coinos/app/LauncherActivity.java27
-rw-r--r--android/app/src/main/res/mipmap-hdpi/ic_launcher.pngbin0 -> 2208 bytes
-rw-r--r--android/app/src/main/res/mipmap-mdpi/ic_launcher.pngbin0 -> 1412 bytes
-rw-r--r--android/app/src/main/res/mipmap-xhdpi/ic_launcher.pngbin0 -> 3084 bytes
-rw-r--r--android/app/src/main/res/mipmap-xxhdpi/ic_launcher.pngbin0 -> 5131 bytes
-rw-r--r--android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 7923 bytes
-rw-r--r--android/app/src/main/res/values/strings.xml11
-rw-r--r--android/build.gradle9
-rw-r--r--android/gradle.properties2
-rw-r--r--android/settings.gradle2
-rw-r--r--build.js9
-rw-r--r--src/app.js32
-rw-r--r--static/.well-known/assetlinks.json8
-rw-r--r--static/coinos.apkbin0 -> 427903 bytes
17 files changed, 212 insertions, 0 deletions
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..3c2470f
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,7 @@
+.gradle/
+build/
+app/build/
+keystore.properties
+*.keystore
+*.jks
+local.properties
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..2d9acbd
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,36 @@
+apply plugin: 'com.android.application'
+
+def keystoreProps = new Properties()
+def keystoreFile = rootProject.file('keystore.properties')
+if (keystoreFile.exists()) keystoreProps.load(new FileInputStream(keystoreFile))
+
+android {
+ namespace 'io.coinos.app'
+ compileSdk 35
+ defaultConfig {
+ applicationId 'io.coinos.app'
+ minSdk 24
+ targetSdk 35
+ versionCode 1
+ versionName '1.0'
+ }
+ signingConfigs {
+ release {
+ if (keystoreFile.exists()) {
+ storeFile rootProject.file(keystoreProps['storeFile'])
+ storePassword keystoreProps['storePassword']
+ keyAlias keystoreProps['keyAlias']
+ keyPassword keystoreProps['keyPassword']
+ }
+ }
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ if (keystoreFile.exists()) signingConfig signingConfigs.release
+ }
+ }
+}
+dependencies {
+ implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c6b7a01
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- TWA wrapper: full-screen Chrome running https://v3.coinos.io (needs the
+ site's /.well-known/assetlinks.json to name this app's signing cert, or
+ Android shows a URL bar). The point of the wrapper is the intent
+ filters: lightning:/bitcoin:/nostr:/lnurl: URIs open this app, and
+ LauncherActivity forwards them into the web app as ?u=<encoded>. -->
+<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" />
+ <application
+ android:label="@string/app_name"
+ android:icon="@mipmap/ic_launcher"
+ android:supportsRtl="true">
+ <meta-data
+ android:name="asset_statements"
+ android:resource="@string/asset_statements" />
+ <activity
+ android:name="io.coinos.app.LauncherActivity"
+ android:exported="true"
+ android:theme="@android:style/Theme.Translucent.NoTitleBar">
+ <meta-data
+ android:name="android.support.customtabs.trusted.DEFAULT_URL"
+ android:value="https://v3.coinos.io/" />
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ <!-- own the site's links (verified against assetlinks.json) -->
+ <intent-filter android:autoVerify="true">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="https" android:host="v3.coinos.io" />
+ </intent-filter>
+ <!-- the payment + nostr schemes: the reason this wrapper exists.
+ lnurlw/lnurlp ride along — bolt cards and LNURL NFC tags emit
+ those schemes. -->
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="lightning" />
+ <data android:scheme="bitcoin" />
+ <data android:scheme="nostr" />
+ <data android:scheme="lnurl" />
+ <data android:scheme="lnurlw" />
+ <data android:scheme="lnurlp" />
+ </intent-filter>
+ <!-- NFC: an NDEF tag carrying one of our URIs (a bolt card tap, an
+ address sticker) dispatches here directly — same forwarding path
+ as a VIEW intent, no NFC permission needed for NDEF dispatch. -->
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="lightning" />
+ <data android:scheme="bitcoin" />
+ <data android:scheme="nostr" />
+ <data android:scheme="lnurl" />
+ <data android:scheme="lnurlw" />
+ <data android:scheme="lnurlp" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.nfc.action.NDEF_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:scheme="https" android:host="v3.coinos.io" />
+ </intent-filter>
+ </activity>
+ </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
new file mode 100644
index 0000000..d0b56bb
--- /dev/null
+++ b/android/app/src/main/java/io/coinos/app/LauncherActivity.java
@@ -0,0 +1,27 @@
+package io.coinos.app;
+
+import android.net.Uri;
+
+/**
+ * Forwards scheme intents into the web app. A lightning:/bitcoin:/nostr:/
+ * lnurl: URI arrives as this activity's data; the web app receives it as
+ * https://v3.coinos.io/?u=<encoded original URI> and routes it (send form
+ * for payments, profile for nostr npubs). https links to the site pass
+ * through untouched, everything else opens the default URL.
+ */
+public class LauncherActivity
+ extends com.google.androidbrowserhelper.trusted.LauncherActivity {
+
+ @Override
+ protected Uri getLaunchingUrl() {
+ Uri data = getIntent().getData();
+ if (data != null) {
+ String scheme = data.getScheme();
+ if (scheme != null && !scheme.equals("https") && !scheme.equals("http")) {
+ return Uri.parse("https://v3.coinos.io/?u=" + Uri.encode(data.toString()));
+ }
+ if ("v3.coinos.io".equals(data.getHost())) return data;
+ }
+ return super.getLaunchingUrl();
+ }
+}
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..f80c3c2
--- /dev/null
+++ b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..3d3df9d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..e5b9241
--- /dev/null
+++ b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4f9cf1d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..5b08781
--- /dev/null
+++ b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..0663994
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">coinos</string>
+ <!-- must match the site's /.well-known/assetlinks.json for URL-bar-less TWA -->
+ <string name="asset_statements">
+ [{
+ \"relation\": [\"delegate_permission/common.handle_all_urls\"],
+ \"target\": { \"namespace\": \"web\", \"site\": \"https://v3.coinos.io\" }
+ }]
+ </string>
+</resources>
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..18d1979
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,9 @@
+// Android TWA wrapper for v3.coinos.io — exists so the app can register OS
+// intent filters for lightning:/bitcoin:/nostr:/lnurl: URIs, which a plain
+// PWA cannot (Chrome's protocol_handlers safelist has no nostr). The web
+// app itself stays the live site; this shell just forwards intents into it.
+buildscript {
+ repositories { google(); mavenCentral() }
+ dependencies { classpath 'com.android.tools.build:gradle:8.7.3' }
+}
+allprojects { repositories { google(); mavenCentral() } }
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..c335529
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx2g
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..7ee3dcb
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'coinos'
+include ':app'
diff --git a/build.js b/build.js
index e48c4f7..daa986d 100644
--- a/build.js
+++ b/build.js
@@ -463,6 +463,15 @@ if (import.meta.main) {
.replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY);
await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody);
for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f));
+ // Android app plumbing: assetlinks.json vouches for the TWA wrapper's
+ // signing cert (URL-bar-less fullscreen), and the wrapper APK itself is
+ // served for sideloading when a build has been dropped in static/.
+ if (await Bun.file('static/.well-known/assetlinks.json').exists()) {
+ await Bun.write('dist/.well-known/assetlinks.json', Bun.file('static/.well-known/assetlinks.json'));
+ }
+ if (await Bun.file('static/coinos.apk').exists()) {
+ await Bun.write('dist/coinos.apk', Bun.file('static/coinos.apk'));
+ }
// default avatars, self-hosted since coinos.io's copies are half-broken
const { readdirSync } = await import('node:fs');
for (const f of readdirSync('static/punks')) await Bun.write('dist/punks/' + f, Bun.file('static/punks/' + f));
diff --git a/src/app.js b/src/app.js
index c815cc5..1343c6f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4850,6 +4850,21 @@ const ctx = {
if (restored) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} }
},
};
+// Android wrapper intents: the TWA app (android/) registers for lightning:/
+// bitcoin:/nostr:/lnurl: URIs and forwards them here as ?u=<encoded>. Read
+// BEFORE features construct: a nostr npub/nprofile rewrites itself onto the
+// existing /npub… profile deep link (which the messages feature reads at
+// construction); payment URIs stash for dispatch once a wallet is open.
+const INTENT_URI = (() => {
+ try {
+ const u = new URLSearchParams(location.search).get('u');
+ if (!u) return null;
+ history.replaceState(null, '', location.pathname || '/');
+ const m = u.match(/^nostr:((npub|nprofile)1[a-z0-9]+)\/?$/i);
+ if (m) { history.replaceState(null, '', '/' + m[1]); return null; }
+ return u;
+ } catch { return null; }
+})();
const FEATURES = buildFeatures(ctx);
// Deferred features (gifts, NWC, hats) hold placeholder slots in FEATURES —
// hook precedence is position — and arrive as separate chunks right after
@@ -4956,6 +4971,23 @@ document.addEventListener('visibilitychange', () => {
// Claim a stashed migrated name as soon as a wallet is actually open — a
// vault unlock or slow boot can put that minutes after 'load'. The stash
// survives reloads, so an unfinished claim resumes on the next visit too.
+// Dispatch a stashed payment intent (lightning:/bitcoin:/lnurl: from the
+// Android wrapper) once a wallet is actually open — a locked vault or slow
+// boot can put that well after load. handleScanned routes it exactly like a
+// scanned QR: bolt11s and lnurls through the feature chain, bitcoin: through
+// BIP-21 (nostr profile intents already rerouted onto the /npub deep link).
+if (INTENT_URI) {
+ const started = Date.now();
+ const tick = setInterval(() => {
+ if (Date.now() - started > 10 * 60_000) return clearInterval(tick);
+ if (!activeAccount() || !wallet.loaded || ui.screen !== 'wallet') return;
+ clearInterval(tick);
+ ui.tab = 'send';
+ render();
+ handleScanned(INTENT_URI);
+ }, 500);
+}
+
window.addEventListener('load', () => {
if (!pendingMigratedName()) return;
const started = Date.now();
diff --git a/static/.well-known/assetlinks.json b/static/.well-known/assetlinks.json
new file mode 100644
index 0000000..9d84b58
--- /dev/null
+++ b/static/.well-known/assetlinks.json
@@ -0,0 +1,8 @@
+[{
+ "relation": ["delegate_permission/common.handle_all_urls"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "io.coinos.app",
+ "sha256_cert_fingerprints": ["76:64:83:AB:83:72:C0:2F:84:06:D2:95:95:E7:38:9A:28:69:19:0B:42:9E:0F:FD:09:2E:BB:0B:FA:2B:C2:F4"]
+ }
+}]
diff --git a/static/coinos.apk b/static/coinos.apk
new file mode 100644
index 0000000..1126d53
--- /dev/null
+++ b/static/coinos.apk
Binary files differ