diff options
16 files changed, 471 insertions, 7 deletions
diff --git a/android/app/build.gradle b/android/app/build.gradle index e11ce56..532db83 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,6 +14,28 @@ android { versionCode 7 versionName '1.6' } + // Two builds of the same wallet. + // + // twa — Chrome renders the site (the better browser, and the one + // people already have installed). Its storage is Chrome's, so + // nothing outside Chrome can wake the wallet, and on a phone + // without Google Play Services nothing inside it can either. + // webview — the wallet runs in the app, so a UnifiedPush message can + // start it off-screen and let it answer. A separate app id, + // because it is a separate wallet: its storage is its own. + flavorDimensions += 'shell' + productFlavors { + twa { + dimension 'shell' + isDefault = true + } + webview { + dimension 'shell' + applicationId 'io.coinos.webview' + versionNameSuffix '-webview' + resValue 'string', 'app_name', 'Coinos (UnifiedPush)' + } + } signingConfigs { release { if (keystoreFile.exists()) { @@ -32,5 +54,6 @@ android { } } dependencies { - implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' + // only the TWA shell needs Chrome's helper; the WebView build carries none + twaImplementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0' } 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 07cabeb..faabfaf 100644 --- a/android/app/src/main/java/io/coinos/app/PushReceiver.java +++ b/android/app/src/main/java/io/coinos/app/PushReceiver.java @@ -44,7 +44,11 @@ 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); - notify(context, 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 — 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); Push.ack(context, intent.getStringExtra(Push.EXTRA_ID)); break; } @@ -84,7 +88,10 @@ public class PushReceiver extends BroadcastReceiver { nm.createNotificationChannel(ch); } - Intent open = new Intent(c, LauncherActivity.class); + // whichever front door this build has — the TWA's launcher or the + // WebView's main activity; the receiver is shared by both + Intent open = c.getPackageManager().getLaunchIntentForPackage(c.getPackageName()); + if (open == null) open = new Intent(Intent.ACTION_VIEW); open.setAction(Intent.ACTION_VIEW); open.setData(Uri.parse(deepLink != null && deepLink.startsWith("https://v3.coinos.io") ? deepLink : "https://v3.coinos.io/")); diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8eca245..8132a82 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="app_name">Coinos</string> <!-- UnifiedPush notifications: what a woken app says before you open it. The payload names a category; the wallet itself has the detail. --> <string name="push_generic">Something happened in your wallet</string> @@ -8,6 +7,8 @@ <string name="push_dm">New message</string> <string name="push_chat">New message in a group</string> <string name="push_nwc">An app is asking to pay — open Coinos to approve</string> + <string name="push_nwc_answering">Answering a payment request…</string> + <string name="wake_working">Checking your wallet…</string> <!-- must match the site's /.well-known/assetlinks.json for URL-bar-less TWA --> <string name="asset_statements"> [{ diff --git a/android/app/src/main/java/io/coinos/app/IntentActivity.java b/android/app/src/twa/java/io/coinos/app/IntentActivity.java index 3de1d62..3de1d62 100644 --- a/android/app/src/main/java/io/coinos/app/IntentActivity.java +++ b/android/app/src/twa/java/io/coinos/app/IntentActivity.java diff --git a/android/app/src/main/java/io/coinos/app/LauncherActivity.java b/android/app/src/twa/java/io/coinos/app/LauncherActivity.java index 654265b..654265b 100644 --- a/android/app/src/main/java/io/coinos/app/LauncherActivity.java +++ b/android/app/src/twa/java/io/coinos/app/LauncherActivity.java diff --git a/android/app/src/twa/java/io/coinos/app/Wake.java b/android/app/src/twa/java/io/coinos/app/Wake.java new file mode 100644 index 0000000..558a6f5 --- /dev/null +++ b/android/app/src/twa/java/io/coinos/app/Wake.java @@ -0,0 +1,21 @@ +package io.coinos.app; + +import android.content.Context; + +/** + * Whether this build can wake the wallet itself. + * + * <p>This is the TWA build's answer: no. The wallet lives in Chrome's storage + * for its own origin, and nothing in this app can reach into that, so a push + * can only raise a notification for someone to tap. The WebView build has its + * own version of this class (src/webview) that says yes. One class per + * flavor, so the shared receiver needs no flavor checks. + */ +final class Wake { + private Wake() {} + + /** @return true when the wallet was woken and the notification isn't needed. */ + static boolean start(Context context, String payload) { + return false; + } +} diff --git a/android/app/src/twa/res/values/strings.xml b/android/app/src/twa/res/values/strings.xml new file mode 100644 index 0000000..1fc8cc7 --- /dev/null +++ b/android/app/src/twa/res/values/strings.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Coinos</string> +</resources> diff --git a/android/app/src/webview/AndroidManifest.xml b/android/app/src/webview/AndroidManifest.xml new file mode 100644 index 0000000..e88217b --- /dev/null +++ b/android/app/src/webview/AndroidManifest.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The WebView build: the wallet runs in this app, so this app can wake it. + See MainActivity for why that trade exists. --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET" /> + <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"> + <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> + <!-- the off-screen wallet a push wakes --> + <service + android:name="io.coinos.app.WakeService" + android:exported="false" + android:foregroundServiceType="dataSync" /> + </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 new file mode 100644 index 0000000..166a1b7 --- /dev/null +++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java @@ -0,0 +1,150 @@ +package io.coinos.app; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.ViewGroup; +import android.webkit.CookieManager; +import android.webkit.PermissionRequest; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +/** + * The wallet, in a WebView this app owns. + * + * <p>The other build is a TWA — Chrome renders the site, and the wallet's + * keys live in Chrome's storage. That's the better browser, but it means + * nothing outside Chrome can wake the wallet, and on a phone without Google + * Play Services nothing inside Chrome can either. This build trades the + * browser for reach: the wallet lives in THIS app's storage, so a + * UnifiedPush message can start it headlessly and let it answer (see + * WakeService). + * + * <p>The trade is real and worth naming: a separate app means a separate + * wallet. Signing in here is signing in again. + */ +public class MainActivity extends Activity { + + static final String BASE = "https://v3.coinos.io/"; + private WebView web; + private ValueCallback<Uri[]> filePicker; + private static final int REQ_FILE = 10, REQ_PERMS = 11; + + @Override + protected void onCreate(Bundle saved) { + super.onCreate(saved); + web = new WebView(this); + setContentView(web, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + WebSettings s = web.getSettings(); + s.setJavaScriptEnabled(true); + s.setDomStorageEnabled(true); // localStorage + IndexedDB: the wallet + s.setDatabaseEnabled(true); + s.setMediaPlaybackRequiresUserGesture(false); + s.setSupportMultipleWindows(false); + s.setLoadWithOverviewMode(true); + s.setUseWideViewPort(true); + CookieManager.getInstance().setAcceptCookie(true); + + web.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest r) { + Uri u = r.getUrl(); + String host = u.getHost(); + // our own pages stay in here; everything else is the world's, and + // belongs in a real browser + if (host != null && (host.equals("v3.coinos.io") || host.endsWith(".coinos.io"))) return false; + try { startActivity(new Intent(Intent.ACTION_VIEW, u)); } catch (Exception ignored) {} + return true; + } + }); + + web.setWebChromeClient(new WebChromeClient() { + @Override + public void onPermissionRequest(final PermissionRequest request) { + // the QR scanner asks for the camera; grant it only once Android has + // granted it to us + runOnUiThread(() -> { + if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + request.grant(request.getResources()); + } else { + request.deny(); + requestPermissions(new String[] { Manifest.permission.CAMERA }, REQ_PERMS); + } + }); + } + + @Override + public boolean onShowFileChooser(WebView v, ValueCallback<Uri[]> cb, FileChooserParams params) { + // attaching a picture to a post + if (filePicker != null) filePicker.onReceiveValue(null); + filePicker = cb; + try { + startActivityForResult(params.createIntent(), REQ_FILE); + return true; + } catch (Exception e) { + filePicker = null; + return false; + } + } + }); + + if (Build.VERSION.SDK_INT >= 33 + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQ_PERMS); } catch (Exception ignored) {} + } + Push.register(this); + web.loadUrl(urlFor(getIntent())); + } + + /** A scheme intent (lightning:, bitcoin:, nostr:) rides in as ?u=, like the TWA's. */ + private String urlFor(Intent intent) { + StringBuilder url = new StringBuilder(BASE); + Uri data = intent == null ? null : intent.getData(); + if (data != null) { + String scheme = data.getScheme(); + if (scheme != null && !scheme.startsWith("http")) url.append("?u=").append(Uri.encode(data.toString())); + else if ("v3.coinos.io".equals(data.getHost())) { url.setLength(0); url.append(data.toString()); } + } + // the UnifiedPush endpoint, on its way to the part of the app that knows + // what to watch for + String endpoint = Push.endpoint(this); + if (endpoint != null && !Push.endpointDelivered(this)) { + url.append(url.indexOf("?") >= 0 ? "&" : "?").append("up=").append(Uri.encode(endpoint)); + Push.markEndpointDelivered(this); + } + return url.toString(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + if (web != null) web.loadUrl(urlFor(intent)); + } + + @Override + protected void onActivityResult(int req, int res, Intent data) { + if (req == REQ_FILE && filePicker != null) { + filePicker.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res, data)); + filePicker = null; + return; + } + super.onActivityResult(req, res, data); + } + + @Override + public void onBackPressed() { + if (web != null && web.canGoBack()) web.goBack(); + else super.onBackPressed(); + } +} diff --git a/android/app/src/webview/java/io/coinos/app/Wake.java b/android/app/src/webview/java/io/coinos/app/Wake.java new file mode 100644 index 0000000..883ec49 --- /dev/null +++ b/android/app/src/webview/java/io/coinos/app/Wake.java @@ -0,0 +1,28 @@ +package io.coinos.app; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +/** + * 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.) + */ +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; + } catch (Exception e) { + // Android refuses background starts in some states; fall back to the + // notification, which is better than nothing happening at all + return false; + } + } +} diff --git a/android/app/src/webview/java/io/coinos/app/WakeService.java b/android/app/src/webview/java/io/coinos/app/WakeService.java new file mode 100644 index 0000000..cd33547 --- /dev/null +++ b/android/app/src/webview/java/io/coinos/app/WakeService.java @@ -0,0 +1,132 @@ +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(); + } +} @@ -469,8 +469,13 @@ if (import.meta.main) { if (await Bun.file('static/.well-known/assetlinks.json').exists()) { await Bun.write('dist/.well-known/assetlinks.json', Bun.file('static/.well-known/assetlinks.json')); } - if (await Bun.file('static/coinos.apk').exists()) { - await Bun.write('dist/coinos.apk', Bun.file('static/coinos.apk')); + // The Android builds. coinos.apk is the TWA (Chrome renders the site); + // coinos-unifiedpush.apk runs the wallet in its own WebView so a phone + // without Google Play Services can be woken — see android/. + for (const apk of ['coinos.apk', 'coinos-unifiedpush.apk']) { + if (await Bun.file('static/' + apk).exists()) { + await Bun.write('dist/' + apk, Bun.file('static/' + apk)); + } } // default avatars, self-hosted since coinos.io's copies are half-broken. // punks-sm carries a 128px copy of each: the art is 240px because that's diff --git a/src/features/nwc.js b/src/features/nwc.js index dd0141d..cefeb31 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -242,6 +242,7 @@ export function nwcFeature(ctx) { content, }, sk); await publish(NWC_RELAYS, evt); + noteAnswered(); // a woken background run can stop once this happens } catch (e) { console.warn('nwc: could not reply', e.message); } @@ -794,6 +795,34 @@ export function nwcFeature(ctx) { }); } + // ---- woken by the Android wrapper -------------------------------------- + // The WebView build starts the wallet off-screen when a push arrives, with + // ?wake=<payload> on the URL. There's no service worker in that picture — + // the page IS the background — so it does the normal thing (subscribe, let + // the request arrive, answer it) and then says it's finished, so the + // 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 {} + if (payload == null) return; + try { history.replaceState(null, '', location.pathname || '/'); } catch {} + const host = typeof window !== 'undefined' && window.CoinosHost; + 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); + } + + // Callers that want to know when this wallet answered something. + const answeredWatchers = new Set(); + function onAnswered(fn) { answeredWatchers.add(fn); return () => answeredWatchers.delete(fn); } + function noteAnswered() { for (const fn of [...answeredWatchers]) { try { fn(); } catch {} } } + // Watchdog: relay sockets can die in ways the pool's reconnect never heals // (an errored socket sets skipReconnection and gives up), and a wallet // service with a dead subscription is indistinguishable from one that's @@ -913,7 +942,7 @@ export function nwcFeature(ctx) { id: 'nwc', nwcOfferPubkey() { return (hook('arkReady') && !wallet.watchOnly) ? offerKeys().pk : null; }, nwcOfferString() { return hook('arkReady') && !wallet.watchOnly ? offerString() : null; }, - init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); }, + init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); serveWake(); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, nostrSettingsCards() { return [nwcCard()]; }, notifySettingsCards() { diff --git a/static/coinos-unifiedpush.apk b/static/coinos-unifiedpush.apk Binary files differnew file mode 100644 index 0000000..8e6de0f --- /dev/null +++ b/static/coinos-unifiedpush.apk diff --git a/static/coinos.apk b/static/coinos.apk Binary files differindex 6bef026..7a30775 100644 --- a/static/coinos.apk +++ b/static/coinos.apk diff --git a/tools/unifiedpush-test.js b/tools/unifiedpush-test.js index 71fbcde..2d656ba 100644 --- a/tools/unifiedpush-test.js +++ b/tools/unifiedpush-test.js @@ -78,6 +78,20 @@ try { const txt = await page.evaluate(() => document.body.innerText); 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)); + + // 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. + await page.evaluateOnNewDocument(() => { + window.__done = 0; + window.CoinosHost = { done: () => { window.__done++; }, notify: () => {} }; + }); + 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 + 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); } console.log(ok ? '\n✅ a de-Googled phone can be reached' : '\n❌ failed'); process.exit(ok ? 0 : 1); |
