| … | … | @@ -1,113 +1,174 @@ |
| 1 | 1 | # favela-surface-android |
| 2 | 2 | |
| 3 | | A **full favela node on a phone**, for the markdown-record stack: `favela-peer` in a |
| 3 | A full favela node on an Android handset: `favela-peer` inside a |
| 4 | 4 | [bare-kit](https://github.com/holepunchto/bare-kit) worklet, with `favela-surface` rendering its |
| 5 | | pages into a WebView. No atproto, no DIDs, no bridge. |
| 5 | pages into a WebView, and the atproto half composed in through `favela-host`. |
| 6 | 6 | |
| 7 | | ## Status — honest |
| 7 | ## Install |
| 8 | 8 | |
| 9 | | **Not built into an APK yet — everything up to that point is done and tested.** The worklet boots, |
| 10 | | signs in to its own node and serves real pages; the RN entry is wired; the worklet bundle packs. |
| 11 | | All of it verified on a laptop, because there is no Android SDK on the machine this was written on. |
| 9 | Sideload 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: |
| 12 | 11 | |
| 13 | | What remains is a gradle build on a machine that has one, and then a handset. |
| 12 | ``` |
| 13 | https://7rnx.net/git/releases/favela-surface-android/<version>/favela-surface-android.apk |
| 14 | ``` |
| 15 | |
| 16 | Requires Android 10 — `android/build.gradle` sets `minSdkVersion = 29`, matching what |
| 17 | react-native-bare-kit 0.14.5 declares. Sideloading needs "install unknown apps" for your browser. |
| 18 | |
| 19 | This is not a pgh app; the deliverable is an APK installed by Android's package manager. The |
| 20 | worklet bundle inside it *is* published to the pgh release registry — see [OTA](#ota). |
| 14 | 21 | |
| 15 | 22 | | | | |
| 16 | 23 | |---|---| |
| 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 |
| 23 | | different identities and different storage. `app_name` is "favela (surface)" for the same reason — |
| 24 | | two 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, |
| 27 | | never the install identity, and renaming the Kotlin package across five files is a change this |
| 28 | | machine cannot compile. An unverifiable rename is how a build breaks silently. What is here is the half of `favela-android` that carries no |
| 29 | | atproto dependency, copied verbatim, plus the native Android project. Everything that boots a node |
| 30 | | or onboards an identity still has to be written. |
| 31 | | |
| 32 | | Nothing 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 |
| 37 | | 2026-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 | | |
| 53 | | The audit judged coupling by IMPORTS, and by imports those eight files are clean. That was true and |
| 54 | | insufficient: 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 |
| 56 | | import 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 |
| 60 | | by 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 | | |
| 64 | | Those eight modules are byte-identical to `favela-android`'s today. They are the expensive, |
| 65 | | fiddly part — worklet hosting, the IPC contract, OTA, Android lifecycle — and neither app is |
| 66 | | better for owning a private version of them. |
| 67 | | |
| 68 | | They were copied rather than extracted because extraction means refactoring a **live, working** |
| 69 | | app to consume a package that does not exist yet, which is risk taken against something that |
| 70 | | currently 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 |
| 73 | | land in two places. When that happens — or sooner, if anyone has the appetite — `lib/host/` is |
| 74 | | the 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 |
| 31 | installing this app replace the other, and they hold different identities and different storage. |
| 32 | The gradle `namespace` is still `to.favela.android` — it sets the R class package and manifest |
| 33 | resolution, never the install identity. |
| 34 | |
| 35 | arm64-v8a alone. An APK is a self-contained ZIP carrying every ABI, and measured on favela-android's |
| 36 | first 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 |
| 42 | npm run prebuild:android # gen:version, pack:worklet, pack:loader, stamp:worklet, gen:tree, stage:addons |
| 43 | ``` |
| 44 | |
| 45 | Then gradle `assembleRelease`. Releases are built by the forge on push — `.pear-git/ci.yaml` holds |
| 46 | the exact invocation, the NDK 27 pin and the signing keystore path. Do not hand-build and upload. |
| 47 | |
| 48 | The signing key is durable and kept outside the repo. Android refuses to upgrade an APK signed with |
| 49 | a different key, and the only way out is uninstall — which deletes app-private storage, meaning the |
| 50 | node's identity, its device chain and its records. |
| 51 | |
| 52 | ## Architecture |
| 53 | |
| 54 | One process. `favela-surface` reaches `favela-peer` over a unix socket on a desktop and does not |
| 55 | here: `favela-peer/lib/rpc.js` exports `dispatch(node, cmd, a)` next to `createServer(node, sock)`, |
| 56 | so `dispatch` is the interface and the socket is one adapter over it. A pathname unix socket cannot |
| 57 | be created on Android anyway — measured with socat on real hardware, `bind(AF_UNIX /path/x.sock)` |
| 58 | returns EACCES because SELinux forbids an unprivileged domain creating a `sock_file`. Loopback TCP |
| 59 | would work and is worse: a socket file carries permissions, a loopback port is reachable by every |
| 60 | other app on the device. |
| 61 | |
| 62 | The HTTP server is a genuine boundary — a WebView is a browser and speaks HTTP. It binds loopback |
| 63 | only 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 |
| 73 | dynamically imports the chosen bundle. The entry had to move there because preload code placed |
| 74 | inside 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 |
| 78 | reaches a device over the OTA that already works rather than waiting on an APK release. Both imports |
| 79 | of `./node.js` are dynamic — a static import is hoisted and evaluated before the preload returns, |
| 80 | which is the ordering the whole mechanism depends on. |
| 81 | |
| 82 | ### Boot |
| 83 | |
| 84 | `lib/node.js` `boot(storage)`, in order: |
| 85 | |
| 86 | 1. `FavelaNode(storage).ready()` — builds the overlay first, because the transport needs it. |
| 87 | 2. `SwarmTransport(swarm, { overlay })`, then `node.announceAll()` — one call, not a hand-rolled |
| 88 | list; joining by hand forgot the namespace and the rooms. |
| 89 | 3. `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. |
| 91 | 4. `serveSurface(node, { port, appUpdate })`, reusing the port from the last run so the WebView URL |
| 92 | survives a restart. |
| 93 | 5. `send({ t: 'ready' })` — proving it serves, not that the import worked. |
| 94 | 6. `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. |
| 96 | 7. `startOta({ storage, apkAddons, currentTreeHash })`. |
| 97 | |
| 98 | `inProcessClient`, `serveSurface`, `listenPreferring` and `signIn` live in `favela-host` and are |
| 99 | re-exported here. They were written in this repo because it was the only place holding both a |
| 100 | favela-peer node and favela-surface; a desktop had no such place, which is why the phone could mint |
| 101 | a DID and a desktop could not. |
| 102 | |
| 103 | Every log line goes to the console as well as over IPC. A worklet whose UI has not attached yet |
| 104 | still 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 |
| 106 | holding a Bare timer threw on the boot path and latched an error screen over a node that had started |
| 107 | cleanly. |
| 108 | |
| 109 | ### `lib/host/` is copied from favela-android |
| 110 | |
| 111 | 14 modules — worklet hosting, the IPC contract, OTA, Android lifecycle. They were copied rather than |
| 112 | extracted because extraction means refactoring a live, working app to consume a package that does not |
| 113 | exist yet. The cost is stated so it is not discovered: the first Doze or foreground-service fix has |
| 114 | to 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 |
| 119 | path, which is load-balanced and replicates between backends asynchronously. A release landed on one |
| 120 | backend and had not reached the other after 600s on 2026-08-25, and the phone correctly reported no |
| 121 | update. |
| 122 | |
| 123 | CI 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 |
| 125 | writes as the bundle to boot. |
| 126 | |
| 127 | Native addons ship over OTA too. A `linked:` bundle names the sonames the APK must supply, so for as |
| 128 | long as only the APK could satisfy them any native change meant a full sideload. `preloadAddons` |
| 129 | registers staged addons in Bare's `Addon.cache` under `linked:<soname>`, which the `linked:` branch |
| 130 | of `Addon.resolve` consults before dlopen-by-soname: a staged addon wins, an unstaged one falls |
| 131 | through to the APK. This works because Android permits dlopen from app-private data at targetSdk 34; |
| 132 | iOS forbids it. |
| 133 | |
| 134 | CI publishes the **full** demanded addon set every release, not the changed ones — a publisher cannot |
| 135 | know which APK a given device holds, so "only what changed" has no definable baseline. The tree is |
| 136 | content-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 |
| 139 | no 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 |
| 141 | carries whatever was in those working trees at pack time while a desktop runs whatever was last |
| 142 | published, and the two drift silently. |
| 143 | |
| 144 | `scripts/verify-apk-addons.mjs` opens the built APK and walks every shipped library's ELF `NEEDED` |
| 145 | entries. It runs in CI after assemble and fails the build rather than the device. Every check before |
| 146 | it inspects a decision — what was staged, what the keep-list kept, what gradle says it excluded — and |
| 147 | all three reported success for 0.0.32 and 0.0.33, which crash-looped on their own bundle. |
| 148 | |
| 149 | ## Test |
| 150 | |
| 151 | ```sh |
| 152 | npm test # brittle-bare test/*.test.js |
| 153 | ``` |
| 154 | |
| 155 | 122 tests, 390 asserts, 17 files, ~5 s. No emulator: the boot, OTA, loader, packaging and |
| 156 | addon-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 |
| 159 | mistake — 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. |
| 110 | 171 | |
| 111 | 172 | ## License |
| 112 | 173 | |
| 113 | | Not yet declared. |
| 174 | Not yet declared — `package.json` carries no `license` field and there is no LICENSE file. |