Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v2] Add support for Android builds #67

Closed

Conversation

lunaticare
Copy link

Summary

This change improves gradle2nix's compatibility with Android builds.

  • Fixes the error in which build fails to write to /var/empty/.android directory on macOS.
    Android Gradle Plugin uses $HOME/.android directory (see ANDROID_USER_HOME variable https://developer.android.com/tools/variables#envar) to store plugin preferences and signing keystores, among other things.
    However the directory becomes the read-only /var/empty/.android in Nix builds, and the plugin fails to write there.
    The PR fixes the problem by setting user.home Gradle parameter to a temporary directory. It is enough to produce an unsigned APK.
  • Allow passing extra build inputs by introducing an extraBuildInputs. For instance, it can be used to pass an environment with Android SDK set up.

How to test

The change can be tested by building this Gradle project:

nix build 'github:lunaticare/identity-samples?ref=feature/gradle2nix_build&dir=CredentialManager#packages.aarch64-darwin.android-app'
find -L ./result
./result
./result/apk
./result/apk/release
./result/apk/release/output-metadata.json
./result/apk/release/app-release.apk
./result/apk/debug
./result/apk/debug/output-metadata.json
./result/apk/debug/app-debug.apk

* set user.home for Android Gradle Plugin
* pass build inputs
@lunaticare lunaticare mentioned this pull request Jun 9, 2024
@expenses
Copy link

Hi, I saw this PR when looking up how best to build android apps with nix and it looks super promising!

Unfortunately when I tried to run that build on x86_64 linux I get the following error:
nix build 'github:lunaticare/identity-samples?ref=feature/gradle2nix_build&dir=CredentialManager#packages.x86_64-linux.android-app' -L

error: builder for '/nix/store/p88iwlydl9hslsm6wkf694zh8r8nhh7i-android-app-1.0.drv' failed with exit code 1;
       last 10 log lines:
       >
       > Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
       >
       > You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
       >
       > For more on this, please refer to https://docs.gradle.org/8.7/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
       >
       > BUILD FAILED in 44s
       > 23 actionable tasks: 23 executed
       >
       For full logs, run 'nix log /nix/store/p88iwlydl9hslsm6wkf694zh8r8nhh7i-android-app-1.0.drv'.

@expenses
Copy link

Ah, the root error is actually Caused by: org.gradle.internal.resolve.ArtifactNotFoundException: Could not find aapt2-8.1.0-10154469-linux.jar (com.android.tools.build:aapt2:8.1.0-10154469).

@expenses
Copy link

I believe that the gradle.lock file is invalid for linux and tried to update it by running gradle2nix build in this devshell:

diff --git a/CredentialManager/flake.nix b/CredentialManager/flake.nix
index bed4f5c..b6e35a7 100644
--- a/CredentialManager/flake.nix
+++ b/CredentialManager/flake.nix
@@ -57,5 +57,8 @@
             echo Welcome to Android shell!
           '';
         };
+        devShells.gradle2nix = pkgs.mkShell {
+          packages = [gradle2nix.packages.${system}.default jdk android-sdk pkgs.aapt];
+        };
       });
 }

that ends with this error:

Caused by: com.android.builder.internal.aapt.v2.Aapt2InternalException: AAPT2 aapt2-8.1.0-10154469-linux Daemon #1: Daemon startup failed
This should not happen under normal circumstances, please file an issue if it does.
	at com.android.builder.internal.aapt.v2.Aapt2Daemon.handleError(Aapt2Daemon.kt:193)
<snip>
	... 21 more
Caused by: com.android.builder.internal.aapt.v2.Aapt2InternalException: Failed to start AAPT2 process.
	at com.android.builder.internal.aapt.v2.Aapt2DaemonImpl.stopQuietly(Aapt2DaemonImpl.kt:126)
	at com.android.builder.internal.aapt.v2.Aapt2DaemonImpl.startProcess(Aapt2DaemonImpl.kt:113)
	at com.android.builder.internal.aapt.v2.Aapt2Daemon.checkStarted(Aapt2Daemon.kt:56)
	... 117 more
Caused by: java.io.IOException: Process unexpectedly exit.
	at com.android.builder.internal.aapt.v2.Aapt2DaemonImpl.startProcess(Aapt2DaemonImpl.kt:114)
	... 118 more

@expenses
Copy link

gradle2nix worked after setting up nix-ld:

diff --git a/CredentialManager/flake.nix b/CredentialManager/flake.nix
index bed4f5c..a91547e 100644
--- a/CredentialManager/flake.nix
+++ b/CredentialManager/flake.nix
@@ -37,7 +37,7 @@
           gradleFlags = [ "build" "--stacktrace" "--info" ];
           src = ./.;
           extraBuildInputs = [
-            android-sdk
+            android-sdk pkgs.aapt
           ];
           postBuild = ''
           mkdir -p $out
