Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ 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:
- 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:
Expand Down
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
5 changes: 4 additions & 1 deletion nix/ci.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ 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; };

# 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
ios-lib = import ./ios.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
30 changes: 21 additions & 9 deletions nix/emulator-all.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,53 @@
# 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; };
# 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; };
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";
};

androidComposition = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "34" ];
platformVersions = [ emulatorApiLevel ];
includeEmulator = true;
includeSystemImages = true;
systemImageTypes = [ "google_apis_playstore" ];
Expand All @@ -60,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}";
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
Loading
Loading