This guide walks through updating a lein-droid-based Clojure-Android project to the current Gradle-based build system. It covers every change: build tooling, project layout, dependencies, activity patterns, REPL setup, and release configuration.
| Aspect | Old | New |
|---|---|---|
| Build tool | Leiningen + lein-droid 0.4.6 | Gradle 8.7+ / AGP 8.9+ |
| Clojure | com.goodanser.clj-android/clojure 1.7.0 fork |
Stock org.clojure:clojure 1.12.0 |
| neko | neko/neko 4.0.0-alpha5 |
com.goodanser.clj-android:neko 5.0.0-SNAPSHOT |
| nREPL | org.clojure/tools.nrepl 0.2.10 |
nrepl:nrepl 1.0.0 (auto-included) |
| Min Android | ~API 14 (Ice Cream Sandwich) | API 26 (Oreo 8.0) |
| Target Android | API 18 (Jelly Bean) | API 35 |
| Java level | 1.6 | 11 |
| Activity pattern | defactivity macro (gen-class) |
Thin Java shims + Clojure functions |
| Application class | neko.App |
com.goodanser.clj_android.runtime.ClojureApp |
| Dexing | SDK dx binary (manual) |
D8 (handled by AGP) |
| Shrinking | ProGuard (manual) | R8 (handled by AGP) |
| MultiDex | Explicit com.android.support/multidex |
Native (API 21+) |
| Dynamic classloader | DalvikDynamicClassLoader (in Clojure fork) |
AndroidDynamicClassLoader (in runtime-repl) |
Before converting your app, install the Clojure-Android toolchain to your local Maven repository. The sample project includes a task that builds and publishes all dependencies in the correct order:
cd sample
./gradlew publishDepsToMavenLocalThis clones all dependencies into build/deps/ (if not already present),
then runs publishToMavenLocal in each one. Alternatively, build each
component individually from its own repo:
cd clojure-patched && ./gradlew publishToMavenLocal
cd ../android-clojure-plugin && ./gradlew publishToMavenLocal
cd ../runtime-core && ./gradlew publishToMavenLocal
cd ../runtime-repl && ./gradlew publishToMavenLocal
cd ../neko && ./gradlew publishToMavenLocalRequirements:
- JDK 17+ (JDK 22+ also works with the
--enable-native-accessflag) - Android SDK with Build Tools and API 26+
- Gradle 8.7+ (the Gradle wrapper is recommended)
An old project has a single project.clj. The new system uses three Gradle
files at the project root plus one per module.
(defproject com.example/myapp "1.0.0"
:source-paths ["src/clojure" "src"]
:java-source-paths ["src/java"]
:javac-options ["-target" "1.6" "-source" "1.6"]
:plugins [[lein-droid "0.4.6"]]
:dependencies [[com.goodanser.clj-android/clojure "1.7.0-RC1" :use-resources true]
[neko/neko "4.0.0-alpha5"]]
:profiles {:dev
{:dependencies [[org.clojure/tools.nrepl "0.2.10"]]
:android {:aot :all-with-unused
:rename-manifest-package "com.example.myapp.debug"}}
:release
{:android {:aot :all
:build-type :release}}}
:android {:target-version 18
:aot-exclude-ns ["clojure.parallel" "clojure.core.reducers"]
:dex-opts ["-JXmx4096M" "--incremental"]
:manifest-options {:app-name "@string/app_name"}})settings.gradle.kts (project root):
pluginManagement {
repositories {
mavenLocal()
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
mavenLocal()
google()
mavenCentral()
maven { url = uri("https://clojars.org/repo") }
}
}
rootProject.name = "my-app"
include(":app")build.gradle.kts (project root):
plugins {
id("com.android.application") version "8.9.0" apply false
id("com.goodanser.clj-android.android-clojure") version "0.5.0-SNAPSHOT" apply false
}app/build.gradle.kts (app module):
plugins {
id("com.android.application")
id("com.goodanser.clj-android.android-clojure")
}
android {
namespace = "com.example.myapp"
compileSdk = 35
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
packaging {
resources {
pickFirsts += listOf("nrepl/socket.clj", "nrepl/socket/dynamic.clj")
}
}
}
clojureOptions {
warnOnReflection.set(true)
}
dependencies {
implementation("org.clojure:clojure:1.12.0")
implementation("com.goodanser.clj-android:neko:5.0.0-SNAPSHOT")
}gradle.properties (project root, needed for JDK 22+):
org.gradle.jvmargs=--enable-native-access=ALL-UNNAMEDThe following project.clj / :android map settings have no equivalent
because the Gradle plugin and AGP handle them automatically:
| Old setting | Disposition |
|---|---|
:sdk-path |
Set via ANDROID_HOME env var or local.properties |
:build-tools-version |
Managed by AGP |
:dex-opts |
D8 handles dexing; no manual flags needed |
:aot-exclude-ns |
Use clojureOptions { aotExcludeNamespaces.set(listOf(...)) } if needed |
:aot :all / :all-with-unused |
All detected .clj files are AOT-compiled by default |
:multi-dex true |
Native since minSdk 21; no configuration needed |
:rename-manifest-package |
Use applicationIdSuffix in build types instead |
:manifest-options |
Use resValue or standard Android resource merging |
Profiles (:dev, :release, :lean) |
Use Gradle buildTypes and productFlavors |
:use-resources true |
Not needed; resource handling is automatic |
When you apply com.goodanser.clj-android.android-clojure, the plugin:
- Registers
src/{sourceSet}/clojure/as source directories - Creates
compile{Variant}Clojuretasks that AOT-compile.cljto.class - Wires compiled classes into AGP's dex pipeline
- Adds
runtime-coreto all variants - Adds
runtime-repl(nREPL + dynamic classloader + dx) to debug variants only - Substitutes stock Clojure with the patched version in debug builds
myapp/
├── project.clj
├── AndroidManifest-template.xml
├── res/
│ └── values/strings.xml
├── src/
│ └── clojure/
│ └── com/example/myapp/
│ └── main.clj # defactivity here
└── src/
└── java/
└── com/example/myapp/
└── (nothing, or R.java)
myapp/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── app/
│ ├── build.gradle.kts
│ └── src/main/
│ ├── AndroidManifest.xml # (not a template)
│ ├── clojure/
│ │ └── com/example/myapp/
│ │ └── main_activity.clj # activity logic (matches MainActivity)
│ ├── java/
│ │ └── com/example/myapp/
│ │ └── MainActivity.java # thin ClojureActivity shim
│ └── res/
│ └── values/
│ └── strings.xml
└── gradle/
└── wrapper/ # Gradle wrapper (check in to VCS)
Key differences:
- Sources move under
app/src/main/ AndroidManifest.xmlis a real file, not a Clostache template- Java shim files are added alongside Clojure sources
- Gradle wrapper files should be checked in
mkdir -p app/src/main/clojure app/src/main/java app/src/main/res
# Move Clojure sources
cp -r src/clojure/* app/src/main/clojure/
# Move Java sources (if any)
cp -r src/java/* app/src/main/java/
# Move resources
cp -r res/* app/src/main/res/This is the most significant code change. The old system used neko's
defactivity macro to generate Activity classes via gen-class. The new
system uses thin Java classes that call into Clojure.
- AOT/Manifest coupling eliminated -- the manifest no longer depends on Clojure's compiler output
- REPL hot-reload works -- the Activity stays fixed while Clojure UI code reloads dynamically
- ART compatibility -- statically compiled Java shims are safer entry points than dynamically generated Clojure classes
- Better developer ergonomics -- standard Android patterns, clearer stack traces
(ns com.example.myapp.main
(:require [neko.activity :refer [defactivity set-content-view!]]
[neko.debug :refer [*a]]
[neko.threading :refer [on-ui]]
[neko.find-view :refer [find-view]]
[neko.notify :refer [toast]]))
(defactivity com.example.myapp.MainActivity
:key :main
(onCreate [this bundle]
(.superOnCreate this bundle)
(neko.debug/keep-screen-on this)
(on-ui
(set-content-view! (*a)
[:linear-layout {:orientation :vertical
:layout-width :fill
:layout-height :wrap}
[:edit-text {:id ::user-input
:hint "Type text here"
:layout-width :fill}]
[:button {:text "Press me"
:on-click (fn [_] (toast "Hello!" :short))}]]))))Java shim (app/src/main/java/com/example/myapp/MainActivity.java):
package com.example.myapp;
import com.goodanser.clj_android.runtime.ClojureActivity;
/**
* ClojureActivity maps this class to the Clojure namespace
* com.example.myapp.main-activity (CamelCase → kebab-case by convention).
*/
public class MainActivity extends ClojureActivity {
// All behavior is defined in the Clojure namespace
// com.example.myapp.main-activity
}ClojureActivity (from runtime-core) automatically:
- Derives a Clojure namespace from the class name (
MainActivity→main-activity) - Requires the namespace on
onCreate - Delegates lifecycle methods (
on-create,on-resume, etc.) to functions in that namespace - Provides
reloadUi()which callsmake-uion the UI thread - Tracks instances for REPL access via
ClojureActivity.getInstance(ns)
Override getClojureNamespace() in the Java class to use a custom namespace
instead of the convention-based one.
Clojure namespace (app/src/main/clojure/com/example/myapp/main_activity.clj):
(ns com.example.myapp.main-activity
(:require [neko.ui :as ui]
[neko.find-view :refer [find-view]]
[neko.log :as log])
(:import android.app.Activity
com.goodanser.clj_android.runtime.ClojureActivity))
(defn make-ui
"Builds the UI tree. Called by on-create and ClojureActivity.reloadUi()."
[^Activity activity]
(ui/make-ui activity
[:linear-layout {:id-holder true
:orientation :vertical
:padding [32 32 32 32]}
[:text-view {:text "Hello from Clojure!"
:text-size [24 :sp]}]
[:button {:text "Press me"
:on-click (fn [_] (log/i "Button clicked"))}]]))
(defn on-create
"Called automatically by ClojureActivity when the activity is created."
[^Activity activity saved-instance-state]
(.setContentView activity (make-ui activity)))
(defn reload-ui!
"Hot-reload the UI from the REPL."
[]
(when-let [activity (ClojureActivity/getInstance
"com.example.myapp.main-activity")]
(.reloadUi ^ClojureActivity activity)))For each defactivity in your old code:
- Create a Java file extending
ClojureActivitywith the same class name referenced in your manifest — the Java class body can be empty - Create a Clojure namespace matching the convention
(
MyActivity→my-activityin the same package) - Define
(on-create [activity bundle])for initialization and(make-ui [activity])for REPL-driven hot-reload - Other lifecycle functions (
on-resume,on-pause, etc.) are optional
Repeat the pattern for each activity. Each gets its own Java shim and Clojure namespace, matched by naming convention:
java/.../MainActivity.java → clojure/.../main_activity.clj
java/.../SettingsActivity.java → clojure/.../settings_activity.clj
java/.../DetailActivity.java → clojure/.../detail_activity.clj
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{{package}}"
android:versionCode="{{version-code}}"
android:versionName="{{version-name}}">
<uses-sdk android:minSdkVersion="{{min-sdk-version}}"
android:targetSdkVersion="{{target-sdk-version}}" />
<application android:label="{{app-name}}"
android:name="neko.App">
<activity android:name="{{activity}}"
android:label="{{app-name}}">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest><?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name="com.goodanser.clj_android.runtime.ClojureApp"
android:label="@string/app_name"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>Key changes:
- No Clostache templates --
package,versionCode,versionName,minSdkVersion,targetSdkVersionare all set inbuild.gradle.kts - Application class:
neko.App→com.goodanser.clj_android.runtime.ClojureApp - Activity names: Reference your Java shim classes, not gen-class output
android:exported="true": Required since API 31 for activities with intent filtersINTERNETpermission: Needed for nREPL over ADB; can be moved tosrc/debug/AndroidManifest.xmlif you don't need it in release
If your code references nREPL namespaces directly (e.g., for middleware configuration), update them:
;; Old
(:require [clojure.tools.nrepl.server :as nrepl])
;; New
(:require [nrepl.server :as nrepl])In practice, you probably don't need to reference nREPL directly. The
ClojureApp application class starts it automatically in debug builds.
The neko.-utils namespace was renamed to neko.internal because AGP's
resource merger excludes files with _ prefixes:
;; Old (if you referenced it directly)
(:require [neko.-utils :as utils])
;; New
(:require [neko.internal :as utils])Most apps don't import this namespace directly.
The notification API was rewritten for modern Android (API 26+ requires notification channels):
;; Old (deprecated APIs)
(neko.notify/notification {:icon R$drawable/ic_launcher
:ticker-text "Hello"
:content-title "Title"
:content-text "Body"})
;; New (notification channels, PendingIntent.FLAG_IMMUTABLE)
;; The neko.notify namespace handles channel creation automatically.
;; See neko source for the updated API.The *a dynamic var and activity :key registry from neko.debug are tied
to the defactivity pattern. With Java shims, you manage the activity
reference yourself (see the currentInstance pattern in the Java shim).
The old (res/import-all) macro imported Android R class fields. In the new
system, access resources through standard Android APIs in the Java shim, or
use (.getResources activity) from Clojure.
lein droid doall # Build, install, run
lein droid forward-port # Set up ADB port forwarding
lein droid repl # Connect via REPLy./gradlew :app:installDebug # Build and install
adb shell am start -n com.example.myapp/.MainActivity # Launch app
adb forward tcp:7888 tcp:7888 # Forward nREPL portThen connect with your editor:
# Emacs + CIDER
M-x cider-connect → localhost:7888
# VS Code + Calva
Calva: Connect to a Running REPL Server → localhost:7888
# Command line
lein repl :connect 7888
Key differences:
- The nREPL server starts automatically when
ClojureAppis the application class (no need for neko.init flags or dev profile setup) - Default port is 7888 (configurable via
clojureOptions { nreplPort.set(N) }) - nREPL is automatically excluded from release builds
- Dynamic classloading uses
InMemoryDexClassLoader(API 26+) instead ofDexFile.loadDex()with temp files
;; Verify connection
(+ 1 2) ;;=> 3
(System/getProperty "java.vm.name") ;;=> "Dalvik"
;; Reload UI after making changes
(require '[com.example.myapp.main-activity :as ma])
(ma/reload-ui!):profiles {:release
{:android {:keystore-path "/path/to/keystore"
:key-alias "mykey"
:aot :all
:build-type :release}}}lein with-profile release droid doall// app/build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}./gradlew :app:assembleReleaseAdd ProGuard/R8 rules for Clojure in app/proguard-rules.pro:
-keep class clojure.** { *; }
-keep class com.example.myapp.** { *; }
-keep class neko.** { *; }
-dontwarn clojure.**
-dontwarn neko.**
The Gradle plugin automatically excludes nREPL, the dynamic classloader, and the dx library from release builds. No manual configuration is needed.
The old :lean profile with Skummet minification is no longer needed. R8
(handled by AGP) provides equivalent or better tree-shaking. Remove any
Skummet-related configuration.
Once migration is complete, remove lein-droid artifacts:
rm project.clj
rm -rf target/
rm -rf .lein-*
rm AndroidManifest-template.xml # if using templateAnd remove from .gitignore any lein-specific entries:
# Remove these:
/target
/classes
/.lein-*
/.nrepl-port
Add Gradle entries:
# Add these:
/.gradle/
/build/
/app/build/
local.properties
[ ] Install toolchain (clojure-patched, runtime-core, runtime-repl, neko, plugin)
[ ] Create settings.gradle.kts
[ ] Create root build.gradle.kts
[ ] Create app/build.gradle.kts
[ ] Create gradle.properties (if JDK 22+)
[ ] Move Clojure sources to app/src/main/clojure/
[ ] Move Java sources to app/src/main/java/
[ ] Move resources to app/src/main/res/
[ ] Replace each defactivity with a Java shim + Clojure namespace
[ ] Update AndroidManifest.xml (remove template syntax, update app class)
[ ] Update nREPL namespace references (clojure.tools.nrepl → nrepl)
[ ] Update neko.-utils → neko.internal (if referenced)
[ ] Update notification code for API 26+ channels (if using neko.notify)
[ ] Add Gradle wrapper (gradle wrapper --gradle-version 8.7)
[ ] Test debug build: ./gradlew :app:installDebug
[ ] Test REPL connection: adb forward tcp:7888 tcp:7888 → connect
[ ] Test release build: ./gradlew :app:assembleRelease
[ ] Verify release APK has no nREPL: zipinfo ... | grep nrepl
[ ] Remove project.clj and lein-droid artifacts
[ ] Update .gitignore
| Task | Old (lein-droid) | New (Gradle) |
|---|---|---|
| Full build + install + run | lein droid doall |
./gradlew :app:installDebug |
| Compile only | lein droid compile |
./gradlew :app:compileDebugClojure |
| Build APK | lein droid apk |
./gradlew :app:assembleDebug |
| Install on device | lein droid install |
./gradlew :app:installDebug |
| Release build | lein with-profile release droid doall |
./gradlew :app:assembleRelease |
| Connect REPL | lein droid repl |
adb forward tcp:7888 tcp:7888 + editor connect |
| Run tests | lein droid local-test |
(Robolectric via standard Gradle test tasks) |
| Verbose output | DEBUG=1 lein droid ... |
./gradlew ... --info |
| Old (project.clj) | New (build.gradle.kts) |
|---|---|
[com.goodanser.clj-android/clojure "1.7.0-RC1"] |
implementation("org.clojure:clojure:1.12.0") |
[neko/neko "4.0.0-alpha5"] |
implementation("com.goodanser.clj-android:neko:5.0.0-SNAPSHOT") |
[org.clojure/tools.nrepl "0.2.10"] (dev) |
Automatic (runtime-repl includes nrepl 1.0.0) |
[com.android.support/multidex "1.0.0"] |
Not needed (native on minSdk 26) |
Note: You declare org.clojure:clojure:1.12.0 (stock Clojure) in your
dependencies. The Gradle plugin automatically substitutes the patched
version (com.goodanser.clj-android:clojure:1.12.0-1) in debug builds for REPL
support. Release builds use stock Clojure with no modifications.
App crashes on startup with Reflector error
The patched Clojure fixes a canAccess crash on ART. Make sure you're
using the toolchain's patched Clojure (installed via
clojure-patched/gradlew publishToMavenLocal). The Gradle plugin handles
substitution automatically in debug builds; for release, stock Clojure
1.12.0 works because Reflector only crashes when canAccess is actually
invoked.
nREPL not starting
Check that android:name="com.goodanser.clj_android.runtime.ClojureApp" is set
in your manifest's <application> tag. Check logcat:
adb logcat -s ClojureApp.
_-prefixed .clj files missing from APK
AGP's resource merger excludes files starting with _. Rename them (e.g.,
_utils.clj → internal.clj) and update the namespace.
AAR dependency classes not found during AOT compilation
The plugin extracts classes.jar from AARs automatically. If you have
non-standard AAR dependencies that fail, check the plugin's compile task
logs with --info.
UnixDomainSocketAddress crash
The runtime-repl module includes TCP-only stubs that replace nREPL's Unix
domain socket code. Ensure the pickFirsts packaging option is set in your
build.gradle.kts (see Step 2).