OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 5ea053c

0.0.33: revert 0.0.32 — it crash-looped on boot
ADDON_NOT_FOUND: Cannot find addon '.' imported from hyper-proto-peer/node_modules/bare-signals/binding.js — on the APK's OWN bundle, so OTA could not rescue it and the app was unusable. ⛔ REVERTED RATHER THAN DIAGNOSED. A crash loop on a handset is not a thing to investigate while somebody is holding it: there is no adb on a user's phone and no UI left to say anything. 0.0.31 is known good, and a higher version installs over the top — a downgrade would need an uninstall, which destroys the identity vault, the DID config and the sealed recovery root. The battery-exemption work and the update diagnostics go back in only once the addon resolution is understood, with the demanded set checked against the APK before it ships rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5ea053c64bf6db4bb50a70638f43b433ba51c4f4
by plankatron <fleeky@gmail.com> · 2026-09-11T20:41:21.000Z

Changed files (9) +7 -110

android/app/src/main/AndroidManifest.xml +0 -7
……@@ -13,13 +13,6 @@
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" />
2316
2417 <application
2518 android:name=".MainApplication"
android/app/src/main/java/to/favela/android/FavelaNodeModule.kt +0 -44
……@@ -5,9 +5,6 @@ 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
118import android.os.Handler
129import android.os.Looper
1310import androidx.core.content.ContextCompat
……@@ -62,47 +59,6 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB
6259 * ⚠ Resolves BEFORE exiting, so the page that asked can show "restarting" rather than watching its
6360 * bridge die mid-call and reporting a failure for something that worked.
6461 */
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
10662 @ReactMethod
10763 fun restartProcess(delayMs: Double, promise: Promise) {
10864 try {
app/App.jsx +0 -7
……@@ -15,7 +15,6 @@
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'
1918import { startNode, applyUpdate } from '../lib/host/worklet'
2019import { parseShellMessage } from '../lib/host/shell-handoff'
2120
……@@ -145,12 +144,6 @@ function Surface ({ client, onRestart }) {
145144 if (ref.current) ref.current.reload()
146145 return
147146 }
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 }
154147 if (m.op === 'restart') {
155148 // ⛔ A REAL RESTART, because that is the only thing that applies a staged bundle —
156149 // chooseBundle runs at worklet start and nowhere else. Reloading the WebView would
lib/host/boot-plan.js +1 -4
……@@ -137,10 +137,7 @@ 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,
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
140 ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [], refused
144141 }))
145142 } catch (e) {
146143 io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
lib/host/ota.js +2 -7
……@@ -401,16 +401,11 @@ 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 // ⛔ 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')
404 fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt, addons, demands }, null, 2) + '\n')
410405 // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
411406 // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
412407 // marker, so it never advertises something that is not fully on disk.
413 fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt, version, addons, demands }, null, 2) + '\n')
408 fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt, addons, demands }, null, 2) + '\n')
414409 }
415410 }
416411}
lib/host/shell-handoff.js +3 -14
……@@ -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, batteryExempt = null } = {}) {
82export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null } = {}) {
8383 return JSON.stringify({
8484 ota: ota === true,
8585 pair: pair === true,
……@@ -88,15 +88,7 @@ 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,
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)
91 : null
10092 })
10193}
10294
……@@ -118,7 +110,6 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } =
118110 staged: isHash(staged) ? String(staged) : null,
119111 // Passed straight through from the shell. The page renders THIS instead of deciding for itself.
120112 refused: s.refused || null,
121 batteryExempt: s.batteryExempt === true ? true : (s.batteryExempt === false ? false : null),
122113 checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null
123114 }
124115}
……@@ -136,8 +127,6 @@ export function parseShellMessage (data) {
136127 let raw = null
137128 try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null }
138129 if (!raw || typeof raw !== 'object' || raw.t !== 'app-update') return null
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 }
130 if (raw.op === 'check' || raw.op === 'restart' || raw.op === 'pair') return { op: raw.op }
142131 return null
143132}
lib/host/worklet.js +0 -21
……@@ -49,26 +49,6 @@ 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
7252async function dnsServers () {
7353 const native = NativeModules.FavelaNode
7454 if (!native || typeof native.dnsServers !== 'function') return []
……@@ -142,7 +122,6 @@ async function boot () {
142122 copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest),
143123 unlink: (path) => RNFS.unlink(path),
144124 dnsServers,
145 batteryExempt,
146125 log: (m) => console.log(m)
147126 }
148127 })
lib/node.js +0 -5
……@@ -211,7 +211,6 @@ export async function boot (storage) {
211211 const writeState = () => {
212212 let staged = null
213213 let stagedBuiltAt = null
214 let stagedVersion = null
215214 try {
216215 const cur = JSON.parse(fs.readFileSync(join(storage, 'bundles', 'current.json'), 'utf8')) || {}
217216 staged = cur.treeHash
……@@ -219,9 +218,6 @@ export async function boot (storage) {
219218 // prefers the ASSET when the app is newer, so a staged-but-older bundle will never be
220219 // applied — and a banner offering to restart for it is a button that does nothing, for ever.
221220 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
225221 } catch {}
226222 // ⛔ THE VERSION IS THE HALF A PERSON CAN ACT ON. Two tree hashes tell you something changed;
227223 // they do not tell you WHICH build you are running, so "is my update installed?" stayed
……@@ -232,7 +228,6 @@ export async function boot (storage) {
232228 version: VERSION,
233229 builtAt: BUILT_AT,
234230 stagedBuiltAt,
235 stagedVersion,
236231 note: lastOtaNote
237232 }
238233 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.32",
3 "version": "0.0.33",
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,