diff options
| author | Adam Soltys <[email protected]> | 2026-11-06 23:01:15 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-06 23:01:15 -0700 |
| commit | 8969744783e2851b287e937f71033ce4b6bb4dc3 (patch) | |
| tree | 682652e80d9119da9106a2e5ce2d11eeadb08d6f /android/app | |
| parent | 1fe10057f301b09bbad0cfccdfecac9826733048 (diff) | |
| download | coinosv3-8969744783e2851b287e937f71033ce4b6bb4dc3.tar.gz coinosv3-8969744783e2851b287e937f71033ce4b6bb4dc3.zip | |
A third Android build, fcm (io.coinos.beta, "Coinos Beta"): the wallet in its own WebView, as the webview build, with Firebase Cloud Messaging for push on phones with Google Play Services — no distributor app to install. A user on the TWA APK found launching slow next to Minibits (2026-11-05); measured on a Pixel 9, a WebView paints ~0.7 s sooner than a cold Chrome. This build installs beside the TWA for a side-by-side trial before deciding whether it replaces it. Fcm asks for the token and stores "fcm:<token>" as the push endpoint, which reaches the page down the UnifiedPush road (?up= on the next launch, or written straight into the page's localStorage once it has loaded, since the token arrives after the first load starts); nwcpush (coinosv3-server 0c50417) sends an fcm: endpoint through Firebase. FcmService hands a message's "body" to the same handler UnifiedPush messages use (PushReceiver.onPush: wake the wallet off-screen, notify unless it answered a silent request). Firebase is set up in code (CoinosApp → Fcm.init) from values build.gradle reads out of app/google-services.json (gitignored) — without it the build still builds and falls back to UnifiedPush. The webview and fdroid builds get a stub Fcm (src/nofcm) and carry no Google code; the notification permission is asked when Firebase or a distributor can deliver; CoinosHost.env reports app "fcm"
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6Ts5Tktega8VbLg31q
Diffstat (limited to 'android/app')
| -rw-r--r-- | android/app/build.gradle | 47 | ||||
| -rw-r--r-- | android/app/src/fcm/AndroidManifest.xml | 66 | ||||
| -rw-r--r-- | android/app/src/fcm/java/io/coinos/app/CoinosApp.java | 12 | ||||
| -rw-r--r-- | android/app/src/fcm/java/io/coinos/app/Fcm.java | 64 | ||||
| -rw-r--r-- | android/app/src/fcm/java/io/coinos/app/FcmService.java | 19 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/PushReceiver.java | 28 | ||||
| -rw-r--r-- | android/app/src/nofcm/java/io/coinos/app/Fcm.java | 14 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/MainActivity.java | 32 |
8 files changed, 266 insertions, 16 deletions
diff --git a/android/app/build.gradle b/android/app/build.gradle index 0119e93..046358d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,6 +22,24 @@ tasks.register('copyWeb', Copy) { into layout.buildDirectory.dir('www') } +// Firebase settings for the fcm build, read from app/google-services.json +// (downloaded from the Firebase console; gitignored). Without it the fcm +// build still builds, and pushes through UnifiedPush like the webview one. +def firebase = [appId: '', apiKey: '', projectId: '', senderId: ''] +def gsFile = file('google-services.json') +if (gsFile.exists()) { + def gs = new groovy.json.JsonSlurper().parse(gsFile) + def client = gs.client.find { it.client_info?.android_client_info?.package_name == 'io.coinos.beta' } + if (client) { + firebase = [ + appId: client.client_info.mobilesdk_app_id, + apiKey: client.api_key[0].current_key, + projectId: gs.project_info.project_id, + senderId: gs.project_info.project_number, + ] + } +} + android { namespace 'io.coinos.app' compileSdk 35 @@ -66,6 +84,21 @@ android { resValue 'string', 'app_name', 'Coinos (UnifiedPush)' buildConfigField 'boolean', 'BUNDLED', 'false' } + // fcm — the webview shell plus Firebase Cloud Messaging: push without a + // distributor app on phones with Google Play Services (see + // src/fcm/java/io/coinos/app/Fcm.java). Its own app id, so it + // installs beside the TWA for a side-by-side trial. + fcm { + dimension 'shell' + applicationId 'io.coinos.beta' + versionNameSuffix '-beta' + resValue 'string', 'app_name', 'Coinos Beta' + buildConfigField 'boolean', 'BUNDLED', 'false' + buildConfigField 'String', 'FCM_APP_ID', "\"${firebase.appId}\"" + buildConfigField 'String', 'FCM_API_KEY', "\"${firebase.apiKey}\"" + buildConfigField 'String', 'FCM_PROJECT_ID', "\"${firebase.projectId}\"" + buildConfigField 'String', 'FCM_SENDER_ID', "\"${firebase.senderId}\"" + } fdroid { dimension 'shell' applicationId 'io.coinos.fdroid' @@ -74,9 +107,19 @@ android { } } sourceSets { + // the webview shell has no Firebase: its Fcm is a stub + webview { + java.srcDirs += 'src/nofcm/java' + } + // the webview shell with Firebase (the manifest is webview's plus FCM) + fcm { + java.srcDirs = ['src/webview/java', 'src/fcm/java'] + res.srcDirs = ['src/webview/res'] + manifest.srcFile 'src/fcm/AndroidManifest.xml' + } // the same shell as webview, plus the bundled web app fdroid { - java.srcDirs = ['src/webview/java'] + java.srcDirs = ['src/webview/java', 'src/nofcm/java'] res.srcDirs = ['src/webview/res'] manifest.srcFile 'src/webview/AndroidManifest.xml' assets.srcDirs += layout.buildDirectory.dir('www').get().asFile @@ -102,6 +145,8 @@ android { dependencies { // only the TWA shell needs Chrome's helper; the WebView build carries none twaImplementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' + // only the fcm build carries Google's push client + fcmImplementation 'com.google.firebase:firebase-messaging:24.1.0' } preBuild.dependsOn copyWeb diff --git a/android/app/src/fcm/AndroidManifest.xml b/android/app/src/fcm/AndroidManifest.xml new file mode 100644 index 0000000..71e75b9 --- /dev/null +++ b/android/app/src/fcm/AndroidManifest.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The fcm build: the WebView build (src/webview/AndroidManifest.xml — keep + the two in step) plus Firebase Cloud Messaging for push on phones with + Google Play Services. See Fcm.java. --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET" /> + <!-- the wake job asks to run when there's a network; the scheduler won't + take that constraint without this --> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.CAMERA" /> + <uses-feature android:name="android.hardware.camera" android:required="false" /> + <application + android:name="io.coinos.app.CoinosApp" + android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:theme="@style/AppTheme" + android:supportsRtl="true" + android:usesCleartextTraffic="false"> + <activity + android:name="io.coinos.app.MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:configChanges="orientation|screenSize|keyboardHidden|uiMode" + android:windowSoftInputMode="adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + <!-- the payment schemes the TWA build claims, claimed here too --> + <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> + </activity> + <!-- pushes through Firebase --> + <service + android:name="io.coinos.app.FcmService" + android:exported="false"> + <intent-filter> + <action android:name="com.google.firebase.MESSAGING_EVENT" /> + </intent-filter> + </service> + <!-- the off-screen wallet a push wakes --> + <service + android:name="io.coinos.app.WakeJob" + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> + </application> +</manifest> diff --git a/android/app/src/fcm/java/io/coinos/app/CoinosApp.java b/android/app/src/fcm/java/io/coinos/app/CoinosApp.java new file mode 100644 index 0000000..855db91 --- /dev/null +++ b/android/app/src/fcm/java/io/coinos/app/CoinosApp.java @@ -0,0 +1,12 @@ +package io.coinos.app; + +import android.app.Application; + +/** Sets Firebase up before anything in the process (a push included) needs it. */ +public class CoinosApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + Fcm.init(this); + } +} diff --git a/android/app/src/fcm/java/io/coinos/app/Fcm.java b/android/app/src/fcm/java/io/coinos/app/Fcm.java new file mode 100644 index 0000000..02ce12c --- /dev/null +++ b/android/app/src/fcm/java/io/coinos/app/Fcm.java @@ -0,0 +1,64 @@ +package io.coinos.app; + +import android.content.Context; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +/** + * Firebase Cloud Messaging, for a phone with Google Play Services: the same + * channel web push rides in Chrome, so the wallet is reached with no + * distributor app to install. Its token goes to the web app as the push + * endpoint "fcm:<token>", down the road a UnifiedPush endpoint takes (?up=, + * or straight into the page's storage), and the notifier sends an fcm: + * endpoint through Firebase. A phone without Play Services, or a build made + * without android/app/google-services.json, falls back to UnifiedPush. + * + * <p>Firebase is set up here from the values build.gradle read out of that + * file, rather than by Google's Gradle plugin. + */ +final class Fcm { + private Fcm() {} + static final String PREFIX = "fcm:"; + + static boolean configured() { return !BuildConfig.FCM_APP_ID.isEmpty(); } + + static boolean available(Context c) { + if (!configured()) return false; + return Push.playServices(c); + } + + /** Called from CoinosApp, before anything (FcmService included) asks for Firebase. */ + static void init(Context c) { + if (!configured()) return; + try { + if (!FirebaseApp.getApps(c).isEmpty()) return; + FirebaseApp.initializeApp(c, new FirebaseOptions.Builder() + .setApplicationId(BuildConfig.FCM_APP_ID) + .setApiKey(BuildConfig.FCM_API_KEY) + .setProjectId(BuildConfig.FCM_PROJECT_ID) + .setGcmSenderId(BuildConfig.FCM_SENDER_ID) + .build()); + } catch (Throwable ignored) {} + } + + /** + * Ask for the token. It arrives later; onEndpoint runs (on any thread) once + * it's stored as this app's push endpoint. + * @return whether Firebase will deliver this app's pushes + */ + static boolean register(Context c, Runnable onEndpoint) { + if (!available(c)) return false; + init(c); + try { + FirebaseMessaging.getInstance().getToken().addOnSuccessListener((token) -> { + if (token == null || token.isEmpty()) return; + String ep = PREFIX + token; + if (!ep.equals(Push.endpoint(c))) Push.setEndpoint(c, ep); + if (onEndpoint != null) onEndpoint.run(); + }); + return true; + } catch (Throwable t) { return false; } + } +} diff --git a/android/app/src/fcm/java/io/coinos/app/FcmService.java b/android/app/src/fcm/java/io/coinos/app/FcmService.java new file mode 100644 index 0000000..24d4aca --- /dev/null +++ b/android/app/src/fcm/java/io/coinos/app/FcmService.java @@ -0,0 +1,19 @@ +package io.coinos.app; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +/** Firebase's half of PushReceiver: the notifier's JSON arrives as data "body". */ +public class FcmService extends FirebaseMessagingService { + @Override + public void onNewToken(String token) { + // a rotated token is a new endpoint; the page gets it on its next load + if (token != null && !token.isEmpty()) Push.setEndpoint(this, Fcm.PREFIX + token); + } + + @Override + public void onMessageReceived(RemoteMessage msg) { + String body = msg.getData() == null ? null : msg.getData().get("body"); + if (body != null) PushReceiver.onPush(this, body); + } +} diff --git a/android/app/src/main/java/io/coinos/app/PushReceiver.java b/android/app/src/main/java/io/coinos/app/PushReceiver.java index ce3e3f0..b14de53 100644 --- a/android/app/src/main/java/io/coinos/app/PushReceiver.java +++ b/android/app/src/main/java/io/coinos/app/PushReceiver.java @@ -49,14 +49,7 @@ public class PushReceiver extends BroadcastReceiver { case Push.ACTION_MESSAGE: { byte[] bytes = intent.getByteArrayExtra(Push.EXTRA_BYTES_MESSAGE); String body = bytes != null ? new String(bytes) : intent.getStringExtra(Push.EXTRA_MESSAGE); - // On the WebView build the wallet lives in THIS app's storage, so it - // can be woken and told to deal with whatever arrived. On the TWA - // build the wallet is Chrome's and out of reach, so the notification - // is the whole of what we can do. - boolean woke = Wake.start(context, body); - // A payment or a message is news whether or not we handled it; a - // wallet-connect request is not, unless nobody is going to answer it. - if (!woke || !isSilent(body)) notify(context, body); + onPush(context, body); Push.ack(context, intent.getStringExtra(Push.EXTRA_ID)); break; } @@ -65,8 +58,23 @@ public class PushReceiver extends BroadcastReceiver { } } + /** + * A push, however it came (a UnifiedPush distributor, or Firebase in the + * fcm build — FcmService): the same JSON either way. + */ + static void onPush(Context context, String body) { + // On the WebView build the wallet lives in THIS app's storage, so it + // can be woken and told to deal with whatever arrived. On the TWA + // build the wallet is Chrome's and out of reach, so the notification + // is the whole of what we can do. + boolean woke = Wake.start(context, body); + // A payment or a message is news whether or not we handled it; a + // wallet-connect request is not, unless nobody is going to answer it. + if (!woke || !isSilent(body)) notify(context, body); + } + /** Requests the woken wallet answers by itself need no notification. */ - private boolean isSilent(String payload) { + private static boolean isSilent(String payload) { try { JSONObject j = new JSONObject(payload == null ? "{}" : payload); String reason = j.optString("reason", j.optString("type", "")); @@ -79,7 +87,7 @@ public class PushReceiver extends BroadcastReceiver { * 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) { + private static 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; diff --git a/android/app/src/nofcm/java/io/coinos/app/Fcm.java b/android/app/src/nofcm/java/io/coinos/app/Fcm.java new file mode 100644 index 0000000..bb68bbc --- /dev/null +++ b/android/app/src/nofcm/java/io/coinos/app/Fcm.java @@ -0,0 +1,14 @@ +package io.coinos.app; + +import android.content.Context; + +/** + * The WebView and F-Droid builds carry no Firebase: push there is UnifiedPush + * only. The fcm build's version of this class (src/fcm) is the real one. + */ +final class Fcm { + private Fcm() {} + static boolean available(Context c) { return false; } + /** @return whether Firebase will deliver this app's pushes (never, here) */ + static boolean register(Context c, Runnable onEndpoint) { return false; } +} 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 d88a969..b26d6c5 100644 --- a/android/app/src/webview/java/io/coinos/app/MainActivity.java +++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java @@ -77,6 +77,11 @@ public class MainActivity extends Activity { } @Override + public void onPageFinished(WebView v, String url) { + endpointToPage(); + } + + @Override public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest r) { Uri u = r.getUrl(); String host = u.getHost(); @@ -118,9 +123,11 @@ public class MainActivity extends Activity { } }); - // 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() + // Notifications come from Firebase (the fcm build, with Play Services) + // or a UnifiedPush distributor, so the permission is asked for only when + // one of them is there to deliver them. + boolean fcm = Fcm.available(this); + if (Build.VERSION.SDK_INT >= 33 && (fcm || !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) {} } @@ -128,10 +135,25 @@ public class MainActivity extends Activity { // can deliver a push — the advice it gives is useless otherwise ("use // Firefox" makes no sense inside a WebView that IS the app). web.addJavascriptInterface(new Host(), "CoinosHost"); - Push.register(this); + // Firebase when it can; otherwise a distributor. A Firebase token + // arrives after the page has started loading, so it goes straight into + // the page rather than waiting for the next launch's ?up=. + if (!Fcm.register(this, () -> runOnUiThread(this::endpointToPage))) Push.register(this); web.loadUrl(urlFor(getIntent())); } + /** + * The push endpoint, put where the page keeps it (messages.js and nwc.js + * read 'coinos-unifiedpush') — once the page is ours to write into. + */ + private void endpointToPage() { + String endpoint = Push.endpoint(this); + String url = web == null ? null : web.getUrl(); + if (endpoint == null || Push.endpointDelivered(this) || url == null || !url.startsWith(BASE)) return; + web.evaluateJavascript("try{localStorage.setItem('coinos-unifiedpush'," + org.json.JSONObject.quote(endpoint) + ")}catch(e){}", null); + Push.markEndpointDelivered(this); + } + /** What the page is allowed to ask about its surroundings. */ private class Host { @android.webkit.JavascriptInterface @@ -139,7 +161,7 @@ public class MainActivity extends Activity { String dist = Push.distributor(MainActivity.this); List<String> all = Push.distributors(MainActivity.this); String endpoint = Push.endpoint(MainActivity.this); - return "{\"app\":\"graphene\",\"distributors\":" + all.size() + return "{\"app\":\"" + (Fcm.available(MainActivity.this) ? "fcm" : "graphene") + "\",\"distributors\":" + all.size() + ",\"distributor\":" + (dist == null ? "null" : "\"" + dist + "\"") + ",\"endpoint\":" + (endpoint != null) + "}"; } |
