Willy's blog

Android apps with Slint on NixOS

Before moving to the south of Holland, and later starting traveling with my girlfriend, we were using a blackboard to keep track of the days we hadn't eaten added sugars. I was inspired by Simon Willision's blog about the power of streaks. Thus I created some mechanics for this streaks game, and we started "playing" it at home. It was a success, one of our streaks even lasted 45 days without added sugar (with some caveats). I've always wanted to transform the experience into an app, so we wouldn't depend on the location we were in.

Now, while traveling, I've been setting up the environment to run the android app. As it was such a struggle, because I know little to nothing of android development, I decided to document the journey.

The goal of this blog is to set up a nix flake, in order to run an android app built with slint.

Slint

Slint is a rust UI library. Might not be the most popular right now, but unlike other UI libraries, slint has its own UI language to describe the layout. Making it akin to Qt's QML. I'm not entirely sure if it's the best approach (that says more about my ignorance than the approach). However, it's a proven approach, as there's a plethora of Qt applications out there, which run quite smoothly, like KDE, Ableton Live, Krita, OBS and more. Therefore, I'm quite happy there's an alternative to QML in rust.

Separating layout from code also makes it easier for designers to work on the UI without needing to know rust, and the slint team has even built a live preview, and a plugin for Figma. I saw Tobias's talk in RustLab 2024 and I was quite blown away by its capabilities.

This is how the slint code looks like:

component MemoryTile inherits Rectangle {
    width: 64px;
    height: 64px;
    background: #3960D5;

    Image {
        source: @image-url("icons/bus.png");
        width: parent.width;
        height: parent.height;
    }
}

export component MainWindow inherits Window {
    MemoryTile {}
}

Prerequisites

Initial flake

Let's start by creating a folder and using my template for a rust development shell:

mkdir android-app
cd android-app
nix flake init -t github:woile/nix-config#rust-shell
git init && git add -N .  # promise to add the files later, so we get a hash for the flake

At the time of writing, we'll get a flake.nix and a .envrc, with a flake.nix that looks like this (I've added the self because we are gonna use it later):

{
  description = "A development shell for rust";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs =
    inputs@{
      flake-parts,
      self,
      ...
    }:
    # https://flake.parts/
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "x86_64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      perSystem =
        { pkgs, inputs', ... }:
        {
          devShells.default = pkgs.mkShell {
            name = "dev";

            # Available packages on https://search.nixos.org/packages
            buildInputs = with pkgs; [
              just
              inputs'.fenix.packages.stable.toolchain
            ];

            shellHook = ''
              echo "Welcome to the rust devshell!"
            '';
          };
        };
    };
}

If we have direnv installed and configured, we can just run direnv allow and we'll have the shell setup and configured for us, as soon as we jump into the folder in our terminals.

Otherwise, you'll have to run nix develop to get into the shell.

Exploring the nix flake

If we start looking at the flake.nix, we'll notice that it has 2 inputs:

  • nixpkgs which is the nixpkgs flake, where all the packages come from
  • fenix which provides an up-to-date rust toolchain, and with it, we can set up everything we need for rust development.

Next, the outputs are created using flake-parts. Which makes it easy to create per-system configurations that run on most popular platforms.

