aboutsummaryrefslogtreecommitdiffstats
path: root/android/app
diff options
context:
space:
mode:
Diffstat (limited to 'android/app')
-rw-r--r--android/app/build.gradle47
-rw-r--r--android/app/src/fcm/AndroidManifest.xml66
-rw-r--r--android/app/src/fcm/java/io/coinos/app/CoinosApp.java12
-rw-r--r--android/app/src/fcm/java/io/coinos/app/Fcm.java64
-rw-r--r--android/app/src/fcm/java/io/coinos/app/FcmService.java19
-rw-r--r--android/app/src/main/java/io/coinos/app/PushReceiver.java28
-rw-r--r--android/app/src/nofcm/java/io/coinos/app/Fcm.java14
-rw-r--r--android/app/src/webview/java/io/coinos/app/MainActivity.java32
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) + "}";
}