Template to create Android apps using nix flakes.

Files
README.md

Highway #

Boilerplate for an Android app: Kotlin + Jetpack Compose + Material 3, minSdk 29 (Android 10) and up, application id cl.frabarz.highway, with a reproducible Nix dev environment and tests that run without a device.

Requirements #

  • Nix with flakes enabled (nix.experimental-features = nix-command flakes).
  • Network access the first time you build (Maven/Google downloads and the Android SDK).

Quick start #

$ nix develop
$ gradle assembleDebug     # debug APK -> app/build/outputs/apk/debug/
$ gradle test              # JVM unit tests + Robolectric UI tests
$ gradle lint              # Android lint
$ gradle installDebug      # install on a connected device

The shell provides JAVA_HOME (JDK 17), ANDROID_HOME (SDK with platform 36 and build-tools 36.0.0), gradle (8.14.4, same version as the wrapper), adb, sdkmanager and the rest of the command line tools.

Stack #

Component Version
Gradle 8.14.4
Android Gradle Plugin 8.13.2
Kotlin 2.2.21
Compose BOM 2026.06.01 (Compose 1.11.4, Material 3 1.4.0)
compileSdk / targetSdk 36
minSdk 29 (Android 10)
JDK 17

Dependency versions live in gradle/libs.versions.toml.

The dependency set is pinned to the last releases that still support compileSdk 36. The 2026.09 Compose BOM and androidx.core 1.19+ require compileSdk 37 and AGP 9.x. See Dependency versions for when to move.

Layout #

app/src/main/java/cl/frabarz/highway/
├── HighwayApplication.kt     Application + manual DI container
├── MainActivity.kt           Single activity, Compose entry point
├── data/                     Repository interface and an in-memory fake
├── di/AppContainer.kt        Dependency wiring (swap the fake for a real backend here)
└── ui/
    ├── home/                 Screen, ViewModel and state
    └── theme/                Colors, typography and the Material 3 theme

The sample feature is a list of upcoming arrivals. FakeArrivalsRepository returns static data; replace it with a network-backed implementation and inject it through DefaultAppContainer.

data/Arrival is the domain model, and the UI talks to it directly. There is no separate DTO layer yet, because there is only one data source. When a second one appears — a database alongside the network, say — put the wire format in data/ and map it to Arrival at the repository boundary, rather than letting DTOs reach the UI.

Dependency versions #

The versions here are pinned to the last releases that still support compileSdk 36. The 2026.09 Compose BOM and androidx.core 1.19+ need compileSdk 37 and AGP 9.x.

Both are available now — the pinned nixpkgs carries Android platform 37, and AGP 9.x is on Google's Maven — so this is a deliberate deferral rather than a blocker. Moving on means bumping compileSdk and targetSdk to 37, adding "37" to the flake's platformVersions, and working through the AGP 9 breaking changes. The one API AGP 8.13 already flags as removed in 9.0 — VariantBuilder.enableUnitTest — has already been replaced.

Testing #

Three layers, all wired up:

$ gradle test                      # unit tests: ViewModel logic + Robolectric Compose UI tests
$ gradle connectedAndroidTest      # instrumented tests, requires a connected device
  • app/src/test/.../HomeViewModelTest.kt — plain JVM tests using JUnit 4, kotlinx-coroutines-test and Turbine. MainDispatcherRule replaces Dispatchers.Main.
  • app/src/test/.../HomeScreenTest.kt — Compose UI tests running on the JVM through Robolectric, so no emulator is needed.
  • app/src/test/.../MainActivityTest.kt — launches the real activity, so the DI wiring is covered too. Runs at both ends of the supported API range.
  • app/src/androidTest/.../MainActivityInstrumentedTest.kt — the parts a device can show and a simulator cannot: real window insets, edge-to-edge, and the activity lifecycle.

Notes:

  • Unit tests run against the debug variant only (the release variant has unit tests disabled in app/build.gradle.kts).
  • Robolectric runs at SDK 35 by default (app/src/test/resources/robolectric.properties). That is the highest level it supports on JDK 17 — SDK 36 needs Java 21. The activity test additionally runs at SDK 29, the project's minSdk.
  • A test class that needs a different API level states it with @Config(sdk = ...), which overrides the properties file. Keeping that file matters: without it Robolectric falls back to the manifest's targetSdk, which is 36 and cannot run on JDK 17.
  • Robolectric downloads its android-all runtime on the first test run. The Nix build pre-fetches the runtimes the suite needs instead — see the Nix flake.
  • adb devices lists connected devices; adb install -r installs the debug APK.
  • The device must be awake and unlocked for the instrumented tests. With the screen off, the activity's window is never laid out, the composition is never created, and every test fails with No compose hierarchies found in the app — a message that points at the test code rather than at the screen. Wake it with adb shell input keyevent KEYCODE_WAKEUP and dismiss the keyguard with adb shell wm dismiss-keyguard before running gradle connectedAndroidTest.