systems = [
  "x86_64-linux"
  "aarch64-darwin"
  "x86_64-darwin"
];
perSystem =
  { pkgs, inputs', ... }:
  {
    ...
  };

Inside the perSystem function, we have a devShells.default that sets up a shell with the just package and the fenix toolchain.

Create the rust project

Once we jump into the shell, the rust toolchain should be available and we can create a rust project and start adding the dependencies for slint:

cargo init --lib .
cargo add slint -F backend-android-activity-06

In the lib.rs let's add all the slint code we are using in this blog. We are embedding the *.slint file directly into the rust code, for simplicity.

#[unsafe(no_mangle)]
fn android_main(app: slint::android::AndroidApp) {
    slint::android::init(app).unwrap();

    slint::slint! {
        export component MainWindow inherits Window {
            Text { text: "Hello World"; }
        }
    }
    MainWindow::new().unwrap().run().unwrap();
}

Configuring the rust project

Next, we'll take a look at the the slint documentation for android.

First thing I've noticed, is that we need to update the Cargo.toml to include the lib configuration:

[lib]
crate-type = ["cdylib"]

And it mentions that we need to add a new target: aarch64-linux-android

This was a problem for me when setting up android, as I'm on a linux with x86_64, and I didn't know the emulator should run on your host arch.

Therefore, we are going to add 2 target architectures:

  • aarch64-linux-android: in case you want to run the app on your phone
  • x86_64-linux-android: in case you want to run the app on an emulator

We need to update the flake to reflect this, our devShells.default.buildInput will now look like this:

buildInputs = with pkgs; [
  just
-  inputs'.fenix.packages.stable.toolchain
+  (
+    with inputs'.fenix.packages;
+    combine [
+      stable.toolchain
+      targets.aarch64-linux-android.stable.rust-std
+      targets.x86_64-linux-android.stable.rust-std
+    ]
+  )
];

Plus, cargo-apk as recommended in the slint docs:

buildInputs = with pkgs; [
  just
  (
    with inputs'.fenix.packages;
    combine [
      stable.toolchain
      targets.aarch64-linux-android.stable.rust-std
      targets.x86_64-linux-android.stable.rust-std
    ]
  )
+ cargo-apk
];

Let's keep track of the command we are going to run in the justfile:

touch justfile
# Run the android app
run-android:
    cargo apk run --target x86_64-linux-android --lib

Notice that we are using the host's architecture to build the apk, because we are going to run the emulator with that arch. If you are running on a different architecture, like for example, if you are actually running against your device, you should change the --target flag to match your host's architecture.

Android setup

To run the android app, we'll have to configure a bunch of things. Knowing little to nothing about android development, and looking at the slint docs, we know that in order to run an android app we need the following env variables:

  • ANDROID_HOME
  • ANDROID_NDK_ROOT
  • JAVA_HOME

The way I see it, if we have those variable set right, slint should be able to build an apk and install it on the android device.

The last one: JAVA_HOME should be the easiest to set up, we add the dependency to the buildInputs of the devShells.default. Which will automatically add the env variable.

buildInputs = with pkgs; [
  just
  (
    with inputs'.fenix.packages;
    combine [
      stable.toolchain
      targets.aarch64-linux-android.stable.rust-std
      targets.x86_64-linux-android.stable.rust-std
    ]
  )
  cargo-apk
+  jdk
];

For the other 2 variables, we read what nix has to say about android:

It took me a while to figure out exactly what I needed, I don't know if it's a docs problem, or my utter ignorance about android development, probably a bit of both. But we cannot use android-studio-full. Instead we are gonna have to refactor the flake quite a bit, by initializing a bunch of variables:

      perSystem =
        {
          pkgs,
          inputs',
+          lib,
+          system,
          ...
        }:
+        let
+          platformVersion = "35";
+          systemImageType = "default";
+          currentPath = builtins.getEnv "PWD";
+          androidEnv = pkgs.androidenv.override { licenseAccepted = true; };
+          androidComp = (
+            androidEnv.composeAndroidPackages {
+              cmdLineToolsVersion = "8.0";
+              includeNDK = true;
+              # we need some platforms
+              platformVersions = [
+                "30"
+                platformVersion
+              ];
+              # we need an emulator
+              includeEmulator = true;
+              includeSystemImages = true;
+              systemImageTypes = [
+                systemImageType
+                # "google_apis"
+              ];
+              abiVersions = [
+                "x86"
+                "x86_64"
+                "armeabi-v7a"
+                "arm64-v8a"
+              ];
+              cmakeVersions = [ "3.10.2" ];
+            }
+          );
+          android-sdk = (pkgs.android-studio.withSdk androidComp.androidsdk);
+        in
        {
         # accept android license (id-2)
+         _module.args.pkgs = import self.inputs.nixpkgs {
+           inherit system;
+           config.allowUnfree = true;
+           config.android_sdk.accept_license = true;
+           config.allowUnfreePredicate =
+             pkg:
+             builtins.elem (lib.getName pkg) [
+               "terraform"
+             ];
+         };
          # same as we previously saw (id-1)
          devShells.default = {
            ...
          };
          ...
        }
These variables will not only be used to initialize the android environment, but also to configure the android emulator, system images and environment variables for the android development tools. They all kind of have to match the android environment.

That's why we set platformVersion to "35" which refers to the API for "Vanilla Ice Cream", which is android 15. Most of the other variables are set based on the nix docs about android.

We also accept android license, by temporarly modifying the nixpkgs configuration on the flake (id-2).

Android emulator

We can either use our phone, or run an emulator. We are choosing the second option, and for that, we'll use our system's architecture.

In nix terms, we are gonna add a package, and use nix to run it.

Let's see how it would look:

let
  ...
in {
  # continue from id-1
  packages.android-emulator = androidEnv.emulateApp {
    name = "emulate-MyAndroidApp";
    platformVersion = platformVersion;
    abiVersion = "x86_64"; # armeabi-v7a, mips, x86_64, arm64-v8a
    systemImageType = systemImageType;
  };
  # same as we previously saw (id-1)
  devShells.default = {
    ...
  };
  ...
}

Now with this package, we can update our justfile to build and run the emulator in a single command:

# Run the android app
run-android:
    cargo apk run --target x86_64-linux-android --lib

+ # Run the android emulator
+ run-emulator:
+     nix run .#android-emulator

Now we can run the emulator with the following command:

just run-emulator

Trying the set up out

What happens if we run the android app?

just run-android

We get the following error:

Error: Android SDK is not found. Please set the path to the Android SDK with the $ANDROID_SDK_ROOT environment variable.
error: Recipe `run-android` failed on line 2 with exit code 1

Hence, slint is smart enough to detect android's not fully configured.

Configuring env variables

Part of the reason we set some variables with let .. in .. in the flake, is to be able to set the android env variables in the devshell.

Let's finish that:

{
  devshell.default = {
    ...
+   ANDROID_HOME = "${androidComp.androidsdk}/libexec/android-sdk";
+   ANDROID_SDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk";
+   ANDROID_NDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk/ndk-bundle";
  };
}

Now with this variables in place, we can run the android app:

just run-android

We should see the emulator opening our android app with a "Hello World" message.

Troubleshooting

There are 2 extra variables I've set that help things go smooth on linux:

{
  devshell.default = {
    ...
    ANDROID_HOME = "${androidComp.androidsdk}/libexec/android-sdk";
    ANDROID_SDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk";
    ANDROID_NDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk/ndk-bundle";

+    CARGO_HOME = "${currentPath}/.cargo-home";
+    LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
+      with pkgs;
+      lib.makeLibraryPath [
+        wayland
+        libxkbcommon
+        fontconfig
+      ]
+    }";
  };
}

