Flutter environment with Nix

Flutter environment with Nix

This guide provides instructions for setting up a Flutter environment with complete support for Linux and Android (including the Android emulator with hardware decoding enabled), on any Linux distribution with the Nix package manager installed. This article also compares Waydroid — an Android simulator leveraging Wayland, complete with hardware decoding capabilities as well — with the official Android emulator. Last, some examples of VSCode launch configurations — including both the emulator and the simulator — are provided to ease development.

1. Environment configuration with Nix

Inside your existing Flutter project, create a flake.nix in your root folder and add the following configuration to it:

{
  description = "Flutter environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";
    nixpkgs.url = "github:NixOS/nixpkgs";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
      let
        pkgs = import nixpkgs {
          inherit system;
          config.allowUnfree = true;
          android_sdk.accept_license = true;
        };
        androidEnv = pkgs.androidenv.override { licenseAccepted = true; };
        androidComposition = androidEnv.composeAndroidPackages {
          cmdLineToolsVersion = "8.0"; # emulator related: newer versions are not only compatible with avdmanager
          platformToolsVersion = "34.0.4";
          buildToolsVersions = [ "30.0.3" "33.0.2" "34.0.0" ];
          platformVersions = [ "28" "31" "32" "33" "34" ];
          abiVersions = [ "x86_64" ]; # emulator related: on an ARM machine, replace "x86_64" with
          # either "armeabi-v7a" or "arm64-v8a", depending on the architecture of your workstation.
          includeNDK = false;
          includeSystemImages = true; # emulator related: system images are needed for the emulator.
          systemImageTypes = [ "google_apis" "google_apis_playstore" ];
          includeEmulator = true; # emulator related: if it should be enabled or not
          useGoogleAPIs = true;
          extraLicenses = [
            "android-googletv-license"
            "android-sdk-arm-dbt-license"
            "android-sdk-license"
            "android-sdk-preview-license"
            "google-gdk-license"
            "intel-android-extra-license"
            "intel-android-sysimage-license"
            "mips-android-sysimage-license"            ];
        };
        androidSdk = androidComposition.androidsdk;
      in
      {
        devShell = with pkgs; mkShell rec {
          ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
          ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
          JAVA_HOME = jdk11.home;
          FLUTTER_ROOT = flutter;
          DART_ROOT = "${flutter}/bin/cache/dart-sdk";
          GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidSdk}/libexec/android-sdk/build-tools/33.0.2/aapt2";
          QT_QPA_PLATFORM = "wayland;xcb"; # emulator related: try using wayland, otherwise fall back to X.
          # NB: due to the emulator's bundled qt version, it currently does not start with QT_QPA_PLATFORM="wayland".
          # Maybe one day this will be supported.
          buildInputs = [
            androidSdk
            flutter
            qemu_kvm
            gradle
            jdk11
          ];
          # emulator related: vulkan-loader and libGL shared libs are necessary for hardware decoding
          LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath [vulkan-loader libGL]}";
          # Globally installed packages, which are installed through `dart pub global activate package_name`,
          # are located in the `$PUB_CACHE/bin` directory.
          shellHook = ''
            if [ -z "$PUB_CACHE" ]; then
              export PATH="$PATH:$HOME/.pub-cache/bin"
            else
              export PATH="$PATH:$PUB_CACHE/bin"
            fi
          '';
        };
      }
    );
}

Change the current directory to you project’s path, and enter the command nix develop. It will download and set up all dependencies. A flake.lock file will be generated. If the project is contained in a git repository, add both flake.nix and flake.lock to it.

1.1. No longer necessary specifying SDK in VSCode settings

Since the following two pull requests were merged, it is no longer needed to specify the SDK in your VSCode settings.

Previously (before the release of nixpkgs flutter 3.16.5) it was needed due to the ecosystem detecting the flutter-unwrapped SDK instead of the flutter one.

With these PRs merged, the VSCode extension, the Neovim plugin, patrol, etc. will all detect the correct SDK directory.

The issue that was present in previous versions of Flutter is explained more in detail here.

1.2. Broken VSCode components

NB: Due to the merged PRs mentioned in 1.1, this subsection should not be necessary anymore. Skip it if not affected.

It may be possible that flutter does not integrate correctly with VSCode. In my case, after upgrading from Flutter 3.13.4 to 3.13.8, Start Debugging, Run Without Debugging and all other VSCode tools for running a Flutter app stopped working: the VSCode running status bar would be shown for one or two seconds, then the process would be killed for some unknown reason. Downgrading to 3.13.4 would enable the tools to work again, however, this was only a temporary workaround.

