aboutsummaryrefslogtreecommitdiffstats
path: root/android/app/src/webview/java/io
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-12 15:47:56 -0700
committerAdam Soltys <[email protected]>2026-09-12 15:47:56 -0700
commit85d7858a0ef4b4f3ee60eb2bd5621e4899195935 (patch)
treeffd283a14df52948461a1125c41bff976a4f47cc /android/app/src/webview/java/io
parent9356684a78cddd106d82296a50a637406908cffd (diff)
downloadcoinosv3-85d7858a0ef4b4f3ee60eb2bd5621e4899195935.tar.gz
coinosv3-85d7858a0ef4b4f3ee60eb2bd5621e4899195935.zip
The wake had to be a job, not a foreground service — Android says so.
Tested on a real phone, and the first attempt didn't work at all: Background started FGS: Disallowed [... code:DENIED ...] startForegroundService() not allowed due to mAllowStartForeground false Android 12 forbids starting a foreground service from a broadcast receiver. An expedited job is the sanctioned replacement — it runs promptly from the background and needs no notification of its own to apologise for existing — so WakeService is now WakeJob, and the notification is decided by what arrived rather than by whether we could wake: a payment or a message is news either way, a wallet-connect request the wallet answers by itself is not. Two more things the phone found. A job with a connectivity constraint needs ACCESS_NETWORK_STATE, or schedule() throws and nothing runs at all. And a wake with nothing to answer used to hold a WebView open for the full thirty seconds — a locked wallet, no wallet on the device, or no NWC connections all end the job immediately now. Also a bug worth naming: ctx carried `wakeDone` by shorthand, which captured the binding while it was still undefined (ctx is built before it's defined). The page could never say it had finished, so every wake ran its full timeout. It reads through a closure now, like its neighbour. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'android/app/src/webview/java/io')
-rw-r--r--android/app/src/webview/java/io/coinos/app/MainActivity.java6
-rw-r--r--android/app/src/webview/java/io/coinos/app/Wake.java36
-rw-r--r--android/app/src/webview/java/io/coinos/app/WakeJob.java78
-rw-r--r--android/app/src/webview/java/io/coinos/app/WakeService.java132
4 files changed, 110 insertions, 142 deletions
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 166a1b7..1c0c9d5 100644
--- a/android/app/src/webview/java/io/coinos/app/MainActivity.java
+++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java
@@ -45,6 +45,12 @@ public class MainActivity extends Activity {
setContentView(web, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ // A debug build can be inspected from a desktop (chrome://inspect); a
+ // release build of a wallet must not be — anything with ADB access could
+ // read its storage through the same door.
+ if ((getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
+ WebView.setWebContentsDebuggingEnabled(true);
+ }
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true); // localStorage + IndexedDB: the wallet
diff --git a/android/app/src/webview/java/io/coinos/app/Wake.java b/android/app/src/webview/java/io/coinos/app/Wake.java
index 883ec49..82fc16f 100644
--- a/android/app/src/webview/java/io/coinos/app/Wake.java
+++ b/android/app/src/webview/java/io/coinos/app/Wake.java
@@ -1,27 +1,43 @@
package io.coinos.app;
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.content.ComponentName;
import android.content.Context;
-import android.content.Intent;
import android.os.Build;
+import android.os.PersistableBundle;
/**
* This build CAN wake the wallet: it lives in this app's own storage, so a
- * push starts WakeService, which loads it off-screen and lets it answer.
- * (The TWA build's version of this class returns false — see src/main.)
+ * push schedules WakeJob, which loads it off-screen and lets it answer.
+ * (The TWA build's version of this class returns false — see src/twa.)
*/
final class Wake {
private Wake() {}
static boolean start(Context context, String payload) {
try {
- Intent i = new Intent(context, WakeService.class);
- i.putExtra("payload", payload);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i);
- else context.startService(i);
- return true;
+ JobScheduler js = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ if (js == null) return false;
+ PersistableBundle extras = new PersistableBundle();
+ extras.putString("payload", payload == null ? "" : payload);
+ JobInfo.Builder b = new JobInfo.Builder(WakeJob.JOB_ID, new ComponentName(context, WakeJob.class))
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .setExtras(extras);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // expedited: runs promptly from the background, which is the whole
+ // point — a normal job could wait for hours
+ b.setExpedited(true);
+ } else {
+ b.setOverrideDeadline(0);
+ }
+ int r = js.schedule(b.build());
+ android.util.Log.i("coinos", "wake: schedule -> " + r);
+ return r == JobScheduler.RESULT_SUCCESS;
} catch (Exception e) {
- // Android refuses background starts in some states; fall back to the
- // notification, which is better than nothing happening at all
+ android.util.Log.w("coinos", "wake: schedule failed: " + e);
+ // out of expedited quota, or a manufacturer being creative: the
+ // notification is still there
return false;
}
}
diff --git a/android/app/src/webview/java/io/coinos/app/WakeJob.java b/android/app/src/webview/java/io/coinos/app/WakeJob.java
new file mode 100644
index 0000000..c866c33
--- /dev/null
+++ b/android/app/src/webview/java/io/coinos/app/WakeJob.java
@@ -0,0 +1,78 @@
+package io.coinos.app;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.webkit.JavascriptInterface;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+
+/**
+ * The wallet, woken without being opened.
+ *
+ * <p>A push starts this job; it loads the wallet into an off-screen WebView —
+ * the same storage the visible app uses, because it's the same app — with
+ * ?wake= naming what arrived. The page does whatever the payload calls for
+ * (answering an NWC request, checking for a payment) and calls back through
+ * the CoinosHost bridge when it's finished, or the job gives up after half a
+ * minute. Either way it ends: nothing here is allowed to sit in the
+ * background burning battery.
+ *
+ * <p>A JOB, not a foreground service. Android 12 forbids starting one of
+ * those from a broadcast receiver — "Background started FGS: Disallowed" —
+ * and an expedited job is the sanctioned replacement: it runs promptly from
+ * the background, with no notification of its own to apologise for.
+ */
+public class WakeJob extends JobService {
+
+ static final int JOB_ID = 4242;
+ private static final long GIVE_UP_MS = 30_000;
+
+ private WebView web;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable giveUp;
+ private JobParameters params;
+
+ @Override
+ public boolean onStartJob(JobParameters p) {
+ params = p;
+ String payload = p.getExtras() == null ? null : p.getExtras().getString("payload");
+ android.util.Log.i("coinos", "wake: job started, payload=" + payload);
+
+ web = new WebView(this);
+ WebSettings s = web.getSettings();
+ s.setJavaScriptEnabled(true);
+ s.setDomStorageEnabled(true);
+ s.setDatabaseEnabled(true);
+ web.addJavascriptInterface(new Host(), "CoinosHost");
+
+ // 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.
+ web.loadUrl(MainActivity.BASE + "?wake=" + Uri.encode(payload == null ? "1" : payload));
+
+ giveUp = () -> finish(false);
+ handler.postDelayed(giveUp, GIVE_UP_MS);
+ return true; // still working
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters p) {
+ finish(false);
+ return false; // don't reschedule: the push will come again if it matters
+ }
+
+ /** What the page calls when it has finished. */
+ private class Host {
+ @JavascriptInterface
+ public void done() { handler.post(() -> finish(true)); }
+ }
+
+ private void finish(boolean fromPage) {
+ android.util.Log.i("coinos", "wake: finishing (" + (fromPage ? "page said so" : "timed out or stopped") + ")");
+ if (giveUp != null) { handler.removeCallbacks(giveUp); giveUp = null; }
+ if (web != null) { web.destroy(); web = null; }
+ if (params != null) { jobFinished(params, false); params = null; }
+ }
+}
diff --git a/android/app/src/webview/java/io/coinos/app/WakeService.java b/android/app/src/webview/java/io/coinos/app/WakeService.java
deleted file mode 100644
index cd33547..0000000
--- a/android/app/src/webview/java/io/coinos/app/WakeService.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package io.coinos.app;
-
-import android.app.Notification;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Handler;
-import android.os.IBinder;
-import android.os.Looper;
-import android.webkit.JavascriptInterface;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-
-/**
- * The wallet, woken without being opened.
- *
- * <p>A UnifiedPush message starts this service; it loads the wallet into an
- * off-screen WebView — the same storage the visible app uses, because it's
- * the same app — with ?wake= naming what arrived. The page does whatever the
- * payload calls for (answering an NWC request, checking for a payment) and
- * calls back through the CoinosHost bridge when it's finished, or the service
- * gives up after half a minute. Either way it stops: nothing about this is
- * allowed to sit in the background burning battery.
- *
- * <p>A foreground service, because Android will not let a background one
- * touch the network reliably. Its notification IS the point rather than an
- * apology for it — something arrived, and this says so while it's dealt with.
- */
-public class WakeService extends Service {
-
- private static final String CHANNEL = "coinos-wake";
- private static final int NOTIF_ID = 42;
- private static final long GIVE_UP_MS = 30_000;
-
- private WebView web;
- private final Handler handler = new Handler(Looper.getMainLooper());
- private Runnable giveUp;
-
- @Override
- public IBinder onBind(Intent intent) { return null; }
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- String payload = intent == null ? null : intent.getStringExtra("payload");
- startForeground(NOTIF_ID, notification(payload));
-
- if (web == null) {
- web = new WebView(this);
- WebSettings s = web.getSettings();
- s.setJavaScriptEnabled(true);
- s.setDomStorageEnabled(true);
- s.setDatabaseEnabled(true);
- web.addJavascriptInterface(new Host(), "CoinosHost");
- }
- // 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.
- String url = MainActivity.BASE + "?wake=" + Uri.encode(payload == null ? "1" : payload);
- handler.post(() -> web.loadUrl(url));
-
- if (giveUp != null) handler.removeCallbacks(giveUp);
- giveUp = this::done;
- handler.postDelayed(giveUp, GIVE_UP_MS);
- return START_NOT_STICKY;
- }
-
- /** What the page calls when it has finished. */
- private class Host {
- @JavascriptInterface
- public void done() { handler.post(WakeService.this::done); }
- /** Lets the page replace our placeholder notification with the real news. */
- @JavascriptInterface
- public void notify(String title, String body) {
- handler.post(() -> {
- NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- if (nm == null) return;
- nm.notify(NOTIF_ID + 1, build(title, body, false));
- });
- }
- }
-
- private void done() {
- if (giveUp != null) handler.removeCallbacks(giveUp);
- if (web != null) { web.destroy(); web = null; }
- stopForeground(true);
- stopSelf();
- }
-
- @Override
- public void onDestroy() {
- if (web != null) { web.destroy(); web = null; }
- super.onDestroy();
- }
-
- private Notification notification(String payload) {
- String body = getString(R.string.wake_working);
- try {
- org.json.JSONObject j = new org.json.JSONObject(payload == null ? "{}" : payload);
- String reason = j.optString("reason", j.optString("type", ""));
- if ("nwc".equals(reason)) body = getString(R.string.push_nwc_answering);
- else if ("payment".equals(reason)) body = getString(R.string.push_payment);
- else if ("dm".equals(reason)) body = getString(R.string.push_dm);
- else if ("chat".equals(reason)) body = getString(R.string.push_chat);
- } catch (Exception ignored) {}
- return build(getString(R.string.app_name), body, true);
- }
-
- private Notification build(String title, String body, boolean quiet) {
- NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm != null) {
- NotificationChannel ch = new NotificationChannel(CHANNEL, getString(R.string.app_name),
- quiet ? NotificationManager.IMPORTANCE_LOW : NotificationManager.IMPORTANCE_DEFAULT);
- nm.createNotificationChannel(ch);
- }
- Intent open = new Intent(this, MainActivity.class);
- open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
- int flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT
- | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? android.app.PendingIntent.FLAG_IMMUTABLE : 0);
- Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- ? new Notification.Builder(this, CHANNEL)
- : new Notification.Builder(this);
- return b.setSmallIcon(R.mipmap.ic_launcher)
- .setContentTitle(title)
- .setContentText(body)
- .setAutoCancel(true)
- .setContentIntent(android.app.PendingIntent.getActivity(this, 0, open, flags))
- .build();
- }
-}