<feed xmlns='http://www.w3.org/2005/Atom'>
<title>coinosv3.git/src/features/nwc.js, branch fdroid-v1.7</title>
<subtitle>coinos v3 — bitcoin, Ark, Lightning and nostr wallet (web + Android)</subtitle>
<id>https://git.coinos.io/coinosv3.git/atom?h=fdroid-v1.7</id>
<link rel='self' href='https://git.coinos.io/coinosv3.git/atom?h=fdroid-v1.7'/>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/'/>
<updated>2026-09-14T16:16:23Z</updated>
<entry>
<title>A zap answers as fast as the route allows.</title>
<updated>2026-09-14T16:16:23Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-14T16:16:23Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=dd1489303f59ccef6b322142f94e08d750c06e79'/>
<id>urn:sha1:dd1489303f59ccef6b322142f94e08d750c06e79</id>
<content type='text'>
The wallet used to add two to four seconds of its own around a Lightning
send: a random pause plus a relay look-back before paying, and a
two-second poll for the result after. The notifier's claim now decides
who pays in one round trip, with the relay look-back kept only for
replays and for when the notifier gives no verdict; a phone answering
from behind another app waits a second so a desktop tab wins the claim
instead of paying at a throttled crawl. After the send, one held
CheckLightningPayment call brings the preimage the moment CLN has it —
for wallet-connect pays, the background worker, and the app's own Send.

Co-Authored-By: Claude Fable 5.1 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
</content>
</entry>
<entry>
<title>One device pays a zap, whichever of them heard it first.</title>
<updated>2026-09-14T16:06:02Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-14T16:06:02Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=671d2a96474f59ca9234c876ce6604d03de95727'/>
<id>urn:sha1:671d2a96474f59ca9234c876ce6604d03de95727</id>
<content type='text'>
Every awake device of a wallet hears a wallet-connect pay request within
the same second, and the per-tab lock only reaches one browser profile:
two profiles on one laptop each locked an HTLC for the same 21-sat zap.
The push notifier now hands each pay request to the first device that
claims it; a device that loses the claim stands down silently, and a
device that cannot reach the notifier answers as before so nobody goes
quiet over a hiccup. The worker path claims the same way.

Co-Authored-By: Claude Fable 5.1 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
</content>
</entry>
<entry>
<title>A zap answered by two tabs no longer reports as failed.</title>
<updated>2026-09-14T15:53:35Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-14T15:53:35Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=6394de43d4e596aaeddd2b262bb7a595679d8fbc'/>
<id>urn:sha1:6394de43d4e596aaeddd2b262bb7a595679d8fbc</id>
<content type='text'>
Every open tab of the same wallet sees every wallet-connect request. Two
hidden tabs on one laptop both tried to pay the same zap: the ASP refused
the second with "payment already in progress", and that error reached
Amethyst half a second before the first tab's preimage. A Web Lock per
request now lets one tab (or the worker) answer while the rest stay
quiet, and a device that does meet "already in progress" waits for the
sibling payment's outcome — relaying its preimage only if the payer never
speaks — instead of shouting a failure over sats that went through. The
losing side also stops writing a failed row into history for it.

Co-Authored-By: Claude Fable 5.1 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
</content>
</entry>
<entry>
<title>End to end over the real network, and the advice now fits the app it's in.</title>
<updated>2026-09-13T00:06:14Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-13T00:06:14Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=ce61a0f20a2c535e0c69f59836a17f31a3d88468'/>
<id>urn:sha1:ce61a0f20a2c535e0c69f59836a17f31a3d88468</id>
<content type='text'>
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) &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
</content>
</entry>
<entry>
<title>The wake had to be a job, not a foreground service — Android says so.</title>
<updated>2026-09-12T22:47:56Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-12T22:47:56Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=85d7858a0ef4b4f3ee60eb2bd5621e4899195935'/>
<id>urn:sha1:85d7858a0ef4b4f3ee60eb2bd5621e4899195935</id>
<content type='text'>
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) &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
</content>
</entry>
<entry>
<title>A second Android build that can wake the wallet, because it holds it.</title>
<updated>2026-09-12T17:32:01Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-12T17:32:01Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=60e755286302647d93bc82070c61fcf93ddbc3b9'/>
<id>urn:sha1:60e755286302647d93bc82070c61fcf93ddbc3b9</id>
<content type='text'>
The TWA can't. Chrome renders the site, so the wallet's keys live in Chrome's
storage for its own origin, and nothing outside Chrome can reach them — a
UnifiedPush message could raise a notification and no more. On a phone with
Google Play Services none of this matters; without it, background answering
was simply unavailable.

So: a second flavour, io.coinos.webview, "Coinos (UnifiedPush)". The wallet
runs in a WebView this app owns, which means a push can start it off-screen
and let it answer for itself. WakeService loads it with ?wake=&lt;payload&gt; into
the same storage the visible app uses, the page subscribes and answers
whatever the payload was about, then calls CoinosHost.done() so the service
stops — capped at 25 seconds from the page and 30 from the service, because
nothing here is allowed to sit in the background costing battery. Its
foreground notification says what it's doing rather than apologising for
existing.

The WebView carries what the wallet actually needs: DOM storage and
IndexedDB, the camera for the QR scanner (granted only once Android has
granted it to us), a file chooser for attaching pictures, and links to
anywhere but coinos.io handed to a real browser.

The trade is named in the code and worth naming here: a separate app is a
separate wallet. Its storage is its own, so signing in there is signing in
again. That's why this is a second build rather than a replacement — the TWA
is untouched, still the better browser for anyone whose phone has push.

The shared push receiver now asks the system for whatever front door this
build has, and asks a per-flavour Wake class whether the wallet can be woken
at all — no flavour checks in the middle of it. Both APKs build and ship:
coinos.apk as before, coinos-unifiedpush.apk beside it.

Co-Authored-By: Claude Opus 5 (1M context) &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
</content>
</entry>
<entry>
<title>UnifiedPush: reach a phone that has no Google in it.</title>
<updated>2026-09-12T17:05:10Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-12T17:05:10Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=a846211262f37238c001e5210938749294f8db6a'/>
<id>urn:sha1:a846211262f37238c001e5210938749294f8db6a</id>
<content type='text'>
Web push on Android is Firebase's, so a phone without Google Play Services —
GrapheneOS, CalyxOS, any de-Googled Android — cannot be woken by a web app at
all. UnifiedPush is the way that doesn't involve Google: the user runs a
distributor (ntfy and friends), apps ask it for an endpoint, and whoever
wants to reach the device POSTs to that endpoint.

THE WRAPPER speaks the protocol directly — four broadcasts each way, so it
pulls in no library; an APK this small has no business growing a Kotlin
runtime for eight intents. It asks the distributor on every launch (the call
is idempotent, and it's how a distributor installed later gets noticed),
raises a notification when a message arrives, and acknowledges it so the
distributor stops resending.

THE ENDPOINT reaches the web app on the launch URL as ?up=, the same road
payment intents already travel — because the distributor talks to the app and
only the web app knows which pubkeys are worth watching. It's registered with
the notifier in place of a browser subscription, for notifications and for
NWC alike, and needs neither permission nor push service.

THE NOTIFIER sends to either kind: one deliver() that POSTs the payload to a
UnifiedPush endpoint, or hands it to web-push as before. Same payloads, same
404/410 pruning. The endpoint is the only secret there, so it's never logged
in full.

Verified: a POST to a live ntfy endpoint delivers the payload byte for byte,
so the app parses exactly what the notifier sent; the APK builds, carries the
receiver, and is signed with the certificate assetlinks already pins (v1.6).
tools/unifiedpush-test.js drives a browser with no push service and checks
the endpoint is taken off the URL, kept, and registered.

Honest about the limit: a UnifiedPush message wakes the ANDROID app, not
Chrome's service worker, and the wallet's keys live in the browser's storage
where nothing here can reach them. So a zap request arrives as a notification
you tap, and the wallet answers when it opens — one tap instead of none.
Automatic answering while closed still needs a browser with a push service.

Co-Authored-By: Claude Opus 5 (1M context) &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
</content>
</entry>
<entry>
<title>Tell a phone with no push service what's actually wrong.</title>
<updated>2026-09-12T16:56:33Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-09-12T16:56:33Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=cab447766734cb99821c36988ebc8d27a0bce228'/>
<id>urn:sha1:cab447766734cb99821c36988ebc8d27a0bce228</id>
<content type='text'>
"Registration failed - push service error" is Chrome's own wording, and it
tells nobody anything. The cause is that Chromium routes web push through
Firebase: on GrapheneOS — or any Android without Google Play Services —
there is no push service to register with, so the app cannot be woken while
it's closed, and no amount of tapping the toggle will change that.

Both places that ask for push (the chat's offer and the NWC "answer while
closed" toggle) now recognise that specific failure and say: this browser has
no push service; on Android that's usually a phone without Play Services;
Firefox brings its own and works there; and zaps are still answered whenever
coinos is open. The last part matters most — the feature isn't broken, it's
narrower than they thought.

The failure is remembered so the app stops retrying it on every launch, and
an explicit tap on the toggle always clears that and tries again, because a
phone that changed browsers deserves another go.

tools/push-unavailable-test.js runs a browser whose subscribe() fails exactly
as Chrome's does and reads back what the user is told.

Co-Authored-By: Claude Opus 5 (1M context) &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
</content>
</entry>
<entry>
<title>The open tab learns the same manners as the worker: a wallet on the wrong network holds its tongue for three seconds before erring on a pay request, giving the right device's answer the floor</title>
<updated>2026-08-22T16:29:53Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-08-22T16:29:53Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=2b7cd02ad584963f7f7b8f94653b2fc0db6a751e'/>
<id>urn:sha1:2b7cd02ad584963f7f7b8f94653b2fc0db6a751e</id>
<content type='text'>
Co-Authored-By: Claude Fable 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_017Fc8SHJRvBBXgMCntjNCri
</content>
</entry>
<entry>
<title>A downed notifier no longer vetoes new connections — only the user's own denial does; self-hosted builds create tab-answering connections and arm background when they can</title>
<updated>2026-08-15T18:51:18Z</updated>
<author>
<name>Adam Soltys</name>
<email>adam@coinos.io</email>
</author>
<published>2026-08-15T18:51:18Z</published>
<link rel='alternate' type='text/html' href='https://git.coinos.io/coinosv3.git/commit/?id=89ee463e94727d0bb605d956cd5c61a33a426a4a'/>
<id>urn:sha1:89ee463e94727d0bb605d956cd5c61a33a426a4a</id>
<content type='text'>
Co-Authored-By: Claude Fable 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
</content>
</entry>
</feed>
