diff --git a/docs/modules/ROOT/pages/kotlinlib/android-examples.adoc b/docs/modules/ROOT/pages/kotlinlib/android-examples.adoc index 94daed7543c..c568707da93 100644 --- a/docs/modules/ROOT/pages/kotlinlib/android-examples.adoc +++ b/docs/modules/ROOT/pages/kotlinlib/android-examples.adoc @@ -23,6 +23,14 @@ This example demonstrates how to create a basic "Hello World" Android applicatio using the Mill build tool. It outlines the minimum setup required to compile Kotlin code, package it into an APK, and run the app on an Android device. +== Kotlin Android Jetpack Compose Application + +include::partial$example/kotlinlib/android/2-jetpack-compose-hello-world.adoc[] + +This example demonstrates how to create a basic "Hello World" Jetpack Compose Android application +using the Mill build tool. It outlines the minimum setup required to compile Kotlin code, +Compiling Jetpack Compose Libraries, package it into an APK, and run the app on an Android device. + == Understanding `AndroidSdkModule` and `AndroidAppKotlinModule` The two main modules you need to understand when building Android apps with Mill diff --git a/example/javalib/android/1-hello-world/app/AndroidManifest.xml b/example/javalib/android/1-hello-world/app/AndroidManifest.xml index b33d6eb4174..c9b02d82f21 100644 --- a/example/javalib/android/1-hello-world/app/AndroidManifest.xml +++ b/example/javalib/android/1-hello-world/app/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/example/javalib/android/1-hello-world/app/resources/values/colors.xml b/example/javalib/android/1-hello-world/app/resources/values/colors.xml new file mode 100644 index 00000000000..fd314b015cb --- /dev/null +++ b/example/javalib/android/1-hello-world/app/resources/values/colors.xml @@ -0,0 +1,4 @@ + + #FFFFFF + #34A853 + diff --git a/example/javalib/android/1-hello-world/app/resources/values/strings.xml b/example/javalib/android/1-hello-world/app/resources/values/strings.xml new file mode 100644 index 00000000000..0144e656903 --- /dev/null +++ b/example/javalib/android/1-hello-world/app/resources/values/strings.xml @@ -0,0 +1,4 @@ + + HelloWorldApp + Hello, World Java! + diff --git a/example/javalib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.java b/example/javalib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.java index 1883d567555..55f7ead2ffb 100644 --- a/example/javalib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.java +++ b/example/javalib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.java @@ -2,11 +2,10 @@ import android.app.Activity; import android.os.Bundle; -import android.view.View; +import android.view.Gravity; import android.widget.TextView; import android.view.ViewGroup.LayoutParams; -import android.view.Gravity; - +import android.graphics.Color; public class MainActivity extends Activity { @Override @@ -16,8 +15,8 @@ protected void onCreate(Bundle savedInstanceState) { // Create a new TextView TextView textView = new TextView(this); - // Set the text to "Hello, World!" - textView.setText("Hello, World!"); + // Set the text to the string resource + textView.setText(getString(R.string.hello_world)); // Set text size textView.setTextSize(32); @@ -25,11 +24,17 @@ protected void onCreate(Bundle savedInstanceState) { // Center the text within the view textView.setGravity(Gravity.CENTER); - // Set layout parameters (width and height) + // Set the layout parameters (width and height) textView.setLayoutParams(new LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + // Set the text color using a resource + textView.setTextColor(getResources().getColor(R.color.text_green)); + + // Set the background color using a resource + textView.setBackgroundColor(getResources().getColor(R.color.white)); + // Set the content view to display the TextView setContentView(textView); } diff --git a/example/kotlinlib/android/1-hello-world/app/AndroidManifest.xml b/example/kotlinlib/android/1-hello-world/app/AndroidManifest.xml index b33d6eb4174..c9b02d82f21 100644 --- a/example/kotlinlib/android/1-hello-world/app/AndroidManifest.xml +++ b/example/kotlinlib/android/1-hello-world/app/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/example/kotlinlib/android/1-hello-world/app/resources/values/colors.xml b/example/kotlinlib/android/1-hello-world/app/resources/values/colors.xml new file mode 100644 index 00000000000..fd314b015cb --- /dev/null +++ b/example/kotlinlib/android/1-hello-world/app/resources/values/colors.xml @@ -0,0 +1,4 @@ + + #FFFFFF + #34A853 + diff --git a/example/kotlinlib/android/1-hello-world/app/resources/values/strings.xml b/example/kotlinlib/android/1-hello-world/app/resources/values/strings.xml new file mode 100644 index 00000000000..1615ee97f66 --- /dev/null +++ b/example/kotlinlib/android/1-hello-world/app/resources/values/strings.xml @@ -0,0 +1,4 @@ + + HelloWorldApp + Hello, World Kotlin! + diff --git a/example/kotlinlib/android/1-hello-world/app/src/main/kotlin/com/helloworld/app/MainActivity.kt b/example/kotlinlib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.kt similarity index 65% rename from example/kotlinlib/android/1-hello-world/app/src/main/kotlin/com/helloworld/app/MainActivity.kt rename to example/kotlinlib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.kt index f7e04a63de9..b7e23c0c846 100644 --- a/example/kotlinlib/android/1-hello-world/app/src/main/kotlin/com/helloworld/app/MainActivity.kt +++ b/example/kotlinlib/android/1-hello-world/app/src/main/java/com/helloworld/app/MainActivity.kt @@ -2,9 +2,10 @@ package com.helloworld.app import android.app.Activity import android.os.Bundle -import android.widget.TextView import android.view.Gravity +import android.widget.TextView import android.view.ViewGroup.LayoutParams +import android.graphics.Color class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -13,8 +14,8 @@ class MainActivity : Activity() { // Create a new TextView val textView = TextView(this) - // Set the text to "Hello, World!" - textView.text = "Hello, World Kotlin!" + // Set the text to the string resource + textView.text = getString(R.string.hello_world) // Set text size textView.textSize = 32f @@ -28,6 +29,12 @@ class MainActivity : Activity() { LayoutParams.MATCH_PARENT ) + // Set the text color using a resource + textView.setTextColor(getColor(R.color.text_green)) // Using hex color code directly + + // Set the background color using a resource + textView.setBackgroundColor(getColor(R.color.white)) // Using hex color code directly + // Set the content view to display the TextView setContentView(textView) } diff --git a/example/kotlinlib/android/1-hello-world/build.mill b/example/kotlinlib/android/1-hello-world/build.mill index 49df51f1e50..3a80a21c23d 100644 --- a/example/kotlinlib/android/1-hello-world/build.mill +++ b/example/kotlinlib/android/1-hello-world/build.mill @@ -22,7 +22,7 @@ object androidSdkModule0 extends AndroidSdkModule{ // Actual android application object app extends AndroidAppKotlinModule { - def kotlinVersion = "2.0.0" + def kotlinVersion = "2.0.20" def androidSdkModule = mill.define.ModuleRef(androidSdkModule0) } diff --git a/example/kotlinlib/android/2-jetpack-compose-hello-world/app/AndroidManifest.xml b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/AndroidManifest.xml new file mode 100644 index 00000000000..ea86cf8de37 --- /dev/null +++ b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/example/kotlinlib/android/2-jetpack-compose-hello-world/app/resources/values/colors.xml b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/resources/values/colors.xml new file mode 100644 index 00000000000..fd314b015cb --- /dev/null +++ b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/resources/values/colors.xml @@ -0,0 +1,4 @@ + + #FFFFFF + #34A853 + diff --git a/example/kotlinlib/android/2-jetpack-compose-hello-world/app/resources/values/strings.xml b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/resources/values/strings.xml new file mode 100644 index 00000000000..64af6cb4c60 --- /dev/null +++ b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/resources/values/strings.xml @@ -0,0 +1,4 @@ + + HelloWorldApp + Hello, World!\nJetpack Compose! + diff --git a/example/kotlinlib/android/2-jetpack-compose-hello-world/app/src/java/com/helloworld/app/MainActivity.kt b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/src/java/com/helloworld/app/MainActivity.kt new file mode 100644 index 00000000000..90004059dfe --- /dev/null +++ b/example/kotlinlib/android/2-jetpack-compose-hello-world/app/src/java/com/helloworld/app/MainActivity.kt @@ -0,0 +1,41 @@ +package com.helloworld.app + +import android.app.Activity +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.ViewGroup.LayoutParams +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val linearLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(getColor(R.color.white)) // Use color from resources + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT + ) + } + + val textView = TextView(this).apply { + text = getString(R.string.hello_world) + textSize = 32f + setTextColor(getColor(R.color.text_green)) // Use color from resources + gravity = Gravity.CENTER + setPadding(16, 16, 16, 16) + } + + linearLayout.addView(textView) + + setContentView(linearLayout) + } +} diff --git a/example/kotlinlib/android/2-jetpack-compose-hello-world/build.mill b/example/kotlinlib/android/2-jetpack-compose-hello-world/build.mill new file mode 100644 index 00000000000..fb51afee396 --- /dev/null +++ b/example/kotlinlib/android/2-jetpack-compose-hello-world/build.mill @@ -0,0 +1,42 @@ +package build + +import mill._ +import kotlinlib._ +import coursier.maven.MavenRepository +import mill.kotlinlib.android.AndroidAppKotlinModule +import mill.javalib.android.AndroidSdkModule + +val maven_google = Seq( + MavenRepository("https://maven.google.com/"), + MavenRepository("https://repo1.maven.org/maven2") +) + +object androidSdkModule0 extends AndroidSdkModule { + def buildToolsVersion = "35.0.0" +} + +object app extends AndroidAppKotlinModule { + def kotlinVersion = "2.0.20" + def androidSdkModule = mill.define.ModuleRef(androidSdkModule0) + + // If Dependencies are not provided but used in MainActivity then error will occur + override def mandatoryIvyDeps: T[Agg[Dep]] = Task { + super.mandatoryIvyDeps() ++ Agg( + // Jetpack Compose dependencies + ivy"androidx.compose.compiler:compiler:1.5.15", + ivy"androidx.activity:activity:1.8.2", + ivy"androidx.activity:activity-compose:1.8.2", + ivy"androidx.compose.runtime:runtime:1.3.1", + ivy"androidx.compose.material3:material3:1.0.1" + ) + } + + def repositoriesTask = T.task { super.repositoriesTask() ++ maven_google } +} + +/** Usage + +> ./mill show app.androidApk +".../out/app/androidApk.dest/app.apk" + +*/ \ No newline at end of file diff --git a/scalalib/src/mill/javalib/android/AndroidAppModule.scala b/scalalib/src/mill/javalib/android/AndroidAppModule.scala index 64195f83d2d..26f4d67b666 100644 --- a/scalalib/src/mill/javalib/android/AndroidAppModule.scala +++ b/scalalib/src/mill/javalib/android/AndroidAppModule.scala @@ -1,9 +1,9 @@ package mill.javalib.android import mill._ +import mill.scalalib._ import mill.api.PathRef import mill.define.ModuleRef -import mill.scalalib.JavaModule /** * Trait for building Android applications using the Mill build tool. @@ -36,32 +36,120 @@ trait AndroidAppModule extends JavaModule { def androidManifest: Task[PathRef] = Task.Source(millSourcePath / "AndroidManifest.xml") /** - * Generates the Android resources (such as layouts, strings, and other assets) needed - * for the application. + * Adds the "aar" type to the set of artifact types handled by this module. * - * This method uses the Android `aapt` tool to compile resources specified in the - * project's `AndroidManifest.xml` and any additional resource directories. It creates - * the necessary R.java files and other compiled resources for Android. These generated - * resources are crucial for the app to function correctly on Android devices. + * @return A task that yields an updated set of artifact types including "aar". + */ + def artifactTypes: T[Set[coursier.Type]] = Task { super.artifactTypes() + coursier.Type("aar") } + + /** + * Task to extract files from AAR files in the classpath. * - * For more details on the aapt tool, refer to: + * @return A sequence of `PathRef` pointing to the extracted JAR files. + */ + def androidUnpackArchives: T[(Seq[PathRef], Seq[PathRef])] = Task { + // Get all AAR files from the compile classpath + val aarFiles = super.compileClasspath().map(_.path).filter(_.ext == "aar").toSeq + + // Initialize sequences for jar files and resource folders + var jarFiles: Seq[PathRef] = Seq() + var resFolders: Seq[PathRef] = Seq() + + // Process each AAR file + aarFiles.foreach { aarFile => + val extractDir = T.dest / aarFile.baseName + os.unzip(aarFile, extractDir) + + // Collect all .jar files in the AAR directory + jarFiles ++= os.walk(extractDir).filter(_.ext == "jar").map(PathRef(_)) + + // If the res folder exists, add it to the resource folders + val resFolder = extractDir / "res" + if (os.exists(resFolder)) { + resFolders :+= PathRef(resFolder) + } + } + + // Return both jar files and resource folders + (jarFiles, resFolders) + } + + /** + * Overrides the `resources` task to include resources from unpacked AAR files + * + * @return Combined sequence of original and filtered AAR resources. + */ + def resources: T[Seq[PathRef]] = Task { + // Call the function to unpack AARs and get the jar and resource paths + val (_, resFolders) = androidUnpackArchives() + + // Combine and return all resources + super.resources() ++ resFolders + } + + /** + * Overrides the compile classpath to replace `.aar` files with the extracted + * `.jar` files. + * + * @return The updated classpath with `.jar` files only. + */ + override def compileClasspath: T[Agg[PathRef]] = Task { + // Call the function to get jar files and resource paths + val (jarFiles, _) = androidUnpackArchives() + super.compileClasspath().filter(_.path.ext == "jar") ++ jarFiles + } + + /** + * Compiles and links Android resources using `aapt2`, generating the `R.java` file. + * + * @return A `PathRef` to the directory containing the generated `R.java`. + * + * For more details on the aapt2 tool, refer to: * [[https://developer.android.com/tools/aapt2 aapt Documentation]] */ def androidResources: T[PathRef] = Task { - val genDir: os.Path = T.dest // Directory to store generated resources. + val genDir: os.Path = T.dest // Directory to store generated R.java + val compiledResDir: os.Path = T.dest / "compiled" // Directory for compiled resources + val resourceDirs = resources().map(_.path).filter(os.exists) // Merge all resource directories + os.makeDir.all(compiledResDir) + var count = 0 + val compiledZips = resourceDirs.map { resDir => + val outputZip = compiledResDir / s"${resDir.last}-${count}.zip" + count = count + 1 + os.call(Seq( + androidSdkModule().aapt2Path().path.toString, // Call aapt2 tool + "compile", + "--dir", + resDir.toString, // Compile each resource directory + "-o", + outputZip.toString // Output directory for compiled resources + )) + outputZip + } - os.call(Seq( - androidSdkModule().aaptPath().path.toString, // Call aapt tool - "package", - "-f", - "-m", - "-J", - genDir.toString, // Generate R.java files - "-M", - androidManifest().path.toString, // Use AndroidManifest.xml - "-I", - androidSdkModule().androidJarPath().path.toString // Include Android SDK JAR - )) + // Filter to find the single "resources" zip and exclude it from the regular zips + val ResourceZip = compiledZips.find(_.toString.contains("resources")) + val libzips = compiledZips.filterNot(_.toString.contains("resources")) + + val compiledlibs = libzips.flatMap(zip => Seq("-R", zip.toString)) + + os.call( + Seq( + androidSdkModule().aapt2Path().path.toString, // AAPT2 tool path + "link", + "-I", + androidSdkModule().androidJarPath().path.toString, // Include Android SDK JAR + "--auto-add-overlay" // Automatically add resources from overlays + ) ++ compiledlibs ++ Seq( + "--manifest", + androidManifest().path.toString, // Use AndroidManifest.xml + "--java", + genDir.toString, // Generate R.java in the genDir + "-o", + s"${genDir / "res.apk"}", + ResourceZip.map(_.toString).getOrElse("") + ) + ) PathRef(genDir) } @@ -124,33 +212,23 @@ trait AndroidAppModule extends JavaModule { androidSdkModule().androidJarPath().path.toString // Include Android framework classes ) ) - PathRef(dexOutputDir) } /** - * Packages the DEX files and Android resources into an unsigned APK using the `aapt` tool. + * Packages DEX files and Android resources into an unsigned APK. * - * The `aapt` tool takes the DEX files (compiled code) and resources (such as layouts and assets), - * and packages them into an APK (Android Package) file. This APK file is unsigned and requires - * further processing to be distributed. + * @return A `PathRef` to the generated unsigned APK file (`app.unsigned.apk`). */ def androidUnsignedApk: T[PathRef] = Task { val unsignedApk: os.Path = T.dest / "app.unsigned.apk" - - os.call( - Seq( - androidSdkModule().aaptPath().path.toString, - "package", - "-f", - "-M", - androidManifest().path.toString, // Path to AndroidManifest.xml - "-I", - androidSdkModule().androidJarPath().path.toString, // Include Android JAR - "-F", - unsignedApk.toString // Output APK - ) ++ Seq(androidDex().path.toString) // Include DEX files - ) + os.copy.over((T.dest / os.up / "androidResources.dest" / "res.apk"), unsignedApk) + os.call(Seq( + "zip", + "-j", + unsignedApk.toString, + s"${androidDex().path / "classes.dex"}" + )) PathRef(unsignedApk) } diff --git a/scalalib/src/mill/javalib/android/AndroidSdkModule.scala b/scalalib/src/mill/javalib/android/AndroidSdkModule.scala index f110d60f13d..413aff21c3f 100644 --- a/scalalib/src/mill/javalib/android/AndroidSdkModule.scala +++ b/scalalib/src/mill/javalib/android/AndroidSdkModule.scala @@ -1,6 +1,7 @@ package mill.javalib.android import mill._ +import scala.util.Try import java.math.BigInteger import java.nio.charset.StandardCharsets @@ -66,6 +67,13 @@ trait AndroidSdkModule extends Module { PathRef(buildToolsPath().path / "aapt") } + /** + * Provides the path to AAPT2, used for resource handling and APK packaging. + */ + def aapt2Path: T[PathRef] = Task { + PathRef(buildToolsPath().path / "aapt2") + } + /** * Provides the path to the Zipalign tool, which optimizes APK files by aligning their data. */ @@ -210,6 +218,7 @@ trait AndroidSdkModule extends Module { } Some(sdkManagerPath).filter(os.exists) } + } private object AndroidSdkLock