Nix flake #

$ nix develop                      # dev shell (JDK, Android SDK, Gradle)
$ nix build .#default              # debug APK as a Nix derivation -> ./result
$ nix flake check                  # runs the checks below
$ nix fmt                          # nixpkgs-fmt

nix flake check runs three checks, defined in flake.nix:

Check Gradle task What it catches
unit-tests test Failing JVM and Robolectric tests
lint lint Lint errors — lint is configured to abort on error
release-build assembleRelease R8 / resource shrinking breaking the release build

Nix builds them in parallel, so the whole set costs about as much as one build. Each result is cached in the Nix store, so re-running nix flake check on an unchanged tree is instant.

Robolectric's runtimes #

Robolectric downloads its android-all runtimes over the network at test time, with no hash check. A stalled download fails the check with Unable to locate dependency or No compose hierarchies found in the app — errors that point at the test code rather than at the network.

The unit-tests check therefore pre-fetches the two runtimes the suite needs (API 29 and API 35) with fetchurl, so they are content-addressed and cached, and points Robolectric at them in offline mode. The Gradle build reads the location from the ROBOLECTRIC_DEPENDENCY_DIR environment variable, so a normal gradle test outside Nix is unaffected and resolves the runtimes as usual.

If Robolectric is upgraded, the runtimes are renamed and the two hashes in flake.nix need updating. Robolectric asks for a missing runtime by name, so the breakage is obvious rather than silent.

The unit-tests and lint checks install their reports alongside the check, so a failure can be read without reproducing it locally:

$ nix build .#checks.x86_64-linux.unit-tests
$ open -a browser $(nix eval --raw .#checks.x86_64-linux.unit-tests)/reports/tests/testDebugUnitTest/index.html

The Android SDK comes from pkgs.androidenv.composeAndroidPackages, so ANDROID_HOME points into the Nix store and nothing is downloaded into your home directory. The androidsdk packages are unfree, which is why the flake enables allowUnfree and android_sdk.accept_license.

nix build and nix flake check run Gradle with the network enabled (the sandbox is turned off for those two derivations), so they are not hermetic and take a few minutes the first time. Both results are cached in the Nix store afterwards.

Continuous integration #

.github/workflows/ci.yml runs on every push to main and on every pull request, with two jobs:

Job What it runs Toolchain
flake-check nix flake check — unit tests, lint, release build Nix, as the flake defines it
instrumented ./gradlew connectedAndroidTest Standard Android SDK and an emulator

The first job runs the same command developers run locally, so there is one answer to "does this pass?". The workflow builds on ubuntu-latest; the checks are defined for all four platforms the flake supports, and Nix checks the current one.

The first run downloads the Android SDK and the Gradle dependencies and takes several minutes. The Nix store is not cached between runs, so expect that cost on every run.

The second job runs the instrumented suite on an emulator at API 35, the level where targetSdk 36 enforces edge-to-edge. It does not use Nix: the Nix Android emulator is Linux-only, needs KVM, and cannot be exercised from a development machine, so the standard Android SDK setup is used instead.

The instrumented job is marked continue-on-error for now. An emulator is the flaky part of any Android CI, and this job has not yet been seen green on a runner. Remove that line once it has been reliable long enough to trust.

The job wakes the emulator and dismisses the keyguard before running. The instrumented tests need a visible, unlocked screen, and the error you get without one points at the test code rather than at the screen.

Adding SDK components #

The flake exposes the platform and platform-tools versions it installs:

androidConfig = {
  platformVersions = [ "36" ];
  ...
};

Add a version there, for example platformVersions = [ "36" "37.0" ], and re-enter the shell. Set includeEmulator = true to get the emulator (Linux only, needs KVM).

The build-tools version is not listed there: the flake reads it from the buildTools entry in gradle/libs.versions.toml, so the catalog is the only place to change it. The same is true of the Gradle version — the flake reads it from gradle/wrapper/gradle-wrapper.properties and fails the evaluation if the two ever disagree.

Android Studio #

Open the project directory in Android Studio and point Settings > Build > Build Tools > Gradle at the wrapper (gradlew), which downloads the same Gradle version the flake provides. Make sure ANDROID_HOME points at the SDK from nix develop, or let Studio use its own SDK installation.