aboutsummaryrefslogtreecommitdiffstats
path: root/android
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 /android
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
Diffstat (limited to 'android')
-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
13 files changed, 163 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'