diff options
| author | Adam Soltys <[email protected]> | 2026-09-04 20:48:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-04 20:48:27 -0700 |
| commit | 8d27a5d2c59e0f1824753d59a7326b6b76887bf7 (patch) | |
| tree | 5894488403d2f72b994255ba8ff6859121ead1d4 | |
| parent | 175600238fb3e9cf60bd65b0164ced476d18f445 (diff) | |
| download | coinosv3-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/.gitignore | 7 | ||||
| -rw-r--r-- | android/app/build.gradle | 36 | ||||
| -rw-r--r-- | android/app/src/main/AndroidManifest.xml | 69 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/LauncherActivity.java | 27 | ||||
| -rw-r--r-- | android/app/src/main/res/mipmap-hdpi/ic_launcher.png | bin | 0 -> 2208 bytes | |||
| -rw-r--r-- | android/app/src/main/res/mipmap-mdpi/ic_launcher.png | bin | 0 -> 1412 bytes | |||
| -rw-r--r-- | android/app/src/main/res/mipmap-xhdpi/ic_launcher.png | bin | 0 -> 3084 bytes | |||
| -rw-r--r-- | android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png | bin | 0 -> 5131 bytes | |||
| -rw-r--r-- | android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png | bin | 0 -> 7923 bytes | |||
| -rw-r--r-- | android/app/src/main/res/values/strings.xml | 11 | ||||
| -rw-r--r-- | android/build.gradle | 9 | ||||
| -rw-r--r-- | android/gradle.properties | 2 | ||||
| -rw-r--r-- | android/settings.gradle | 2 | ||||
| -rw-r--r-- | build.js | 9 | ||||
| -rw-r--r-- | src/app.js | 32 | ||||
| -rw-r--r-- | static/.well-known/assetlinks.json | 8 | ||||
| -rw-r--r-- | static/coinos.apk | bin | 0 -> 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 Binary files differnew file mode 100644 index 0000000..f80c3c2 --- /dev/null +++ b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..3d3df9d --- /dev/null +++ b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..e5b9241 --- /dev/null +++ b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..4f9cf1d --- /dev/null +++ b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..5b08781 --- /dev/null +++ b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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' @@ -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)); @@ -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 Binary files differnew file mode 100644 index 0000000..1126d53 --- /dev/null +++ b/static/coinos.apk |
