OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 61ebb1c

README: the app shipped, and this file still said the APK was never built
Every structural claim in this README was false. It said "Not built into an APK yet", "APK never built — no Android SDK here" and "No atproto, no DIDs, no bridge", and carried a "What must be written" list of things that are written. On disk: favela-surface-android.apk, 157 MB, built 2026-09-15 14:15, version 0.0.42, with hyper-proto-peer and favela-host as dependencies and bootAtproto called from lib/node.js. Issues #69-#73 describe it running on a handset and publishing to Bluesky. Rewritten as a reference in the holepunch shape, per favela-markdown 462e960: name, one sentence, Install, Build, Architecture, OTA, Test, License. Glyph count 21 -> 0. Every number measured just now rather than carried over: APK 164,980,489 bytes (157 MB), arm64-v8a worklet.bundle 42,184,136 bytes (40 MB) loader.bundle 232,897 bytes (227 KB) tests 122/122, 390 asserts, 17 files, ~5 s The old file's 8.2 MB bundle and 10/10 + 79/79 test figures were from before the atproto half landed. Documents what was undocumented: the two-bundle split and why the entry moved to loader.bundle, the boot order in lib/node.js and the reason each step sits where it does, native addons over OTA via Addon.cache, gen-version.mjs and the DEPS record, and verify-apk-addons.mjs as the one check that reads the artifact rather than a decision. Replaced the port-audit table (a record of a move finished two weeks ago) with the lib/host/ copy debt, which is still live. Known gaps section states three real ones: no updates.minver (#72), the APK is an unresumable HTTPS GET with apk.tree.json published and unconsumed, and arm64-only addon publication. package.json's description claimed "no atproto" as well. Same fix, same commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
61ebb1c02f8220e354883d153c5deef9552adbfd
by plankatron <fleeky@gmail.com> · 2026-09-20T20:34:26.000Z

Changed files (2) +163 -102