The first one, CARGO_HOME is for rust to not share the same directory with other rust installations. The second one, LD_LIBRARY_PATH is for running the application on KDE.

Final result

We get this beautiful flake.nix

{
  description = "A development shell for rust";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs =
    inputs@{
      flake-parts,
      self,
      ...
    }:
    # https://flake.parts/
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "x86_64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      perSystem =
        {
          pkgs,
          inputs',
          lib,
          system,
          ...
        }:
        let
          platformVersion = "35";
          systemImageType = "default";
          currentPath = builtins.getEnv "PWD";
          androidEnv = pkgs.androidenv.override { licenseAccepted = true; };
          androidComp = (
            androidEnv.composeAndroidPackages {
              cmdLineToolsVersion = "8.0";
              includeNDK = true;
              # we need some platforms
              platformVersions = [
                "30"
                platformVersion
              ];
              # we need an emulator
              includeEmulator = true;
              includeSystemImages = true;
              systemImageTypes = [
                systemImageType
                # "google_apis"
              ];
              abiVersions = [
                "x86"
                "x86_64"
                "armeabi-v7a"
                "arm64-v8a"
              ];
              cmakeVersions = [ "3.10.2" ];
            }
          );
          android-sdk = (pkgs.android-studio.withSdk androidComp.androidsdk);
        in
        {
          _module.args.pkgs = import self.inputs.nixpkgs {
            inherit system;
            config.allowUnfree = true;
            config.android_sdk.accept_license = true;
            config.allowUnfreePredicate =
              pkg:
              builtins.elem (lib.getName pkg) [
                "terraform"
              ];
          };
          packages.android-emulator = androidEnv.emulateApp {
            name = "emulate-MyAndroidApp";
            platformVersion = platformVersion;
            abiVersion = "x86_64"; # armeabi-v7a, mips, x86_64, arm64-v8a
            systemImageType = systemImageType;
          };
          devShells.default = pkgs.mkShell {
            name = "dev";

            # Available packages on https://search.nixos.org/packages
            buildInputs = with pkgs; [
              just
              (
                with inputs'.fenix.packages;
                combine [
                  stable.toolchain
                  targets.aarch64-linux-android.stable.rust-std
                  targets.x86_64-linux-android.stable.rust-std
                ]
              )
              cargo-apk
              jdk
              android-sdk
            ];

            shellHook = ''
              echo "Welcome to the rust devshell!"
            '';

            ANDROID_HOME = "${androidComp.androidsdk}/libexec/android-sdk";
            ANDROID_SDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk";
            ANDROID_NDK_ROOT = "${androidComp.androidsdk}/libexec/android-sdk/ndk-bundle";

            CARGO_HOME = "${currentPath}/.cargo-home";
            LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${
              with pkgs;
              lib.makeLibraryPath [
                wayland
                libxkbcommon
                fontconfig
              ]
            }";
          };
        };
    };
}

If you are running direnv a simple direnv allow should load everything into your terminal, otherwise you'll have to run nix develop.

And our justfile would stay the same:

# Run the android app
run-android:
    cargo apk run --target x86_64-linux-android --lib

# Run the android emulator
run-emulator:
    nix run .#android-emulator

Conclusion

After a long struggle, I managed to set up NixOS for android applications with Slint. It was tough!

Slint's documentation is relatively good, and NixOS's documentation could be better, by explaining concepts more in depth. Why? Because NixOS's users know they cannot start as everybody else. For example, this case, if I'm starting with Android, I won't go to Android documentation first, but to NixOS documentation, cause for sure it's going to be different than what Android docs say.

On the plus side, once you get things right, they just work, which is mind blowing, and reproducible!

I hope you enjoyed this article and let me know your thoughts in mastodon:

@woile