OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 0ad3ee5

the notification says what the node is actually serving
It is the only always-visible surface this app has, and it read "Replicating in the background" in every state — including, on 2026-08-18, while the node served ONE identity its owner believed was two, and a post went out as the wrong one. A surface that reads identically in the healthy and the wrong case carries no information. Now, once the worklet can answer for itself: Serving wj62.7rnx.net one identity, NAMED — with one DID the count says nothing and the name is the fact that was got wrong Serving 2 identities Serving a.example — read-only StateBase never admitted this device; the node is up, replicating, and will refuse every write Read-only — not paired nothing of ours to replicate at all Never speculatively: the service still starts with the generic line, and it is replaced only after hostedDids() returns. A shell that claimed an identity before boot finished would be making a claim it cannot support. ACTION_UPDATE deliberately goes through startForeground rather than a bare notify(): if the service is not running, startForegroundService with no startForeground inside 5s is a guaranteed ANR kill, so the update path must not be the one that can kill the app. Idempotent on an already-foreground service. describeNode is pure and dependency-free — 7/7 off-device, incl. the null-handle branch that actually ships on the handset. Kotlin compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0ad3ee53642deb6f33082e909c7b917f6e012118
by plankatron <fleeky@gmail.com> · 2026-08-18T14:30:05.000Z

Changed files (6) +180 -3

android/app/src/main/java/to/favela/android/FavelaNodeModule.kt +22 -0
……@@ -47,6 +47,28 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB
4747 awaitRunning(promise, System.currentTimeMillis() + START_TIMEOUT_MS)
4848 }
4949
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
5072 /** Drop the notification. Used on app teardown; the Stop action goes the other way, via the service. */
5173 @ReactMethod
5274 fun stop(promise: Promise) {
android/app/src/main/java/to/favela/android/NodeService.kt +26 -2
……@@ -36,6 +36,22 @@ class NodeService : Service() {
3636 companion object {
3737 const val ACTION_START = "to.favela.android.NODE_START"
3838 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
3955
4056 private const val CHANNEL_ID = "favela-node"
4157 private const val NOTIFICATION_ID = 1
……@@ -75,6 +91,13 @@ class NodeService : Service() {
7591 return START_NOT_STICKY
7692 }
7793
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
78101 createChannel()
79102 ServiceCompat.startForeground(
80103 this,
……@@ -133,8 +156,9 @@ class NodeService : Service() {
133156 .setSmallIcon(android.R.drawable.stat_notify_sync)
134157 .setContentTitle("favela")
135158 // 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)
138162 .setContentIntent(open)
139163 .addAction(0, "Stop", stop)
140164 .setOngoing(true)
app/worklet.js +17 -0
……@@ -10,6 +10,7 @@ import RNFS from 'react-native-fs'
1010import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'
1111import { WorkletClient } from '../lib/ipc-client'
1212import { createLifecycle } from '../lib/lifecycle'
13import { describeNode } from '../lib/describe'
1314import { encodeUtf8, createUtf8Decoder } from '../lib/utf8'
1415
1516let started = null
……@@ -36,6 +37,14 @@ const service = {
3637 }
3738}
3839
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.
43async function describe (text) {
44 const native = NativeModules.FavelaNode
45 if (native && text) { try { await native.describe(text) } catch {} }
46}
47
3948/**
4049 * Start the node once per app launch and resolve with its client.
4150 *
……@@ -126,6 +135,14 @@ async function boot () {
126135 IPC.on('close', () => client.close('worklet stream closed'))
127136 IPC.on('error', (err) => client.close('worklet stream error: ' + (err && err.message)))
128137
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
129146 started = { worklet, client, storage }
130147 return started
131148}
lib/describe.js +51 -0
……@@ -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.
19function 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 */
31export 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}
package.json +1 -1
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "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",
1313 "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))\""
1414 },
1515 "dependencies": {
test/describe.test.mjs +63 -0
……@@ -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.
10import test from 'brittle'
11import { describeNode } from '../lib/describe.js'
12
13test('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
21test('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
26test('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
33test('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
41test('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
52test('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
59test('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})