README.md +162 -101
……@@ -1,113 +1,174 @@
11# favela-surface-android
22
3A **full favela node on a phone**, for the markdown-record stack: `favela-peer` in a
3A full favela node on an Android handset: `favela-peer` inside a
44[bare-kit](https://github.com/holepunchto/bare-kit) worklet, with `favela-surface` rendering its
5pages into a WebView. No atproto, no DIDs, no bridge.
5pages into a WebView, and the atproto half composed in through `favela-host`.
66
7## Status — honest
7## Install
88
9**Not built into an APK yet — everything up to that point is done and tested.** The worklet boots,
10signs in to its own node and serves real pages; the RN entry is wired; the worklet bundle packs.
11All of it verified on a laptop, because there is no Android SDK on the machine this was written on.
9Sideload the APK the forge builds on push. It is served with
10`application/vnd.android.package-archive`, so a phone browser treats it as an installer:
1211
13What remains is a gradle build on a machine that has one, and then a handset.
12```
13https://7rnx.net/git/releases/favela-surface-android/<version>/favela-surface-android.apk
14```
15
16Requires Android 10 — `android/build.gradle` sets `minSdkVersion = 29`, matching what
17react-native-bare-kit 0.14.5 declares. Sideloading needs "install unknown apps" for your browser.
18
19This is not a pgh app; the deliverable is an APK installed by Android's package manager. The
20worklet bundle inside it *is* published to the pgh release registry — see [OTA](#ota).
1421
1522| | |
1623|---|---|
17| worklet bundle | **8.2 MB** (`npm run pack:worklet`) — favela-android's is 19 MB, and the difference is the atproto stack |
18| tests | 10/10, 79/79, no emulator |
19| APK | ⛔ never built — no Android SDK here |
20
21⛔ **`applicationId` is `to.favela.surface`**, deliberately different from favela-android's
22`to.favela.android`. Sharing one would make installing this app REPLACE the other, and they hold
23different identities and different storage. `app_name` is "favela (surface)" for the same reason —
24two identical launcher icons during the transition is a worse bug than an ugly name.
25
26⚠ `namespace` is still `to.favela.android`. It sets the R class package and manifest resolution,
27never the install identity, and renaming the Kotlin package across five files is a change this
28machine cannot compile. An unverifiable rename is how a build breaks silently. What is here is the half of `favela-android` that carries no
29atproto dependency, copied verbatim, plus the native Android project. Everything that boots a node
30or onboards an identity still has to be written.
31
32Nothing in this README is a plan for what it will do. It is a record of what is on disk.
33
34## Why a new repo rather than a branch of favela-android
35
36`favela-android` is tied to the atproto stack, and the tie is not superficial. Measured
372026-09-07 against its working tree:
38
39| | lines | |
40|---|---|---|
41| **carried over here** — `worklet.js`, `ota.js`, `ipc-client.js`, `shell-handoff.js`, `lifecycle.js`, `utf8.js`, `choose-bundle.js`, `repair-store-backend.js` | **1,361** | no atproto imports — see the correction below |
42| **carried over here** — the `android/` native project | 15 source files | foreground service, Doze policy, gradle, signing |
43| **must be adapted** — `App.jsx` | 274 | WebView host and boot stages transfer; the `/auth/handoff` hop is favela-web's |
44| **must be rewritten** — `node.js` 971, `session.js` 174, `favela-api.js` 107, `richtext.js` 101, `roster.js` 72, `describe.js` 51, `first-run.js` 39, `hosting.js` 36, `adopt.js` 8, `mint.js` 7 | **1,566** | `node.js` alone imports hyper-proto-peer, favela-web, atproto-store and atproto-identity across 9 dynamic imports |
45| **must be rewritten** — `Pair.jsx` 176, `Create.jsx` 212 | 388 | DID-shaped onboarding; favela-peer pairs a device credential over blind-pairing instead |
46| **did not come** — `Screens.jsx`, `Post.jsx`, `RichText.jsx` | ~707 | already unreachable there: `App.jsx` imports one layout helper out of `Screens.jsx` and nothing else reaches the rest |
47
48⇒ A fork would have been mostly deletion, and the deletions would have been the parts that give
49`favela-android` its shape. A new repo starts from the 1,361 lines that were never about atproto.
50
51### ⚠ A correction to that table
52
53The audit judged coupling by IMPORTS, and by imports those eight files are clean. That was true and
54insufficient: about a third of `ipc-client.js` was a **typed API for favela-android's ops** —
55`compose`, `follow`, `hostedDids`, `viewDid`, `syncFollows`, `revokeDevice` and a dozen more. They
56import nothing; they simply name things this worklet does not implement, so calling one returns
57"unknown op". They have been removed here, leaving the transport and `status`.
58
59⇒ **"No imports" is not "no coupling."** Found by writing `app/App.jsx` against the file rather than
60by reading it — the same way the last three defects in this port were found.
61
62## ⛔ `lib/host/` is COPIED, and that is a debt
63
64Those eight modules are byte-identical to `favela-android`'s today. They are the expensive,
65fiddly part — worklet hosting, the IPC contract, OTA, Android lifecycle — and neither app is
66better for owning a private version of them.
67
68They were copied rather than extracted because extraction means refactoring a **live, working**
69app to consume a package that does not exist yet, which is risk taken against something that
70currently runs on hardware, for a benefit that only arrives later.
71
72⚠ **The cost is stated so it is not discovered:** the first Doze or foreground-service fix has to
73land in two places. When that happens — or sooner, if anyone has the appetite — `lib/host/` is
74the extraction boundary, and it is a clean one precisely because it has no atproto imports.
75
76## What must be written
77
78- **`lib/node.js`** — the worklet boot. `favela-android`'s is 971 lines of hyper-proto-peer +
79 favela-web; this one boots `favela-peer` and serves `favela-surface` to loopback.
80- ⛔ **The UDS problem — RESOLVED, see spec/architecture.md §2.** `favela-surface` reaches
81 `favela-peer` over a unix socket, and a worklet cannot host two processes. The answer is
82 **in-process dispatch**: `favela-peer/lib/rpc.js` already exports `dispatch(node, cmd, a)` with
83 `createServer` as a socket adapter over it, so there is nothing to emulate. A pathname UDS is
84 SELinux-blocked on Android outright, and loopback TCP would be reachable by every other app on the
85 device. The one production change it needs is `favela-surface` accepting an injected client.
86- **Onboarding** — `favela pair` over blind-pairing, replacing the DID ceremonies.
87- ✅ **`app/App.jsx`** — written. Boot stages, the WebView, hardware back, and the session handoff.
88- ✅ **Browser key registration** — written and tested end to end. The app persists an anproto
89 keypair, claims the node on first run (TOFU) and authenticates on every run after, then mints a
90 session and a one-time handoff token that `favela-surface` swaps for the HttpOnly cookie. The
91 test asserts the whole chain ends in a **200 with a real page**, not a login redirect.
92 ⛔ The claim goes over the IN-PROCESS client, never the listener: TOFU accepts the first key to
93 prove itself, and on Android every app shares loopback, so a claim made over a port could in
94 principle be raced. Through `dispatch` it happens before the listener exists.
95
96## What is deliberately NOT here
97
98- ⛔ **No hand-written React Native screens.** `favela-android` built 715 lines of them on
99 2026-08-07 and reversed it four days later: *"the native surface looked nothing like favela and
100 could only be brought closer by rebuilding every screen a second time, by hand, to a lower
101 standard"* (`favela-android/spec/architecture.md:3`). `favela-surface` is server-rendered HTML,
102 so a WebView gets every screen it gains for free. That decision is inherited, not re-taken.
103- **Not a pgh app.** The deliverable is an APK, installed by Android's package manager.
104
105## Related
106
107- `favela-peer` — the daemon this embeds
108- `favela-surface` — the pages this renders
109- `favela-android` — where `lib/host/` came from, and the atproto-stack equivalent of this repo
24| version | 0.0.42 |
25| APK | 157 MB, arm64-v8a only, signed |
26| `applicationId` | `to.favela.surface` |
27| `worklet.bundle` | 40 MB |
28| `loader.bundle` | 227 KB |
29
30`applicationId` is deliberately not favela-android's `to.favela.android`: sharing one would make
31installing this app replace the other, and they hold different identities and different storage.
32The gradle `namespace` is still `to.favela.android` — it sets the R class package and manifest
33resolution, never the install identity.
34
35arm64-v8a alone. An APK is a self-contained ZIP carrying every ABI, and measured on favela-android's
36first build 475.8 MB of a 527 MB APK was native libs, ~351 MB of it armeabi-v7a + x86 + x86_64.
37`abiFilters` in `android/app/build.gradle`.
38
39## Build
40
41```sh
42npm run prebuild:android # gen:version, pack:worklet, pack:loader, stamp:worklet, gen:tree, stage:addons
43```
44
45Then gradle `assembleRelease`. Releases are built by the forge on push — `.pear-git/ci.yaml` holds
46the exact invocation, the NDK 27 pin and the signing keystore path. Do not hand-build and upload.
47
48The signing key is durable and kept outside the repo. Android refuses to upgrade an APK signed with
49a different key, and the only way out is uninstall — which deletes app-private storage, meaning the
50node's identity, its device chain and its records.
51
52## Architecture
53
54One process. `favela-surface` reaches `favela-peer` over a unix socket on a desktop and does not
55here: `favela-peer/lib/rpc.js` exports `dispatch(node, cmd, a)` next to `createServer(node, sock)`,
56so `dispatch` is the interface and the socket is one adapter over it. A pathname unix socket cannot
57be created on Android anyway — measured with socat on real hardware, `bind(AF_UNIX /path/x.sock)`
58returns EACCES because SELinux forbids an unprivileged domain creating a `sock_file`. Loopback TCP
59would work and is worse: a socket file carries permissions, a loopback port is reachable by every
60other app on the device.
61
62The HTTP server is a genuine boundary — a WebView is a browser and speaks HTTP. It binds loopback
63only and serves the same pages a desktop gets.
64
65### Two bundles, and which one boots
66
67| file | packed from | entry for | OTA |
68|---|---|---|---|
69| `loader.bundle` | `lib/loader.js` | the APK | no — ships in the APK |
70| `worklet.bundle` | `lib/bootstrap.js` | the staged bundle | yes |
71
72`loader.bundle` is the APK's worklet entry. It preloads any OTA-staged native addons, then
73dynamically imports the chosen bundle. The entry had to move there because preload code placed
74inside the staged bundle is circular: its own import of `bare-fs` resolves to
75`linked:libbare-fs.<staged version>.so`, which may be the very soname that is missing.
76
77`lib/bootstrap.js` is the staged bundle's entry and does the same preload again, so the capability
78reaches a device over the OTA that already works rather than waiting on an APK release. Both imports
79of `./node.js` are dynamic — a static import is hoisted and evaluated before the preload returns,
80which is the ordering the whole mechanism depends on.
81
82### Boot
83
84`lib/node.js` `boot(storage)`, in order:
85
861. `FavelaNode(storage).ready()` — builds the overlay first, because the transport needs it.
872. `SwarmTransport(swarm, { overlay })`, then `node.announceAll()` — one call, not a hand-rolled
88 list; joining by hand forgot the namespace and the rooms.
893. `signIn(node, storage)` — before the listener exists. TOFU claiming over a loopback port could be
90 raced by any other app on the device; claiming through `dispatch` cannot.
914. `serveSurface(node, { port, appUpdate })`, reusing the port from the last run so the WebView URL
92 survives a restart.
935. `send({ t: 'ready' })` — proving it serves, not that the import worked.
946. `bootAtproto(node)`, not awaited. It opens a corestore, a Hyperswarm and a topic per identity, so
95 awaiting it blocked everything below including the OTA state write.
967. `startOta({ storage, apkAddons, currentTreeHash })`.
97
98`inProcessClient`, `serveSurface`, `listenPreferring` and `signIn` live in `favela-host` and are
99re-exported here. They were written in this repo because it was the only place holding both a
100favela-peer node and favela-surface; a desktop had no such place, which is why the phone could mint
101a DID and a desktop could not.
102
103Every log line goes to the console as well as over IPC. A worklet whose UI has not attached yet
104still needs its boot story readable in `adb logcat`. `safeStringify` is used rather than
105`JSON.stringify` because the encode used to be unguarded while the write was guarded — one payload
106holding a Bare timer threw on the boot path and latched an error screen over a node that had started
107cleanly.
108
109### `lib/host/` is copied from favela-android
110
11114 modules — worklet hosting, the IPC contract, OTA, Android lifecycle. They were copied rather than
112extracted because extraction means refactoring a live, working app to consume a package that does not
113exist yet. The cost is stated so it is not discovered: the first Doze or foreground-service fix has
114to land in two places. `lib/host/` is the extraction boundary when anyone has the appetite.
115
116## OTA
117
118`pgh-runtime`, over Hyperswarm from the pgh release registry — not over the forge's HTTPS release
119path, which is load-balanced and replicates between backends asynchronously. A release landed on one
120backend and had not reached the other after 600s on 2026-08-25, and the phone correctly reported no
121update.
122
123CI publishes a two-entry tree: `package.json` plus `worklet.bundle`. The bundle is therefore selected
124**by name** on the phone, never by position — `bundleStore.materialize` keeps the last file entry it
125writes as the bundle to boot.
126
127Native addons ship over OTA too. A `linked:` bundle names the sonames the APK must supply, so for as
128long as only the APK could satisfy them any native change meant a full sideload. `preloadAddons`
129registers staged addons in Bare's `Addon.cache` under `linked:<soname>`, which the `linked:` branch
130of `Addon.resolve` consults before dlopen-by-soname: a staged addon wins, an unstaged one falls
131through to the APK. This works because Android permits dlopen from app-private data at targetSdk 34;
132iOS forbids it.
133
134CI publishes the **full** demanded addon set every release, not the changed ones — a publisher cannot
135know which APK a given device holds, so "only what changed" has no definable baseline. The tree is
136content-addressed, so unchanged bytes are never fetched twice.
137
138`lib/version.js` is generated by `scripts/gen-version.mjs` and committed. The packed bundle carries
139no readable `package.json`, so without it a worklet cannot say which build it is. It also records
140`DEPS` — the versions of the `file:../` siblings this bundle was packed from, because a bundle
141carries whatever was in those working trees at pack time while a desktop runs whatever was last
142published, and the two drift silently.
143
144`scripts/verify-apk-addons.mjs` opens the built APK and walks every shipped library's ELF `NEEDED`
145entries. It runs in CI after assemble and fails the build rather than the device. Every check before
146it inspects a decision — what was staged, what the keep-list kept, what gradle says it excluded — and
147all three reported success for 0.0.32 and 0.0.33, which crash-looped on their own bundle.
148
149## Test
150
151```sh
152npm test # brittle-bare test/*.test.js
153```
154
155122 tests, 390 asserts, 17 files, ~5 s. No emulator: the boot, OTA, loader, packaging and
156addon-preload paths are all covered off-device.
157
158`test/boundary.test.js` and `test/apk-packaging.test.js` are the two that fail for someone else's
159mistake — the first pins what this shell may import, the second what the APK must ship.
160
161## Known gaps
162
163- No `updates.minver`. The addon demand diff catches a genuinely new library and structurally cannot
164 see a new *symbol* in an existing one, so JS that starts calling a function its APK's copy lacks
165 installs and fails at runtime. hyper-social #72 has the design.
166- The APK is fetched as a single unresumable HTTPS GET. `apk.tree.json` is published so a swarm-side
167 fetcher can be written, and nothing consumes it yet; installing one needs `PackageInstaller` and
168 `REQUEST_INSTALL_PACKAGES`.
169- Only arm64-v8a addons are published. A second ABI belongs under `by-arch/` with pgh-runtime's
170 `skipPath`, which `PghRuntime` does not expose yet.
110171
111172## License
112173
113Not yet declared.
174Not yet declared — `package.json` carries no `license` field and there is no LICENSE file.
package.json +1 -1
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-surface-android",
33 "version": "0.0.42",
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.",
4 "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records) with favela-surface rendering its pages in a WebView, and the atproto half composed in through favela-host.",
55 "type": "module",
66 "private": true,
77 "scripts": {