OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 9a7dcd4

0.0.32: stop being frozen, and explain yourself when stuck
⛔ THE FREEZE. Samsung's Freecess froze the node whenever the app was backgrounded, and a frozen app has no swarm — so the OTA watch was never told a release existed. Measured that day and written down as "not a product", then left in the carried list while three separate stalls were blamed on the updater. The app now declares REQUEST_IGNORE_BATTERY_OPTIMIZATIONS and can ask for the exemption; the person still decides, and `isBatteryExempt` is the only thing that knows the answer. ⛔ AND IT SAYS WHY IT IS STUCK. The staged bundle's VERSION is recorded alongside its hash — the release knew it and it was being dropped — so the page can say "0.0.31 is downloaded" instead of "staged bbbb…". A refused bundle shows chooseBundle's own sentence and no restart button. ⚠ Needs one sideload: the permission and the two native methods are APK code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9a7dcd414c16e86517dbc1ed59ae3fc11045f37b
by plankatron <fleeky@gmail.com> · 2026-09-11T20:30:38.000Z

Changed files (9) +110 -7

android/app/src/main/AndroidManifest.xml +7 -0
……@@ -13,6 +13,13 @@
1313 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1414 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
1515 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
16 <!-- ⛔ WITHOUT THIS THE NODE IS FROZEN WHEN BACKGROUNDED, and a frozen app has no swarm — so the
17 OTA watch is never told a release exists. Measured on an SM-S906U: Samsung's Freecess logs
18 "freeze to.favela.surface", and an update published while the app was in the background did
19 not arrive until it was foregrounded ~8 minutes later. An updater that only runs while you
20 are looking at it is not an updater.
21 ⚠ Requesting the exemption only opens the system dialog; the person still decides. -->
22 <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
1623
1724 <application
1825 android:name=".MainApplication"
android/app/src/main/java/to/favela/android/FavelaNodeModule.kt +44 -0
……@@ -5,6 +5,9 @@ import android.app.PendingIntent
55import android.content.Context
66import android.content.Intent
77import android.net.ConnectivityManager
8import android.net.Uri
9import android.os.PowerManager
10import android.provider.Settings
811import android.os.Handler
912import android.os.Looper
1013import androidx.core.content.ContextCompat
……@@ -59,6 +62,47 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB
5962 * ⚠ Resolves BEFORE exiting, so the page that asked can show "restarting" rather than watching its
6063 * bridge die mid-call and reporting a failure for something that worked.
6164 */
65 /**
66 * Is this app exempt from battery optimisation — i.e. allowed to keep running in the background?
67 *
68 * ⛔ THE QUESTION BEHIND IT IS WHETHER OTA WORKS AT ALL. A frozen app has no swarm, so the release
69 * watch is never told anything; measured on a handset, an update published while backgrounded did
70 * not arrive until the app was brought to the front eight minutes later. The node is a PEER, and a
71 * peer that only exists while somebody is looking at it is not one.
72 */
73 @ReactMethod
74 fun isBatteryExempt(promise: Promise) {
75 try {
76 val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
77 promise.resolve(pm.isIgnoringBatteryOptimizations(ctx.packageName))
78 } catch (e: Throwable) {
79 promise.reject("battery_check_failed", e.message, e)
80 }
81 }
82
83 /**
84 * Ask for the exemption. Opens the system dialog; the person decides, and may say no.
85 *
86 * ⚠ NOT A GUARANTEE, and the caller must not treat it as one — resolving means the dialog was
87 * shown, not that the answer was yes. `isBatteryExempt` is the only thing that knows.
88 */
89 @ReactMethod
90 fun requestBatteryExemption(promise: Promise) {
91 try {
92 val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
93 if (pm.isIgnoringBatteryOptimizations(ctx.packageName)) { promise.resolve(true); return }
94 val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
95 .setData(Uri.parse("package:" + ctx.packageName))
96 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
97 ctx.startActivity(intent)
98 promise.resolve(true)
99 } catch (e: Throwable) {
100 // ⚠ Some OEM builds refuse this intent outright. Reported rather than crashed: the node still
101 // runs, it just gets frozen, and the settings page can say so.
102 promise.reject("battery_request_failed", e.message, e)
103 }
104 }
105
62106 @ReactMethod
63107 fun restartProcess(delayMs: Double, promise: Promise) {
64108 try {
app/App.jsx +7 -0
……@@ -15,6 +15,7 @@
1515import React, { useEffect, useState, useCallback, useRef } from 'react'
1616import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
1717import { WebView } from 'react-native-webview'
18import { NativeModules } from 'react-native'
1819import { startNode, applyUpdate } from '../lib/host/worklet'
1920import { parseShellMessage } from '../lib/host/shell-handoff'
2021
……@@ -144,6 +145,12 @@ function Surface ({ client, onRestart }) {
144145 if (ref.current) ref.current.reload()
145146 return
146147 }
148 if (m.op === 'battery') {
149 // ⛔ ASKS, DOES NOT GRANT. This opens the system dialog and the person decides; the answer
150 // shows up as `batteryExempt` on the next boot, which is the only thing that knows.
151 try { await NativeModules.FavelaNode.requestBatteryExemption() } catch { /* OEM refused the intent */ }
152 return
153 }
147154 if (m.op === 'restart') {
148155 // ⛔ A REAL RESTART, because that is the only thing that applies a staged bundle —
149156 // chooseBundle runs at worklet start and nowhere else. Reloading the WebView would
lib/host/boot-plan.js +4 -1
……@@ -137,7 +137,10 @@ export async function planBoot ({ storage, io }) {
137137 const dns = await io.dnsServers()
138138 try {
139139 await io.writeText(storage + '/' + SHELL_FILE, encodeShell({
140 ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [], refused
140 ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [], refused,
141 // ⚠ Asked here because only this side can ask the OS, and undefined when the caller did not —
142 // an older shell must not make the page claim the node is being frozen when it does not know.
143 batteryExempt: typeof io.batteryExempt === 'function' ? await io.batteryExempt() : null
141144 }))
142145 } catch (e) {
143146 io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
lib/host/ota.js +7 -2
……@@ -401,11 +401,16 @@ export function bundleStore ({ dir, apkAddons = null }) {
401401 // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it
402402 // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree
403403 // hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up.
404 fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt, addons, demands }, null, 2) + '\n')
404 // ⛔ THE VERSION, NOT JUST THE HASH. Everything downstream of this — the update banner, the
405 // "why am I not on the latest" question a person actually has — could only say
406 // `staged bbbb…` against `running aaaa…`, two truncated hashes that answer nothing. The
407 // release already knows its version and it was being dropped here.
408 const version = (pendingRelease && pendingRelease.version) || null
409 fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt, version, addons, demands }, null, 2) + '\n')
405410 // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
406411 // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
407412 // marker, so it never advertises something that is not fully on disk.
408 fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt, addons, demands }, null, 2) + '\n')
413 fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt, version, addons, demands }, null, 2) + '\n')
409414 }
410415 }
411416}
lib/host/shell-handoff.js +14 -3
……@@ -79,7 +79,7 @@ export function parseShell (text) {
7979}
8080
8181/** The shell's side of the same shape. Written before every worklet start, never appended to. */
82export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null } = {}) {
82export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null, batteryExempt = null } = {}) {
8383 return JSON.stringify({
8484 ota: ota === true,
8585 pair: pair === true,
……@@ -88,7 +88,15 @@ export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [
8888 apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [],
8989 refused: refused && isHash(refused.hash)
9090 ? { hash: String(refused.hash), reason: String(refused.reason || 'refused') }
91 : null
91 : null,
92 // ⛔ WHETHER THIS NODE IS ALLOWED TO EXIST IN THE BACKGROUND. A frozen app has no swarm, so the
93 // release watch is never told anything — measured: an update published while backgrounded did not
94 // arrive until the app was foregrounded eight minutes later. Only the shell can ask the OS, and
95 // only the page can tell somebody, so it crosses here.
96 //
97 // ⚠ Tri-state. `null` is "this shell did not ask" — an older one — and must not read as "no",
98 // which would show a warning to people who have nothing wrong.
99 batteryExempt: batteryExempt === true ? true : (batteryExempt === false ? false : null)
92100 })
93101}
94102
……@@ -110,6 +118,7 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } =
110118 staged: isHash(staged) ? String(staged) : null,
111119 // Passed straight through from the shell. The page renders THIS instead of deciding for itself.
112120 refused: s.refused || null,
121 batteryExempt: s.batteryExempt === true ? true : (s.batteryExempt === false ? false : null),
113122 checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null
114123 }
115124}
……@@ -127,6 +136,8 @@ export function parseShellMessage (data) {
127136 let raw = null
128137 try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null }
129138 if (!raw || typeof raw !== 'object' || raw.t !== 'app-update') return null
130 if (raw.op === 'check' || raw.op === 'restart' || raw.op === 'pair') return { op: raw.op }
139 // ⛔ STILL AN ALLOW-LIST, now of four. 'battery' opens the system exemption dialog — it asks, it
140 // does not grant, and the person still decides.
141 if (raw.op === 'check' || raw.op === 'restart' || raw.op === 'pair' || raw.op === 'battery') return { op: raw.op }
131142 return null
132143}
lib/host/worklet.js +21 -0
……@@ -49,6 +49,26 @@ const service = {
4949// because favela-web captures its resolver at import. That matches how every other favela node behaves
5050// (they read resolv.conf once, at start), so it is a known limit rather than a regression — the fix is a
5151// resolver that can be re-pointed, which is a change on the favela-web side.
52/**
53 * Is this app allowed to keep running in the background?
54 *
55 * ⛔ IT DECIDES WHETHER OTA WORKS AT ALL. A frozen app has no swarm, so the release watch is never
56 * told anything — measured on a handset: an update published while backgrounded arrived only after
57 * the app was foregrounded, eight minutes later.
58 *
59 * ⚠ NULL, not false, when this cannot be asked. An older shell or an OEM that refuses the query must
60 * not make the page tell somebody their node is being frozen when nothing knows that.
61 */
62async function batteryExempt () {
63 const native = NativeModules.FavelaNode
64 if (!native || typeof native.isBatteryExempt !== 'function') return null
65 try {
66 return await native.isBatteryExempt()
67 } catch {
68 return null
69 }
70}
71
5272async function dnsServers () {
5373 const native = NativeModules.FavelaNode
5474 if (!native || typeof native.dnsServers !== 'function') return []
……@@ -122,6 +142,7 @@ async function boot () {
122142 copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest),
123143 unlink: (path) => RNFS.unlink(path),
124144 dnsServers,
145 batteryExempt,
125146 log: (m) => console.log(m)
126147 }
127148 })
lib/node.js +5 -0
……@@ -211,6 +211,7 @@ export async function boot (storage) {
211211 const writeState = () => {
212212 let staged = null
213213 let stagedBuiltAt = null
214 let stagedVersion = null
214215 try {
215216 const cur = JSON.parse(fs.readFileSync(join(storage, 'bundles', 'current.json'), 'utf8')) || {}
216217 staged = cur.treeHash
……@@ -218,6 +219,9 @@ export async function boot (storage) {
218219 // prefers the ASSET when the app is newer, so a staged-but-older bundle will never be
219220 // applied — and a banner offering to restart for it is a button that does nothing, for ever.
220221 stagedBuiltAt = cur.builtAt || null
222 // ⛔ AND ITS VERSION. "staged bbbb… · running aaaa…" is two truncated hashes, which is not an
223 // answer to "why am I not on the latest?" — the only question anyone opens this section with.
224 stagedVersion = cur.version || null
221225 } catch {}
222226 // ⛔ THE VERSION IS THE HALF A PERSON CAN ACT ON. Two tree hashes tell you something changed;
223227 // they do not tell you WHICH build you are running, so "is my update installed?" stayed
……@@ -228,6 +232,7 @@ export async function boot (storage) {
228232 version: VERSION,
229233 builtAt: BUILT_AT,
230234 stagedBuiltAt,
235 stagedVersion,
231236 note: lastOtaNote
232237 }
233238 try { fs.writeFileSync(join(storage, STATE_FILE), JSON.stringify(st)) } catch { /* status only */ }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.31",
3 "version": "0.0.32",
44 "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.",
55 "type": "module",
66 "private": true,