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 andandroidx.core1.19+ requirecompileSdk 37and 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-testand Turbine.MainDispatcherRulereplacesDispatchers.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'sminSdk. - 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'stargetSdk, which is 36 and cannot run on JDK 17. - Robolectric downloads its
android-allruntime on the first test run. The Nix build pre-fetches the runtimes the suite needs instead — see the Nix flake. adb deviceslists connected devices;adb install -rinstalls 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 withadb shell input keyevent KEYCODE_WAKEUPand dismiss the keyguard withadb shell wm dismiss-keyguardbefore runninggradle 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.
- Kotlin 79%
- Nix 21%