OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 6ada0eb

keep the node replicating when the screen goes off
The node was not being killed by Android — react-native-bare-kit suspends it on purpose. Its module body registers AppState.addEventListener('change', BareKitWorklet.update) and update() is `case 'background': return this.suspend()`, so every worklet in the process is suspended on the first screen lock, before Doze or any OEM battery manager has done anything. A foreground service alone would therefore have kept a SUSPENDED node alive very reliably. So two halves, both required: lib/lifecycle.js takes the policy over — suspend follows whether we are ALLOWED to keep running (a live foreground service), not whether a screen is on. No service, library behaviour: a peer must not run in a cached process. NodeService.kt a dataSync foreground service, so the process is not a cached process — survives task-swipe and memory pressure, keeps background network. START_NOT_STICKY deliberately: the worklet lives in the JS runtime, so a sticky restart would bring the notification back with no node behind it. The Stop action drops the exemption rather than tearing the node down. The worklet stays owned by the JS runtime. ReactHostImpl.onHostDestroy does not destroy the instance, so it survives the activity as long as the process does — which is what the service is for. Hosting it in Kotlin would mean reimplementing bare-kit's IPC bridge for no measured gain. Verified off-device: policy 7/7 (with a control replicating the library's update() verbatim), :app:compileReleaseKotlin, merged manifest. NOT verified: that replication survives a locked screen on real hardware — spec §7.5 names the acceptance test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6ada0eb59261490734ea1a9577d7d97505a0b301
by plankatron <fleeky@gmail.com> · 2026-08-18T12:46:48.000Z

Changed files (11) +668 -11