You may first try running flutter clean.

In my case, flutter clean was not enough. The solution consisted in renaming/moving /home/manuel/.config/VSCodium to /home/manuel/.config/VSCodium-backup and restoring my previously backed up /home/manuel/.config/VSCodium/User/settings.json and /home/manuel/.config/VSCodium/User/keybinding. Afterwards, the VSCode tools worked with 3.13.8.

1.3. Malfunctioning Flutter and Dart binaries

NB: Due to the merged PRs mentioned in 1.1, this subsection should not be necessary anymore. Skip it if not affected.

It’s important to consider adding flutter-unwrapped to your main environment configuration only in case the flutter and dart binaries encounter issues. Under specific circumstances, the Nix path to flutter-unwrapped may be positioned before the Nix path to flutter in the PATH, potentially leading to malfunctioning binaries. This is precisely why, in the configuration below, flutter-unwrapped is defined after flutter. By doing so, you ensure that the flutter and dart binaries provided by flutter take precedence over those supplied by flutter-unwrapped.

buildInputs = [
  androidSdk
  flutter
  flutter-unwrapped
  qemu_kvm
  gradle
  jdk11
];

1.3.1. Wrong SDK

In case the wrong SDK (for example 3.13.4 instead of 3.13.8), there should be no need to specify it in your VSCode settings; it should be enough to run flutter clean and reopen the project with VSCode. Then 3.13.8 should be used.

1.3.2. Specify another SDK

This section should not be needed unless you want to go ahead and use a different SDK.

Unfortunately, it is currently not possible to load environment variables in VSCode settings, i.e., it is not possible to get the Flutter SDK path this way:

settings.json:

{
  "dart.flutterSdkPath": "${env:FLUTTER_ROOT}",
  "dart.sdkPath": "${env:FLUTTER_ROOT}/bin/cache/dart-sdk/bin",
}

Instead, the flutter-wrapped path needs to be inserted manually, e.g.:

{
  "dart.flutterSdkPath": "/nix/store/3fbs65nsjkzk0mjj1085r8z90lkfg9qr-flutter-wrapped-3.13.4-sdk-links",
  "dart.sdkPath": "/nix/store/3fbs65nsjkzk0mjj1085r8z90lkfg9qr-flutter-wrapped-3.13.4-sdk-links/bin/cache/dart-sdk/bin",
}

2. Direnv

Direnv is a powerful utility that automates the loading and unloading of environment variables based on the current directory. It’s especially valuable in Nix systems, as it helps manage project-specific dependencies, including Flutter, without the need for global installations.

To make the most of Direnv, follow these steps:

  1. Global Installation: First, ensure you have Direnv installed globally on your system. You can easily install it using your package manager.

  2. VSCode Integration: Install the direnv extension for VSCode, which enhances the integration and user experience with Direnv.

  3. Project Setup: Create a .envrc file in the root directory of your project. This file will contain directives to manage your environment automatically.

    Example .envrc file:

    use flake
    

Then add it to git and add the following line to .gitignore:

.direnv
  1. Automatic Environment Loading: After setting up Direnv, your environment will automatically load whenever you change into the project directory using cd or open it in VSCode (NB: the first time you will be asked whether you trust the project). This ensures that project-specific dependencies, like Flutter, are readily available without global installations or manual setup.

By following these steps, the development workflow can be streamlined and dependencies efficiently managed, ensuring a clean and isolated environment for each project.

3. Sqlite support

SQLite libs can be shipped or not at the app level. I recommend shipping them, as this will ship the SQLite interpreter, thus reducing the surface for bugs across different Android (and iOS) versions, but also different Linux distributions and versions. Also, new SQLite features can be safely used on every device.

3.1. Shipping latest sqlite3 libraries (my recommendation)

If the dependency sqlite3_flutter_libs is specified in your project’s pubspec.yaml, then there is nothing else to do. However, sqlite can still be added to the flake.nix’s buildInputs for debugging purposes..

3.2. Using sqlite3 libs provided by the OS

If sqlite3_flutter_libs is not specified in the pubspec.yaml, then the latest sqlite3 version will not be shipped with the app. In this case, the version of SQLite used will be the one that comes with your OS. See this list for reference.

In this case, sqlite3’s shared libs need to be added to the flake.nix by adding sqlite and sqlite.dev libs to LD_LIBRARY_PATH, i.e.:

LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath [sqlite sqlite.dev vulkan-loader libGL]}";

