Why a notary in Termux keeps dropping its doorbell, what a PWA can and cannot do about it, and the smallest Android app that would actually work.
The short version. A PWA cannot hold a WebSocket while the screen is off — no amount of cleverness changes that, so it is a viewer, not a notary. The Android app is genuinely small if you keep the decision in JavaScript and let Kotlin do only the three things JavaScript cannot: survive, store a key properly, and wake up. Two evenings to a working demo.
Before designing anything, there is a fault visible in the console you pasted, and it will follow KEA onto any host:
20:56:51 doorbell connected
20:58:56 doorbell closed — reconnecting in 5s (2m 05s)
20:59:01 doorbell connected
21:01:06 doorbell closed — reconnecting in 5s (2m 05s)
21:01:12 doorbell connected
21:03:17 doorbell closed — reconnecting in 5s (2m 05s)
Two minutes and five seconds, four times, to the second. That is not mobile flakiness — flaky sockets die at irregular intervals. That is an idle timeout somewhere between the phone and the pool, almost certainly at the edge, cutting a connection with no traffic on it.
KEA's doorbell never sends anything once it has subscribed, so on a quiet pool the socket is idle by definition. It has no ping. The ws library supports one and we simply never call it.
Send a WebSocket ping every ~45 s in kea/adapters/doorbell.js, and treat a missed pong as a reconnect. That is about five lines and it is worth doing before any of the work below, because every host in this document inherits the problem — and because a reconnect every two minutes is a two-minute window in which a raise can be missed and only the sweep catches it, twenty seconds late.
KEA is small, but it is specific about what it needs. Any candidate host has to do all six:
| Needs | Why | Browser / PWA | Android app |
|---|---|---|---|
| Outbound WebSocket, held open | the doorbell | only while visible | yes |
| Outbound HTTPS | txRead, addSig, hello, verdict, mirror | yes | yes |
| ed25519 sign over arbitrary bytes | the signature itself | WebCrypto | yes |
| Private key at rest, not readable by other apps | it is the whole asset | no — IndexedDB is plain | Keystore |
| Run with the screen off | claims arrive at 3am | no | foreground service |
Run the decide() core | the eight checks; JCS; JMESPath; JSONLogic | it is JS | see §4 |
The honest limits, in order of how much they hurt:
What it is good for: everything that is not signing. A PWA wrapper around the /keaton/ dashboard — installable, on the home screen, notifying you when a deal you care about has been waiting too long — is a genuinely nice thing and costs almost nothing, because the page already exists. Just don't let it hold a key.
The one exception: the KEA web app (signer C) already signs in a browser, and that is fine, because a human is sitting there with it open. Attended signing is a different job from unattended signing.
The design question that decides the size of this is: where does decide() run?
You would be reimplementing JCS canonicalisation, dealId hashing, JMESPath and JSONLogic, and then maintaining two implementations that must agree byte for byte forever. The whole trust argument for KEA is "the signer reproduces the transaction itself" — two reproducers that can disagree is worse than one, and the divergence would show up as a refusal nobody can explain.
Kotlin does the three things it is actually needed for; the decision stays exactly the code that runs on the box.
| Layer | What | Roughly |
|---|---|---|
| Foreground service | A persistent notification ("KEA — watching 16 deals"), so Android will not kill it. This is what buys you screen-off operation. | ~80 lines |
| Socket + HTTP | OkHttp WebSocket with pingInterval set — which solves §1 for free — and OkHttp for the reads and posts. | ~150 lines |
| Key storage | Android Keystore. Ed25519 has patchy Keystore support across versions, so the pragmatic version is a Keystore-wrapped AES key encrypting the ed25519 seed in EncryptedSharedPreferences. | ~60 lines |
| The core | kea/dist/kea-core.browser.js — the bundle that already exists for the web app — evaluated in a headless WebView or QuickJS. Kotlin passes in the record and the chain reads; JS returns the verdict. | ~120 lines of bridge |
| Signing | Do it in Kotlin, not JS, so the key never enters the JS heap. The core says which keys and over what bytes; Kotlin signs. | ~30 lines |
That split is not a compromise — it is the right boundary. The core is pure and has no business touching a key; it already takes its chain reads through a port, which is exactly the seam a Kotlin host plugs into.
Start with WebView: zero dependencies, `evaluateJavascript` is a one-liner, and it can load the same bundle the web app uses. Move to QuickJS (via quickjs-android) only if the WebView's lifecycle inside a service proves awkward — it is the smaller and more predictable engine, but it is another dependency to justify.
A foreground service survives Doze, but network access can still be deferred in deep idle. Expect to need the socket plus the existing sweep as a backstop — which you already have, and which is another argument for never narrowing the sweep.
Xiaomi, Huawei, OnePlus and Samsung all ship aggressive background-app killers that ignore the documented rules. Your S20 and OnePlus are exactly the affected brands. Budget an afternoon for per-device battery-optimisation exemptions, and treat "it died overnight on one handset" as expected rather than a bug in your code.
Play Store review dislikes persistent foreground services without an obvious user-facing reason, and a "signs blockchain transactions in the background" app invites a fight. Sideload it. This is a test rig for you and a handful of devices, not a product — and keeping it off the store also keeps it out of the conversation about who is responsible when a key on a phone signs something.
Everything above assumes more devices, run by more people, on a board anyone can read. That brings forward a question worth answering before it is urgent: on the device list, what is actually true?
Exactly one thing is proven, and it is worth being blunt about which:
by. That is what the signature establishes, and it is all it establishes.by. A device can announce any label it likes and list any public keys it likes — including yours. Nothing stops it.So yes, to answer the question directly: someone can sign with their own key and claim to hold anyone's. Today the pool would store it and the dashboard would draw it. Two of my own recent changes widened that, and I should name them: dropping the rule that by must be one of the announced keys (so that an identity key need not be a signing key), and returning the announcement whole. Both were right for their own reasons; together they mean the keys map is now purely a claim.
by as the device's identity and everything else as claimed, with the label visibly subordinate to the key. Costs nothing and removes the illusion. This is the one to do now.by.Steps 1 and 5 are page work. Step 2 is perhaps thirty lines in the pool. Steps 3 and 4 are design decisions worth taking deliberately, and none of this is urgent while the board is behind Cloudflare Access and every announcing device is one of yours — which is precisely why it is the right time to decide it.