Skip to content

Commit 787a791

Browse files
authored
Android/hilt unit tests (#5169)
## Summary Prove that we can run the architecture-sample unit tests The major feature missing here is to allow for kotlin modules to mark any internal modules they depend on as friend modules (there was already an isolated case for KotlinTests and the main source code) . Following how the shared-tests of architecture samples depends on app main module and in turn tests depend on shared-tests and main sources (as they do) , I've covered the missing gaps to get everything to work properly. ## Features added - Kotlin `-X-friend-paths` is now used depending on transitive module deps, for AndroidKotlinModules (this can later be expanded to KotlinModule too) - An androidNamespace is required for internal libs to function - Android manifest for internal libs/modules needs to add metadata to the manifest too - AndroidHiltSupport should also be available to all android modules, not just apps ## Fixes - The AndroidHiltTransformAsm was dropping the META-INF, but it is required for `-X-friend-paths` to function , as the kotlin module contains the symbols necessary for internal members to be available to the depending modules - Ironed out the usage of androidApplicationNamespace vs androidApplicationId as their usage becomes clearer to me the more apps we integrate - this is under development and I expect their usage to be stabilised once build variants are added too ## Unlocked workflows This PR proves the following to be working: - Complex unit test setup with Hilt - A shared android module (shared-tests) can be given as a dependency (it's a dependency of `app.test` and `app.androidTest` which will be wired in in a follow up PR) - Android Kotlin internal and top level members can be used between depended modules (app.test can use app top level members as app compiled path is passed as a friend path) ## Previous work reference This is a continuous development for supporting android worfklows using Hilt and by extension Kotlin KSP features. Previous related work items: - #4759 - #4485 - #4557 ## Kotlin build issue Need to check why arrow is not building, but probably I'll move the friend paths functionality to AndroidKotlinModule if I don't manage to identify the issue for the time being Due to the use of outer in KotlinTest ``` override def kotlincOptions: T[Seq[String]] = Task { outer.kotlincOptions().filterNot(_.startsWith("-Xcommon-sources")) ++ Seq(s"-Xfriend-paths=${outer.compile().classes.path.toString()}") } ``` The `-Xfriend-paths` are picked from the parent and not computed properly (so the module that a kotlin test directly depends on is missing. e.g. ``` -Xfriend-paths=/home/vnicolaou/mill/out/example/thirdparty/arrow/downloadedRepo.dest/arrow-bc9bf92cc98e01c21bdd2bf8640cf7db0f97204a/out/arrow-libs/core/arrow-annotations/jvm/compile.dest/classes,/home/vnicolaou/mill/out/example/thirdparty/arrow/downloadedRepo.dest/arrow-bc9bf92cc98e01c21bdd2bf8640cf7db0f97204a/out/arrow-libs/core/arrow-atomic/jvm/compile.dest/classes,/home/vnicolaou/mill/out/example/thirdparty/arrow/downloadedRepo.dest/arrow-bc9bf92cc98e01c21bdd2bf8640cf7db0f97204a/out/arrow-libs/core/arrow-core/jvm/compile.dest/classes ``` but the kotlinc friend paths are from the outer module ``` [ "-Xfriend-paths=/home/vnicolaou/mill/out/example/thirdparty/arrow/downloadedRepo.dest/arrow-bc9bf92cc98e01c21bdd2bf8640cf7db0f97204a/out/arrow-libs/core/arrow-annotations/jvm/compile.dest/classes,/home/vnicolaou/mill/out/example/thirdparty/arrow/downloadedRepo.dest/arrow-bc9bf92cc98e01c21bdd2bf8640cf7db0f97204a/out/arrow-libs/core/arrow-atomic/jvm/compile.dest/classes,/home/vnicolaou/mill/out/example/thirdparty/arrow/downloadedRepo.dest/arrow-bc9bf92cc98e01c21bdd2bf8640cf7db0f97204a/out/arrow-libs/core/arrow-core/jvm/compile.dest/classes", "-Xexpect-actual-classes" ] ``` I'm not gonna handle this in this PR, instead I've moved this logic to AndroidKotlinModule and we can deal with it at a later stage Pull request: #5169
1 parent fe89505 commit 787a791

File tree

9 files changed

+183
-43
lines changed

9 files changed

+183
-43
lines changed

example/thirdparty/androidtodo/build.mill

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ package build
33
import mill._, androidlib._, kotlinlib._
44
import hilt.AndroidHiltSupport
55

6+
object Versions {
7+
val kotlinVersion = "2.0.21"
8+
val kotlinLanguageVersion = "1.9"
9+
10+
val kspVersion = "1.0.28"
11+
val androidCompileSdk = 35
12+
val androidMinSdk = 26
13+
}
14+
615
// Create and configure an Android SDK module to manage Android SDK paths and tools.
716
object androidSdkModule0 extends AndroidSdkModule {
817
def buildToolsVersion = "35.0.0"
@@ -11,20 +20,19 @@ object androidSdkModule0 extends AndroidSdkModule {
1120
// Mill configuration for the Android Todo App project.
1221
object app extends AndroidAppKotlinModule with AndroidBuildConfig with AndroidHiltSupport {
1322

14-
def kotlinVersion = "2.0.21"
15-
def kotlinLanguageVersion = "1.9"
16-
17-
def kspVersion = "1.0.28"
23+
def kotlinVersion = Versions.kotlinVersion
24+
def kotlinLanguageVersion = Versions.kotlinLanguageVersion
25+
def kspVersion = Versions.kspVersion
1826

1927
def androidApplicationNamespace = "com.example.android.architecture.blueprints.todoapp"
2028
// TODO change this to com.example.android.architecture.blueprints.main when mill supports build variants
2129
def androidApplicationId = "com.example.android.architecture.blueprints.todoapp"
2230

2331
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
2432

25-
def androidCompileSdk = 35
33+
def androidCompileSdk = Versions.androidCompileSdk
2634

27-
def androidMinSdk = 26
35+
def androidMinSdk = Versions.androidMinSdk
2836

2937
def androidEnableCompose = true
3038

@@ -73,28 +81,92 @@ object app extends AndroidAppKotlinModule with AndroidBuildConfig with AndroidHi
7381
mvn"com.google.dagger:hilt-android-compiler:2.56"
7482
)
7583

76-
def kotlincPluginMvnDeps: T[Seq[Dep]] = Task {
77-
Seq(
78-
mvn"org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:${kotlinVersion()}"
84+
object test extends AndroidAppKotlinTests with AndroidHiltSupport with TestModule.Junit4 {
85+
86+
def moduleDeps = super.moduleDeps ++ Seq(`shared-test`)
87+
88+
def androidEnableCompose = true
89+
90+
override def kspVersion = Versions.kspVersion
91+
92+
def kotlinSymbolProcessors: T[Seq[Dep]] = Seq(
93+
mvn"androidx.room:room-compiler:2.6.1",
94+
mvn"com.google.dagger:hilt-android-compiler:2.56"
7995
)
80-
}
8196

82-
object test extends AndroidAppKotlinTests with TestModule.Junit4 {
8397
def mvnDeps = super.mvnDeps() ++ Seq(
84-
mvn"junit:junit:4.13.2"
98+
mvn"junit:junit:4.13.2",
99+
mvn"androidx.arch.core:core-testing:2.2.0",
100+
mvn"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0",
101+
mvn"org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0",
102+
mvn"androidx.navigation:navigation-testing:2.8.5",
103+
mvn"androidx.test.espresso:espresso-core:3.6.1",
104+
mvn"androidx.test.espresso:espresso-contrib:3.6.1",
105+
mvn"androidx.test.espresso:espresso-intents:3.6.1",
106+
mvn"com.google.truth:truth:1.4.4",
107+
mvn"androidx.compose:compose-bom:2024.12.01",
108+
mvn"androidx.compose.ui:ui-test-junit4:1.7.6",
109+
mvn"com.google.dagger:hilt-android-testing:2.56"
85110
)
86111
}
87112

88113
// TODO support instrumented tests on Hilt setups
89114
object androidTest extends AndroidAppKotlinInstrumentedTests
90-
with AndroidTestModule.AndroidJUnit {}
115+
with AndroidTestModule.AndroidJUnit with AndroidHiltSupport {
116+
override def kspVersion = Versions.kspVersion
117+
118+
}
119+
120+
}
121+
122+
object `shared-test` extends AndroidKotlinModule with AndroidHiltSupport {
123+
124+
def moduleDeps = Seq(app)
125+
126+
def kotlinVersion = Versions.kotlinVersion
127+
def kotlinLanguageVersion = Versions.kotlinLanguageVersion
128+
def kspVersion = Versions.kspVersion
129+
def androidIsDebug = true
130+
131+
def androidSdkModule = mill.define.ModuleRef(androidSdkModule0)
132+
def androidCompileSdk = Versions.androidCompileSdk
133+
def androidMinSdk = Versions.androidMinSdk
134+
135+
def androidEnableCompose = true
136+
137+
def androidNamespace = "com.example.android.architecture.blueprints.todoapp.shared.test"
138+
139+
def kotlinSymbolProcessors: T[Seq[Dep]] = Seq(
140+
mvn"androidx.room:room-compiler:2.6.1",
141+
mvn"com.google.dagger:hilt-android-compiler:2.56"
142+
)
91143

144+
def mvnDeps = super.mvnDeps() ++ Seq(
145+
mvn"junit:junit:4.13.2",
146+
mvn"androidx.arch.core:core-testing:2.2.0",
147+
mvn"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0",
148+
mvn"org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0",
149+
mvn"androidx.test:core-ktx:1.6.1",
150+
mvn"androidx.test.ext:junit-ktx:1.2.1",
151+
mvn"androidx.test:rules:1.6.1",
152+
mvn"com.google.dagger:hilt-android:2.56",
153+
mvn"com.google.dagger:hilt-android-testing:2.56",
154+
mvn"androidx.room:room-runtime:2.6.1",
155+
mvn"androidx.room:room-ktx:2.6.1"
156+
)
92157
}
93158

94159
/** Usage
95160

96161
> ./mill app.androidApk
97162

163+
> ./mill app.test
164+
165+
> cat out/app/test/testForked.dest/test-report.xml
166+
<?xml version='1.0' encoding='UTF-8'?>
167+
<testsuites tests="46" failures="0" errors="0" skipped="0" time="...">
168+
...
169+
98170
> ./mill show app.createAndroidVirtualDevice
99171
...Name: test, DeviceId: medium_phone...
100172

libs/androidlib/hilt/src/mill/androidlib/hilt/AndroidHiltTransformAsm.scala

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ object AndroidHiltTransformAsm {
2525

2626
val destination = os.Path(args.last)
2727

28-
transformAsm(scanDirectory, os.walk(scanDirectory).filter(_.ext == "class"), destination)
28+
val allCompiledFiles = os.walk(scanDirectory).filter(os.isFile)
29+
30+
transformAsm(scanDirectory, allCompiledFiles.filter(_.ext == "class"), destination)
31+
32+
allCompiledFiles.filterNot(_.ext == "class").foreach { file =>
33+
val destinationFile = destination / file.relativeTo(scanDirectory)
34+
os.copy(file, destinationFile, createFolders = true)
35+
}
2936

3037
}
3138

@@ -41,7 +48,6 @@ object AndroidHiltTransformAsm {
4148
val destination = destinationDir / path.relativeTo(baseDir)
4249
transform(path, destination)
4350
}
44-
4551
}
4652

4753
private def transform(`class`: os.Path, destination: os.Path): os.Path = {

libs/androidlib/src/mill/androidlib/AndroidAppKotlinModule.scala

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,34 @@ import upickle.implicits.namedTuples.default.given
2626
@mill.api.experimental
2727
trait AndroidAppKotlinModule extends AndroidKotlinModule with AndroidAppModule { outer =>
2828

29-
def kotlinSources = Task.Sources("src/main/kotlin")
29+
private def kotlinSources = Task.Sources("src/main/kotlin")
3030
override def sources: T[Seq[PathRef]] =
3131
super[AndroidAppModule].sources() ++ kotlinSources()
3232

33-
trait AndroidAppKotlinTests extends KotlinTests with AndroidAppTests {
34-
def kotlinSources = Task.Sources("src/test/kotlin")
33+
trait AndroidAppKotlinTests extends AndroidAppKotlinModule with AndroidAppTests {
34+
override def kotlinVersion: T[String] = outer.kotlinVersion
35+
36+
private def kotlinSources = Task.Sources("src/test/kotlin")
37+
3538
override def sources: T[Seq[PathRef]] =
3639
super[AndroidAppTests].sources() ++ kotlinSources()
40+
41+
override def kotlincPluginMvnDeps: T[Seq[Dep]] = outer.kotlincPluginMvnDeps()
3742
}
3843

3944
trait AndroidAppKotlinInstrumentedTests extends AndroidAppKotlinModule
4045
with AndroidAppInstrumentedTests {
4146

42-
override final def kotlinVersion = outer.kotlinVersion
43-
override final def androidSdkModule = outer.androidSdkModule
47+
override final def kotlinVersion: T[String] = outer.kotlinVersion
48+
override final def androidSdkModule: ModuleRef[AndroidSdkModule] = outer.androidSdkModule
49+
50+
private def kotlinSources = Task.Sources("src/androidTest/kotlin")
51+
52+
override def kotlincPluginMvnDeps: T[Seq[Dep]] = outer.kotlincPluginMvnDeps()
4453

45-
def kotlinSources = Task.Sources("src/androidTest/kotlin")
4654
override def sources: T[Seq[PathRef]] =
4755
super[AndroidAppInstrumentedTests].sources() ++ kotlinSources()
56+
4857
}
4958

5059
trait AndroidAppKotlinScreenshotTests extends AndroidAppKotlinModule with TestModule with Junit5 {

libs/androidlib/src/mill/androidlib/AndroidAppModule.scala

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ trait AndroidAppModule extends AndroidModule { outer =>
7171
* In the case of android apps this the [[androidApplicationNamespace]].
7272
* @return
7373
*/
74-
protected override def androidGeneratedResourcesPackage: String = androidApplicationNamespace
74+
override final def androidNamespace: String = androidApplicationNamespace
7575

7676
/**
7777
* Android Application Id which is typically package.main .
@@ -97,7 +97,7 @@ trait AndroidAppModule extends AndroidModule { outer =>
9797
val manifestElem = XML.loadFile(manifestFromSourcePath.toString())
9898
// add the application package
9999
val manifestWithPackage =
100-
manifestElem % Attribute(None, "package", Text(androidApplicationId), Null)
100+
manifestElem % Attribute(None, "package", Text(androidApplicationNamespace), Null)
101101

102102
val manifestWithUsesSdk = manifestWithPackage.copy(
103103
child = androidManifestUsesSdkSection() ++ manifestWithPackage.child
@@ -1059,7 +1059,7 @@ trait AndroidAppModule extends AndroidModule { outer =>
10591059

10601060
override def androidApplicationId: String = outer.androidApplicationId
10611061

1062-
override def androidApplicationNamespace: String = outer.androidApplicationNamespace
1062+
override def androidApplicationNamespace: String = s"${outer.androidApplicationNamespace}.test"
10631063

10641064
override def moduleDir: Path = outer.moduleDir
10651065

@@ -1086,8 +1086,8 @@ trait AndroidAppModule extends AndroidModule { outer =>
10861086

10871087
override def resolutionParams: Task[ResolutionParams] = Task.Anon(outer.resolutionParams())
10881088

1089-
override def androidApplicationId: String = s"${outer.androidApplicationId}.test"
1090-
override def androidApplicationNamespace: String = outer.androidApplicationNamespace
1089+
override def androidApplicationId: String = s"${outer.androidApplicationId}"
1090+
override def androidApplicationNamespace: String = s"${outer.androidApplicationNamespace}.test"
10911091

10921092
override def androidReleaseKeyAlias: T[Option[String]] = outer.androidReleaseKeyAlias()
10931093
override def androidReleaseKeyName: Option[String] = outer.androidReleaseKeyName
@@ -1105,7 +1105,7 @@ trait AndroidAppModule extends AndroidModule { outer =>
11051105

11061106
private def androidInstrumentedTestsBaseManifest: Task[Elem] = Task.Anon {
11071107
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package={
1108-
androidApplicationId
1108+
androidApplicationNamespace
11091109
}>
11101110
{androidManifestUsesSdkSection()}
11111111
</manifest>
@@ -1122,7 +1122,7 @@ trait AndroidAppModule extends AndroidModule { outer =>
11221122
val manifestWithInstrumentation = {
11231123
val instrumentation =
11241124
<instrumentation android:name={testFrameworkName} android:targetPackage={
1125-
androidApplicationNamespace
1125+
outer.androidApplicationNamespace
11261126
}/>
11271127
baseManifestElem.copy(child = baseManifestElem.child ++ instrumentation)
11281128
}
@@ -1181,7 +1181,7 @@ trait AndroidAppModule extends AndroidModule { outer =>
11811181
"instrument",
11821182
"-w",
11831183
"-r",
1184-
s"${androidApplicationId}/${testFramework()}"
1184+
s"${androidApplicationNamespace}/${testFramework()}"
11851185
)
11861186
).spawn()
11871187

libs/androidlib/src/mill/androidlib/AndroidKotlinModule.scala

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import mill.{T, Task}
66

77
// TODO expose Compose configuration options
88
// https://kotlinlang.org/docs/compose-compiler-options.html possible options
9-
trait AndroidKotlinModule extends KotlinModule {
9+
trait AndroidKotlinModule extends KotlinModule with AndroidModule {
1010

1111
/**
1212
* Enable Jetpack Compose support in the module. Default is `false`.
@@ -24,10 +24,36 @@ trait AndroidKotlinModule extends KotlinModule {
2424
// Compose compiler version -> Kotlin version
2525
Task.fail("Compose can be used only with Kotlin version 2 or newer.")
2626
} else {
27-
deps ++ Seq(
28-
mvn"org.jetbrains.kotlin:kotlin-compose-compiler-plugin:${kotlinVersion()}"
29-
)
27+
if (kotlinUseEmbeddableCompiler())
28+
deps ++ Seq(
29+
mvn"org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:${kotlinVersion()}"
30+
)
31+
else
32+
deps ++ Seq(
33+
mvn"org.jetbrains.kotlin:kotlin-compose-compiler-plugin:${kotlinVersion()}"
34+
)
3035
}
3136
} else deps
3237
}
38+
39+
/**
40+
* If this module has any module dependencies, we need to tell the kotlin compiler to
41+
* handle the compiled output as a friend path so top level declarations are visible.
42+
*/
43+
def kotlincFriendPaths: T[Option[String]] = Task {
44+
val compiledCodePaths = Task.traverse(transitiveModuleCompileModuleDeps)(m =>
45+
Task.Anon {
46+
Seq(m.compile().classes.path)
47+
}
48+
)().flatten
49+
50+
val friendlyPathFlag: Option[String] =
51+
compiledCodePaths.headOption.map(_ => s"-Xfriend-paths=${compiledCodePaths.mkString(",")}")
52+
53+
friendlyPathFlag
54+
}
55+
56+
override def kotlincOptions: T[Seq[String]] = Task {
57+
super.kotlincOptions() ++ kotlincFriendPaths().toSeq
58+
}
3359
}

libs/androidlib/src/mill/androidlib/AndroidLibModule.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ trait AndroidLibModule extends AndroidModule with PublishModule {
1818
*/
1919
def androidLibPackage: String
2020

21-
override protected def androidGeneratedResourcesPackage: String = androidLibPackage
21+
override final def androidNamespace: String = androidLibPackage
2222

2323
/**
2424
* Provides os.Path to an XML file containing configuration and metadata about your android application.

libs/androidlib/src/mill/androidlib/AndroidModule.scala

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import mill.util.Jvm
1010
import upickle.implicits.namedTuples.default.given
1111

1212
import scala.collection.immutable
13-
import scala.xml.XML
13+
import scala.xml.*
1414

1515
trait AndroidModule extends JavaModule {
1616

@@ -47,10 +47,34 @@ trait AndroidModule extends JavaModule {
4747
*/
4848
def androidSdkModule: ModuleRef[AndroidSdkModule]
4949

50+
private def androidManifestUsesSdkSection: Task[Elem] = Task.Anon {
51+
val minSdkVersion = androidMinSdk().toString
52+
val targetSdkVersion = androidTargetSdk().toString
53+
<uses-sdk android:minSdkVersion={minSdkVersion} />
54+
}
55+
5056
/**
51-
* Provides os.Path to an XML file containing configuration and metadata about your android library.
57+
* Provides os.Path to an XML file containing configuration and metadata about your android application.
58+
* TODO dynamically add android:debuggable
5259
*/
53-
def androidManifest: Task[PathRef] = Task.Source("src/main/AndroidManifest.xml")
60+
def androidManifest: T[PathRef] = Task {
61+
val manifestFromSourcePath = moduleDir / "src/main/AndroidManifest.xml"
62+
63+
val manifestElem = XML.loadFile(manifestFromSourcePath.toString()) %
64+
Attribute(None, "xmlns:android", Text("http://schemas.android.com/apk/res/android"), Null)
65+
// add the application package
66+
val manifestWithPackage =
67+
manifestElem % Attribute(None, "package", Text(androidNamespace), Null)
68+
69+
val manifestWithUsesSdk = manifestWithPackage.copy(
70+
child = androidManifestUsesSdkSection() ++ manifestWithPackage.child
71+
)
72+
73+
val generatedManifestPath = Task.dest / "AndroidManifest.xml"
74+
os.write(generatedManifestPath, manifestWithUsesSdk.mkString)
75+
76+
PathRef(generatedManifestPath)
77+
}
5478

5579
/**
5680
* Controls debug vs release build type. Default is `true`, meaning debug build will be generated.
@@ -360,8 +384,11 @@ trait AndroidModule extends JavaModule {
360384
libClasses :+ PathRef(mainRClassPath)
361385
}
362386

363-
/** In which package to place the generated R sources */
364-
protected def androidGeneratedResourcesPackage: String
387+
/**
388+
* Namespace of the Android module.
389+
* Used in manifest package and also used as the package to place the generated R sources
390+
*/
391+
def androidNamespace: String
365392

366393
/**
367394
* Compiles Android resources and generates `R.java` and `res.apk`.
@@ -443,7 +470,7 @@ trait AndroidModule extends JavaModule {
443470
"--manifest",
444471
androidMergedManifest().path.toString,
445472
"--custom-package",
446-
androidGeneratedResourcesPackage,
473+
androidNamespace,
447474
"--java",
448475
rClassDir.toString,
449476
"--min-sdk-version",

0 commit comments

Comments
 (0)