NB: This means that when the Flutter and Dart versions are updated (through nix flake update), then these paths must be manually updated.

4. Problem building Flutter app for Android

When creating a new project, chances are you will encounter the following showstopper when building the Flutter app for Android:

* What went wrong:
Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: '1.0.0']
> A problem occurred configuring project ':gradle'.
   > Could not create service of type OutputFilesRepository using ExecutionGradleServices.createOutputFilesRepository().
      > Failed to create parent directory '/nix/store/y8c32gi8z15isnjb501806zfygd3fqx1-flutter-wrapped-3.16.7-sdk-links/packages/flutter_tools/gradle/.gradle' when creating directory '/nix/store/y8c32gi8z15isnjb501806zfygd3fqx1-flutter-wrapped-3.16.7-sdk-links/packages/flutter_tools/gradle/.gradle/buildOutputCleanup'

This is due to the generated Gradle scripts that, as of February 2024, are incompatible with the flutter from nixpkgs.

4.1. Solution

The solution is made up of three steps.

4.1.1. Change beginning of build.gradle

Change what is in android/app/build.gradle from:

plugins {
    id "com.android.application"
    id "kotlin-android"
    id "dev.flutter.flutter-gradle-plugin"
}

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

to the code that was generated in previous releases:

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}

def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

4.1.2. Keep namespace in build.gradle

Within the same file, make sure the namespace field is not removed, e.g. namespace "com.example.app.project_name".

4.1.3. Change settings.gradle

At this point it might already work again. If not, change also android/settings.gradle from

pluginManagement {
    def flutterSdkPath = {
        def properties = new Properties()
        file("local.properties").withInputStream { properties.load(it) }
        def flutterSdkPath = properties.getProperty("flutter.sdk")
        assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
        return flutterSdkPath
    }
    settings.ext.flutterSdkPath = flutterSdkPath()

    includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")

    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }

    plugins {
        id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false
    }
}

plugins {
    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
    id "com.android.application" version "7.3.0" apply false
}

include ":app"

to what was generated in the previous releases:

include ':app'

def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()

assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }

def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

5. Android emulator

The official Android emulator needs to be defined in the flake.nix, as it is already done by the author of this guide. Afterwards, installing and starting an Android virtual device is very simple. The following example shows how to do this.

avdmanager create avd --force --name phone --package 'system-images;android-32;google_apis;x86_64'
emulator -avd phone -skin 720x1280 --gpu host

5.1. Hardware decoding

vulkan-loader and libGL shared libs — which are also already present in the flake.nix — are necessary for enabling hardware decoding.

NB: The --gpu host part ensures the emulator is running with hardware decoding. If you are using an Nvidia graphic card or if you are troubleshooting some bugs, then use --gpu guest to run the emulator using software decoding. However, this would incur a significant performance penalty.

5.2. Audio issues

If your workstation sound is distorted when running the emulator, a workaround is to run it with the -noaudio flag. For example:

emulator -avd phone -skin 720x1280 --gpu host -noaudio

For an actual solution try with this (not tested).

5.3. Wayland support

The official emulator cannot be started natively under Wayland because it is bundled with a specific, older Qt version.

This guide will be updated if a way to use the emulator with the host’s Qt version is found.

5.4. Supported Android version

The emulator supports all Android version, which makes it very practical.

5.5. When to use the official emulator

I usually use the emulator for most development. However, if you mainly use a simulator such as Waydroid, at least consider using the emulator for testing specific things on different Android versions (e.g. across versions 7 to 14).

Edge cases:

  • Specific features for specific Android versions (e.g. cloud backups)
  • Major changes (Android permissions changing across major versions)
  • Database compatibility (in case sqlite3-common-libs is not used)

6. Waydroid, an Android simulator

Waydroid is an Android simulator: it is a compatibility layer that allows running Android apps on a Linux distribution. Waydroid primarily leverages KVM (Kernel-based Virtual Machine) and QEMU (Quick Emulator) to create a virtualized environment for Android. It requires Wayland.

6.1. How to install

Follow the Install on Desktops section of the official project.

6.2. Comparison with the official emulator

The official emulator uses a second kernel layer, which constitutes a performance penalty. The emulator also allows multiple configurations, such as running an ARM virtual device on an X86_64 host. In this case there will still be the second kernel layer, but the performance will be very bad due to the host and the guest not sharing the same architecture.

