| … | … | @@ -47,6 +47,28 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB |
| 47 | 47 | awaitRunning(promise, System.currentTimeMillis() + START_TIMEOUT_MS) |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | /** | |
| 51 | * Replace what the notification says about the node. | |
| 52 | * | |
| 53 | * Called once the worklet has answered for itself, never speculatively — the shell does not know what | |
| 54 | * the node is serving until it asks, and the notification is the one surface a user sees without | |
| 55 | * opening anything. | |
| 56 | */ | |
| 57 | @ReactMethod | |
| 58 | fun describe(text: String, promise: Promise) { | |
| 59 | try { | |
| 60 | ContextCompat.startForegroundService( | |
| 61 | ctx, | |
| 62 | Intent(ctx, NodeService::class.java) | |
| 63 | .setAction(NodeService.ACTION_UPDATE) | |
| 64 | .putExtra(NodeService.EXTRA_TEXT, text) | |
| 65 | ) | |
| 66 | promise.resolve(true) | |
| 67 | } catch (e: Throwable) { | |
| 68 | promise.resolve(false) | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 50 | 72 | /** Drop the notification. Used on app teardown; the Stop action goes the other way, via the service. */ |
| 51 | 73 | @ReactMethod |
| 52 | 74 | fun stop(promise: Promise) { |
| … | … | @@ -36,6 +36,22 @@ class NodeService : Service() { |
| 36 | 36 | companion object { |
| 37 | 37 | const val ACTION_START = "to.favela.android.NODE_START" |
| 38 | 38 | const val ACTION_STOP = "to.favela.android.NODE_STOP" |
| 39 | const val ACTION_UPDATE = "to.favela.android.NODE_UPDATE" | |
| 40 | const val EXTRA_TEXT = "text" | |
| 41 | ||
| 42 | /** | |
| 43 | * What the notification currently says about the node. | |
| 44 | * | |
| 45 | * ⛔ THIS IS THE ONLY ALWAYS-VISIBLE SURFACE THE APP HAS, so it must never say more than is known. | |
| 46 | * It starts as the one thing the service itself guarantees — that the node is allowed to keep | |
| 47 | * running — and is replaced only once the worklet has ANSWERED for itself. A default that claimed | |
| 48 | * "serving your identity" before boot finished would be a claim the shell cannot support: on | |
| 49 | * 2026-08-18 the node ran for 11 minutes serving one DID while the user believed it was serving two. | |
| 50 | */ | |
| 51 | @Volatile | |
| 52 | @JvmStatic | |
| 53 | var text: String = "Replicating in the background" | |
| 54 | private set | |
| 39 | 55 | |
| 40 | 56 | private const val CHANNEL_ID = "favela-node" |
| 41 | 57 | private const val NOTIFICATION_ID = 1 |
| … | … | @@ -75,6 +91,13 @@ class NodeService : Service() { |
| 75 | 91 | return START_NOT_STICKY |
| 76 | 92 | } |
| 77 | 93 | |
| 94 | // ACTION_UPDATE is deliberately the SAME path as ACTION_START, differing only in the text it carries. | |
| 95 | // Handling it as a separate "just call notify()" branch would leave a hole: if the service is not | |
| 96 | // running, startForegroundService + no startForeground within 5s is a guaranteed ANR crash. Going | |
| 97 | // through startForeground every time is idempotent — on an already-foreground service it simply | |
| 98 | // replaces the notification — so the update path cannot be the one that kills the app. | |
| 99 | intent?.getStringExtra(EXTRA_TEXT)?.let { if (it.isNotBlank()) text = it } | |
| 100 | ||
| 78 | 101 | createChannel() |
| 79 | 102 | ServiceCompat.startForeground( |
| 80 | 103 | this, |
| … | … | @@ -133,8 +156,9 @@ class NodeService : Service() { |
| 133 | 156 | .setSmallIcon(android.R.drawable.stat_notify_sync) |
| 134 | 157 | .setContentTitle("favela") |
| 135 | 158 | // SAY WHAT IS TRUE. Not "syncing" (it is idle most of the time) and not "connected" (it may not be). |
| 136 | // This claims only what the service guarantees: the node is allowed to keep running. | |
| 137 | .setContentText("Replicating in the background") | |
| 159 | // Until the worklet answers, this claims only what the service guarantees: the node is allowed to | |
| 160 | // keep running. After that it carries what the node reported about ITSELF. | |
| 161 | .setContentText(text) | |
| 138 | 162 | .setContentIntent(open) |
| 139 | 163 | .addAction(0, "Stop", stop) |
| 140 | 164 | .setOngoing(true) |
| … | … | @@ -10,6 +10,7 @@ import RNFS from 'react-native-fs' |
| 10 | 10 | import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native' |
| 11 | 11 | import { WorkletClient } from '../lib/ipc-client' |
| 12 | 12 | import { createLifecycle } from '../lib/lifecycle' |
| 13 | import { describeNode } from '../lib/describe' | |
| 13 | 14 | import { encodeUtf8, createUtf8Decoder } from '../lib/utf8' |
| 14 | 15 | |
| 15 | 16 | let started = null |
| … | … | @@ -36,6 +37,14 @@ const service = { |
| 36 | 37 | } |
| 37 | 38 | } |
| 38 | 39 | |
| 40 | // Replace the notification's text once the node can answer for itself. Never speculatively: the shell | |
| 41 | // does not know what is being served until it asks, and the shade is the one surface a user reads | |
| 42 | // without opening anything. | |
| 43 | async function describe (text) { | |
| 44 | const native = NativeModules.FavelaNode | |
| 45 | if (native && text) { try { await native.describe(text) } catch {} } | |
| 46 | } | |
| 47 | ||
| 39 | 48 | /** |
| 40 | 49 | * Start the node once per app launch and resolve with its client. |
| 41 | 50 | * |
| … | … | @@ -126,6 +135,14 @@ async function boot () { |
| 126 | 135 | IPC.on('close', () => client.close('worklet stream closed')) |
| 127 | 136 | IPC.on('error', (err) => client.close('worklet stream error: ' + (err && err.message))) |
| 128 | 137 | |
| 138 | // Once the node is up, let the notification say what it is actually serving rather than the generic | |
| 139 | // line the service starts with. Fire-and-forget and non-blocking: boot takes ~40s, a node that cannot | |
| 140 | // describe itself still runs, and nothing here may delay the UI appearing. | |
| 141 | client.whenReady() | |
| 142 | .then(() => client.hostedDids()) | |
| 143 | .then((r) => describe(describeNode(r && r.dids))) | |
| 144 | .catch(() => {}) | |
| 145 | ||
| 129 | 146 | started = { worklet, client, storage } |
| 130 | 147 | return started |
| 131 | 148 | } |
| … | … | @@ -0,0 +1,51 @@ |
| 1 | // What the notification says about the node — the app's only always-visible surface. | |
| 2 | // | |
| 3 | // Everything else in favela requires opening the app. This line sits in the notification shade for as | |
| 4 | // long as the node is alive, which makes it the one place a user learns what their phone is doing | |
| 5 | // without asking. It is also, structurally, the honest liveness signal: the worklet lives in this | |
| 6 | // process, so if the node dies the notification goes with it. Its PRESENCE means the node is up. | |
| 7 | // | |
| 8 | // ⛔ SAME RULE AS EVERY OTHER CLAIM IN THIS UI: never say more than is known. "Replicating in the | |
| 9 | // background" was true of the service and told the user nothing about the node — on 2026-08-18 it read | |
| 10 | // exactly the same while the node served one identity its owner thought was two, and while a post went | |
| 11 | // out as the wrong one. A surface that reads identically in the healthy and the wrong case carries no | |
| 12 | // information. | |
| 13 | // | |
| 14 | // Pure and dependency-free so it can be tested without a handset (test/describe.test.mjs), like | |
| 15 | // lib/ipc-client.js and lib/lifecycle.js. | |
| 16 | ||
| 17 | // A did:plc rendered for humans. Same shape favela-web uses, duplicated rather than imported because | |
| 18 | // this module must stay dependency-free — 32 characters of base32 in a notification is decoration. | |
| 19 | function shortDid (did) { | |
| 20 | const d = String(did || '') | |
| 21 | const id = d.startsWith('did:plc:') ? d.slice('did:plc:'.length) : d | |
| 22 | return id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * One line describing what this node is serving, from `hostedDids().dids`. | |
| 27 | * | |
| 28 | * @param {Array<{did: string, handle: ?string, signing: boolean}>} dids | |
| 29 | * @returns {string} | |
| 30 | */ | |
| 31 | export function describeNode (dids) { | |
| 32 | const list = Array.isArray(dids) ? dids.filter(Boolean) : [] | |
| 33 | ||
| 34 | // No identity is not "replicating quietly" — it is a node with nothing of its own to replicate and no | |
| 35 | // way to write. Saying so is how a fresh install stops looking like a working one. | |
| 36 | if (!list.length) return 'Read-only — not paired to an identity' | |
| 37 | ||
| 38 | // `signing` is whether the DID's StateBase admitted THIS device. False means the node is up, is | |
| 39 | // replicating, and will refuse every write — the state that looks perfectly healthy until you post. | |
| 40 | const canWrite = list.some((d) => d && d.signing) | |
| 41 | const suffix = canWrite ? '' : ' — read-only' | |
| 42 | ||
| 43 | if (list.length === 1) { | |
| 44 | const d = list[0] || {} | |
| 45 | // WHICH identity, not how many: with one DID the number carries nothing and the name is the fact | |
| 46 | // that was got wrong. | |
| 47 | return 'Serving ' + (d.handle || shortDid(d.did)) + suffix | |
| 48 | } | |
| 49 | ||
| 50 | return 'Serving ' + list.length + ' identities' + suffix | |
| 51 | } |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run stage:addons", |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs", | |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs", | |
| 13 | 13 | "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"" |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | // What the notification says about the node. | |
| 2 | // | |
| 3 | // ⛔ IT IS THE ONLY ALWAYS-VISIBLE SURFACE THIS APP HAS. Everything else requires opening the app; this | |
| 4 | // sits in the shade whenever the node is alive. So it is worth the same care as any other claim the UI | |
| 5 | // makes, and it is subject to the same rule: never say more than is known. | |
| 6 | // | |
| 7 | // The case that motivated it: on 2026-08-18 the node ran for 11 minutes serving ONE identity while its | |
| 8 | // owner believed it was serving two, and posted to the wrong one. Nothing on screen — including a | |
| 9 | // notification reading "Replicating in the background" — could have corrected that belief. | |
| 10 | import test from 'brittle' | |
| 11 | import { describeNode } from '../lib/describe.js' | |
| 12 | ||
| 13 | test('no identity at all is stated as read-only, not as replication', (t) => { | |
| 14 | // "Replicating in the background" on an unpaired node describes work that cannot be happening: with no | |
| 15 | // DID there is nothing of ours to replicate and nothing can be written. | |
| 16 | t.is(describeNode([]), 'Read-only — not paired to an identity') | |
| 17 | t.is(describeNode(null), 'Read-only — not paired to an identity') | |
| 18 | t.is(describeNode(undefined), 'Read-only — not paired to an identity') | |
| 19 | }) | |
| 20 | ||
| 21 | test('one identity is NAMED, because which one is the question that was got wrong', (t) => { | |
| 22 | t.is(describeNode([{ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', handle: 'wj62.7rnx.net', signing: true }]), | |
| 23 | 'Serving wj62.7rnx.net') | |
| 24 | }) | |
| 25 | ||
| 26 | test('a null handle falls back to a short did, never 32 raw characters', (t) => { | |
| 27 | // handle is null on every boot of the handset, so this is the branch that actually ships. | |
| 28 | const s = describeNode([{ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', handle: null, signing: true }]) | |
| 29 | t.is(s, 'Serving wj62ebbu…ogzo') | |
| 30 | t.absent(s.includes('did:plc:'), 'the raw did never reaches the shade') | |
| 31 | }) | |
| 32 | ||
| 33 | test('several identities are counted', (t) => { | |
| 34 | const dids = [ | |
| 35 | { did: 'did:plc:aaa', handle: 'a.example', signing: true }, | |
| 36 | { did: 'did:plc:bbb', handle: 'b.example', signing: true } | |
| 37 | ] | |
| 38 | t.is(describeNode(dids), 'Serving 2 identities') | |
| 39 | }) | |
| 40 | ||
| 41 | test('an identity it cannot author for says so — reading is not the same as posting', (t) => { | |
| 42 | // signing:false means the StateBase never admitted this device. The node is up and replicating and | |
| 43 | // every write will be refused, which is exactly the state that looks fine until you try to post. | |
| 44 | t.is(describeNode([{ did: 'did:plc:aaa', handle: 'a.example', signing: false }]), | |
| 45 | 'Serving a.example — read-only') | |
| 46 | t.is(describeNode([ | |
| 47 | { did: 'did:plc:aaa', handle: 'a.example', signing: false }, | |
| 48 | { did: 'did:plc:bbb', handle: 'b.example', signing: false } | |
| 49 | ]), 'Serving 2 identities — read-only') | |
| 50 | }) | |
| 51 | ||
| 52 | test('mixed signing is not called read-only — one writable identity is not none', (t) => { | |
| 53 | t.is(describeNode([ | |
| 54 | { did: 'did:plc:aaa', handle: 'a.example', signing: true }, | |
| 55 | { did: 'did:plc:bbb', handle: 'b.example', signing: false } | |
| 56 | ]), 'Serving 2 identities') | |
| 57 | }) | |
| 58 | ||
| 59 | test('a malformed entry never throws — the notification must not be able to crash the shell', (t) => { | |
| 60 | t.ok(typeof describeNode([{}]) === 'string') | |
| 61 | t.ok(typeof describeNode([null]) === 'string') | |
| 62 | t.ok(typeof describeNode('nonsense') === 'string') | |
| 63 | }) |