| … | … | @@ -29,9 +29,12 @@ Everything below is measured. |
| 29 | 29 | | IPC contract | 14 assertions against the real worklet, not a mock | |
| 30 | 30 | | storage in a worklet thread | 8/8 on Linux, incl. lock release on exit (the Doze-restart case) | |
| 31 | 31 | | 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 | | |
| 32 | 33 | |
| 33 | Untested: the WebView surface itself under real use, foreground-service survival, and hyperdht | |
| 34 | hole-punching on cellular/CGNAT. | |
| 34 | Untested: 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 | |
| 36 | the suspend-policy override that make it possible are built and verified off-device (spec §7), but the | |
| 37 | acceptance test (post from elsewhere, lock the phone 30 minutes, check it arrived) has not been run. | |
| 35 | 38 | |
| 36 | 39 | ## Architecture in one paragraph |
| 37 | 40 |
| … | … | @@ -1,8 +1,13 @@ |
| 1 | 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
| 2 | 2 | |
| 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. --> | |
| 6 | 11 | <uses-permission android:name="android.permission.INTERNET" /> |
| 7 | 12 | <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
| 8 | 13 | <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
| … | … | @@ -25,5 +30,19 @@ |
| 25 | 30 | <category android:name="android.intent.category.LAUNCHER" /> |
| 26 | 31 | </intent-filter> |
| 27 | 32 | </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" /> | |
| 28 | 47 | </application> |
| 29 | 48 | </manifest> |
| … | … | @@ -0,0 +1,93 @@ |
| 1 | package to.favela.android | |
| 2 | ||
| 3 | import android.content.Intent | |
| 4 | import android.os.Handler | |
| 5 | import android.os.Looper | |
| 6 | import androidx.core.content.ContextCompat | |
| 7 | import com.facebook.react.bridge.Promise | |
| 8 | import com.facebook.react.bridge.ReactApplicationContext | |
| 9 | import com.facebook.react.bridge.ReactContextBaseJavaModule | |
| 10 | import com.facebook.react.bridge.ReactMethod | |
| 11 | import 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 | */ | |
| 20 | class 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 | } |
| … | … | @@ -0,0 +1,15 @@ |
| 1 | package to.favela.android | |
| 2 | ||
| 3 | import com.facebook.react.ReactPackage | |
| 4 | import com.facebook.react.bridge.NativeModule | |
| 5 | import com.facebook.react.bridge.ReactApplicationContext | |
| 6 | import com.facebook.react.uimanager.ViewManager | |
| 7 | ||
| 8 | /** The app's own native modules. Autolinking cannot find these — they are not a node_modules package. */ | |
| 9 | class 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 | } |
| … | … | @@ -17,8 +17,10 @@ class MainApplication : Application(), ReactApplication { |
| 17 | 17 | override val reactNativeHost: ReactNativeHost = |
| 18 | 18 | object : DefaultReactNativeHost(this) { |
| 19 | 19 | // 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() | |
| 22 | 24 | override fun getJSMainModuleName(): String = "app/index" |
| 23 | 25 | override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG |
| 24 | 26 | override val isHermesEnabled: Boolean = true |
| … | … | @@ -0,0 +1,146 @@ |
| 1 | package to.favela.android | |
| 2 | ||
| 3 | import android.app.Notification | |
| 4 | import android.app.NotificationChannel | |
| 5 | import android.app.NotificationManager | |
| 6 | import android.app.PendingIntent | |
| 7 | import android.app.Service | |
| 8 | import android.content.Intent | |
| 9 | import android.content.pm.ServiceInfo | |
| 10 | import android.os.IBinder | |
| 11 | import androidx.core.app.NotificationCompat | |
| 12 | import 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 | */ | |
| 34 | class 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 | } |
| … | … | @@ -7,11 +7,34 @@ |
| 7 | 7 | // device-only to verify. Everything RN-specific is confined here. |
| 8 | 8 | import { Worklet } from 'react-native-bare-kit' |
| 9 | 9 | import RNFS from 'react-native-fs' |
| 10 | import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native' | |
| 10 | 11 | import { WorkletClient } from '../lib/ipc-client' |
| 12 | import { createLifecycle } from '../lib/lifecycle' | |
| 11 | 13 | import { encodeUtf8, createUtf8Decoder } from '../lib/utf8' |
| 12 | 14 | |
| 13 | 15 | let started = null |
| 14 | 16 | let starting = null |
| 17 | let 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. | |
| 24 | const 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 | } | |
| 15 | 38 | |
| 16 | 39 | /** |
| 17 | 40 | * Start the node once per app launch and resolve with its client. |
| … | … | @@ -67,6 +90,26 @@ async function boot () { |
| 67 | 90 | // Path + null source => NativeBareKit.startFile: the runtime reads it, not us. |
| 68 | 91 | worklet.start(dest, source, [storage]) |
| 69 | 92 | 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 | ||
| 70 | 113 | const { IPC } = worklet |
| 71 | 114 | |
| 72 | 115 | // ⛔ 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 () { |
| 91 | 134 | * Stop the node. Called when the app is being torn down for real — NOT on backgrounding. |
| 92 | 135 | * |
| 93 | 136 | * ⚠ 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. | |
| 96 | 139 | */ |
| 97 | 140 | export function stopNode () { |
| 98 | 141 | 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 } | |
| 99 | 145 | try { started.client.close('app teardown') } catch {} |
| 100 | 146 | try { started.worklet.terminate() } catch {} |
| 101 | 147 | started = null |
| … | … | @@ -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 | */ | |
| 32 | export 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 | } |
| … | … | @@ -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", | |
| 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", | |
| 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": { |
| … | … | @@ -122,4 +122,84 @@ So favela-android depends on **both** favela-web and hyper-proto-peer, and neith |
| 122 | 122 | once, at bootstrap, and never again**. Client-only delegation is dropped: it was the odd one out |
| 123 | 123 | precisely because it is not a device chain. Two orthogonal traps remain: the joiner/creator flip and |
| 124 | 124 | the N=2 quorum trap. |
| 125 | 4. **Notifications** are the main reason for choosing RN, and nothing about them is designed yet. | |
| 125 | 4. **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 | |
| 132 | way to keep the node up, and the manifest has carried the permissions for one since the first build. What | |
| 133 | it 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 | |
| 140 | AppState.addEventListener('change', BareKitWorklet.update.bind(BareKitWorklet)) | |
| 141 | ``` | |
| 142 | ||
| 143 | and `update(state)` is `case 'background': return this.suspend()`. Every worklet in the process is | |
| 144 | suspended the moment the app is backgrounded — which on a phone is every screen lock, before Android's | |
| 145 | Doze, phantom-process killer or OEM battery manager has done anything at all. | |
| 146 | ||
| 147 | That policy is right for a worklet that serves a UI and is idle otherwise. It is wrong for one that IS a | |
| 148 | peer: it holds swarm connections and owes other people replication. And it means the obvious fix is | |
| 149 | inverted — **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 | ||
| 158 | The rule that replaces "is the screen on" is: **suspend when we are not allowed to keep running** — i.e. | |
| 159 | when there is no live foreground service. So if the service does not start (notifications denied, an OEM | |
| 160 | build that refuses, a future Android that tightens the rules), the library's behaviour comes back and the | |
| 161 | node suspends in the background rather than burning battery in a cached process that Android is about to | |
| 162 | kill 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 | |
| 166 | the mechanism being overridden is pinned rather than remembered. | |
| 167 | ||
| 168 | ### 7.3 The worklet stays owned by the JS runtime | |
| 169 | ||
| 170 | The alternative — a Kotlin-owned worklet hosted *by* the service — was rejected. `react-native-bare-kit` | |
| 171 | offers no way to attach RN's JS side to a worklet it did not create (its turbo module mints them itself), | |
| 172 | so it would mean reimplementing the bundle load and the IPC bridge natively and forking from the | |
| 173 | sanctioned path. The benefit would be surviving destruction of the RN instance, and RN does not destroy | |
| 174 | it: `ReactHostImpl.onHostDestroy(activity)` moves lifecycle state and clears the current activity, it does | |
| 175 | not tear down the runtime. While the process lives, the JS runtime and the worklet live — and keeping the | |
| 176 | process alive is precisely what the service is for. | |
| 177 | ||
| 178 | Revisit 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 | ||
| 193 | Everything above is verified off-device (policy tests, Kotlin compile, merged manifest). What no test here | |
| 194 | can answer: | |
| 195 | ||
| 196 | 1. **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. | |
| 198 | 2. **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. | |
| 200 | 3. **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. | |
| 203 | 4. **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. |
| … | … | @@ -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. | |
| 15 | import test from 'brittle' | |
| 16 | import { 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. | |
| 20 | function 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 | ||
| 44 | function 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 | ||
| 53 | test('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 | ||
| 60 | test('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 | ||
| 76 | test('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 | ||
| 90 | test('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 | ||
| 103 | test('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 | ||
| 116 | test('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 | ||
| 128 | test('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 | }) |