apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
// The one version number this app has. See the note in defaultConfig.
def favelaPkg = new groovy.json.JsonSlurper().parseText(file("../../package.json").getText("UTF-8"))
def favelaVersion = favelaPkg.version
def favelaParts = favelaVersion.tokenize(".")
def favelaVersionCode = favelaParts[0].toInteger() * 10000 + favelaParts[1].toInteger() * 100 + favelaParts[2].toInteger()
// Native addons the worklet bundle does NOT demand, computed at configure time.
//
// bare-link (which bare-kit runs from its own `preBuild.dependsOn link`) walks the PACKAGE graph and
// stages every dependency whose package.json says `addon: true`. bare-pack resolves the MODULE graph
// reachable from lib/node.js. The difference is real: 61 staged vs 40 demanded on 0.0.1 — 21 libs and
// 50 MB of arm64, including four stale rocksdb-native versions (3.15.0/3.15.2/3.16.0/3.17.1) reached
// only through the symlinked sibling checkouts, each carrying its own installed node_modules.
//
// ⛔ THIS MUST BE A PACKAGING EXCLUDE, NOT A PRUNE. Deleting the extra files before the build does
// nothing — bare-kit's link task restages all four hosts on every build, so a pruned tree produces a
// BYTE-IDENTICAL APK (measured). Packaging is the one point downstream of restaging.
//
// The bundle header is the source of truth, so this cannot go stale as dependencies move: it names
// exactly what bare-kit will dlopen. We only ever exclude libs absent from that list, so this can
// shrink the APK but never break a load. Requires `npm run prebuild:android` first (it packs the
// bundle and populates the addon dir this enumerates).
def computeUndemandedAddons() {
def worklet = file("src/main/assets/worklet.bundle")
def addonsDir = file("../../node_modules/react-native-bare-kit/android/src/main/addons")
if (!worklet.exists()) {
logger.warn("WARNING: no worklet.bundle — run `npm run prebuild:android`. " +
"Packaging every staged addon; the APK will be much larger than it needs to be.")
return []
}
if (!addonsDir.exists()) return []
def raf = new RandomAccessFile(worklet, "r")
try {
// bare-bundle layout: ASCII decimal header length, "\n", then that many bytes of JSON.
def digits = new StringBuilder()
int c
while ((c = raf.read()) != -1 && c != 10) digits.append((char) c)
byte[] header = new byte[digits.toString().trim() as int]
raf.readFully(header)
def parsed = new groovy.json.JsonSlurper().parseText(new String(header, "UTF-8").trim())
def demanded = parsed.addons.collect { it.replaceFirst(/^linked:/, "") } as Set
def excess = [] as Set
addonsDir.eachDir { abi ->
abi.eachFileMatch(~/.*\.so/) { so ->
// ABI-qualified for the same reason as the keep-list branch above, so both branches hand
// `jniLibs.excludes` the same shape and it never has to know which one it got.
if (!demanded.contains(so.name)) excess << ("**/" + abi.name + "/" + so.name)
}
}
logger.lifecycle("addons: ${demanded.size()} demanded by bundle, ${excess.size()} excluded")
return excess.toList().sort()
} finally {
raf.close()
}
}
// ⛔ PREFER THE KEEP-LIST. computeUndemandedAddons() below reads the bundle header, which names only
// what the JS MODULE graph imports. A demanded addon can LINK against another .so that no JS module
// imports — libbare-fs.4.7.3.so needs libbare-buffer.3.6.1.so — and excluding it produced a load
// failure that surfaced as ADDON_NOT_FOUND (resolution succeeded; dlopen did not). scripts/stage-addons.mjs
// computes the transitive ELF NEEDED closure and writes addon-keep.json; when it is present it wins.
// Its absence falls back to the header-only computation, which is better than nothing but is the thing
// that broke pairing.
def keepFile = file("addon-keep.json")
if (keepFile.exists()) {
def keep = new groovy.json.JsonSlurper().parseText(keepFile.text)
def addonsDir = file("../../node_modules/react-native-bare-kit/android/src/main/addons")
def excess = [] as Set
if (addonsDir.exists()) {
addonsDir.eachDir { abi ->
def keepSet = (keep[abi.name] ?: []) as Set
// ⛔ QUALIFIED BY ABI, AND THIS IS THE WHOLE BUG. `excess` used to collect bare FILE NAMES
// across all four staged ABIs, and the exclude below wrote them as `**/<name>` — a pattern
// that matches every ABI. The keep-list is computed PER ABI (a library's ELF NEEDED closure
// differs between them), so a lib arm64 keeps and x86 does not was excluded from arm64 too.
//
// Measured on 0.0.33: arm64 kept libbare-os.3.8.7.so and libbare-url.2.4.1.so as native
// dependencies, the other three ABIs did not, and the APK shipped neither — so
// libbare-signals.4.2.0.so and libbare-fs.4.7.2.so could not dlopen. The app crash-looped
// on its own bundle, reporting ADDON_NOT_FOUND against bare-signals, which was present.
abi.eachFileMatch(~/.*\.so/) { so -> if (!keepSet.contains(so.name)) excess << ("**/" + abi.name + "/" + so.name) }
}
}
logger.lifecycle("addons: keep-list ${keep.values().collect { it.size() }.max() ?: 0} kept, ${excess.size()} excluded (native closure included)")
ext.undemandedAddons = excess.toList().sort()
} else {
ext.undemandedAddons = computeUndemandedAddons()
}
react {
// Metro bundles app/index.js; bare-pack separately bundles the worklet into app/worklet.bundle,
// which Metro then picks up as an asset (.bundle is an assetExt) and ships as res/*.bundle.
// TWO BUNDLERS, TWO ARTIFACTS — see spec/architecture.md §6.2. They are not interchangeable and
// only the worklet bundle is covered by pgh-runtime OTA.
root = file("../../")
entryFile = file("../../app/index.js")
autolinkLibrariesWithApp()
}
android {
namespace "to.favela.android"
compileSdk rootProject.ext.compileSdkVersion
ndkVersion rootProject.ext.ndkVersion
defaultConfig {
// ⛔ DIFFERENT from favela-android's `to.favela.android`. The applicationId is the install
// identity: sharing one would make installing this app REPLACE the other, and they hold
// different identities, different storage and different stacks. They must coexist on a
// handset, at least until the atproto app is retired.
//
// ⚠ `namespace` above is deliberately left alone. It only sets the R class package and
// manifest resolution, not the install identity, and renaming the Kotlin package across
// five files is a change this machine cannot compile — there is no Android SDK here. An
// unverifiable rename is how a build breaks silently.
applicationId "to.favela.surface"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
// ⛔ READ FROM package.json, NEVER TYPED HERE. These were hardcoded 2 / "0.0.1" and never
// moved, so Android's own app info reported 0.0.1 for every build ever shipped — and
// hyper-social #44 records the cost the last time: an APK was "confirmed" installed by a
// version string that could not change, so md5 against the artifact was the only check that
// meant anything. A version that cannot move is worse than none, because it is believed.
//
// package.json is already the single source: gen-version.mjs stamps the same field into the
// worklet bundle, so the in-app "Running x.y.z" and the launcher's version now come from one
// number and cannot drift.
versionName favelaVersion
// ⚠ Android requires a monotonically increasing INTEGER, so the semver is packed into one:
// major*10000 + minor*100 + patch. 0.0.8 -> 8, 0.1.0 -> 100, 1.0.0 -> 10000. Never decreases
// while the version only goes up, which is the property Android actually enforces on install.
versionCode favelaVersionCode
ndk {
// ⛔ ARM64 ONLY. An APK is a self-contained sideloadable ZIP with no notion of "the arch I
// need" — it carries every ABI under lib/ and the installer picks one at install time. The
// splitting lives in the STORE (Play generates per-device APKs from an AAB), and we
// distribute a direct .apk off the forge, so nothing splits for us. Without this filter the
// build packages all four: measured on 0.0.1, 475.8 MB of a 527 MB APK was native libs, of
// which ~351 MB was armeabi-v7a + x86 + x86_64. Every user downloads and stores all four to
// run one.
//
// Nothing else is worth carrying: minSdk is 31, so a 32-bit-only armeabi-v7a device is
// effectively nonexistent, and x86/x86_64 are emulators and Chromebooks, not phones. To
// build for an emulator (spike 3/5), override on the command line rather than editing this:
// ./gradlew assembleRelease -PreactNativeArchitectures=x86_64 (and drop this filter)
abiFilters "arm64-v8a"
}
}
signingConfigs {
// ⛔ A DURABLE KEY, NOT A PER-BUILD DEBUG KEY. Android refuses to UPGRADE an APK signed with a
// different key; the only way out is uninstall, which deletes app-private storage — i.e. the
// node's identity, its device chain and its repo. A throwaway debug key would make every
// rebuild a data-loss event. Generated once by build.sh and kept.
release {
storeFile file(System.getenv("FAVELA_KEYSTORE") ?: "${System.getenv('HOME')}/android-toolchain/favela-spike.keystore")
storePassword System.getenv("FAVELA_KEYSTORE_PASS") ?: "favela"
keyAlias System.getenv("FAVELA_KEY_ALIAS") ?: "favela"
keyPassword System.getenv("FAVELA_KEY_PASS") ?: "favela"
}
}
buildTypes {
debug { signingConfig signingConfigs.release }
release {
signingConfig signingConfigs.release
minifyEnabled false
}
}
packagingOptions {
// bare-kit and our addons both ship libc++_shared; first one wins rather than failing the build.
pickFirst "**/libc++_shared.so"
jniLibs {
// Already full `**/<abi>/<name>` patterns — see the note where they are built. Re-prefixing
// them here is what produced a cross-ABI exclude.
excludes += undemandedAddons
}
}
}
dependencies {
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:hermes-android")
}