@@ -57,5 +57,14 @@
             echo Welcome to Android shell!
           '';
         };
+        devShells.gradle2nix = pkgs.mkShell {
+          NIX_LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [
+            stdenv.cc.cc
+            #openssl
+            # ...
+          ];
+          NIX_LD = with pkgs; lib.fileContents "${stdenv.cc}/nix-support/dynamic-linker";
+          packages = [gradle2nix.packages.${system}.default jdk android-sdk pkgs.aapt];
+        };
       });
 }
diff --git a/CredentialManager/gradle.lock b/CredentialManager/gradle.lock
index f1cd87f..4212d9e 100644
--- a/CredentialManager/gradle.lock
+++ b/CredentialManager/gradle.lock
@@ -1210,9 +1210,9 @@
     }
   },
   "com.android.tools.build:aapt2:8.1.0-10154469": {
-    "aapt2-8.1.0-10154469-osx.jar": {
-      "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2/8.1.0-10154469/aapt2-8.1.0-10154469-osx.jar",
-      "hash": "sha256-w09cJqAUGZ2o6GyF9O0L++gE/Ah7mFOclIGt0j6uBeA="
+    "aapt2-8.1.0-10154469-linux.jar": {
+      "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2/8.1.0-10154469/aapt2-8.1.0-10154469-linux.jar",
+      "hash": "sha256-CUCBg0GadMQF18WpZKgA2pFnWJiMvFxs/bscMJquoDw="
     },
     "aapt2-8.1.0-10154469.pom": {
       "url": "https://dl.google.com/dl/android/maven2/com/android/tools/build/aapt2/8.1.0-10154469/aapt2-8.1.0-10154469.pom",

It still doesn't work yet:

android-app> Caused by: com.android.builder.internal.aapt.v2.Aapt2InternalException: AAPT2 aapt2-8.1.0-10154469-linux Daemon #3: Daemon startup failed
android-app> This should not happen under normal circumstances, please file an issue if it does.
android-app>    at com.android.builder.internal.aapt.v2.Aapt2Daemon.handleError(Aapt2Daemon.kt:193)
<snip>
android-app>    ... 5 more
android-app> Caused by: java.io.IOException: Cannot run program "/build/tmp.0KESdBmJY7/caches/transforms-4/45c6636fab9cf0348a2ca6ba55d67777/transformed/aapt2-8.1.0-10154469-linux/aapt2": error=2, No such file or directory
android-app>    at java.base/java.lang.ProcessBuilder.start(ProcessBuilder.java:1170)
android-app>    at java.base/java.lang.ProcessBuilder.start(ProcessBuilder.java:1089)
android-app>    at org.gradle.internal.classpath.Instrumented.start(Instrumented.java:320)
android-app>    at com.android.builder.internal.aapt.v2.Aapt2DaemonImpl.startProcess(Aapt2DaemonImpl.kt:84)
android-app>    at com.android.builder.internal.aapt.v2.Aapt2Daemon.checkStarted(Aapt2Daemon.kt:56)
android-app>    ... 120 more
android-app> Caused by: java.io.IOException: error=2, No such file or directory
android-app>    at java.base/java.lang.ProcessImpl.forkAndExec(Native Method)
android-app>    at java.base/java.lang.ProcessImpl.<init>(ProcessImpl.java:295)
android-app>    at java.base/java.lang.ProcessImpl.start(ProcessImpl.java:225)
android-app>    at java.base/java.lang.ProcessBuilder.start(ProcessBuilder.java:1126)
android-app>    ... 124 more
android-app> Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
android-app> You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
android-app> For more on this, please refer to https://docs.gradle.org/8.7/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
android-app> BUILD FAILED in 43s
android-app> 30 actionable tasks: 30 executed
android-app>

This aapt2 thing seems like it's going to be a real pain point. I did see tadfisher/android-nixpkgs#49 but it seems like that might not help much.

@lunaticare
Copy link
Author

Hi @expenses , thank you for testing the build on Linux!

I fixed the aapt problem using this example:

devShells.default = pkgs.mkShell {
  packages = [ android-sdk jdk gradle2nixPackage ];
  shellHook = ''
    # for impure shells
    unset JAVA_HOME
    unset JAVA

    # Override path to aapt2 tool
    # https://ryantm.github.io/nixpkgs/languages-frameworks/android/#notes-on-environment-variables-in-android-projects
    export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=${android-sdk}/share/android-sdk/build-tools/34.0.0/aapt2"

    echo Welcome to Android shell!
  '';
};

It fixed local builds on macOS inside Nix shell (without gradle2nix), might work on Linux too.

@lunaticare
Copy link
Author

lunaticare commented Jun 15, 2024

Ah, the root error is actually Caused by: org.gradle.internal.resolve.ArtifactNotFoundException: Could not find aapt2-8.1.0-10154469-linux.jar (com.android.tools.build:aapt2:8.1.0-10154469).

It would help to have separate lock files per architecture, or to be able to easily merge lock files.

@tadfisher
Copy link
Owner

As of the latest commit to V2, you can override dependencies with the overrides argument. To run autoPatchelf on AAPT2, this works:

with lib; let
  gradleLock = builtins.fromJSON (builtins.readFile ./gradle.lock);

  patchJars = moduleFilter: artifactFilter: args: f: let
    modules =
      filterAttrs
      (name: _: moduleFilter name)
      gradleLock;

    artifacts = filterAttrs (name: _: artifactFilter name);

    patch = src: runCommand src.name args (f src);
  in
    mapAttrs (
      _: module:
        mapAttrs (_: _: patch) (artifacts module)
    )
    modules;

  aapt2LinuxJars = optionalAttrs stdenv.isLinux (patchJars
    (hasPrefix "com.android.tools.build:aapt2:")    # moduleFilter
    (hasSuffix "-linux.jar")                        # artifactFilter
    {                                               # args to runCommand
      nativeBuildInputs = [jdk autoPatchelfHook];
      buildInputs = [stdenv.cc.cc.lib];
      dontAutoPatchelf = true;
    }
    (src: ''                                        # function to make a runCommand script
      cp ${src} aapt2.jar                           # src <- derivation to download source
      jar xf aapt2.jar aapt2
      chmod +x aapt2
      autoPatchelf aapt2
      jar uf aapt2.jar aapt2
      cp aapt2.jar $out
      echo $out
    ''));
in
buildGradlePackage {
  # ...
  overrides = aapt2LinuxJars;
}

@tadfisher
Copy link
Owner

It would help to have separate lock files per architecture,

I'm not sure this should be gradle2nix's responsibility. Gradle resolves whatever dependencies are given to it by the build script, which is a Turing-complete program that generates dependencies to resolve, with full access to the Internet. To do this right, one would have to run Gradle on each architecture, each value of 4 Across on this week's New York Times crossword puzzles, or the the least significant 4 bits from a PRNG; in short, any condition in that build script that affects the resolved dependency set. This is why Gradle is bad and Nix is good.

So if you want separate lock files, you can run gradle2nix -l gradle.${system}.lock on the target you are locking for, and choose which one to use based on system when building the package in Nix. This is relatively rare in the JVM world, as most packages don't ship native binaries per-target. But, as you've seen, the Android team has chosen to distribute aapt2 this way and configure their Gradle plugin to choose dependencies based on the target OS. This is worse for Kotlin multiplatform projects with up to about a dozen OS/arch combinations.

or to be able to easily merge lock files.

Hmm, we could add a dependencies argument which just the parsed lock file, then you can merge their dependencies like this:

let
  lockFiles = [ ./gradle.aarch64-darwin.lock ./gradle.x86_64-linux.lock ];
  
  dependencySets = map (f: builtins.fromJSON (builtins.readFile f)) lockFiles;

in
buildGradlePackage {
  # ...
  dependencies = foldl' lib.recursiveUpdate { } dependencySets;
}

Of course, we can merge this and lockFile for you, giving you a way to easily add dependencies in Nix.

@expenses
Copy link

Thanks everyone for your advice! I think this PR can be closed as the latest V2 commit lets you set nativeBuildInputs. We could possibly move any gradle2nix+android discussions to an issue.

export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=${android-sdk}/share/android-sdk/build-tools/34.0.0/aapt2"

Unfortunately this didn't help as that aapt2 binary wasn't patched for nix. I found that using nix-ld works well enough for the dev shells.

To run autoPatchelf on AAPT2, this works:

This helped get my app building though! nix build github:expenses/nix-rust-android-app#apk should now just work. The one kinda weird thing I had to do was patch the lock file itself to include a missing dependency:
https://github.com/expenses/nix-rust-android-app/blob/0ed6635a918595085401c847893ba644492404c1/flake.nix#L94-L110. Unless I'm mistaken, this isn't something you can do with overrides at the moment.

@expenses
Copy link

Also see #69.

@lunaticare lunaticare closed this Jun 17, 2024
@expenses
Copy link

port GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=${android-sdk}/share/android-sdk/build-tools/34.0.0/aapt2"

Unfortunately this didn't help as that aapt2 binary wasn't patched for nix.

I tried using tadfisher/android-nixpkgs#104 and got a different error:

android-app> ERROR: AAPT: unknown option '--source-path'.
android-app> aapt2 compile [options] -o arg files...

Patching the jar is the best solution at the moment.

@numinit
Copy link

numinit commented Jun 18, 2024

One thing that may be useful for anyone building Android apps that require jars with FHS envs to run in the sandbox is this:

ElvishJerricco/nixpkgs@05742c1

My modified version looks like this:

{ lib, buildFHSEnvChroot }:

# Similar to runInLinuxVM, except we run under a FHS user env instead
# of a VM. This allows you to use build systems that depend on the FHS
# without any sort of patching. Resulting binaries may not work on
# NixOS without wrapping them in FHS though.
drv: envArgs: let
  fhsWrapper = buildFHSEnvChroot ({
    name = "${drv.name}-fhs-wrapper";
    runScript = "$@";
  } // envArgs);
in lib.overrideDerivation drv (old: {
  builder = "${fhsWrapper}/bin/${fhsWrapper.name}";
  args = [old.builder] ++ old.args;
})

I added an overlay in my flake (note that I use Flake Parts so the syntax is a little different):

overlayAttrs = with pkgs; {
  runInFHSEnvChroot = callPackage ./run-in-fhs.nix {};
};

Then you can simply do:

runInFHSEnvChroot (buildGradlePackage {
  # ... gradle derivation options go here ...
}) {
  # ... FHS env options go here. For example, there was a prepackaged binary in my deps that required `zlib`:
  targetPkgs = pkgs: with pkgs; [ zlib ]; 
}

Other Gradle opts that help:

  • "-Dorg.gradle.daemon=false"
  • "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidSdkRoot}/build-tools/${android.versions.buildTools}/aapt2"
    • I use androidenv for my builds so just used the same one that was in my Android composition which was already patched to work
  • "-Dorg.gradle.java.home=${jdk.home}"
  • "--console=plain"

If you test with robolectric, you need to set:

  • "-Drobolectric.offline=true"
  • "-Drobolectric.dependency.dir=${fixed-output derivation containing robolectric jars in the root of $out}"
    • You can do a fixed-output derivation that pulls these: https://mvnrepository.com/artifact/org.robolectric/android-all-instrumented
    • https://github.com/utzcoz/robolectric-android-all-fetcher/ was a good base for it; use something like https://repo1.maven.org/maven2 for the Maven repo URLs - that mvn -s ${mavenSettings} dependency:get -Dartifact=$artifactName approach works well; then you get the repo path with mvn -s ${mavenSettings} help:evaluate -Dexpression=settings.localRepository -q -DforceStdout and do something like find "$repo/$namespacePath" -type f -name "$artifactName-$version.jar" -exec cp -v {} $out \;) to get all the jars into the correct place.

Note that you may also need to provide systemProperty directives in your testOptions to pass them down to the test processes:

testOptions {
    unitTests.all {
        def val = null
        if ((val = System.getProperty('robolectric.offline'))) {
            systemProperty 'robolectric.offline', val
        }
        if ((val = System.getProperty('robolectric.dependency.dir'))) {
            systemProperty 'robolectric.dependency.dir', val
        }
    }
}

@thomaseizinger
Copy link

As of the latest commit to V2, you can override dependencies with the overrides argument. To run autoPatchelf on AAPT2, this works:

with lib; let
  gradleLock = builtins.fromJSON (builtins.readFile ./gradle.lock);

  patchJars = moduleFilter: artifactFilter: args: f: let
    modules =
      filterAttrs
      (name: _: moduleFilter name)
      gradleLock;

    artifacts = filterAttrs (name: _: artifactFilter name);

    patch = src: runCommand src.name args (f src);
  in
    mapAttrs (
      _: module:
        mapAttrs (_: _: patch) (artifacts module)
    )
    modules;

  aapt2LinuxJars = optionalAttrs stdenv.isLinux (patchJars
    (hasPrefix "com.android.tools.build:aapt2:")    # moduleFilter
    (hasSuffix "-linux.jar")                        # artifactFilter
    {                                               # args to runCommand
      nativeBuildInputs = [jdk autoPatchelfHook];
      buildInputs = [stdenv.cc.cc.lib];
      dontAutoPatchelf = true;
    }
    (src: ''                                        # function to make a runCommand script
      cp ${src} aapt2.jar                           # src <- derivation to download source
      jar xf aapt2.jar aapt2
      chmod +x aapt2
      autoPatchelf aapt2
      jar uf aapt2.jar aapt2
      cp aapt2.jar $out
      echo $out
    ''));
in
buildGradlePackage {
  # ...
  overrides = aapt2LinuxJars;
}

Is this something that every (Android) project is meant to be doing? I am fairly new to Nix but could this be provided by gradle2nix somehow?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants