From 7a5fc272f325dc6d4f4dd47f68ba1ab79c2783d7 Mon Sep 17 00:00:00 2001 From: jappeace-sloth Date: Mon, 6 Apr 2026 21:32:40 +0000 Subject: [PATCH 1/4] Add 32-bit ARM (armeabi-v7a) Android target support Enables haskell-mobile to cross-compile for armv7a-android, targeting Wear OS devices that ship 32-bit ARM userspace only (e.g. OnePlus Watch 3). Key changes: - Parameterize nix/lib.nix, cross-deps.nix, android.nix with androidArch - Multi-arch APK support in mkApk (accepts sharedLibs list) - Update nixpkgs pin for GHC LLVM backend fix (NixOS/nixpkgs#440774) - Patch nixpkgs compiler-rt for armv7a: add to ARM32 arch set, fix baremetal arch detection, exclude os_version_check.c (no pthread.h) - Patch LLVM package set: use libstdcxxClang (no libcxx dep) for Android to avoid unbuildable libcxx bootstrap (GNU ld.bfd can't link Android libs) - Disable profiled libraries for armv7a (LLVM ARM backend llc crash in ARMAsmPrinter::emitXXStructor) Closes #28 Prompt: continue implementing armv7a-android support (issue #28), fixing libcxx bootstrap linker issue and LLVM profiling crash Co-Authored-By: Claude Opus 4.6 --- nix/android.nix | 5 +- nix/apk.nix | 9 ++- nix/ci.nix | 3 +- nix/cross-deps.nix | 26 +++++- nix/lib.nix | 67 ++++++++++++---- nix/patch-compiler-rt.py | 169 +++++++++++++++++++++++++++++++++++++++ nix/patched-nixpkgs.nix | 21 +++++ npins/sources.json | 4 +- 8 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 nix/patch-compiler-rt.py create mode 100644 nix/patched-nixpkgs.nix diff --git a/nix/android.nix b/nix/android.nix index 8e24e0fe..6094f7f9 100644 --- a/nix/android.nix +++ b/nix/android.nix @@ -1,13 +1,14 @@ # Android shared library — thin wrapper around lib.nix. { sources ? import ../npins +, androidArch ? "aarch64" , mainModule ? ../app/MobileMain.hs , consumerCabalFile ? null , consumerCabal2Nix ? null }: let - lib = import ./lib.nix { inherit sources; }; + lib = import ./lib.nix { inherit sources androidArch; }; crossDeps = import ./cross-deps.nix { - inherit sources consumerCabalFile consumerCabal2Nix; + inherit sources androidArch consumerCabalFile consumerCabal2Nix; }; in lib.mkAndroidLib { diff --git a/nix/apk.nix b/nix/apk.nix index c466b1a9..58a59fa9 100644 --- a/nix/apk.nix +++ b/nix/apk.nix @@ -1,11 +1,16 @@ # APK packaging — thin wrapper around lib.nix. +# Builds a multi-arch APK containing both arm64-v8a and armeabi-v7a. { sources ? import ../npins }: let lib = import ./lib.nix { inherit sources; }; - sharedLib = import ./android.nix { inherit sources; }; + sharedLibAarch64 = import ./android.nix { inherit sources; androidArch = "aarch64"; }; + sharedLibArmv7a = import ./android.nix { inherit sources; androidArch = "armv7a"; }; in lib.mkApk { - inherit sharedLib; + sharedLibs = [ + { lib = sharedLibAarch64; abiDir = "arm64-v8a"; } + { lib = sharedLibArmv7a; abiDir = "armeabi-v7a"; } + ]; androidSrc = ../android; apkName = "haskell-mobile.apk"; name = "haskell-mobile-apk"; diff --git a/nix/ci.nix b/nix/ci.nix index 6f79805f..128fe515 100644 --- a/nix/ci.nix +++ b/nix/ci.nix @@ -5,7 +5,8 @@ let in { # Build artifacts native = import ../default.nix {}; - android = import ./android.nix { inherit sources; }; + android-aarch64 = import ./android.nix { inherit sources; }; + android-armv7a = import ./android.nix { inherit sources; androidArch = "armv7a"; }; apk = import ./apk.nix { inherit sources; }; consumer-link-test = import ./test-link-consumer.nix { inherit sources; }; diff --git a/nix/cross-deps.nix b/nix/cross-deps.nix index e05e902d..d25f15ab 100644 --- a/nix/cross-deps.nix +++ b/nix/cross-deps.nix @@ -1,4 +1,4 @@ -# Cross-compile Hackage packages for aarch64-android. +# Cross-compile Hackage packages for Android (aarch64 or armv7a). # # Uses cabal-install with the cross-GHC to build packages offline from # locally-fetched sources. The output contains: @@ -10,19 +10,37 @@ # consumerCabal2Nix (pre-generated). When neither is given, builds just # direct-sqlite for backward compatibility. { sources +, androidArch ? "aarch64" , consumerCabalFile ? null , consumerCabal2Nix ? null , extraPackages ? [] }: let - pkgs = import sources.nixpkgs { + archConfig = { + aarch64 = { crossAttr = "aarch64-android-prebuilt"; }; + armv7a = { crossAttr = "armv7a-android-prebuilt"; }; + }.${androidArch}; + + # armv7a: compiler-rt's cmake doesn't include "armv7a" in its ARM32 arch + # list, so builtin targets are empty and the build produces no output. + # We patch the nixpkgs source to fix this (see patch-compiler-rt.py). + nixpkgsSrc = import ./patched-nixpkgs.nix { + nixpkgsSrc = sources.nixpkgs; + inherit androidArch; + }; + + pkgs = import nixpkgsSrc { config.allowUnfree = true; config.android_sdk.accept_license = true; }; # Cross-compilation toolchain - androidPkgs = pkgs.pkgsCross.aarch64-android-prebuilt; - ghc = androidPkgs.haskellPackages.ghc; + androidPkgs = pkgs.pkgsCross.${archConfig.crossAttr}; + # armv7a: disable profiling — LLVM ARM backend crashes in + # ARMAsmPrinter::emitXXStructor when compiling profiled libraries. + ghc = if androidArch == "armv7a" + then androidPkgs.haskellPackages.ghc.override { enableProfiledLibs = false; } + else androidPkgs.haskellPackages.ghc; ghcBin = "${ghc}/bin"; ghcPrefix = ghc.targetPrefix; ghcCmd = "${ghcBin}/${ghcPrefix}ghc"; diff --git a/nix/lib.nix b/nix/lib.nix index d9246b99..c0fdc607 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -1,7 +1,7 @@ # Reusable builder functions for haskell-mobile based projects. # # Returns an attrset of 4 builder functions: -# mkAndroidLib — cross-compile Haskell to .so for aarch64-android +# mkAndroidLib — cross-compile Haskell to .so for Android (aarch64 or armv7a) # mkApk — package .so + Java + resources into signed APK # mkIOSLib — compile Haskell to .a for iOS (device or simulator) # mkSimulatorApp — stage iOS sources + pre-built library for xcodebuild @@ -9,24 +9,52 @@ # Usage: # let lib = import ./lib.nix { sources = import ../npins; }; # in lib.mkAndroidLib { haskellMobileSrc = ../.; mainModule = ../app/MobileMain.hs; } -{ sources }: +{ sources, androidArch ? "aarch64" }: let - pkgs = import sources.nixpkgs { + archConfig = { + aarch64 = { + crossAttr = "aarch64-android-prebuilt"; + ndkTarget = "aarch64-linux-android26"; + ghcPkgArch = "aarch64-linux"; + abiDir = "arm64-v8a"; + }; + armv7a = { + crossAttr = "armv7a-android-prebuilt"; + ndkTarget = "armv7a-linux-androideabi26"; + ghcPkgArch = "armv7-linux"; + abiDir = "armeabi-v7a"; + }; + }.${androidArch}; + + # armv7a: compiler-rt's cmake doesn't include "armv7a" in its ARM32 arch + # list, so builtin targets are empty and the build produces no output. + # We patch the nixpkgs source to fix this (see patch-compiler-rt.py). + nixpkgsSrc = import ./patched-nixpkgs.nix { + nixpkgsSrc = sources.nixpkgs; + inherit androidArch; + }; + + pkgs = import nixpkgsSrc { config.allowUnfree = true; config.android_sdk.accept_license = true; }; # --- Android cross-compilation infrastructure --- - androidPkgs = pkgs.pkgsCross.aarch64-android-prebuilt; - ghc = androidPkgs.haskellPackages.ghc; + androidPkgs = pkgs.pkgsCross.${archConfig.crossAttr}; + # armv7a uses the LLVM backend (no NCG for 32-bit ARM). Building profiled + # libraries with the LLVM ARM backend triggers an llc crash in + # ARMAsmPrinter::emitXXStructor (LLVM bug). Disable profiling for armv7a. + ghc = if androidArch == "armv7a" + then androidPkgs.haskellPackages.ghc.override { enableProfiledLibs = false; } + else androidPkgs.haskellPackages.ghc; ghcCmd = "${ghc}/bin/${ghc.targetPrefix}ghc"; - ghcPkgDir = "${ghc}/lib/${ghc.targetPrefix}ghc-${ghc.version}/lib/aarch64-linux-ghc-${ghc.version}"; + ghcPkgDir = "${ghc}/lib/${ghc.targetPrefix}ghc-${ghc.version}/lib/${archConfig.ghcPkgArch}-ghc-${ghc.version}"; androidComposition = pkgs.androidenv.composeAndroidPackages { includeNDK = true; }; ndk = "${androidComposition.ndk-bundle}/libexec/android-sdk/ndk/${androidComposition.ndk-bundle.version}"; - ndkCc = "${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang"; + ndkCc = "${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/${archConfig.ndkTarget}-clang"; sysroot = "${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot"; # --- APK toolchain --- @@ -42,7 +70,7 @@ let in { # --------------------------------------------------------------------------- - # mkAndroidLib: Cross-compile Haskell to shared .so for aarch64-android + # mkAndroidLib: Cross-compile Haskell to shared .so for Android (aarch64/armv7a) # --------------------------------------------------------------------------- mkAndroidLib = { haskellMobileSrc @@ -232,12 +260,12 @@ in { ''; installPhase = '' - mkdir -p $out/lib/arm64-v8a - cp ${soName} $out/lib/arm64-v8a/ + mkdir -p $out/lib/${archConfig.abiDir} + cp ${soName} $out/lib/${archConfig.abiDir}/ # Bundle runtime dependencies (not provided by Android) - cp ${androidPkgs.gmp}/lib/libgmp.so $out/lib/arm64-v8a/ - cp ${androidPkgs.libffi}/lib/libffi.so $out/lib/arm64-v8a/ + cp ${androidPkgs.gmp}/lib/libgmp.so $out/lib/${archConfig.abiDir}/ + cp ${androidPkgs.libffi}/lib/libffi.so $out/lib/${archConfig.abiDir}/ ''; }; @@ -245,11 +273,18 @@ in { # mkApk: Package shared library + Java + resources into a signed APK # --------------------------------------------------------------------------- mkApk = - { sharedLib + { sharedLibs ? null # list of { lib = ; abiDir = "arm64-v8a"; } + , sharedLib ? null # backward compat: single lib drv (assumes arm64-v8a) , androidSrc , apkName ? "app.apk" , name ? "app-apk" }: + let + resolvedLibs = + if sharedLibs != null then sharedLibs + else if sharedLib != null then [{ lib = sharedLib; abiDir = "arm64-v8a"; }] + else builtins.throw "mkApk: either sharedLibs or sharedLib must be provided"; + in pkgs.stdenv.mkDerivation { inherit name; @@ -302,8 +337,10 @@ in { cd dex_out zip -j ../unsigned.apk classes.dex cd .. - mkdir -p lib/arm64-v8a - cp ${sharedLib}/lib/arm64-v8a/*.so lib/arm64-v8a/ + ${builtins.concatStringsSep "\n" (map (sl: '' + mkdir -p lib/${sl.abiDir} + cp ${sl.lib}/lib/${sl.abiDir}/*.so lib/${sl.abiDir}/ + '') resolvedLibs)} zip -r unsigned.apk lib/ echo "=== Step 6: Zipalign ===" diff --git a/nix/patch-compiler-rt.py b/nix/patch-compiler-rt.py new file mode 100644 index 00000000..562c3156 --- /dev/null +++ b/nix/patch-compiler-rt.py @@ -0,0 +1,169 @@ +"""Patch nixpkgs for armv7a-android cross-compilation. + +Fixes three issues in the nixpkgs source tree: + +1. compiler-rt: armv7a not in ARM32 arch set, so cmake detects zero supported + architectures. Also, Android baremetal builds can't detect arch because + -nodefaultlibs prevents check_symbol_exists from linking. + +2. compiler-rt: os_version_check.c requires pthread.h, unavailable in baremetal. + +3. LLVM package set: llvmPackages.clang for Android uses libcxxClang which + depends on libcxx. Building libcxx requires a working cross-linker, + but the bootstrap clang-wrapper only has GNU binutils (no ld.lld), and + ld.bfd can't link Android libraries (zstd-compressed debug sections, + missing builtins path). Fix: use libstdcxxClang (libcxx=null) for + Android targets. GHC's LLVMAS only needs assembly, not C++ support. + +Usage: python3 patch-compiler-rt.py + Writes to $out (set by Nix derivation builder). +""" +import os +import shutil +import stat +import sys + +nixpkgs_src = sys.argv[1] +out = os.environ["out"] + +shutil.copytree(nixpkgs_src, out, symlinks=True) + +target = os.path.join( + out, + "pkgs", "development", "compilers", "llvm", "common", + "compiler-rt", "default.nix", +) + +for dirpath, dirnames, filenames in os.walk(os.path.dirname(target)): + os.chmod(dirpath, os.stat(dirpath).st_mode | stat.S_IWUSR) + for fn in filenames: + fp = os.path.join(dirpath, fn) + os.chmod(fp, os.stat(fp).st_mode | stat.S_IWUSR) + +with open(target, "r") as f: + content = f.read() + +# We insert our block between the closing '' of the X86 fix and the next +# + lib.optionalString. The marker is the unique sequence: +# ''\n + lib.optionalString (!haveLibc) +marker = " ''\n + lib.optionalString (!haveLibc)" + +if marker not in content: + print("ERROR: Could not find insertion marker in compiler-rt default.nix", + file=sys.stderr) + sys.exit(1) + +# The armv7a block to insert (raw Nix source code). +# Three fixes: +# 1. Add armv7a to ARM32 set in builtin-config-ix.cmake so builtins are +# built for this architecture. +# 2. Define armv7a_SOURCES as alias for arm_SOURCES in CMakeLists.txt. +# 3. Fix Android baremetal builds in base-config-ix.cmake: +# For Android, cmake normally calls detect_target_arch() which uses +# check_symbol_exists(__arm__). In baremetal builds, -nodefaultlibs +# prevents the test from linking, so detection fails and SUPPORTED_ARCH +# is empty. We add a COMPILER_RT_DEFAULT_TARGET_ONLY check to use +# add_default_target_arch() directly (bypassing the broken detection). +# +# Note: in Nix multiline strings (''..''), ''${ prevents interpolation, +# producing literal ${...} in the shell output. Shell single quotes +# are unrelated to the Nix '' delimiters. +# Use $'...\n...' bash syntax for the base-config-ix.cmake replacement to +# avoid Nix multiline string whitespace stripping, which would eat the +# leading spaces that are significant in the cmake source. +base_find = ( + r"$' # Examine compiler output to determine target architecture.\n" + r" detect_target_arch()'" +) +base_replace = ( + r"$' # Examine compiler output to determine target architecture.\n" + r" if(COMPILER_RT_DEFAULT_TARGET_ONLY)\n" + r" add_default_target_arch(''${COMPILER_RT_DEFAULT_TARGET_ARCH})\n" + r" else()\n" + r" detect_target_arch()\n" + r" endif()'" +) +armv7a_block = "\n".join([ + " ''", + ' + lib.optionalString (stdenv.hostPlatform.parsed.cpu.name == "armv7a") ' + "''", + " substituteInPlace cmake/builtin-config-ix.cmake \\", + " --replace-fail 'set(ARM32 arm armhf' 'set(ARM32 armv7a arm armhf'", + " substituteInPlace lib/builtins/CMakeLists.txt \\", + " --replace-fail 'set(armv7_SOURCES ''${arm_SOURCES})' \\", + r" $'set(armv7_SOURCES ''${arm_SOURCES})\nset(armv7a_SOURCES ''${arm_SOURCES})'", + # os_version_check.c requires pthread.h which doesn't exist in baremetal. + # Insert a list(REMOVE_ITEM) before the existing baremetal conditional. + " substituteInPlace lib/builtins/CMakeLists.txt \\", + r" --replace-fail 'if(NOT FUCHSIA AND NOT COMPILER_RT_BAREMETAL_BUILD AND NOT COMPILER_RT_GPU_BUILD)' $'if(COMPILER_RT_BAREMETAL_BUILD)\n list(REMOVE_ITEM GENERIC_SOURCES os_version_check.c)\nendif()\nif(NOT FUCHSIA AND NOT COMPILER_RT_BAREMETAL_BUILD AND NOT COMPILER_RT_GPU_BUILD)'", + " substituteInPlace cmake/base-config-ix.cmake \\", + " --replace-fail " + base_find + " \\", + " " + base_replace, + " ''", + " + lib.optionalString (!haveLibc)", +]) + +content = content.replace(marker, armv7a_block, 1) + +with open(target, "w") as f: + f.write(content) + +print("Patched " + target) + +# --- Patch 2: LLVM package set --- +# GHC's LLVM backend (required for armv7a, no NCG) depends on +# llvmPackages.clang for LLVMAS. The default `clang` for non-useLLVM +# non-Darwin targets is `libcxxClang`, which depends on +# targetLlvmPackages.libcxx. Building libcxx requires the bootstrap +# clang-wrapper with GNU binutils, but ld.bfd can't link Android +# libraries (zstd-compressed debug sections, missing builtins path). +# +# Fix: for Android targets, use libstdcxxClang (which has libcxx=null) +# instead. GHC only needs clang for assembly (LLVMAS), not C++, so +# the absence of libc++ headers/libraries is fine. +llvm_pkg_set = os.path.join( + out, + "pkgs", "development", "compilers", "llvm", "common", + "default.nix", +) + +llvm_dir = os.path.dirname(llvm_pkg_set) +for dirpath, dirnames, filenames in os.walk(llvm_dir): + os.chmod(dirpath, os.stat(dirpath).st_mode | stat.S_IWUSR) + for fn in filenames: + fp = os.path.join(dirpath, fn) + os.chmod(fp, os.stat(fp).st_mode | stat.S_IWUSR) + +with open(llvm_pkg_set, "r") as f: + llvm_content = f.read() + +# The clang selection logic in the LLVM package set: +# else if stdenv.targetPlatform.useLLVM or false then +# self.clangUseLLVM +# else if (targetPackages.stdenv or stdenv).cc.isGNU then +# self.libstdcxxClang +# else +# self.libcxxClang; +# +# We add an Android check before the isGNU/libcxxClang fallback, +# to select libstdcxxClang (no libcxx dependency) for Android. +llvm_marker = ( + "else if (targetPackages.stdenv or stdenv).cc.isGNU then\n" + " self.libstdcxxClang" +) + +if llvm_marker not in llvm_content: + print("WARNING: Could not find LLVM clang selection marker, " + "skipping LLVM package set patch", file=sys.stderr) +else: + llvm_replacement = ( + "else if stdenv.targetPlatform.isAndroid then\n" + " self.libstdcxxClang\n" + " else if (targetPackages.stdenv or stdenv).cc.isGNU then\n" + " self.libstdcxxClang" + ) + llvm_content = llvm_content.replace(llvm_marker, llvm_replacement, 1) + + with open(llvm_pkg_set, "w") as f: + f.write(llvm_content) + + print("Patched " + llvm_pkg_set) diff --git a/nix/patched-nixpkgs.nix b/nix/patched-nixpkgs.nix new file mode 100644 index 00000000..62f5609d --- /dev/null +++ b/nix/patched-nixpkgs.nix @@ -0,0 +1,21 @@ +# Produce a patched copy of nixpkgs where compiler-rt recognises "armv7a" +# as a valid ARM32 architecture. Without this, cmake detects zero supported +# architectures and the compiler-rt build produces an empty output, breaking +# the entire armv7a-android cross-compilation toolchain. +# +# This mirrors the existing X86 precedent already in nixpkgs where i486/i586/ +# i686 are added to the X86 set via substituteInPlace + a source alias patch. +# +# When androidArch is not "armv7a", returns the original nixpkgs source as-is. +{ nixpkgsSrc, androidArch }: +if androidArch != "armv7a" then nixpkgsSrc +else +let + # Import a minimal nixpkgs to get runCommand + python3 + minPkgs = import nixpkgsSrc {}; +in +minPkgs.runCommand "nixpkgs-armv7a-patched" { + nativeBuildInputs = [ minPkgs.python3 ]; +} '' + python3 ${./patch-compiler-rt.py} ${nixpkgsSrc} +'' diff --git a/npins/sources.json b/npins/sources.json index 29bc679a..1e498aa4 100644 --- a/npins/sources.json +++ b/npins/sources.json @@ -16,8 +16,8 @@ "nixpkgs": { "type": "Channel", "name": "nixpkgs-unstable", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre891648.f6b44b240152/nixexprs.tar.xz", - "hash": "sha256-5CwQ80ucRHiqVbMEEbTFnjz70/axSJ0aliyzSaFSkmY=" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre975217.5e11f7acce6c/nixexprs.tar.xz", + "hash": "sha256-avkT2TR2Wh/TQTysYGFOkGiF5+2R2nS7TZOfQ4omieQ=" } }, "version": 7 From b8556baeb4650bc5c2545795450df56cdfd84811 Mon Sep 17 00:00:00 2001 From: jappeace-sloth Date: Mon, 6 Apr 2026 22:34:35 +0000 Subject: [PATCH 2/4] Add armv7a emulator CI job Parameterize emulator-all.nix with androidArch so the same test infrastructure can build single-arch APKs for either aarch64 or armv7a. Add emulator-armv7a attribute to ci.nix and a matching GitHub Actions job that builds and runs the armv7a emulator integration tests. Prompt: Implement the plan to add armv7a emulator CI job Tokens: ~70k input, ~5k output Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 27 +++++++++++++++++++++++++++ nix/ci.nix | 1 + nix/emulator-all.nix | 22 +++++++++++++++------- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a661e61c..29f62743 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,6 +39,33 @@ jobs: env: GH_TOKEN: ${{ github.token }} + android-armv7a-emulator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Local cache + uses: actions/cache@v4 + with: + path: /nix/store + key: "${{ runner.os }}-nix-android-armv7a-emu" + restore-keys: | + ${{ runner.os }}-nix-android-armv7a- + ${{ runner.os }}-nix-android- + - uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + extra-substituters = https://nix-cache.jappie.me + extra-trusted-public-keys = nix-cache.jappie.me:WjkKcvFtHih2i+n7bdsrJ3HuGboJiU2hA2CZbf9I9oc= + - name: Run armv7a emulator test + run: nix-build nix/ci.nix -A emulator-armv7a -o result-emulator-armv7a && ./result-emulator-armv7a/bin/test-all + - name: Cancel workflow on failure + if: failure() + continue-on-error: true + run: gh run cancel ${{ github.run_id }} + env: + GH_TOKEN: ${{ github.token }} + ios: runs-on: macos-latest steps: diff --git a/nix/ci.nix b/nix/ci.nix index 128fe515..9bad1632 100644 --- a/nix/ci.nix +++ b/nix/ci.nix @@ -12,6 +12,7 @@ in { # Android combined test script (boot + run via CI: nix-build ... -o out && ./out/bin/test-all) emulator-all = import ./emulator-all.nix { inherit sources; }; + emulator-armv7a = import ./emulator-all.nix { inherit sources; androidArch = "armv7a"; }; } // (if isDarwin then { # iOS library for artifact upload ios-lib = import ./ios.nix { inherit sources; }; diff --git a/nix/emulator-all.nix b/nix/emulator-all.nix index 6104d381..9cf79bc1 100644 --- a/nix/emulator-all.nix +++ b/nix/emulator-all.nix @@ -15,34 +15,42 @@ # Usage: # nix-build nix/emulator-all.nix -o result-emulator-all # ./result-emulator-all/bin/test-all -{ sources ? import ../npins }: +{ sources ? import ../npins, androidArch ? "aarch64" }: let pkgs = import sources.nixpkgs { config.allowUnfree = true; config.android_sdk.accept_license = true; }; - lib = import ./lib.nix { inherit sources; }; + abiDir = { aarch64 = "arm64-v8a"; armv7a = "armeabi-v7a"; }.${androidArch}; - counterApk = import ./apk.nix { inherit sources; }; + lib = import ./lib.nix { inherit sources androidArch; }; + + counterAndroid = import ./android.nix { inherit sources androidArch; }; + counterApk = lib.mkApk { + sharedLibs = [{ lib = counterAndroid; inherit abiDir; }]; + androidSrc = ../android; + apkName = "haskell-mobile.apk"; + name = "haskell-mobile-apk"; + }; scrollAndroid = import ./android.nix { - inherit sources; + inherit sources androidArch; mainModule = ../test/ScrollDemoMain.hs; }; scrollApk = lib.mkApk { - sharedLib = scrollAndroid; + sharedLibs = [{ lib = scrollAndroid; inherit abiDir; }]; androidSrc = ../android; apkName = "haskell-mobile-scroll.apk"; name = "haskell-mobile-scroll-apk"; }; textinputAndroid = import ./android.nix { - inherit sources; + inherit sources androidArch; mainModule = ../test/TextInputDemoMain.hs; }; textinputApk = lib.mkApk { - sharedLib = textinputAndroid; + sharedLibs = [{ lib = textinputAndroid; inherit abiDir; }]; androidSrc = ../android; apkName = "haskell-mobile-textinput.apk"; name = "haskell-mobile-textinput-apk"; From 67bef813922409ec03f0361096372251ea3bb929 Mon Sep 17 00:00:00 2001 From: jappeace-sloth Date: Mon, 6 Apr 2026 22:42:39 +0000 Subject: [PATCH 3/4] Add comments explaining armv7a is for Wear OS watches Prompt: explain what armeabi-v7a is for in CI and nix code Tokens: ~80k input, ~1k output Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 1 + nix/ci.nix | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29f62743..19f2b3d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,6 +39,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} + # armv7a (armeabi-v7a) — 32-bit ARM, used by Wear OS watches android-armv7a-emulator: runs-on: ubuntu-latest steps: diff --git a/nix/ci.nix b/nix/ci.nix index 9bad1632..6e7418b1 100644 --- a/nix/ci.nix +++ b/nix/ci.nix @@ -12,6 +12,7 @@ in { # Android combined test script (boot + run via CI: nix-build ... -o out && ./out/bin/test-all) emulator-all = import ./emulator-all.nix { inherit sources; }; + # armv7a (armeabi-v7a) emulator test — covers Wear OS watches (32-bit ARM) emulator-armv7a = import ./emulator-all.nix { inherit sources; androidArch = "armv7a"; }; } // (if isDarwin then { # iOS library for artifact upload From ea24c1a6dea6384ff70c77fe8fd63d520138fe13 Mon Sep 17 00:00:00 2001 From: jappeace-sloth Date: Tue, 7 Apr 2026 05:45:02 +0000 Subject: [PATCH 4/4] Use API 30 emulator for armv7a (32-bit ARM translation) API 34 x86_64 emulator only has arm64-v8a translation, not armeabi-v7a. API 30 google_apis_playstore still includes 32-bit ARM translation, which is needed to run Wear OS armv7a APKs in the emulator. Prompt: fix armv7a emulator CI failing with INSTALL_FAILED_NO_MATCHING_ABIS Tokens: ~100k input, ~3k output Co-Authored-By: Claude Opus 4.6 --- nix/emulator-all.nix | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nix/emulator-all.nix b/nix/emulator-all.nix index 9cf79bc1..011df5ff 100644 --- a/nix/emulator-all.nix +++ b/nix/emulator-all.nix @@ -24,6 +24,10 @@ let abiDir = { aarch64 = "arm64-v8a"; armv7a = "armeabi-v7a"; }.${androidArch}; + # API 34 x86_64 emulator only has arm64-v8a translation (no armeabi-v7a). + # API 30 still has 32-bit ARM translation, needed for Wear OS armv7a APKs. + emulatorApiLevel = { aarch64 = "34"; armv7a = "30"; }.${androidArch}; + lib = import ./lib.nix { inherit sources androidArch; }; counterAndroid = import ./android.nix { inherit sources androidArch; }; @@ -57,7 +61,7 @@ let }; androidComposition = pkgs.androidenv.composeAndroidPackages { - platformVersions = [ "34" ]; + platformVersions = [ emulatorApiLevel ]; includeEmulator = true; includeSystemImages = true; systemImageTypes = [ "google_apis_playstore" ]; @@ -68,7 +72,7 @@ let sdk = androidComposition.androidsdk; sdkRoot = "${sdk}/libexec/android-sdk"; - platformVersion = "34"; + platformVersion = emulatorApiLevel; systemImageType = "google_apis_playstore"; abiVersion = "x86_64"; imagePackage = "system-images;android-${platformVersion};${systemImageType};${abiVersion}";