diff options
| author | Adam Soltys <[email protected]> | 2026-09-12 17:06:14 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-12 17:06:14 -0700 |
| commit | ce61a0f20a2c535e0c69f59836a17f31a3d88468 (patch) | |
| tree | bc80738e4181af5bb8b3ca0844a16d52766c415a | |
| parent | 8fe3d0d28d5f15643c1737809c5f1773cb8f9f94 (diff) | |
| download | coinosv3-ce61a0f20a2c535e0c69f59836a17f31a3d88468.tar.gz coinosv3-ce61a0f20a2c535e0c69f59836a17f31a3d88468.zip | |
End to end over the real network, and the advice now fits the app it's in.
A push sent to ntfy.sh reached a killed app on a real phone:
17:02:21.259 ntfy.sh → the phone's ntfy → broadcast to io.coinos.webview
17:02:21.308 app process started (it had been killed)
17:02:21.458 wake: job started
17:02:22.926 wake: finishing (page said so) — 1.7s, and "You were paid"
One thing that took two goes: `adb shell am force-stop` marks a package
STOPPED, and Android delivers no broadcasts to a stopped package unless the
sender sets FLAG_INCLUDE_STOPPED_PACKAGES — which `am broadcast` does and a
real distributor rightly doesn't. The first attempt looked like a failure and
was a test artefact; `am kill`, which is what Android itself does to a
backgrounded app, delivers fine.
And the WebView build can now say something useful about push. It was
inheriting the browser's line — "your browser has no push service, try
Firefox" — which is nonsense inside a WebView that IS the app. It asks the
host what's actually there and says: install a push app like ntfy (nothing
installed), or wait a moment and reopen (a distributor that hasn't answered
yet). Payments and messages still arrive whenever coinos is open, which is
the part that matters most to someone reading an apology.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
| -rw-r--r-- | android/app/src/webview/java/io/coinos/app/MainActivity.java | 22 | ||||
| -rw-r--r-- | src/features/messages.js | 26 | ||||
| -rw-r--r-- | src/features/nwc.js | 2 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | static/graphene.apk | bin | 38840 -> 39508 bytes | |||
| -rw-r--r-- | tools/unifiedpush-test.js | 26 |
6 files changed, 74 insertions, 4 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 1c0c9d5..a7980c4 100644 --- a/android/app/src/webview/java/io/coinos/app/MainActivity.java +++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java @@ -17,6 +17,8 @@ import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; +import java.util.List; + /** * The wallet, in a WebView this app owns. * @@ -108,10 +110,30 @@ public class MainActivity extends Activity { && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQ_PERMS); } catch (Exception ignored) {} } + // The page can ask where it's running and whether anything on this phone + // 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); web.loadUrl(urlFor(getIntent())); } + /** What the page is allowed to ask about its surroundings. */ + private class Host { + @android.webkit.JavascriptInterface + public String env() { + 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() + + ",\"distributor\":" + (dist == null ? "null" : "\"" + dist + "\"") + + ",\"endpoint\":" + (endpoint != null) + "}"; + } + /** The wake job's bridge has this too; here it's a no-op. */ + @android.webkit.JavascriptInterface + public void done() {} + } + /** A scheme intent (lightning:, bitcoin:, nostr:) rides in as ?u=, like the TWA's. */ private String urlFor(Intent intent) { StringBuilder url = new StringBuilder(BASE); diff --git a/src/features/messages.js b/src/features/messages.js index 9fbd19b..b153dba 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1284,6 +1284,25 @@ export function messagesFeature(ctx) { } catch {} })(); + // What to tell someone whose phone can't be woken. In a browser that's + // "your browser has no push service"; inside the UnifiedPush build of the + // Android app it's about the distributor, because that build IS the push — + // telling a WebView to go and use Firefox would be nonsense. + function pushAdvice() { + const env = hostEnv(); + if (!env || env.app !== 'graphene') return t('nwcNoPushService'); + if (!env.distributors) return t('upNoDistributor'); + if (!env.endpoint) return t('upWaiting', { app: (env.distributor || '').split('.').pop() }); + return t('nwcNoPushService'); + } + function hostEnv() { + try { + const h2 = typeof window !== 'undefined' && window.CoinosHost; + if (!h2 || typeof h2.env !== 'function') return null; + return JSON.parse(h2.env()); + } catch { return null; } + } + async function registerPush({ interactive = false } = {}) { try { // A UnifiedPush endpoint needs no permission and no push service: it's @@ -4038,9 +4057,10 @@ export function messagesFeature(ctx) { }, t('msgDismiss'))))); // A browser with no push service at all: the offer above is beside the // point (permission may even be granted), so say what's actually wrong - // wherever the user got to. + // wherever the user got to. Inside the Android build that carries its own + // push, the answer is a different one — see pushAdvice. if (st().noPushService && !st().push) - kids.push(h('div', { class: 'notice info small' }, t('nwcNoPushService'))); + kids.push(h('div', { class: 'notice info small' }, pushAdvice())); // ---- the feed, above the conversations: it's the thing you read, they're // the things you answer @@ -4548,6 +4568,8 @@ export function messagesFeature(ctx) { // Also the way a zap sent from a form reaches the tally right away, // without waiting on its receipt. zapSettled(eventId, ok, sats) { settleZap(eventId, ok, sats); return true; }, + // the right thing to say about push on THIS device, wherever it's asked + pushAdvice() { return pushAdvice(); }, // Chat lives behind a header button and takes over the whole screen — // no balance card, no tabs; each view carries its own way back. // The bare avatar node for the app header's identity menu. diff --git a/src/features/nwc.js b/src/features/nwc.js index 2cb9d81..656c254 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -884,7 +884,7 @@ export function nwcFeature(ctx) { list.length ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, load().pushUnavailable && !load().background - ? h('div', { class: 'small faint' }, t('nwcNoPushService')) + ? h('div', { class: 'small faint' }, hook('pushAdvice') || t('nwcNoPushService')) : null, h('div', { class: 'row between' }, h('span', { class: 'small' }, t('nwcBackground')), diff --git a/src/i18n.js b/src/i18n.js index 38836d2..6f29a77 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -854,6 +854,8 @@ const STR = { nwcBackground: 'Answer while closed', nwcNoPushHere: 'This browser can\u2019t receive push messages.', nwcNoPermission: 'Notifications weren\u2019t allowed.', + upNoDistributor: 'To be reached while this app is closed you need a push app installed alongside it — ntfy is the usual one (F\u2011Droid or the Play Store). Install it, open it once, then reopen coinos. Until then, payments and messages arrive whenever coinos is open.', + upWaiting: 'Waiting for {app} to hand over a push address \u2014 reopen coinos in a moment and it should be here.', nwcNoPushService: 'This browser has no push service, so it can\u2019t be woken while closed. On Android that\u2019s usually a phone without Google Play Services \u2014 Firefox brings its own push and works there. Zaps are still answered whenever coinos is open.', nwcOn: 'On', nwcOff: 'Off', diff --git a/static/graphene.apk b/static/graphene.apk Binary files differindex 29ca693..0688a40 100644 --- a/static/graphene.apk +++ b/static/graphene.apk diff --git a/tools/unifiedpush-test.js b/tools/unifiedpush-test.js index 6cf7b6e..022ead6 100644 --- a/tools/unifiedpush-test.js +++ b/tools/unifiedpush-test.js @@ -14,6 +14,7 @@ import puppeteer from 'puppeteer-core'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { buildHtml } from '../build.js'; +import { cacheKeyFor } from '../src/wallet.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; @@ -57,7 +58,8 @@ try { await click('get started'); await sleep(500); await click('import existing'); await sleep(400); await page.waitForSelector('textarea'); - await page.type('textarea', generateMnemonic(wordlist)); + const mn = generateMnemonic(wordlist); + await page.type('textarea', mn); await click('open wallet'); await waitText('receive', 20000); @@ -79,6 +81,28 @@ try { check('the browser\'s own dead push is no longer the story', !/no push service/i.test(txt), (txt.match(/[^\n]*push service[^\n]*/i) || [''])[0].slice(0, 50)); + // Inside the Android build, the advice about push is about the + // distributor, not about browsers — it IS the browser. + await page.evaluateOnNewDocument(() => { + window.CoinosHost = { env: () => JSON.stringify({ app: 'graphene', distributors: 0, distributor: null, endpoint: false }) }; + }); + // a phone that has never managed a push: no endpoint, and the browser's + // own push already known to be dead + await page.evaluate((k) => { + localStorage.removeItem('coinos-unifiedpush'); + const st = JSON.parse(localStorage.getItem(k) || '{}'); + st.push = false; st.noPushService = true; + localStorage.setItem(k, JSON.stringify(st)); + }, cacheKeyFor(mn + '\n') + ':messages'); + await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(2500); + const advice = await page.evaluate(() => document.body.innerText); + check('with no distributor installed it says to install one', /ntfy/i.test(advice), + (advice.match(/[^\n]*ntfy[^\n]*/i) || ['nothing about a distributor'])[0].slice(0, 60)); + check('...and not to go and use Firefox', !/Firefox/.test(advice)); + // The WebView build wakes the wallet off-screen with ?wake=<payload> and a // CoinosHost bridge. The page has to notice, tidy the URL, and say when // it's finished so the service can stop. |
