Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions nix/android.nix
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions nix/apk.nix
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
3 changes: 2 additions & 1 deletion nix/ci.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; };

Expand Down
26 changes: 22 additions & 4 deletions nix/cross-deps.nix
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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";
Expand Down
67 changes: 52 additions & 15 deletions nix/lib.nix
Original file line number Diff line number Diff line change
@@ -1,32 +1,60 @@
# 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
#
# 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 ---
Expand All @@ -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
Expand Down Expand Up @@ -232,24 +260,31 @@ 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}/
'';
};

# ---------------------------------------------------------------------------
# mkApk: Package shared library + Java + resources into a signed APK
# ---------------------------------------------------------------------------
mkApk =
{ sharedLib
{ sharedLibs ? null # list of { lib = <drv>; 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;

Expand Down Expand Up @@ -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 ==="
Expand Down
169 changes: 169 additions & 0 deletions nix/patch-compiler-rt.py
Original file line number Diff line number Diff line change
@@ -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 <nixpkgs-src-path>
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)
Loading
Loading