| Build the APK from the flake, and from nothing 6aa2a6b nandi 18d ago | 1 | # The APK, as derivations rather than as a buck2 graph. |
| 2 | # |
| 3 | # android/BUCK builds the same five things, and does it better for a person at |
| Take every jolt-native half from the release, and only from there a008d3b nandi 18d ago | 4 | # a terminal: it is incremental, and it fetches jolt-native's release by digest |
| 5 | # rather than rebuilding the world. Nothing here replaces that. What this adds |
| 6 | # is the other build — from nothing, on a |
| Build the APK from the flake, and from nothing 6aa2a6b nandi 18d ago | 7 | # machine with no Android SDK, no NDK, no hand-built Chez cross target and no |
| 8 | # ~/.cache at all: |
| 9 | # |
| 10 | # nix build .#apk |
| 11 | # |
| 12 | # On a machine with a remote builder configured, prefer |
| 13 | # |
| 14 | # nix build .#apk --store ssh-ng://eu.nixbuild.net --eval-store auto |
| 15 | # |
| 16 | # rather than letting `builders` do it. With `builders`, nix copies the output |
| 17 | # of every remotely-built derivation back, and androidenv's NDK is both |
| 18 | # `preferLocalBuild` and absent from cache.nixos.org — so the 3.1 GB unpacked |
| 19 | # toolchain is built here and uploaded. With the remote as the *store* the |
| 20 | # whole graph stays there, only .drv files go up, and the builder fetches |
| 21 | # Google's zip over its own link. |
| 22 | # |
| 23 | # Every path .buckconfig.local answers for is answered here by the store |
| 24 | # instead. The steps are in the same order and do the same work; where a |
| 25 | # genrule read `read_root_config`, a derivation takes an argument. |
| 26 | { pkgs, lib, self, chez-src, jolt-native, glimmer, joltAndroid, androidSdk, ndk }: |
| 27 | |
| 28 | let |
| 29 | # What the APK targets, in the three spellings the tools want it in. |
| 30 | apiLevel = "28"; |
| 31 | targetSdk = "36"; |
| 32 | abi = "arm64-v8a"; |
| 33 | package = "uk.nandi.frq"; |
| 34 | version = "0.1.0"; |
| 35 | |
| 36 | sdk = "${androidSdk}/libexec/android-sdk"; |
| 37 | buildTools = "${sdk}/build-tools/36.0.0"; |
| 38 | androidJar = "${sdk}/platforms/android-${targetSdk}/android.jar"; |
| 39 | |
| 40 | # The NDK's clang finds its sysroot, resource directory and the rest of LLVM |
| 41 | # relative to itself, so it is named by path rather than copied anywhere. |
| 42 | # androidenv installs the tree under libexec/android-sdk and leaves |
| 43 | # `ndk-bundle` pointing at the versioned directory beside it. |
| 44 | ndkRoot = "${ndk}/libexec/android-sdk/ndk-bundle"; |
| 45 | ndkBin = "${ndkRoot}/toolchains/llvm/prebuilt/linux-x86_64/bin"; |
| 46 | cc = "${ndkBin}/aarch64-linux-android${apiLevel}-clang"; |
| 47 | |
| 48 | # The two halves that come out of jolt-native's releases, by the digests |
| 49 | # scripts/*.dotslash pins. Same bytes buck fetches; DotSlash's `digest` is |
| 50 | # over the archive, which is what fetchurl hashes too. |
| 51 | libvidya = pkgs.fetchurl { |
| 52 | url = "https://gitlab.com/-/project/85910092/uploads/a7264f20582e6d42d45b17fe626ba471/jolt-native-android-arm64-v0.1.1.tar.gz"; |
| 53 | sha256 = "bb85c57ea263b9bfe113927c6f6b0c8c6414b97056bcb0281b8444187616a233"; |
| 54 | }; |
| 55 | |
| 56 | glue = pkgs.fetchurl { |
| 57 | url = "https://gitlab.com/-/project/85910092/uploads/16b1a3ea32dac6737fc21aec701d7b0c/jolt-native-android-glue-v0.1.1.tar.gz"; |
| 58 | sha256 = "7f4c179d72a3660ce8e80c3cf52a788ea33d7ef97d6f624310e61e7f4f98851b"; |
| 59 | }; |
| 60 | |
| 61 | # Unpacked once, so the three consumers below name files rather than repeat |
| 62 | # the tar. |
| 63 | vidyaLib = pkgs.runCommand "libvidya-android" { } '' |
| 64 | mkdir -p "$out" |
| 65 | tar -xzf ${libvidya} -C "$out" |
| 66 | ''; |
| 67 | |
| 68 | glueSrc = pkgs.runCommand "jolt-android-glue" { } '' |
| 69 | mkdir -p "$out" |
| 70 | tar -xzf ${glue} -C "$out" --strip-components=1 |
| 71 | ''; |
| 72 | |
| 73 | # --- Chez's arm64 cross target ------------------------------------------ |
| 74 | # The one piece with no nixpkgs equivalent: `pkgs.chez` builds a Scheme for |
| 75 | # this machine, and what the boot image needs is Chez's `tarm64le` workarea — |
| 76 | # the target boot files, the cross compiler's xpatch, and the arm64 |
| 77 | # libkernel.a that libjoltapp links. |
| 78 | # |
| 79 | # Three builds in one derivation, because each needs the one before it: |
| 80 | # |
| 81 | # ta6le a host Scheme, which is what cross-compiles anything |
| 82 | # boot XM=... the target's boot files and xpatch, made by that host |
| 83 | # tarm64le the target kernel, compiled by the NDK |
| 84 | # |
| 85 | # The flags are the ones the hand-built tree under ~/.cache/vidya-chez-android |
| 86 | # was configured with, read back out of its Mf-config. zlib is Android's own |
| 87 | # (`-lz`, which Bionic has); lz4 is the in-tree submodule, built for arm64 |
| 88 | # here because a cross configure links it rather than building it. |
| 89 | chezAndroid = pkgs.stdenv.mkDerivation { |
| 90 | pname = "chez-scheme-android"; |
| 91 | version = "10.4.1"; |
| 92 | src = chez-src; |
| 93 | |
| 94 | strictDeps = true; |
| 95 | nativeBuildInputs = with pkgs; [ gnumake which ]; |
| 96 | |
| 97 | dontConfigure = true; |
| 98 | |
| 99 | buildPhase = '' |
| 100 | runHook preBuild |
| 101 | |
| 102 | # Both workareas turn off the expression editor's two dependencies, which |
| 103 | # is what ~/.cache/vidya-chez-android was configured with (its Mf-config |
| 104 | # has empty cursesLib/ncursesLib, and disablex11=yes on the target). The |
| 105 | # host Scheme here is only ever a cross compiler, and Bionic has no |
| 106 | # curses.h at all — so on the target it is not a preference but a |
| 107 | # requirement. |
| 108 | ./configure -m=ta6le --disable-x11 --disable-curses CC_FOR_BUILD="$CC" |
| 109 | make -j"$NIX_BUILD_CORES" |
| 110 | make boot XM=tarm64le -j"$NIX_BUILD_CORES" |
| 111 | |
| 112 | # lz4 for the phone, not for this machine: the host build above left an |
| 113 | # x86_64 liblz4.a in the same place, and the cross link needs it gone. |
| 114 | make -C lz4/lib clean |
| 115 | make -C lz4/lib liblz4.a -j"$NIX_BUILD_CORES" \ |
| 116 | CC=${cc} AR=${ndkBin}/llvm-ar |
| 117 | |
| 118 | # --disable-auto-flags stops configure appending -lrt and -lpthread, which |
| 119 | # is what its unix branch does for a glibc host and what Bionic has no |
| 120 | # separate libraries for — both live in libc there. Everything it would |
| 121 | # otherwise add is passed explicitly below, matching the Mf-config of |
| 122 | # the tree this was reconstructed from. |
| 123 | ./configure -m=tarm64le --cross --disable-x11 --disable-curses \ |
| 124 | --disable-auto-flags \ |
| 125 | LIBS="-ldl -lm" \ |
| 126 | CC=${cc} \ |
| 127 | AR=${ndkBin}/llvm-ar \ |
| 128 | CC_FOR_BUILD="$CC" \ |
| 129 | ZLIB=-lz \ |
| 130 | LZ4="$PWD/lz4/lib/liblz4.a" \ |
| 131 | CPPFLAGS="-I$PWD/lz4/lib" \ |
| 132 | CFLAGS="-O2 -D_REENTRANT -pthread -fPIC" |
| 133 | make -j"$NIX_BUILD_CORES" |
| 134 | |
| 135 | runHook postBuild |
| 136 | ''; |
| 137 | |
| 138 | # The whole workarea, at the paths CHEZ_ANDROID means: the host Scheme |
| 139 | # loads xpatch out of xc-tarm64le/s, and the link below reads two archives |
| 140 | # from elsewhere in the tree. Pruning it would only be guessing at which |
| 141 | # of those the cross compiler still opens. |
| 142 | installPhase = '' |
| 143 | runHook preInstall |
| 144 | mkdir -p "$out" |
| 145 | cp -r . "$out/" |
| 146 | runHook postInstall |
| 147 | ''; |
| 148 | |
| 149 | # A Scheme built for this machine and a kernel built for another one; the |
| 150 | # usual fixups have an opinion about both, and neither wants it. |
| 151 | dontStrip = true; |
| 152 | dontPatchELF = true; |
| 153 | }; |
| 154 | |
| 155 | hostScheme = "${chezAndroid}/ta6le/bin/ta6le/scheme"; |
| 156 | targetBoot = "${chezAndroid}/boot/tarm64le"; |
| 157 | xpatch = "${chezAndroid}/xc-tarm64le/s/xpatch"; |
| 158 | |
| 159 | # --- the Jolt half -------------------------------------------------------- |
| 160 | # frq's Scheme, cross-compiled to an arm64 boot image. android/build-jolt-boot.bb |
| 161 | # does this by hand-writing a deps.edn of :paths and then driving Chez; so |
| 162 | # does this, for the same reason — there is no dependency resolution inside a |
| 163 | # cross compile, so every source root deps.edn would have resolved is named. |
| 164 | # |
| 165 | # The jolt that runs it is the fork, not upstream and not the one the desktop |
| 166 | # package builds: upstream reads the socket address out of `struct addrinfo` |
| 167 | # at glibc's offset, which on Bionic is `ai_canonname`, and an APK built with |
| 168 | # it cannot open a TLS connection at all. |
| 169 | joltBoot = pkgs.stdenv.mkDerivation { |
| 170 | pname = "frq-jolt-boot"; |
| 171 | inherit version; |
| 172 | |
| 173 | dontUnpack = true; |
| 174 | strictDeps = true; |
| 175 | nativeBuildInputs = [ joltAndroid ]; |
| 176 | |
| 177 | buildPhase = '' |
| 178 | runHook preBuild |
| 179 | |
| 180 | export HOME="$TMPDIR" |
| 181 | mkdir -p project cross |
| 182 | |
| 183 | cat > project/deps.edn <<EOF |
| 184 | {:paths ["${self}/src" "${glimmer}/src" "${jolt-native}/jolt/glimmer-vidya/src"]} |
| 185 | EOF |
| 186 | |
| 187 | # The flat build, which is the one shape make-boot-file can take. |
| 188 | ( cd project && JOLT_NO_FLAT_SPLIT=1 jolt build -m frq.app -o app ) |
| 189 | |
| 190 | cat > cross/compile.ss <<EOF |
| 191 | (import (chezscheme)) |
| 192 | (load "${xpatch}") |
| 193 | (optimize-level 2) |
| 194 | (generate-inspector-information #f) |
| 195 | (compile-file "$PWD/project/app.build/flat.ss" "$PWD/cross/flat.so") |
| 196 | (make-boot-file "$PWD/jolt.boot" '() |
| 197 | "${targetBoot}/petite.boot" |
| 198 | "${targetBoot}/scheme.boot" |
| 199 | "$PWD/cross/flat.so") |
| 200 | EOF |
| 201 | |
| 202 | SCHEMEHEAPDIRS="${chezAndroid}/ta6le/boot/ta6le" \ |
| 203 | ${hostScheme} --script cross/compile.ss |
| 204 | |
| 205 | runHook postBuild |
| 206 | ''; |
| 207 | |
| 208 | # scheme.h travels with the image because jolt_main.c includes it. |
| 209 | installPhase = '' |
| 210 | runHook preInstall |
| 211 | mkdir -p "$out" |
| 212 | cp jolt.boot "$out/jolt.boot" |
| 213 | cp ${targetBoot}/scheme.h "$out/scheme.h" |
| 214 | runHook postInstall |
| 215 | ''; |
| 216 | }; |
| 217 | |
| 218 | # The image travels as a blob in an object file's data section. The |
| 219 | # _binary_jolt_boot_{start,end} symbols jolt_main.c reads are named after the |
| 220 | # input *path*, so this copies the file somewhere it is called exactly |
| 221 | # `jolt.boot` before converting it. |
| 222 | joltBootObj = pkgs.runCommand "jolt-boot-obj" { } '' |
| 223 | cp ${joltBoot}/jolt.boot jolt.boot |
| 224 | ${ndkBin}/llvm-objcopy \ |
| 225 | --input-target=binary --output-target=elf64-littleaarch64 \ |
| 226 | --binary-architecture=aarch64 jolt.boot "$out" |
| 227 | ''; |
| 228 | |
| 229 | # The glue: jolt-native's jolt_main.c over the boot image, linked against |
| 230 | # libvidya by name. --no-undefined is what makes a symbol the Scheme side |
| 231 | # registers but the ABI no longer exports a build failure here rather than a |
| 232 | # crash on the phone. |
| 233 | libjoltapp = pkgs.runCommand "libjoltapp.so" { } '' |
| 234 | mkdir -p lib |
| 235 | cp ${vidyaLib}/libvidya.so lib/libvidya.so |
| 236 | |
| 237 | ${cc} -shared -fPIC -O2 -o "$out" \ |
| 238 | ${glueSrc}/android/jolt_main.c \ |
| 239 | ${joltBootObj} \ |
| 240 | -I${joltBoot} \ |
| 241 | -I${glueSrc}/include \ |
| 242 | -Llib \ |
| 243 | ${chezAndroid}/tarm64le/boot/tarm64le/libkernel.a \ |
| 244 | ${chezAndroid}/lz4/lib/liblz4.a \ |
| 245 | -lvidya -landroid -llog -lz -ldl -lm -Wl,--no-undefined |
| 246 | ''; |
| 247 | |
| 248 | # --- the Java half -------------------------------------------------------- |
| 249 | # One class: the photo chooser's result has to land somewhere, and native |
| 250 | # code is not somewhere. |
| 251 | classesDex = pkgs.runCommand "classes.dex" |
| 252 | { |
| 253 | nativeBuildInputs = [ pkgs.jdk17 ]; |
| 254 | } '' |
| 255 | mkdir -p classes out |
| 256 | # -encoding, because a build sandbox has no locale and javac then reads |
| 257 | # the source as US-ASCII — on which the comments' em dashes are errors. |
| 258 | javac --release 17 -encoding UTF-8 --class-path ${androidJar} -d classes \ |
| 259 | $(find ${self}/android/java -name '*.java') |
| 260 | ${buildTools}/d8 --min-api ${apiLevel} --output out $(find classes -name '*.class') |
| 261 | cp out/classes.dex "$out" |
| 262 | ''; |
| 263 | |
| 264 | # --- the package ---------------------------------------------------------- |
| 265 | # OpenSSL travels with the app because the platform's own is not ours to |
| 266 | # load: an app's linker namespace refuses /system/lib64/libssl.so, and |
| 267 | # without one there is no TLS on the phone at all. |
| 268 | # Built by the NDK rather than by pkgsCross.aarch64-android: that cross |
| 269 | # stdenv cannot build its own compiler-rt on this nixpkgs — os_version_check.c |
| 270 | # includes <pthread.h> and the sysroot it is handed has no such header — and |
| 271 | # an APK has no use for a second toolchain anyway. OpenSSL's own android-arm64 |
| 272 | # target wants the NDK's llvm on PATH and takes the API level from the flag. |
| 273 | # The version is the one ~/.cache/frq-openssl-android was built from. |
| 274 | opensslAndroid = pkgs.stdenv.mkDerivation { |
| 275 | pname = "openssl-android"; |
| 276 | version = "3.5.4"; |
| 277 | |
| 278 | src = pkgs.fetchurl { |
| 279 | url = "https://github.com/openssl/openssl/releases/download/openssl-3.5.4/openssl-3.5.4.tar.gz"; |
| 280 | sha256 = "16ay6ppxsky3qhg6573370iz93kihfwx9n5ipmlnjcam97w12wwn"; |
| 281 | }; |
| 282 | |
| 283 | strictDeps = true; |
| 284 | nativeBuildInputs = with pkgs; [ perl ]; |
| 285 | |
| 286 | configurePhase = '' |
| 287 | runHook preConfigure |
| 288 | export ANDROID_NDK_ROOT="${ndkRoot}" |
| 289 | export PATH="${ndkBin}:$PATH" |
| 290 | # Through perl rather than as a program: its shebang is /usr/bin/env, |
| 291 | # which a build sandbox does not have. |
| 292 | perl ./Configure android-arm64 -D__ANDROID_API__=${apiLevel} \ |
| 293 | shared no-tests no-docs \ |
| 294 | --prefix="$out" --openssldir="$out/etc/ssl" |
| 295 | runHook postConfigure |
| 296 | ''; |
| 297 | |
| 298 | # install_sw, not install: the rest of an OpenSSL install is for a machine |
| 299 | # that runs it, and this one only ships two .so files into an APK. |
| 300 | installTargets = [ "install_sw" ]; |
| 301 | |
| 302 | dontStrip = true; |
| 303 | dontPatchELF = true; |
| 304 | }; |
| 305 | |
| 306 | # The libraries are stored rather than deflated: the loader maps them |
| 307 | # straight out of the APK. The dex is read rather than mapped, so it may as |
| 308 | # well compress. |
| 309 | apkUnsigned = pkgs.runCommand "frq-unsigned.apk" |
| 310 | { |
| 311 | nativeBuildInputs = [ pkgs.zip ]; |
| 312 | } '' |
| 313 | mkdir -p stage/lib/${abi} |
| 314 | cp ${vidyaLib}/libvidya.so stage/lib/${abi}/libvidya.so |
| 315 | cp ${libjoltapp} stage/lib/${abi}/libjoltapp.so |
| 316 | cp ${opensslAndroid.out}/lib/libssl.so stage/lib/${abi}/libssl.so |
| 317 | cp ${opensslAndroid.out}/lib/libcrypto.so stage/lib/${abi}/libcrypto.so |
| 318 | cp ${classesDex} stage/classes.dex |
| 319 | chmod -R u+w stage |
| 320 | |
| 321 | ${buildTools}/aapt2 link -o "$out" -I ${androidJar} \ |
| 322 | --manifest ${self}/android/AndroidManifest.xml \ |
| 323 | --min-sdk-version ${apiLevel} --target-sdk-version ${targetSdk} \ |
| 324 | --version-code 1 --version-name ${version} |
| 325 | |
| 326 | ( cd stage && \ |
| 327 | zip -q -0 "$out" lib/${abi}/libvidya.so lib/${abi}/libjoltapp.so \ |
| 328 | lib/${abi}/libssl.so lib/${abi}/libcrypto.so && \ |
| 329 | zip -q "$out" classes.dex ) |
| 330 | ''; |
| 331 | |
| 332 | # Aligned and signed with a debug key. The key is generated here rather than |
| 333 | # read from ~/.android, which is the one place this build is deliberately |
| 334 | # not the buck one: a keystore outside the store would make the output |
| 335 | # depend on the machine, and a release key has no business in the store at |
| 336 | # all. So this output is installable and not reproducible — keytool stamps |
| 337 | # the certificate with the time — and anything meant for a store should be |
| 338 | # signed from .#apk-unsigned instead. |
| 339 | apk = pkgs.runCommand "frq-${version}.apk" |
| 340 | { |
| 341 | nativeBuildInputs = [ pkgs.jdk17 ]; |
| 342 | meta = { |
| 343 | description = "frq for Android, debug-signed"; |
| 344 | platforms = [ "x86_64-linux" ]; |
| 345 | }; |
| 346 | } '' |
| 347 | export HOME="$TMPDIR" |
| 348 | keytool -genkeypair -keystore debug.keystore \ |
| 349 | -storepass android -keypass android -alias androiddebugkey \ |
| 350 | -keyalg RSA -keysize 2048 -validity 10000 \ |
| 351 | -dname 'CN=Android Debug,O=Android,C=US' |
| 352 | |
| 353 | ${buildTools}/zipalign -f -p 4 ${apkUnsigned} aligned.apk |
| 354 | ${buildTools}/apksigner sign --ks debug.keystore \ |
| 355 | --ks-key-alias androiddebugkey \ |
| 356 | --ks-pass pass:android --key-pass pass:android \ |
| 357 | --out "$out" aligned.apk |
| 358 | ${buildTools}/apksigner verify "$out" |
| 359 | ''; |
| 360 | in |
| 361 | { |
| 362 | inherit chezAndroid joltBoot libjoltapp classesDex apk; |
| 363 | apk-unsigned = apkUnsigned; |
| 364 | } |