README.md +5 -2
……@@ -29,9 +29,12 @@ Everything below is measured.
2929| IPC contract | 14 assertions against the real worklet, not a mock |
3030| storage in a worklet thread | 8/8 on Linux, incl. lock release on exit (the Doze-restart case) |
3131| storage on device | `rocksdb dlopen` + `Corestore.ready` on API 34, app-private |
32| background policy | 7/7 off-device — the node is not suspended while the foreground service runs |
3233
33Untested: the WebView surface itself under real use, foreground-service survival, and hyperdht
34hole-punching on cellular/CGNAT.
34Untested: the WebView surface itself under real use, hyperdht hole-punching on cellular/CGNAT, and
35**whether replication actually survives a locked screen on real hardware** — the foreground service and
36the suspend-policy override that make it possible are built and verified off-device (spec §7), but the
37acceptance test (post from elsewhere, lock the phone 30 minutes, check it arrived) has not been run.
3538
3639## Architecture in one paragraph
3740
android/app/src/main/AndroidManifest.xml +22 -3
……@@ -1,8 +1,13 @@
11<manifest xmlns:android="http://schemas.android.com/apk/res/android">
22
3 <!-- INTERNET for the swarm. The foreground-service pair is what lets the node keep replicating
4 with the screen off; spec/mobile-node.md §5 is explicit that Android will still kill it, so
5 the node must be correct while intermittently online rather than assume it stays up. -->
3 <!-- INTERNET for the swarm. The foreground-service pair is what lets the node keep replicating with
4 the screen off — see the <service> below and spec/architecture.md §7. POST_NOTIFICATIONS is
5 runtime-requested from API 33: without it the service still RUNS but its notification is not
6 displayed, i.e. the node would replicate in the background with nothing on screen saying so.
7
8 ⚠ None of this makes the phone always-on. spec/mobile-node.md §5 is explicit that Doze and OEM
9 battery managers still win, so the node must be correct while intermittently online rather than
10 assume it stays up. -->
611 <uses-permission android:name="android.permission.INTERNET" />
712 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
813 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
……@@ -25,5 +30,19 @@
2530 <category android:name="android.intent.category.LAUNCHER" />
2631 </intent-filter>
2732 </activity>
33
34 <!-- The node's licence to keep running while the app is not on screen. It hosts nothing itself —
35 the worklet is a thread in this process — so what it buys is that the process is not a cached
36 process: it survives task-swipe and memory pressure and keeps network access in the
37 background. See NodeService.kt, and lib/lifecycle.js for the half that stops
38 react-native-bare-kit suspending the worklet on every screen lock.
39
40 stopWithTask=false so swiping the app away leaves the node replicating; the Stop action on the
41 notification is how a user ends it. -->
42 <service
43 android:name=".NodeService"
44 android:exported="false"
45 android:stopWithTask="false"
46 android:foregroundServiceType="dataSync" />
2847 </application>
2948</manifest>
android/app/src/main/java/to/favela/android/FavelaNodeModule.kt +93 -0
……@@ -0,0 +1,93 @@
1package to.favela.android
2
3import android.content.Intent
4import android.os.Handler
5import android.os.Looper
6import androidx.core.content.ContextCompat
7import com.facebook.react.bridge.Promise
8import com.facebook.react.bridge.ReactApplicationContext
9import com.facebook.react.bridge.ReactContextBaseJavaModule
10import com.facebook.react.bridge.ReactMethod
11import com.facebook.react.modules.core.DeviceEventManagerModule
12
13/**
14 * The JS handle on NodeService. Two verbs and one event — everything else about the node's lifecycle is
15 * policy, and policy lives in lib/lifecycle.js where it can be tested without a handset.
16 *
17 * A plain legacy module on purpose: react-native-fs is exactly this shape in this app and works under
18 * the new architecture's interop layer, so there is no reason to take on a codegen spec for two methods.
19 */
20class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {
21
22 override fun getName(): String = NAME
23
24 /**
25 * Start the foreground service and resolve with whether it is ACTUALLY RUNNING.
26 *
27 * ⛔ NOT "did startForegroundService throw". That call is asynchronous — it returns long before the
28 * service has called startForeground, and on an OEM build that refuses the start, or when the app is
29 * not in a state allowed to start one, the failure arrives later or not at all. lib/lifecycle.js
30 * decides the entire suspend policy from this boolean, so a `true` that means "the request was
31 * accepted" would be the same class of lie as a health check that only proves a port is open.
32 */
33 @ReactMethod
34 fun start(promise: Promise) {
35 try {
36 NodeService.onStopRequested = { emit(EVENT_SERVICE_STOPPED) }
37 ContextCompat.startForegroundService(
38 ctx,
39 Intent(ctx, NodeService::class.java).setAction(NodeService.ACTION_START)
40 )
41 } catch (e: Throwable) {
42 // Resolve, never reject: not getting the exemption is a degraded mode the app reports, not a boot
43 // failure that should surface as an unhandled rejection on the splash screen.
44 promise.resolve(false)
45 return
46 }
47 awaitRunning(promise, System.currentTimeMillis() + START_TIMEOUT_MS)
48 }
49
50 /** Drop the notification. Used on app teardown; the Stop action goes the other way, via the service. */
51 @ReactMethod
52 fun stop(promise: Promise) {
53 try {
54 ctx.stopService(Intent(ctx, NodeService::class.java))
55 } catch (_: Throwable) {
56 }
57 promise.resolve(true)
58 }
59
60 override fun invalidate() {
61 // The callback holds this module, which holds the RN context. Nothing should outlive the runtime it
62 // emits into.
63 NodeService.onStopRequested = null
64 super.invalidate()
65 }
66
67 private fun awaitRunning(promise: Promise, deadline: Long) {
68 if (NodeService.running) {
69 promise.resolve(true)
70 return
71 }
72 if (System.currentTimeMillis() >= deadline) {
73 promise.resolve(false)
74 return
75 }
76 Handler(Looper.getMainLooper()).postDelayed({ awaitRunning(promise, deadline) }, POLL_MS)
77 }
78
79 private fun emit(event: String) {
80 try {
81 ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java).emit(event, null)
82 } catch (_: Throwable) {
83 // The JS runtime is gone. The service stopping is exactly the right outcome in that case.
84 }
85 }
86
87 companion object {
88 const val NAME = "FavelaNode"
89 const val EVENT_SERVICE_STOPPED = "favela-node-service-stopped"
90 private const val START_TIMEOUT_MS = 5000L
91 private const val POLL_MS = 50L
92 }
93}
android/app/src/main/java/to/favela/android/FavelaPackage.kt +15 -0
……@@ -0,0 +1,15 @@
1package to.favela.android
2
3import com.facebook.react.ReactPackage
4import com.facebook.react.bridge.NativeModule
5import com.facebook.react.bridge.ReactApplicationContext
6import com.facebook.react.uimanager.ViewManager
7
8/** The app's own native modules. Autolinking cannot find these — they are not a node_modules package. */
9class FavelaPackage : ReactPackage {
10 override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
11 listOf(FavelaNodeModule(reactContext))
12
13 override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
14 emptyList()
15}
android/app/src/main/java/to/favela/android/MainApplication.kt +4 -2
……@@ -17,8 +17,10 @@ class MainApplication : Application(), ReactApplication {
1717 override val reactNativeHost: ReactNativeHost =
1818 object : DefaultReactNativeHost(this) {
1919 // PackageList picks up react-native-bare-kit via autolinking — its react-native.config.js
20 // registers it as a C++ module, so there is nothing to add by hand here.
21 override fun getPackages(): List<ReactPackage> = PackageList(this).packages
20 // registers it as a C++ module, so there is nothing to add by hand here. FavelaPackage is OURS
21 // (NodeService, the foreground service) and lives in this app rather than in node_modules, so
22 // autolinking cannot see it and it must be appended by hand.
23 override fun getPackages(): List<ReactPackage> = PackageList(this).packages + FavelaPackage()
2224 override fun getJSMainModuleName(): String = "app/index"
2325 override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
2426 override val isHermesEnabled: Boolean = true
android/app/src/main/java/to/favela/android/NodeService.kt +146 -0
……@@ -0,0 +1,146 @@
1package to.favela.android
2
3import android.app.Notification
4import android.app.NotificationChannel
5import android.app.NotificationManager
6import android.app.PendingIntent
7import android.app.Service
8import android.content.Intent
9import android.content.pm.ServiceInfo
10import android.os.IBinder
11import androidx.core.app.NotificationCompat
12import androidx.core.app.ServiceCompat
13
14/**
15 * The foreground service that lets the node keep replicating while the app is not on screen.
16 *
17 * It runs NO code of its own. The node is a bare-kit worklet owned by the React Native JS runtime
18 * (app/worklet.js), and a worklet is a THREAD in this process — so what the node needs from Android is
19 * not a host but a reason to keep the process alive and out of the cached bucket. That is exactly what a
20 * foreground service is: while one is running, the process is not a background process, it survives
21 * task-swipe and memory pressure, and it keeps network access when the app is not visible.
22 *
23 * ⛔ THIS SERVICE IS HALF THE FIX AND USELESS ALONE. react-native-bare-kit suspends every worklet on
24 * `AppState 'background'` by design (see lib/lifecycle.js) — so without the policy override this service
25 * would keep a SUSPENDED node alive very reliably. The two ship together.
26 *
27 * ⛔ START_NOT_STICKY, DELIBERATELY. If the process dies the worklet dies with it, because it lives in
28 * the JS runtime. A sticky restart would bring back this service — and its notification — with no node
29 * behind it: a permanent "favela is replicating" claim about nothing. That is the exact failure named in
30 * hyper-social #48 §8 (a check that confidently answers a different question), and it is worse than the
31 * app simply being gone until the user opens it. Restart-after-death belongs to a headless JS entry
32 * point, which does not exist yet.
33 */
34class NodeService : Service() {
35
36 companion object {
37 const val ACTION_START = "to.favela.android.NODE_START"
38 const val ACTION_STOP = "to.favela.android.NODE_STOP"
39
40 private const val CHANNEL_ID = "favela-node"
41 private const val NOTIFICATION_ID = 1
42
43 /**
44 * True only between a successful startForeground and onDestroy. FavelaNodeModule.start() waits on
45 * this rather than on startForegroundService() returning, because that call returns immediately and
46 * says nothing about whether the service actually came up — and lib/lifecycle.js decides the whole
47 * suspend policy from that boolean.
48 */
49 @Volatile
50 @JvmStatic
51 var running: Boolean = false
52 private set
53
54 /**
55 * Set by FavelaNodeModule so the notification's Stop action can reach JS. Same process, so a
56 * callback rather than a broadcast; cleared when the RN context goes away.
57 */
58 @Volatile
59 @JvmStatic
60 var onStopRequested: (() -> Unit)? = null
61 }
62
63 override fun onBind(intent: Intent?): IBinder? = null
64
65 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
66 if (intent?.action == ACTION_STOP) {
67 // The user asked us to stop replicating in the background. Tell JS FIRST — it drops the exemption,
68 // so the node suspends the next time the app is backgrounded — then take the notification away.
69 //
70 // The node itself is NOT torn down here. It is owned by the JS side, the app may be open in front
71 // of the user, and a service that also destroyed the node would make "stop background sync" and
72 // "quit" the same button.
73 try { onStopRequested?.invoke() } catch (_: Throwable) {}
74 stopForegroundAndSelf()
75 return START_NOT_STICKY
76 }
77
78 createChannel()
79 ServiceCompat.startForeground(
80 this,
81 NOTIFICATION_ID,
82 buildNotification(),
83 // Required from API 34, and the honest type: this process is holding swarm connections and
84 // replicating a repo. ⚠ Apps targeting API 35+ get a 6h/24h cumulative cap on dataSync with an
85 // onTimeout callback; targetSdk is 34 today, so that does not apply yet — it is the first thing to
86 // re-read when targetSdk is bumped.
87 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
88 )
89 running = true
90 return START_NOT_STICKY
91 }
92
93 override fun onDestroy() {
94 running = false
95 super.onDestroy()
96 }
97
98 private fun stopForegroundAndSelf() {
99 ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
100 running = false
101 stopSelf()
102 }
103
104 private fun createChannel() {
105 val manager = getSystemService(NotificationManager::class.java) ?: return
106 if (manager.getNotificationChannel(CHANNEL_ID) != null) return
107 // IMPORTANCE_LOW: no sound, no heads-up. This notification is a disclosure that the app is running,
108 // not an event worth interrupting anyone for.
109 val channel = NotificationChannel(CHANNEL_ID, "favela node", NotificationManager.IMPORTANCE_LOW)
110 channel.description = "Shown while favela is replicating in the background."
111 channel.setShowBadge(false)
112 manager.createNotificationChannel(channel)
113 }
114
115 private fun buildNotification(): Notification {
116 val open = PendingIntent.getActivity(
117 this,
118 0,
119 Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
120 PendingIntent.FLAG_IMMUTABLE
121 )
122 val stop = PendingIntent.getService(
123 this,
124 1,
125 Intent(this, NodeService::class.java).setAction(ACTION_STOP),
126 PendingIntent.FLAG_IMMUTABLE
127 )
128
129 return NotificationCompat.Builder(this, CHANNEL_ID)
130 // A platform drawable rather than a new asset: notification small icons must be a white-on-
131 // transparent silhouette, and shipping a wrong-shaped one renders as a grey blob. Replace with a
132 // favela mark when there is one designed for this size.
133 .setSmallIcon(android.R.drawable.stat_notify_sync)
134 .setContentTitle("favela")
135 // 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")
138 .setContentIntent(open)
139 .addAction(0, "Stop", stop)
140 .setOngoing(true)
141 .setSilent(true)
142 .setCategory(NotificationCompat.CATEGORY_SERVICE)
143 .setPriority(NotificationCompat.PRIORITY_LOW)
144 .build()
145 }
146}
app/worklet.js +48 -2
……@@ -7,11 +7,34 @@
77// device-only to verify. Everything RN-specific is confined here.
88import { Worklet } from 'react-native-bare-kit'
99import RNFS from 'react-native-fs'
10import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'
1011import { WorkletClient } from '../lib/ipc-client'
12import { createLifecycle } from '../lib/lifecycle'
1113import { encodeUtf8, createUtf8Decoder } from '../lib/utf8'
1214
1315let started = null
1416let starting = null
17let lifecycle = null
18
19// NodeService, as lib/lifecycle.js wants to see it. The permission request lives here rather than in the
20// policy because it is a UI event: from Android 13 a foreground service still RUNS without
21// POST_NOTIFICATIONS, but its notification is not displayed — so the node would be replicating in the
22// background with nothing on screen saying so, which is the one thing the notification exists to prevent.
23// Denial is not fatal and must not block the service.
24const service = {
25 async start () {
26 const native = NativeModules.FavelaNode
27 if (!native) return false
28 if (Platform.OS === 'android' && Platform.Version >= 33) {
29 try { await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS) } catch {}
30 }
31 return native.start()
32 },
33 async stop () {
34 const native = NativeModules.FavelaNode
35 if (native) await native.stop()
36 }
37}
1538
1639/**
1740 * Start the node once per app launch and resolve with its client.
……@@ -67,6 +90,26 @@ async function boot () {
6790 // Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
6891 worklet.start(dest, source, [storage])
6992 console.log('[worklet] start() returned')
93
94 // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node
95 // is under react-native-bare-kit's own policy — an AppState listener it registers at import time that
96 // suspends EVERY worklet on 'background', i.e. on every screen lock. lib/lifecycle.js takes that over
97 // and ties suspend to whether the foreground service is running instead. Awaited rather than fired off:
98 // the policy must be settled before the user can background the app, and it costs ~100ms against a
99 // ~40s boot.
100 lifecycle = createLifecycle({ worklet, service, log: (m) => console.log('[worklet] ' + m) })
101 const foreground = await lifecycle.start()
102 console.log('[worklet] background replication ' + (foreground ? 'ON (foreground service)' : 'OFF — the node will suspend when the app is not on screen'))
103 AppState.addEventListener('change', (state) => { lifecycle.onAppState(state) })
104 // The Stop action on the notification. The service drops the exemption and goes away; the node is NOT
105 // torn down — it keeps running while the app is open and suspends once it is backgrounded, which is
106 // what "stop replicating in the background" means. Tearing it down here would make one button mean two
107 // things.
108 DeviceEventEmitter.addListener('favela-node-service-stopped', () => {
109 console.log('[worklet] foreground service stopped — the node will suspend when the app is not on screen')
110 lifecycle.onServiceStopped()
111 })
112
70113 const { IPC } = worklet
71114
72115 // ⛔ BYTES ON THE WIRE, TEXT IN THE CLIENT. bare-kit's IPC converts nothing in either direction: it
……@@ -91,11 +134,14 @@ async function boot () {
91134 * Stop the node. Called when the app is being torn down for real — NOT on backgrounding.
92135 *
93136 * ⚠ Backgrounding must NOT stop the node: that is the whole point of running a peer on the phone, and
94 * spec/mobile-node.md §5 is explicit that Android will kill it soon enough on its own. Keeping it alive
95 * through background is a foreground-service problem, not a teardown one.
137 * spec/mobile-node.md §5 is explicit that Android will kill it soon enough on its own. Backgrounding is
138 * handled by lib/lifecycle.js + NodeService (spec/architecture.md §7), never here.
96139 */
97140export function stopNode () {
98141 if (!started) return
142 // The notification goes first: it says the node is replicating in the background, and for the moment
143 // between terminate() and the service being told, it would be claiming something untrue.
144 if (lifecycle) { lifecycle.stop().catch(() => {}); lifecycle = null }
99145 try { started.client.close('app teardown') } catch {}
100146 try { started.worklet.terminate() } catch {}
101147 started = null
lib/lifecycle.js +114 -0
……@@ -0,0 +1,114 @@
1// Whether the node keeps running when the app is not on screen.
2//
3// ⛔ THE DEFAULT IS TO STOP, AND IT IS NOT ANDROID DOING IT. react-native-bare-kit registers, at import
4// time, `AppState.addEventListener('change', BareKitWorklet.update)` — and `update` is
5// `case 'background': return this.suspend()`. So every worklet in the process is suspended the moment the
6// app is backgrounded, which on a phone is every screen lock. That policy is right for a worklet that
7// serves a UI and does nothing else; it is exactly wrong for one that IS a peer, holds swarm connections
8// and owes other people replication. It is also why a foreground service ALONE fixes nothing: it would
9// faithfully keep a suspended node alive.
10//
11// So this module takes the policy over. The rule it replaces "is the screen on" with:
12//
13// suspend when we are NOT ALLOWED to keep running — i.e. when there is no live foreground service.
14//
15// Both halves are load-bearing. Without the override, the service keeps a suspended thread alive. Without
16// the service, an unsuspended thread runs in a CACHED process: Android kills it under memory pressure and
17// on task-swipe, and the CPU it burns until then is a battery bug with nothing to show for it. Hence the
18// fallback: no service, library behaviour.
19//
20// ⛔ RUNTIME-AGNOSTIC ON PURPOSE, like lib/ipc-client.js. No react-native import, so the policy is
21// testable off-device (test/lifecycle.test.mjs) instead of only being observable on a handset overnight.
22// app/worklet.js is the only file that knows AppState and the native module exist.
23
24/**
25 * @param {object} opts
26 * @param {{ suspend(): void, resume(): void, suspended: boolean, terminated: boolean, update?: Function }} opts.worklet
27 * the bare-kit worklet. `update` is overwritten — see takeOver below.
28 * @param {{ start(): Promise<boolean>, stop(): Promise<any> }} opts.service
29 * the foreground service. `start` resolves truthy only if the service is actually running.
30 * @param {(msg: string) => void} [opts.log]
31 */
32export function createLifecycle ({ worklet, service, log = () => {} }) {
33 let foreground = false
34 let appState = 'active'
35
36 // Take the library's hands off the wheel.
37 //
38 // The AppState listener is registered on the CLASS at module import, before any of our code runs, and
39 // there is no handle to remove it — but all it does is call `worklet.update(state)` per worklet, so an
40 // own property on the instance shadows the prototype method and the listener becomes inert for us.
41 // Deliberately NOT `Worklet._worklets.delete(worklet)`: that reaches into a private static and would
42 // also disable the static suspend/resume/wakeup helpers, which is a bigger change than the one needed.
43 function takeOver () {
44 worklet.update = () => {}
45 }
46
47 function suspend () {
48 if (worklet.terminated || worklet.suspended) return
49 try { worklet.suspend() } catch (e) { log('could not suspend the node: ' + ((e && e.message) || e)) }
50 }
51
52 function resume () {
53 if (worklet.terminated || !worklet.suspended) return
54 try { worklet.resume() } catch (e) { log('could not resume the node: ' + ((e && e.message) || e)) }
55 }
56
57 function apply () {
58 if (foreground) return resume() // running in the background is the point; only ever undo a suspend
59 if (appState === 'background') suspend()
60 else resume()
61 }
62
63 return {
64 /** Is the node currently exempt from suspend-on-background? */
65 get foreground () { return foreground },
66
67 /**
68 * Take over the lifecycle and ask for the foreground service.
69 *
70 * Resolves with whether the service is running. FALSE IS NOT A BOOT FAILURE: the node still works
71 * while the app is open, it just goes back to being suspended in the background. A phone where the
72 * user denied notifications, or an OEM build that refuses the FGS start, lands here — and it must
73 * say so rather than silently behave like a different product.
74 */
75 async start () {
76 takeOver()
77 try {
78 foreground = !!(await service.start())
79 if (!foreground) log('the foreground service did not start — the node will suspend in the background')
80 } catch (e) {
81 foreground = false
82 log('the foreground service did not start — the node will suspend in the background: ' + ((e && e.message) || e))
83 }
84 // The ~40s boot is long enough to lock the screen in, so the library's listener may already have
85 // suspended us before we got here. apply() undoes a suspend we would not have made.
86 apply()
87 return foreground
88 },
89
90 /** AppState changes, ours rather than the library's. */
91 async onAppState (state) {
92 if (state !== 'active' && state !== 'background') return // 'inactive' is iOS-only; ignore, do not guess
93 appState = state
94 apply()
95 },
96
97 /**
98 * Called when the service tells us it is going away — the notification's stop action, or Android
99 * revoking it. The node is NOT terminated here: stopping it is the app's decision, and a policy
100 * module that also tore down the node would make one failure look like the other.
101 */
102 onServiceStopped () {
103 foreground = false
104 apply()
105 },
106
107 /** Drop the notification and go back to the library policy. */
108 async stop () {
109 foreground = false
110 try { await service.stop() } catch (e) { log('could not stop the foreground service: ' + ((e && e.message) || e)) }
111 apply()
112 }
113 }
114}
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",
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",
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": {
spec/architecture.md +81 -1
……@@ -122,4 +122,84 @@ So favela-android depends on **both** favela-web and hyper-proto-peer, and neith
122122 once, at bootstrap, and never again**. Client-only delegation is dropped: it was the odd one out
123123 precisely because it is not a device chain. Two orthogonal traps remain: the joiner/creator flip and
124124 the N=2 quorum trap.
1254. **Notifications** are the main reason for choosing RN, and nothing about them is designed yet.
1254. **Notifications** are the main reason for choosing RN, and nothing about them is designed yet. (The
126 ongoing foreground-service notification in §7 is a disclosure, not a notification *feature* — it says
127 the app is running and offers a way to stop it. Push, mentions and replies are still undesigned.)
128
129## 7. Staying alive when the app is not on screen
130
131**Built 2026-08-18.** `favela-web/spec/mobile-node.md` §5 named a foreground service as the sanctioned
132way to keep the node up, and the manifest has carried the permissions for one since the first build. What
133it did not name is the reason a foreground service alone would not have worked.
134
135### 7.1 The node was not being killed. It was being suspended, on purpose
136
137`react-native-bare-kit/index.js` ends its module body with
138
139```js
140AppState.addEventListener('change', BareKitWorklet.update.bind(BareKitWorklet))
141```
142
143and `update(state)` is `case 'background': return this.suspend()`. Every worklet in the process is
144suspended the moment the app is backgrounded — which on a phone is every screen lock, before Android's
145Doze, phantom-process killer or OEM battery manager has done anything at all.
146
147That policy is right for a worklet that serves a UI and is idle otherwise. It is wrong for one that IS a
148peer: it holds swarm connections and owes other people replication. And it means the obvious fix is
149inverted — **a foreground service on its own would have kept a suspended node alive very reliably.**
150
151### 7.2 Two halves, and both are required
152
153| half | file | what it buys |
154|---|---|---|
155| policy | `lib/lifecycle.js` | the worklet is not suspended on background |
156| process | `android/…/NodeService.kt` | the process is not a *cached* process — survives task-swipe and memory pressure, keeps background network |
157
158The rule that replaces "is the screen on" is: **suspend when we are not allowed to keep running** — i.e.
159when there is no live foreground service. So if the service does not start (notifications denied, an OEM
160build that refuses, a future Android that tightens the rules), the library's behaviour comes back and the
161node suspends in the background rather than burning battery in a cached process that Android is about to
162kill anyway. Degraded, reported, and never silent.
163
164`lifecycle.js` is dependency-free for the same reason `ipc-client.js` is: the policy is tested off-device
165(`test/lifecycle.test.mjs`, 7/7), including a control that replicates the library's `update()` verbatim so
166the mechanism being overridden is pinned rather than remembered.
167
168### 7.3 The worklet stays owned by the JS runtime
169
170The alternative — a Kotlin-owned worklet hosted *by* the service — was rejected. `react-native-bare-kit`
171offers no way to attach RN's JS side to a worklet it did not create (its turbo module mints them itself),
172so it would mean reimplementing the bundle load and the IPC bridge natively and forking from the
173sanctioned path. The benefit would be surviving destruction of the RN instance, and RN does not destroy
174it: `ReactHostImpl.onHostDestroy(activity)` moves lifecycle state and clears the current activity, it does
175not tear down the runtime. While the process lives, the JS runtime and the worklet live — and keeping the
176process alive is precisely what the service is for.
177
178Revisit only if measurement shows the RN host being torn down under the activity.
179
180### 7.4 What the service deliberately does not do
181
182- **`START_NOT_STICKY`.** If the process dies, the worklet dies with it. A sticky restart would bring back
183 the service and its "Replicating in the background" notification with no node behind it — a confident
184 claim about nothing, which is the failure shape hyper-social #48 §8 names. Restart-after-death needs a
185 headless JS entry point, which does not exist yet.
186- **The Stop action does not tear the node down.** It drops the exemption; the node keeps running while
187 the app is open and suspends once backgrounded. One button, one meaning.
188- **No wake lock.** Whether one helps is a measurement, not a guess: under full Doze the system ignores
189 wake locks anyway, and the cost is battery. See 7.5.
190
191### 7.5 Untested, and how to settle it
192
193Everything above is verified off-device (policy tests, Kotlin compile, merged manifest). What no test here
194can answer:
195
1961. **Does replication actually continue with the screen off?** Post from another node, lock the phone for
197 30 minutes, check the record arrived. This is the acceptance test and it has not been run.
1982. **What survives Doze?** `adb shell dumpsys deviceidle force-idle` and repeat (1). Produces the number
199 mobile-node.md §5 is currently guessing at, and settles the wake-lock question with data.
2003. **Overnight on an OEM build.** Xiaomi/Samsung/Huawei battery managers beat foreground services, and no
201 amount of correct code changes that — but it decides whether the product needs a "disable battery
202 optimisation for favela" onboarding step.
2034. **targetSdk 35.** Apps targeting Android 15+ get a cumulative **6h/24h cap** on `dataSync` foreground
204 services with an `onTimeout` callback. targetSdk is 34, so it does not apply yet; it is the first thing
205 to re-read when that is bumped, and it may force a different service type.
test/lifecycle.test.mjs +139 -0
……@@ -0,0 +1,139 @@
1// Who decides whether the node keeps running when the screen goes off.
2//
3// ⛔ THE CONTROL BELOW IS THE BUG. react-native-bare-kit ends its module body with
4//
5// AppState.addEventListener('change', BareKitWorklet.update.bind(BareKitWorklet))
6//
7// and `update(state)` is `case 'background': return this.suspend()` — so EVERY worklet is suspended the
8// moment the app is backgrounded, which on Android is every screen lock. That is a deliberate library
9// policy for an app whose worklet serves a UI, and exactly wrong for one whose worklet IS a peer. It also
10// means a foreground service on its own fixes nothing: it would keep a SUSPENDED node alive.
11//
12// So the first test replicates the library's update() verbatim to pin what we are overriding, and the
13// rest assert the policy that replaces it: suspend follows whether we are ALLOWED to keep running (a
14// live foreground service), not whether a screen is on.
15import test from 'brittle'
16import { createLifecycle } from '../lib/lifecycle.js'
17
18// A stand-in for react-native-bare-kit's Worklet, carrying its real suspend/resume semantics — including
19// `update`, which is the method the library's AppState listener calls and the one we take over.
20function fakeWorklet () {
21 const w = {
22 suspended: false,
23 terminated: false,
24 calls: [],
25 suspend () {
26 if (w.terminated) throw new Error('Worklet has been terminated')
27 w.calls.push('suspend'); w.suspended = true
28 },
29 resume () {
30 if (w.terminated) throw new Error('Worklet has been terminated')
31 w.calls.push('resume'); w.suspended = false
32 },
33 // react-native-bare-kit/index.js, verbatim.
34 update (state) {
35 switch (state) {
36 case 'active': return w.resume()
37 case 'background': return w.suspend()
38 }
39 }
40 }
41 return w
42}
43
44function fakeService (startResult) {
45 return {
46 started: 0,
47 stopped: 0,
48 async start () { this.started++; return startResult },
49 async stop () { this.stopped++ }
50 }
51}
52
53test('CONTROL: the library suspends the node on background — this is what we are replacing', (t) => {
54 const w = fakeWorklet()
55 w.update('background')
56 t.is(w.suspended, true, 'a screen lock suspends the node')
57 t.alike(w.calls, ['suspend'])
58})
59
60test('with the foreground service running, backgrounding does NOT suspend the node', async (t) => {
61 const w = fakeWorklet()
62 const svc = fakeService(true)
63 const lc = createLifecycle({ worklet: w, service: svc })
64
65 t.is(await lc.start(), true, 'the service started')
66 t.is(svc.started, 1)
67
68 // Both paths: the library's own listener (which calls worklet.update) and ours.
69 w.update('background')
70 await lc.onAppState('background')
71
72 t.is(w.suspended, false, 'the node keeps running with the screen off')
73 t.alike(w.calls, [], 'neither suspend nor resume was called')
74})
75
76test('without the service, the library behaviour is kept — a cached process must not run a peer', async (t) => {
77 const w = fakeWorklet()
78 const svc = fakeService(false)
79 const lc = createLifecycle({ worklet: w, service: svc })
80
81 t.is(await lc.start(), false, 'the service refused to start')
82
83 await lc.onAppState('background')
84 t.is(w.suspended, true, 'backgrounded without a service, the node suspends as before')
85
86 await lc.onAppState('active')
87 t.is(w.suspended, false, 'and resumes when the app comes back')
88})
89
90test('backgrounded BEFORE the service answers, then the service starts: the node is resumed', async (t) => {
91 const w = fakeWorklet()
92 const svc = fakeService(true)
93 const lc = createLifecycle({ worklet: w, service: svc })
94
95 // The user locks the screen during the ~40s boot: the library's listener already suspended us.
96 w.update('background')
97 t.is(w.suspended, true)
98
99 await lc.start()
100 t.is(w.suspended, false, 'taking over means undoing a suspend we would not have made')
101})
102
103test('stopping the node stops the service, and the fallback policy comes back with it', async (t) => {
104 const w = fakeWorklet()
105 const svc = fakeService(true)
106 const lc = createLifecycle({ worklet: w, service: svc })
107 await lc.start()
108
109 await lc.stop()
110 t.is(svc.stopped, 1, 'the notification goes away')
111
112 await lc.onAppState('background')
113 t.is(w.suspended, true, 'no service, no exemption')
114})
115
116test('a terminated worklet is never suspended or resumed', async (t) => {
117 const w = fakeWorklet()
118 const lc = createLifecycle({ worklet: w, service: fakeService(false) })
119 await lc.start()
120
121 w.terminated = true
122 await lc.onAppState('background')
123 await lc.onAppState('active')
124
125 t.alike(w.calls, [], 'no calls survive termination — they would throw')
126})
127
128test('a service that throws is a service that did not start, not a crashed boot', async (t) => {
129 const w = fakeWorklet()
130 const said = []
131 const svc = { async start () { throw new Error('FGS start not allowed') }, async stop () {} }
132 const lc = createLifecycle({ worklet: w, service: svc, log: (m) => said.push(m) })
133
134 t.is(await lc.start(), false)
135 t.ok(said.some((m) => /FGS start not allowed/.test(m)), 'and it SAYS why, or nobody will ever know')
136
137 await lc.onAppState('background')
138 t.is(w.suspended, true, 'falling back to the library policy')
139})