diff options
| author | Adam Soltys <[email protected]> | 2026-09-12 15:47:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-12 15:47:56 -0700 |
| commit | 85d7858a0ef4b4f3ee60eb2bd5621e4899195935 (patch) | |
| tree | ffd283a14df52948461a1125c41bff976a4f47cc | |
| parent | 9356684a78cddd106d82296a50a637406908cffd (diff) | |
| download | coinosv3-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
| -rw-r--r-- | android/app/src/main/java/io/coinos/app/PushReceiver.java | 20 | ||||
| -rw-r--r-- | android/app/src/webview/AndroidManifest.xml | 16 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/MainActivity.java | 6 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/Wake.java | 36 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/WakeJob.java | 78 | ||||
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/WakeService.java | 132 | ||||
| -rw-r--r-- | android/app/src/webview/res/values/styles.xml | 10 | ||||
| -rw-r--r-- | src/app.js | 34 | ||||
| -rw-r--r-- | src/features/nwc.js | 15 | ||||
| -rw-r--r-- | static/graphene.apk | bin | 38692 -> 38908 bytes | |||
| -rw-r--r-- | tools/unifiedpush-test.js | 2 |
11 files changed, 189 insertions, 160 deletions
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 faabfaf..508c331 100644 --- a/android/app/src/main/java/io/coinos/app/PushReceiver.java +++ b/android/app/src/main/java/io/coinos/app/PushReceiver.java @@ -45,10 +45,13 @@ public class PushReceiver extends BroadcastReceiver { 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 — no tap. On - // the TWA build the wallet is Chrome's and out of reach, so the - // notification is the whole of what we can do. - if (!Wake.start(context, body)) notify(context, body); + // 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); Push.ack(context, intent.getStringExtra(Push.EXTRA_ID)); break; } @@ -57,6 +60,15 @@ public class PushReceiver extends BroadcastReceiver { } } + /** Requests the woken wallet answers by itself need no notification. */ + private boolean isSilent(String payload) { + try { + JSONObject j = new JSONObject(payload == null ? "{}" : payload); + String reason = j.optString("reason", j.optString("type", "")); + return "nwc".equals(reason) || "request".equals(reason); + } catch (Exception e) { return false; } + } + /** * The payload is the same JSON the web push path sends. We show what it * describes; anything we don't recognise still gets a notification, because diff --git a/android/app/src/webview/AndroidManifest.xml b/android/app/src/webview/AndroidManifest.xml index e88217b..0942377 100644 --- a/android/app/src/webview/AndroidManifest.xml +++ b/android/app/src/webview/AndroidManifest.xml @@ -3,11 +3,17 @@ See MainActivity for why that trade exists. --> <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-permission android:name="android.permission.FOREGROUND_SERVICE" /> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> - <application android:usesCleartextTraffic="false"> + <application + 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" @@ -43,8 +49,8 @@ </activity> <!-- the off-screen wallet a push wakes --> <service - android:name="io.coinos.app.WakeService" + android:name="io.coinos.app.WakeJob" android:exported="false" - android:foregroundServiceType="dataSync" /> + android:permission="android.permission.BIND_JOB_SERVICE" /> </application> </manifest> 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(); - } -} diff --git a/android/app/src/webview/res/values/styles.xml b/android/app/src/webview/res/values/styles.xml new file mode 100644 index 0000000..c4eec32 --- /dev/null +++ b/android/app/src/webview/res/values/styles.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- The TWA has no chrome of its own; neither should this. Same system-bar + colours as the TWA declares, so the two builds look like one app. --> + <style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar"> + <item name="android:statusBarColor">@color/statusBarColor</item> + <item name="android:navigationBarColor">@color/navigationBarColor</item> + <item name="android:windowBackground">@color/statusBarColor</item> + </style> +</resources> @@ -5045,6 +5045,12 @@ const ctx = { parseAmount: (v, u) => parseAmount(v, u, rateNow()), worthLine, openImage: (src) => { ui.lightbox = src; render(); }, + // What woke this run, if anything did, and how to say we're finished. + // Both read through a closure: ctx is built before these are defined, and + // a bare `wakeDone` shorthand captured the binding while it was still + // undefined — the job then ran its full half minute every time. + wakePayload: () => WAKE_PAYLOAD, + wakeDone: () => wakeDone(), // "The next balance isn't a change, it's the first real value" — state // arriving late (the ark store opening past boot's patience) must not read // as money landing: without this the balance counts up from 0 in green, @@ -5124,6 +5130,23 @@ const ctx = { // BEFORE features construct: a nostr npub/nprofile rewrites itself onto the // existing /npub… profile deep link (which the messages feature reads at // construction); payment URIs stash for dispatch once a wallet is open. +// Woken by the Android wrapper (see android/, WakeJob): the wallet is being +// loaded off-screen to deal with something. The payload is read once, here, +// because whether anything CAN be done depends on whether a wallet opens at +// all — and if none does, the job should end straight away rather than hold +// a WebView open for half a minute over a locked wallet. +const WAKE_PAYLOAD = (() => { + try { + const w = new URLSearchParams(location.search).get('wake'); + if (w == null) return null; + history.replaceState(null, '', location.pathname || '/'); + return w; + } catch { return null; } +})(); +const wakeDone = () => { + try { if (WAKE_PAYLOAD != null && window.CoinosHost && window.CoinosHost.done) window.CoinosHost.done(); } catch {} +}; + const INTENT_URI = (() => { try { const u = new URLSearchParams(location.search).get('u'); @@ -5311,5 +5334,14 @@ loadLocale(getLang()).finally(async () => { const storeReady = featureHook('arkStoreReady'); if (storeReady) await storeReady; _bootDeciding = false; // from here on the screen is a decision, not a guess - if (!restoreAccountsState()) render(); + const opened = restoreAccountsState(); + if (!opened) render(); + if (WAKE_PAYLOAD != null) { + // Nothing opened — a locked vault, or no wallet on this device. There is + // nothing to answer with, so say so now. + if (!opened) wakeDone(); + // And a backstop: a wallet that opened but has no feature interested in + // this payload must still end the job. + else setTimeout(wakeDone, 40_000); + } }); diff --git a/src/features/nwc.js b/src/features/nwc.js index cefeb31..2cb9d81 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -803,16 +803,17 @@ export function nwcFeature(ctx) { // service can stop rather than sit there costing battery. A cap, because // "finished" is not always reachable: the relay may have nothing for us. function serveWake() { - let payload = null; - try { payload = new URLSearchParams(location.search).get('wake'); } catch {} + const payload = ctx.wakePayload ? ctx.wakePayload() : null; if (payload == null) return; - try { history.replaceState(null, '', location.pathname || '/'); } catch {} - const host = typeof window !== 'undefined' && window.CoinosHost; + if (!ctx.wakeDone) return; + let settled = false; + const finish = () => { if (settled) return; settled = true; ctx.wakeDone(); }; + // No connections means nothing here could have been asked of us: end the + // job now rather than hold a WebView open for half a minute on the + // strength of a payload we can't act on. + if (!conns().length) { finish(); return; } listen(); // the request is on a relay; this is what hears it reconcileBg(); // and make sure the pouch behind it is current - if (!host || typeof host.done !== 'function') return; - let settled = false; - const finish = () => { if (settled) return; settled = true; try { host.done(); } catch {} }; // an answer published is the signal we were woken for const stop = onAnswered(() => setTimeout(finish, 1500)); setTimeout(() => { try { stop(); } catch {} finish(); }, 25_000); diff --git a/static/graphene.apk b/static/graphene.apk Binary files differindex 8e6de0f..49f0fc5 100644 --- a/static/graphene.apk +++ b/static/graphene.apk diff --git a/tools/unifiedpush-test.js b/tools/unifiedpush-test.js index 2d656ba..6cf7b6e 100644 --- a/tools/unifiedpush-test.js +++ b/tools/unifiedpush-test.js @@ -89,7 +89,7 @@ try { await page.goto('http://localhost:5263/?wake=' + encodeURIComponent(JSON.stringify({ type: 'nwc', servicePubkey: 'a'.repeat(64) })), { waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); check('a woken run takes the payload off the URL', !(await page.url()).includes('wake='), await page.url()); - await sleep(27000); // the cap is 25s; a real answer ends it sooner + await sleep(8000); // a wallet with no connections has nothing to wait for const done = await page.evaluate(() => window.__done); check('...and tells the service when to stop', done > 0, done + ' call(s) to CoinosHost.done()'); } finally { await browser.close(); server.stop(true); } |
