| … | … | @@ -13,6 +13,13 @@ |
| 13 | 13 | <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
| 14 | 14 | <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> |
| 15 | 15 | <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" /> | |
| 16 | 23 | |
| 17 | 24 | <application |
| 18 | 25 | android:name=".MainApplication" |
| … | … | @@ -5,6 +5,9 @@ import android.app.PendingIntent |
| 5 | 5 | import android.content.Context |
| 6 | 6 | import android.content.Intent |
| 7 | 7 | import android.net.ConnectivityManager |
| 8 | import android.net.Uri | |
| 9 | import android.os.PowerManager | |
| 10 | import android.provider.Settings | |
| 8 | 11 | import android.os.Handler |
| 9 | 12 | import android.os.Looper |
| 10 | 13 | import androidx.core.content.ContextCompat |
| … | … | @@ -59,6 +62,47 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB |
| 59 | 62 | * ⚠ Resolves BEFORE exiting, so the page that asked can show "restarting" rather than watching its |
| 60 | 63 | * bridge die mid-call and reporting a failure for something that worked. |
| 61 | 64 | */ |
| 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 | ||
| 62 | 106 | @ReactMethod |
| 63 | 107 | fun restartProcess(delayMs: Double, promise: Promise) { |
| 64 | 108 | try { |
| … | … | @@ -15,6 +15,7 @@ |
| 15 | 15 | import React, { useEffect, useState, useCallback, useRef } from 'react' |
| 16 | 16 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native' |
| 17 | 17 | import { WebView } from 'react-native-webview' |
| 18 | import { NativeModules } from 'react-native' | |
| 18 | 19 | import { startNode, applyUpdate } from '../lib/host/worklet' |
| 19 | 20 | import { parseShellMessage } from '../lib/host/shell-handoff' |
| 20 | 21 | |
| … | … | @@ -144,6 +145,12 @@ function Surface ({ client, onRestart }) { |
| 144 | 145 | if (ref.current) ref.current.reload() |
| 145 | 146 | return |
| 146 | 147 | } |
| 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 | } | |
| 147 | 154 | if (m.op === 'restart') { |
| 148 | 155 | // ⛔ A REAL RESTART, because that is the only thing that applies a staged bundle — |
| 149 | 156 | // chooseBundle runs at worklet start and nowhere else. Reloading the WebView would |
| … | … | @@ -137,7 +137,10 @@ export async function planBoot ({ storage, io }) { |
| 137 | 137 | const dns = await io.dnsServers() |
| 138 | 138 | try { |
| 139 | 139 | 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 | |
| 141 | 144 | })) |
| 142 | 145 | } catch (e) { |
| 143 | 146 | io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message) |
| … | … | @@ -401,11 +401,16 @@ export function bundleStore ({ dir, apkAddons = null }) { |
| 401 | 401 | // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it |
| 402 | 402 | // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree |
| 403 | 403 | // 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') | |
| 405 | 410 | // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and |
| 406 | 411 | // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the |
| 407 | 412 | // 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') | |
| 409 | 414 | } |
| 410 | 415 | } |
| 411 | 416 | } |
| … | … | @@ -79,7 +79,7 @@ export function parseShell (text) { |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | /** The shell's side of the same shape. Written before every worklet start, never appended to. */ |
| 82 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null } = {}) { | |
| 82 | export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null, batteryExempt = null } = {}) { | |
| 83 | 83 | return JSON.stringify({ |
| 84 | 84 | ota: ota === true, |
| 85 | 85 | pair: pair === true, |
| … | … | @@ -88,7 +88,15 @@ export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [ |
| 88 | 88 | apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [], |
| 89 | 89 | refused: refused && isHash(refused.hash) |
| 90 | 90 | ? { 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) | |
| 92 | 100 | }) |
| 93 | 101 | } |
| 94 | 102 | |
| … | … | @@ -110,6 +118,7 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } = |
| 110 | 118 | staged: isHash(staged) ? String(staged) : null, |
| 111 | 119 | // Passed straight through from the shell. The page renders THIS instead of deciding for itself. |
| 112 | 120 | refused: s.refused || null, |
| 121 | batteryExempt: s.batteryExempt === true ? true : (s.batteryExempt === false ? false : null), | |
| 113 | 122 | checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null |
| 114 | 123 | } |
| 115 | 124 | } |
| … | … | @@ -127,6 +136,8 @@ export function parseShellMessage (data) { |
| 127 | 136 | let raw = null |
| 128 | 137 | try { raw = typeof data === 'string' ? JSON.parse(data) : data } catch { return null } |
| 129 | 138 | 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 } | |
| 131 | 142 | return null |
| 132 | 143 | } |
| … | … | @@ -49,6 +49,26 @@ const service = { |
| 49 | 49 | // because favela-web captures its resolver at import. That matches how every other favela node behaves |
| 50 | 50 | // (they read resolv.conf once, at start), so it is a known limit rather than a regression — the fix is a |
| 51 | 51 | // 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 | */ | |
| 62 | async 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 | ||
| 52 | 72 | async function dnsServers () { |
| 53 | 73 | const native = NativeModules.FavelaNode |
| 54 | 74 | if (!native || typeof native.dnsServers !== 'function') return [] |
| … | … | @@ -122,6 +142,7 @@ async function boot () { |
| 122 | 142 | copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest), |
| 123 | 143 | unlink: (path) => RNFS.unlink(path), |
| 124 | 144 | dnsServers, |
| 145 | batteryExempt, | |
| 125 | 146 | log: (m) => console.log(m) |
| 126 | 147 | } |
| 127 | 148 | }) |
| … | … | @@ -211,6 +211,7 @@ export async function boot (storage) { |
| 211 | 211 | const writeState = () => { |
| 212 | 212 | let staged = null |
| 213 | 213 | let stagedBuiltAt = null |
| 214 | let stagedVersion = null | |
| 214 | 215 | try { |
| 215 | 216 | const cur = JSON.parse(fs.readFileSync(join(storage, 'bundles', 'current.json'), 'utf8')) || {} |
| 216 | 217 | staged = cur.treeHash |
| … | … | @@ -218,6 +219,9 @@ export async function boot (storage) { |
| 218 | 219 | // prefers the ASSET when the app is newer, so a staged-but-older bundle will never be |
| 219 | 220 | // applied — and a banner offering to restart for it is a button that does nothing, for ever. |
| 220 | 221 | 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 | |
| 221 | 225 | } catch {} |
| 222 | 226 | // ⛔ THE VERSION IS THE HALF A PERSON CAN ACT ON. Two tree hashes tell you something changed; |
| 223 | 227 | // 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) { |
| 228 | 232 | version: VERSION, |
| 229 | 233 | builtAt: BUILT_AT, |
| 230 | 234 | stagedBuiltAt, |
| 235 | stagedVersion, | |
| 231 | 236 | note: lastOtaNote |
| 232 | 237 | } |
| 233 | 238 | try { fs.writeFileSync(join(storage, STATE_FILE), JSON.stringify(st)) } catch { /* status only */ } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface-android", |
| 3 | "version": "0.0.31", | |
| 3 | "version": "0.0.32", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "private": true, |