It is possible to use e.g. an x86_64 based Android OS in order to be a lot performant while running on an x86_64 based primary OS (e.g. x86_64 NixOS). The same can be said for ARM and ARM-based macOS machines, or ARM-based Linux distributions. However, there is still going to be a noticeable performance penalty due to the second kernel layer.

With Waydroid, no second kernel layer is needed. Instead, this is what happens: Waydroid runs Android in a container, separated from your existing OS, and uses the same kernel your OS is currently using to bind the hardware to Android for interaction. QEMU provides the necessary hardware abstraction and allows Android to run as if it were on real hardware. It is therefore much faster than the official android emulator. It uses hardware decoding on Intel and AMD GPUs.

However, there are some concerns that make Waydroid adoption questionable: Android versions and Prop setting

6.3. Android versions

Waydroid supports less Android versions than the emulator. As of October 2023, the latest supported version is Android 11.

6.4. Broken prop set

Unfortunately, prop set does not work on my NixOS machine. For example, none of the following commands worked:

waydroid prop set persist.waydroid.fake_touch "*"
waydroid prop set persist.waydroid.width 720
waydroid prop set persist.waydroid.height 1280

It follows that the user experience is not great, as, for example, swipe-to-scroll does not work due to the fake_touch prop setting not working, mouse-wheel scrolling must be used instead. This is particularly bad when running a webview within an application, where mouse-wheel scrolling for some reason does not work. Thus, it is not possible to scroll the webview content at all. This observation alone implies Waydroid is not a reliable tool for development yet.

6.5. How to connect to detect Waydroid virtual device

  • Figure out the IP address of the Waydroid device under System Settings (in my case it is 192.168.240.112).
  • Execute adb connect 192.168.240.112:5555 in the VSCode terminal.

6.6. When to use the Waydroid simulator

Although I thoroughly enjoy Waydroid for being much more lightweight w.r.t. the emulator, I cannot recommend it due to:

  • prop setting not working at all
  • lack of new Android versions
  • no simple way to rollback to older Android versions, or have different profiles with different Android versions

I personally see Waydroid more of a Android media consumption tool than a development assistant.

Therefore, I advise to entirely rely on the official emulator for development purposes.

7. VSCode launch configurations

This section doesn’t directly pertain to Flutter with Nix. However, given our coverage of the Android emulator and Waydroid, it would be beneficial to include some practical launch configurations as well.

In your development workflow, using VSCode launch configurations is essential. These configurations can be tailored to specific devices or device sets, ensuring a smoother debugging experience.

For both Android virtual and physical devices, it’s recommended to include the --device-user=0 option. This setting ensures that the app is installed for the current user only, rather than globally, as it would be by default. This approach simplifies the uninstallation process.

Replace pixel with your physical device name.

Feel free to take inspiration from my own .vscode/launch.json configuration file (NB: profile and release mode do not make sense with simulators and emulators):

{    
    "configurations": [
        {
            "name": "Emulator - debug mode - w/ debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "debug",
            "deviceId": "sdk gphone64",
        },
        {
            "name": "Emulator - debug mode - w/out debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "debug",
            "deviceId": "sdk gphone64",
            "noDebug": true,
        },
        {
            "name": "Waydroid - debug mode - w/ debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "debug",
            "deviceId": "WayDroid",
        },
        {
            "name": "Waydroid - debug mode - w/out debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "debug",
            "deviceId": "WayDroid",
            "noDebug": true,
        },
        {
            "name": "Pixel - debug mode - w/ debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "debug",
            "deviceId": "pixel",
        },
        {
            "name": "Pixel - debug mode - w/out debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "debug",
            "deviceId": "pixel",
            "noDebug": true,
        },
        {
            "name": "Pixel - profile mode",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "profile",
            "deviceId": "pixel",
        },
        {
            "name": "Pixel - release mode",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [
                "--device-user=0"
            ],
            "flutterMode": "release",
            "deviceId": "pixel"
        },
        {
            "name": "Linux - debug mode - w/ debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [],
            "flutterMode": "debug",
            "deviceId": "linux",
        },
        {
            "name": "Linux - debug mode - w/out debugger",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [],
            "flutterMode": "debug",
            "deviceId": "linux",
            "noDebug": true,
        },
        {
            "name": "Linux - profile mode",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [],
            "flutterMode": "profile",
            "deviceId": "linux",
        },
        {
            "name": "Linux - release mode",
            "request": "launch",
            "type": "dart",
            "program": "lib/main.dart",
            "args": [],
            "flutterMode": "release",
            "deviceId": "linux",
        },
    ],
}