Android Java Projects
This page provides an example of using Mill as a build tool for Android applications. This workflow is still pretty rough and nowhere near production ready, but can serve as a starting point for further experimentation and development.
Relevant Modules
These are the main Mill Modules that are relevant for building Android apps:
-
mill.androidlib.AndroidSdkModule
: Handles Android SDK management and tools. -
mill.androidlib.AndroidAppModule
: Provides a framework for building Android applications. -
mill.javalib.JavaModule
: General Java build tasks like compiling Java code and creating JAR files.
Simple Android Hello World Application
This section sets up a basic Android project using Mill.
We utilize AndroidAppModule
and AndroidSdkModule
to streamline the process of
building an Android application with minimal configuration.
By extending AndroidAppModule
, we inherit all Android-related tasks such as
resource generation, APK building, DEX conversion, and APK signing.
Additionally, AndroidSdkModule
is embedded, making SDK management seamless.
Create and configure an Android SDK module to manage Android SDK paths and tools.
object androidSdkModule0 extends AndroidSdkModule {
def buildToolsVersion = "35.0.0"
}
Actual android application
object app extends AndroidAppModule {
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
def androidMinSdk = 19
def androidCompileSdk = 35
def androidApplicationId = "com.helloworld.app"
def androidApplicationNamespace = "com.helloworld.app"
/**
* Configuration for ReleaseKey
* WARNING: Replace these default values with secure and private credentials before using in production.
* Never use these defaults in a production environment as they are not secure.
* This is just for testing purposes.
*/
def androidReleaseKeyName: Option[String] = Some("releaseKey.jks")
def androidReleaseKeyAlias: T[Option[String]] = Task { Some("releaseKey") }
def androidReleaseKeyPass: T[Option[String]] = Task { Some("MillBuildTool") }
def androidReleaseKeyStorePass: T[Option[String]] = Task { Some("MillBuildTool") }
override def androidVirtualDeviceIdentifier: String = "java-test"
object test extends AndroidAppTests with TestModule.Junit4 {
def junit4Version = "4.13.2"
}
object it extends AndroidAppInstrumentedTests with AndroidTestModule.AndroidJUnit {
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
/* TODO currently the dependency resolution ignores the platform type and kotlinx-coroutines-core has
* conflicting classes with kotlinx-coroutines-core-jvm . Remove the exclusions once the dependency
* resolution resolves conflicts between androidJvm and jvm platform types
*/
def mvnDeps = super.mvnDeps() ++ Seq(
mvn"androidx.test.ext:junit:1.2.1".exclude((
"org.jetbrains.kotlinx",
"kotlinx-coroutines-core-jvm"
)),
mvn"androidx.test:runner:1.6.2",
mvn"androidx.test.espresso:espresso-core:3.5.1".exclude((
"org.jetbrains.kotlinx",
"kotlinx-coroutines-core-jvm"
)),
mvn"junit:junit:4.13.2"
)
}
}
> ./mill show app.androidApk
".../out/app/androidApk.dest/app.apk"
This command triggers the build process, which installs the Android Setup, compiles the Java
code, generates Android resources, converts Java bytecode to DEX format, packages everything
into an APK, optimizes the APK using zipalign
, and finally signs it.
This Mill build configuration is designed to build a simple "Hello World" Android application.
By extending AndroidAppModule
, we leverage its predefined Android build tasks, ensuring that
all necessary steps (resource generation, APK creation, and signing) are executed automatically.
Project Structure:
The project follows the standard Android app layout. Below is a typical project folder structure:
. ├── app │ └── src │ ├── androidTest/java/com/helloworld/app/ExampleInstrumentedTest.java │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java/com/helloworld/app/MainActivity.java │ │ └── res │ │ └── values │ │ ├── colors.xml │ │ └── strings.xml │ └── test/java/com/helloworld/app/ExampleUnitTest.java └── build.mill
> ./mill show app.test
...compiling 2 Java source...
> cat out/app/test/testForked.dest/out.json
["",[{"fullyQualifiedName":"com.helloworld.ExampleUnitTest.textSize_isCorrect","selector":"com.helloworld.ExampleUnitTest.textSize_isCorrect","duration":...,"status":"Success"}]]
This command runs unit tests on your local environment.
> ./mill show app.createAndroidVirtualDevice
...Name: java-test, DeviceId: medium_phone...
> ./mill show app.startAndroidEmulator
> ./mill show app.adbDevices
...emulator-5554...device...
> ./mill show app.it
...
[
"",
[
{
"fullyQualifiedName": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"selector": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"duration": ...,
"status": "Success"
}
]
]
...
> cat out/app/it/testForked.dest/test-report.xml
...
<?xml version='1.0' encoding='UTF-8'?>
<testsuites tests="1" failures="0" errors="0" skipped="0" time="...">
<testsuite name="com.helloworld.app.ExampleInstrumentedTest.useAppContext" tests="1" failures="0" errors="0" skipped="0" time="0.0" timestamp="...">
<properties>
</properties>
<testcase classname="com.helloworld.app.ExampleInstrumentedTest.useAppContext" name="com.helloworld.app.ExampleInstrumentedTest.useAppContext" time="...">
</testcase>
</testsuite>
</testsuites>
...
> ./mill show app.stopAndroidEmulator
> ./mill show app.deleteAndroidVirtualDevice
The android tests (existing typically in androidTest directory, aka instrumented tests) typically run on an android device. The createAndroidVirtualDevice command creates an AVD (Android Virtual Device) and the startAndroidEmulator command starts the AVD. The it task runs the android tests against the available AVD. The stopAndroidEmulator command stops the AVD and the destroyAndroidVirtualDevice command destroys the AVD. The provided commands can be used in a CI/CD pipeline assuming the right setup is in place.
This example demonstrates how to create a basic "Hello World" Android application using the Mill build tool. It outlines the minimum setup required to compile Java code, package it into an APK, and run the app on an Android device.
Understanding AndroidSdkModule
and AndroidAppModule
The two main modules you need to understand when building Android apps with Mill
are AndroidSdkModule
and AndroidAppModule
.
AndroidSdkModule
:
-
This module manages the installation and configuration of the Android SDK, which includes tools like
aapt
,d8
,zipalign
, andapksigner
. These tools are used for compiling, packaging, and signing Android applications.
AndroidAppModule
:
This module provides the step-by-step workflow for building an Android app. It handles
everything from compiling the code to generating a signed APK for distribution.
-
Compiling Java code: The module compiles your Java code into
.class
files, which is the first step in creating an Android app. -
Packaging into JAR: It then packages the compiled
.class
files into a JAR file, which is necessary before converting to Android’s format. -
Converting to DEX format: The JAR file is converted into DEX format, which is the executable format for Android applications.
-
Creating an APK: The DEX files and Android resources (like layouts and strings) are packaged together into an APK file, which is the installable file for Android devices.
-
Optimizing with zipalign: The APK is optimized using
zipalign
to ensure better performance on Android devices. -
Signing the APK: Finally, the APK is signed with a digital signature, allowing it to be distributed and installed on Android devices.
After creating Simple Android Application now let’s focus on how to create Android App Bundle Using Mill Build Tool
Android App Bundle
This Example provides the basic setup for building Android App Bundle using Mill Build Tool.
By extending AndroidAppBundle
, we inherit all Android Bundle tasks such as
Android Bundle zip generation, Bundle Creation and Bundle signing.
Additionally, AndroidSdkModule
is embedded, making SDK management seamless.
package build
import mill._, androidlib._
object androidSdkModule0 extends AndroidSdkModule {
def buildToolsVersion = "35.0.0"
}
object bundle extends AndroidAppBundle {
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
def androidCompileSdk = 35
/**
* Configuration for ReleaseKey
* WARNING: Replace these default values with secure and private credentials before using in production.
* Never use these defaults in a production environment as they are not secure.
* This is just for testing purposes.
*/
def androidApplicationId = "com.helloworld.app"
def androidApplicationNamespace = "com.helloworld.app"
def androidReleaseKeyName: Option[String] = Some("releaseKey.jks")
def androidReleaseKeyAlias: T[Option[String]] = Task { Some("releaseKey") }
def androidReleaseKeyPass: T[Option[String]] = Task { Some("MillBuildTool") }
def androidReleaseKeyStorePass: T[Option[String]] = Task { Some("MillBuildTool") }
}
> ./mill show bundle.androidBundle
".../out/bundle/androidBundle.dest/signedBundle.aab"
This command triggers the App Bundle Build process, which installs Bundle Tool then resource compilation, and then building Android Bundle Zip then Creation of Android App Bundle and finally signs it.
This Mill build configuration is designed to build a simple "Hello World" Android App Bundle.
By extending AndroidAppBundle
, we leverage its predefined Android App Bundle build tasks,
ensuring that all necessary steps (generation, creation, and signing) are executed automatically.
Understanding AndroidAppBundle
The AndroidAppBundle
trait is used to create and manage Android App Bundles (AAB) in Mill. It provides tasks for creating, building, and signing an AAB from Android resources and DEX files.
-
mill.androidlib.AndroidAppBundle
: Provides a framework for building Android App Bundle.
Key Functions
-
androidAaptOptions: Here, Overrides
androidAaptOptions
to add the--proto-format
option to AAPT commands, enabling protocol buffer format for assets. -
androidBundleZip: Creates a zip archive containing:
Compiled DEX files
,Resources
,libraries
, andassets
, TheAndroid manifest
. This zip follows the Android App Bundle format, as outlined in the official documentation. -
androidUnsignedBundle: Uses the
bundleTool
to build an unsigned AAB from the bundle zip. -
androidBundle: Signs the AAB using a specified keystore with the
jarsigner
tool, producing a signed Android App Bundle (AAB).
Using the R8 optimization tool in Android modules
R8 is a code shrinker and obfuscator for Android applications. Is designed to optimize the size of the APK while maintaining the functionality of the app. When using R8, you can configure the rules for code shrinking and obfuscation in a ProGuard configuration file. R8 relies on ProGuard rules files to adjust its default behavior and gain a better understanding of your app’s structure, such as identifying the classes that act as entry points. While you can modify some of these rules files, certain rules may be automatically generated by compile-time tools (like AAPT2) or inherited from your app’s library dependencies. You need 2 files on the root of your project:
-
proguard-rules.pro: This file contains the rules for R8 to follow when shrinking and obfuscating the code.
-
test-proguard-rules.pro: This file contains the rules for R8 to follow when shrinking and obfuscating the unit and instrumented tests.
You can also override the default Proguard files by using override def proguardConfigs: T[Seq[PathRef]]
More informations about the R8 in general and about the Proguard rules files can be found in the links below:
package build
import mill._, androidlib._, scalalib._
object androidSdkModule0 extends AndroidSdkModule { (1)
def buildToolsVersion = "35.0.0"
}
object app extends AndroidAppModule { (2)
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
def androidMinSdk = 19
def androidCompileSdk = 35
def androidApplicationId = "com.helloworld.app"
def androidApplicationNamespace = "com.helloworld.app"
/**
* Configuration for ReleaseKey
* WARNING: Replace these default values with secure and private credentials before using in production.
* Never use these defaults in a production environment as they are not secure.
* This is just for testing purposes.
*/
def androidReleaseKeyName: Option[String] = Some("releaseKey.jks")
def androidReleaseKeyAlias: T[Option[String]] = Task { Some("releaseKey") }
def androidReleaseKeyPass: T[Option[String]] = Task { Some("MillBuildTool") }
def androidReleaseKeyStorePass: T[Option[String]] = Task { Some("MillBuildTool") }
override def androidVirtualDeviceIdentifier: String = "java-test"
override def androidIsDebug: T[Boolean] = Task { false }
override def androidReleaseSettings: T[AndroidBuildTypeSettings] = Task {
super.androidReleaseSettings().withProguardLocalFiles(
Seq(
moduleDir / "proguard-rules.pro"
)
)
}
// Unit tests for the application
object test extends AndroidAppTests with TestModule.Junit4 {
def mvnDeps = super.mvnDeps() ++ Seq(
mvn"junit:junit:4.13.2"
)
}
// Instrumented tests (runs on emulator)
object it extends AndroidAppInstrumentedTests with AndroidTestModule.AndroidJUnit {
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
override def androidIsDebug: T[Boolean] = Task {
false
}
override def androidReleaseSettings: T[AndroidBuildTypeSettings] = Task {
AndroidBuildTypeSettings(isMinifyEnabled = false)
}
/* TODO currently the dependency resolution ignores the platform type and kotlinx-coroutines-core has
* conflicting classes with kotlinx-coroutines-core-jvm . Remove the exclusions once the dependency
* resolution resolves conflicts between androidJvm and jvm platform types
*/
def mvnDeps = super.mvnDeps() ++ Seq(
mvn"androidx.test.ext:junit:1.2.1".exclude(
("org.jetbrains.kotlinx", "kotlinx-coroutines-core-jvm")
),
mvn"androidx.test:runner:1.6.2",
mvn"androidx.test.espresso:espresso-core:3.5.1".exclude(
("org.jetbrains.kotlinx", "kotlinx-coroutines-core-jvm")
),
mvn"junit:junit:4.13.2"
)
}
}
1 | Create and configure an Android SDK module to manage Android SDK paths and tools. |
2 | The actual Android application |
> ./mill show app.androidApk
> ./mill show app.createAndroidVirtualDevice
...Name: java-test, DeviceId: medium_phone...
> ./mill show app.startAndroidEmulator
> ./mill show app.androidInstall
...All files should be loaded. Notifying the device...
> ./mill show app.it
...
[
"",
[
{
"fullyQualifiedName": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"selector": "com.helloworld.app.ExampleInstrumentedTest.useAppContext",
"duration": ...,
"status": "Success"
}
]
]
> ./mill show app.stopAndroidEmulator
> ./mill show app.deleteAndroidVirtualDevice
R8 will run automatically when you run the androidInstall
task with androidIsDebug set to false.
If you want to create the APK without R8, you can set the androidReleaseSettings isMinifyEnabled to false. You can also
run the andoidInstall
task that will automaticaly run the androidApk
and also install it in the emulator.
The release settings used in androidInstall
task will install the optimized APK on the emulator.
So first you need to create the emulator and start it.
After the emulator is started, you can run the androidReleaseInstall
task and see the app in the emulator.
Using Third-Party Native Libraries
Native libraries are libraries written in C/C that are compiled to android code and are used in the app. They are usually used for performance reasons or to use libraries that are not available in Java/Kotlin. This example shows how to use native libraries in the app. The native library is a simple C library that returns a string "Hello from C" and is used in the app to show a toast with the string. In my example i used also one of the core C/C libraires that can be used in the android and are already given ( <android/log.h> ). There is a list of the core C/C++ libraries that can be used in the android here: https://developer.android.com/ndk/reference
package build
import mill._, androidlib._, scalalib._
object androidSdkModule0 extends AndroidSdkModule {
def buildToolsVersion = "35.0.0"
}
object app extends AndroidNativeAppModule { (1)
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
def androidMinSdk = 19
def androidCompileSdk = 35
def androidApplicationId = "com.helloworld.app"
def androidApplicationNamespace = "com.helloworld.app"
/**
* Configuration for ReleaseKey
* WARNING: Replace these default values with secure and private credentials before using in production.
* Never use these defaults in a production environment as they are not secure.
* This is just for testing purposes.
*/
def androidReleaseKeyAlias: T[Option[String]] = Task { Some("releaseKey") }
def androidReleaseKeyPass: T[Option[String]] = Task { Some("MillBuildTool") }
def androidReleaseKeyStorePass: T[Option[String]] = Task { Some("MillBuildTool") }
override def androidVirtualDeviceIdentifier: String = "java-test"
def androidExternalNativeLibs = Task { (2)
Seq(
PathRef(Task.workspace / "app/src/main/cpp/native-lib.cpp")
)
}
// Instrumented tests (runs on emulator)
object it extends AndroidAppInstrumentedTests with AndroidTestModule.AndroidJUnit {
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
/* TODO currently the dependency resolution ignores the platform type and kotlinx-coroutines-core has
* conflicting classes with kotlinx-coroutines-core-jvm . Remove the exclusions once the dependency
* resolution resolves conflicts between androidJvm and jvm platform types
*/
def mvnDeps = super.mvnDeps() ++ Seq(
mvn"androidx.test.ext:junit:1.2.1".exclude(
("org.jetbrains.kotlinx", "kotlinx-coroutines-core-jvm")
),
mvn"androidx.test:runner:1.6.2",
mvn"androidx.test.espresso:espresso-core:3.5.1".exclude(
("org.jetbrains.kotlinx", "kotlinx-coroutines-core-jvm")
),
mvn"junit:junit:4.13.2"
)
}
}
1 | You need to extend AndroidNativeLibs trait to use native libraries in the app. |
2 | This is the path to the native library that will be used in the app. |
> ./mill show app.androidApk
".../out/app/androidApk.dest/app.apk"
> ./mill show app.createAndroidVirtualDevice
...Name: java-test, DeviceId: medium_phone...
> ./mill show app.startAndroidEmulator
> ./mill show app.androidInstall
...All files should be loaded. Notifying the device...
> ./mill show app.it
...
[
"",
[
{
"fullyQualifiedName": "com.helloworld.app.ExampleInstrumentedTest.textViewDisplaysHelloFromCpp",
"selector": "com.helloworld.app.ExampleInstrumentedTest.textViewDisplaysHelloFromCpp",
"duration": ...,
"status": "Success"
}
]
]
...
> ./mill show app.stopAndroidEmulator
> ./mill show app.deleteAndroidVirtualDevice