aboutsummaryrefslogtreecommitdiffstats
path: root/android/app
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-04 23:02:15 -0700
committerAdam Soltys <[email protected]>2026-10-04 23:02:15 -0700
commite1bc34923bed8fd00c5a3cdacdfb7f102c87d7a3 (patch)
tree93ba587663ff503c84b96b077676086159d2f9ce /android/app
parentbd935e0cab30461dc4c8702ad54704b6e3d90726 (diff)
downloadcoinosv3-fdroid-v1.9.tar.gz
coinosv3-fdroid-v1.9.zip
Android: TWA activities only in the TWA build, push wakes run the bundled app, the origin fails closed; 1.9 (code 10).fdroid-v1.9
F-Droid review of 1.8: the shared manifest declared the TWA's LauncherActivity/IntentActivity in the webview and fdroid builds, which don't compile them (a second, crashing launcher icon). They move to src/twa/AndroidManifest.xml. WakeJob's off-screen WebView now answers the origin from assets like MainActivity's, and Assets returns 404 for any v3.coinos.io path it doesn't carry outside the live API list instead of fetching it. POST_NOTIFICATIONS is asked only when a UnifiedPush distributor is installed. The description names the third-party hosts (DoH, post media). Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'android/app')
-rw-r--r--android/app/src/main/AndroidManifest.xml84
-rw-r--r--android/app/src/twa/AndroidManifest.xml85
-rw-r--r--android/app/src/webview/java/io/coinos/app/Assets.java21
-rw-r--r--android/app/src/webview/java/io/coinos/app/MainActivity.java4
-rw-r--r--android/app/src/webview/java/io/coinos/app/WakeJob.java22
5 files changed, 130 insertions, 86 deletions
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b0dddbd..0444b76 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,9 +1,8 @@
<?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>. -->
+<!-- What every build shares: notifications and the UnifiedPush receiver.
+ Each flavor's own manifest adds its activities — the TWA's live in
+ src/twa, the WebView's (webview and fdroid) in src/webview — so no build
+ declares an activity whose class it doesn't compile. -->
<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" />
@@ -20,81 +19,6 @@
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/" />
- <!-- System-bar colors: without these the TWA status bar defaults to
- white. Light follows the page background, dark the PWA's
- theme_color, so the bar icons keep their contrast either way. -->
- <meta-data
- android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
- android:resource="@color/statusBarColor" />
- <meta-data
- android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
- android:resource="@color/statusBarColorDark" />
- <meta-data
- android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
- android:resource="@color/navigationBarColor" />
- <meta-data
- android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
- android:resource="@color/navigationBarColorDark" />
- <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>
- </activity>
- <!-- Scheme + NFC intents land on this per-intent trampoline, not the
- launcher: a TWA already on screen ignores a new intent to its
- launcher, so a second lightning: tap used to do nothing. The
- trampoline relaunches the TWA with the new URL and finishes.
- lnurlw/lnurlp ride along — bolt cards and LNURL NFC tags emit
- those schemes; NDEF dispatch needs no NFC permission. -->
- <activity
- android:name="io.coinos.app.IntentActivity"
- android:exported="true"
- android:noHistory="true"
- android:theme="@android:style/Theme.Translucent.NoTitleBar">
- <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>
- <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>
<!-- UnifiedPush: the distributor broadcasts here. Web push needs Google
Play Services on Android, so on a de-Googled phone this is the only
way the wallet can be reached at all while it isn't open. -->
diff --git a/android/app/src/twa/AndroidManifest.xml b/android/app/src/twa/AndroidManifest.xml
new file mode 100644
index 0000000..c430cdf
--- /dev/null
+++ b/android/app/src/twa/AndroidManifest.xml
@@ -0,0 +1,85 @@
+<?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">
+ <application>
+ <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/" />
+ <!-- System-bar colors: without these the TWA status bar defaults to
+ white. Light follows the page background, dark the PWA's
+ theme_color, so the bar icons keep their contrast either way. -->
+ <meta-data
+ android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
+ android:resource="@color/statusBarColor" />
+ <meta-data
+ android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
+ android:resource="@color/statusBarColorDark" />
+ <meta-data
+ android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
+ android:resource="@color/navigationBarColor" />
+ <meta-data
+ android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
+ android:resource="@color/navigationBarColorDark" />
+ <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>
+ </activity>
+ <!-- Scheme + NFC intents land on this per-intent trampoline, not the
+ launcher: a TWA already on screen ignores a new intent to its
+ launcher, so a second lightning: tap used to do nothing. The
+ trampoline relaunches the TWA with the new URL and finishes.
+ lnurlw/lnurlp ride along — bolt cards and LNURL NFC tags emit
+ those schemes; NDEF dispatch needs no NFC permission. -->
+ <activity
+ android:name="io.coinos.app.IntentActivity"
+ android:exported="true"
+ android:noHistory="true"
+ android:theme="@android:style/Theme.Translucent.NoTitleBar">
+ <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>
+ <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/webview/java/io/coinos/app/Assets.java b/android/app/src/webview/java/io/coinos/app/Assets.java
index ff3390b..b946500 100644
--- a/android/app/src/webview/java/io/coinos/app/Assets.java
+++ b/android/app/src/webview/java/io/coinos/app/Assets.java
@@ -6,6 +6,7 @@ import android.net.Uri;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
@@ -22,6 +23,10 @@ import java.util.Map;
* the paths the site's own nginx and service worker treat as live. A path
* with no file behind it and no extension is a deep link (/g/…, /invite/…,
* /npub…): it gets index.html, as the site's SPA fallback would.
+ *
+ * <p>Nothing else under the origin reaches the network: a file the APK
+ * doesn't carry (coinos.apk, say) is a 404 here, not a fetch from the live
+ * site, so no code this build didn't ship can run in the app's origin.
*/
final class Assets {
private Assets() {}
@@ -48,20 +53,20 @@ final class Assets {
/** The asset answering this request, or null to let the network have it. */
static WebResourceResponse serve(Context ctx, WebResourceRequest req) {
Uri u = req.getUrl();
- if (u == null || !"https".equals(u.getScheme()) || !HOST.equals(u.getHost())) return null;
- if (!"GET".equalsIgnoreCase(req.getMethod())) return null;
+ if (u == null || !HOST.equals(u.getHost())) return null;
String path = u.getPath();
if (path == null || path.isEmpty()) path = "/";
- for (String live : LIVE) if (path.startsWith(live)) return null;
+ if ("https".equals(u.getScheme())) for (String live : LIVE) if (path.startsWith(live)) return null;
+ if (!"GET".equalsIgnoreCase(req.getMethod()) || !"https".equals(u.getScheme())) return missing();
String rel = path.equals("/") ? "index.html" : path.substring(1);
AssetManager am = ctx.getAssets();
InputStream in = open(am, rel);
if (in == null) {
String last = rel.substring(rel.lastIndexOf('/') + 1);
- if (last.contains(".")) return null; // a real file we don't carry (coinos.apk): the network's
+ if (last.contains(".")) return missing(); // a real file we don't carry (coinos.apk)
rel = "index.html";
in = open(am, rel);
- if (in == null) return null;
+ if (in == null) return missing();
}
String ext = rel.substring(rel.lastIndexOf('.') + 1).toLowerCase();
String mime = MIME.containsKey(ext) ? MIME.get(ext) : "application/octet-stream";
@@ -73,6 +78,12 @@ final class Assets {
return res;
}
+ /** Fail closed: the origin's files are the APK's or nobody's. */
+ private static WebResourceResponse missing() {
+ return new WebResourceResponse("text/plain", "utf-8", 404, "Not Found",
+ new HashMap<>(), new ByteArrayInputStream(new byte[0]));
+ }
+
private static InputStream open(AssetManager am, String rel) {
try { return am.open(rel); } catch (IOException e) { return null; }
}
diff --git a/android/app/src/webview/java/io/coinos/app/MainActivity.java b/android/app/src/webview/java/io/coinos/app/MainActivity.java
index e75e723..d88a969 100644
--- a/android/app/src/webview/java/io/coinos/app/MainActivity.java
+++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java
@@ -118,7 +118,9 @@ public class MainActivity extends Activity {
}
});
- if (Build.VERSION.SDK_INT >= 33
+ // Notifications only ever come from UnifiedPush, so the permission is
+ // asked for only when a distributor is installed to deliver them.
+ if (Build.VERSION.SDK_INT >= 33 && !Push.distributors(this).isEmpty()
&& checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQ_PERMS); } catch (Exception ignored) {}
}
diff --git a/android/app/src/webview/java/io/coinos/app/WakeJob.java b/android/app/src/webview/java/io/coinos/app/WakeJob.java
index a6cb1a2..a6175f7 100644
--- a/android/app/src/webview/java/io/coinos/app/WakeJob.java
+++ b/android/app/src/webview/java/io/coinos/app/WakeJob.java
@@ -6,8 +6,11 @@ import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.webkit.JavascriptInterface;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
+import android.webkit.WebViewClient;
/**
* The wallet, woken without being opened.
@@ -49,6 +52,25 @@ public class WakeJob extends JobService {
s.setDomStorageEnabled(true);
s.setDatabaseEnabled(true);
web.addJavascriptInterface(new Host(), "CoinosHost");
+ // The same files the visible app runs: the bundled build answers the
+ // site's own paths from assets here too, or a wake would run whatever
+ // the live site serves today in this app's origin and storage.
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView v, WebResourceRequest r) {
+ if (BuildConfig.BUNDLED) {
+ WebResourceResponse res = Assets.serve(WakeJob.this, r);
+ if (res != null) return res;
+ }
+ return super.shouldInterceptRequest(v, r);
+ }
+
+ // off-screen, nothing navigates anywhere: no one is there to see it
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest r) {
+ return true;
+ }
+ });
// The payload rides in as a parameter the page can read: it carries the
// event that woke us, so the wallet needn't hunt for it on a relay.