Skip to content

Commit

Permalink
Android Studio Integration: Attempt via bsp for Java android apps (co…
Browse files Browse the repository at this point in the history
…m-lihaoyi#4362)

## This PR provides

- ability to import a mill android project to android studio via BSP,
including sources and generated sources.
- necessary changes to the way sources are resolved (through module
dependency instead of class inheritance).
- Changes to tests (e.g. number of sources compiled) reflect these
changes

### Not provided

- Compilation still needs to be done from the command line in order for
the paths to be resolved inside the IDE.

Note that to make compilation, running, testing and debugging work from
android studio, we'll need to develop a plugin for it

## Note

This is an early draft and I need to cleanup or review a few rough
edges, I submit this as a draft to get some early feedback.

I've implemented java to get the basics to work, as kotlin has multiple
kinds of sources and files that need to be taken care of, so this is
just a small step.

Don't hesitate to point out any issues as I've really just braced
through and hacked around to get this to work!

@0xnm @lihaoyi  any feedback will be appreciated!

## Android Studio Integration

The attempt focuses on BSP. Although bsp as a plugin does not seem to be
available for android studio, it strangely becomes available if it is
installed through intelliJ (see video)


### Basic changes

- Use the moduleDeps instead of resolving the classpath bits (sources,
resources) through inheritance. This makes BSP and studio integration
work (see the directories that are highlighted correctly)
- Crude implementation of bsp methods to get the IDE to report the
imported modules correctly

### Tricky parts

- Resources are compiled twice due to how aapt linking and later the
module deps compile hierarchy. In order to avoid this, I've added an
empty generatedSources method in android instrumentation module .

### Demo

You can see in the demo most of the IDE static import features work (R
is not recognised but I'll look into it either in this PR or
subsequent).


https://github.com/user-attachments/assets/77fe5e52-57e7-4480-9971-b50cc8d4d839



EDIT: I've fixed the R not being recognised and the kotlin import


![image](https://github.com/user-attachments/assets/69d810c9-9dd5-40ca-98d3-387d8693cc0e)


EDIT 2: Added summary at the top
  • Loading branch information
vaslabs authored and gamlerhart committed Feb 9, 2025
1 parent 3d71b15 commit 6b50855
Show file tree
Hide file tree
Showing 12 changed files with 102 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.helloworld.SampleLogic;
import org.junit.Test;
import org.junit.runner.RunWith;

Expand All @@ -20,5 +21,6 @@ public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.helloworld.app", appContext.getPackageName());
assertEquals(32.0f, SampleLogic.textSize(), 0.0001f);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.helloworld;

class SampleLogic {
public class SampleLogic {

public static float textSize() {
return 32f;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.junit.Assert.*;

import com.helloworld.app.R;
import org.junit.Test;

/**
Expand All @@ -12,6 +13,8 @@
public class ExampleUnitTest {
@Test
public void textSize_isCorrect() {

assertEquals(32f, SampleLogic.textSize(), 0.000001f);
assertEquals(0x7f010000, R.color.text_green);
}
}
3 changes: 1 addition & 2 deletions example/android/javalib/1-hello-world/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ package build
import mill._, javalib._

import mill.javalib.android.{AndroidAppModule, AndroidSdkModule}
import coursier.maven.MavenRepository
import mill.javalib.android.AndroidTestModule

// Create and configure an Android SDK module to manage Android SDK paths and tools.
Expand Down Expand Up @@ -107,7 +106,7 @@ object app extends AndroidAppModule {
/** Usage

> ./mill show app.test
...compiling 3 Java sources...
...compiling 1 Java source...

> cat out/app/test/test.dest/out.json
["",[{"fullyQualifiedName":"com.helloworld.ExampleUnitTest.textSize_isCorrect","selector":"com.helloworld.ExampleUnitTest.textSize_isCorrect","duration":...,"status":"Success"}]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.helloworld.app

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.helloworld.SampleLogic
import com.helloworld.SampleLogicInKotlinDir
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -18,5 +20,7 @@ class ExampleInstrumentedTest {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.helloworld.app", appContext.packageName)
assertEquals(32f, SampleLogic.textSize(), 0.0001f)
assertEquals(64f, SampleLogicInKotlinDir.textSize(), 0.00001f)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup.LayoutParams
import android.widget.TextView
import com.helloworld.SampleLogic

class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -17,7 +18,7 @@ class MainActivity : Activity() {
textView.text = getString(R.string.hello_world)

// Set text size
textView.textSize = 32f
textView.textSize = SampleLogic.textSize()

// Center the text within the view
textView.gravity = Gravity.CENTER
Expand Down
2 changes: 1 addition & 1 deletion example/android/kotlinlib/1-hello-kotlin/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ object app extends AndroidAppKotlinModule {
/** Usage

> ./mill show app.test
...Compiling 5 Kotlin sources...
...Compiling 2 Kotlin sources...

> cat out/app/test/test.dest/out.json
["",[{"fullyQualifiedName":"com.helloworld.ExampleUnitTest.text_size_is_correct","selector":"com.helloworld.ExampleUnitTest.text_size_is_correct","duration":...,"status":"Success"},{"fullyQualifiedName":"com.helloworld.ExampleUnitTestInKotlinDir.kotlin_dir_text_size_is_correct","selector":"com.helloworld.ExampleUnitTestInKotlinDir.kotlin_dir_text_size_is_correct","duration":...,"status":"Success"}]]
Expand Down
1 change: 0 additions & 1 deletion example/android/kotlinlib/2-compose/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ package build

import mill._
import kotlinlib._
import coursier.{Repository, MavenRepository}
import coursier.core.{MinimizedExclusions, ModuleName, Organization}
import coursier.params.ResolutionParams
import mill.kotlinlib.android.AndroidAppKotlinModule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import mill.javalib.android.AndroidAppModule
trait AndroidAppKotlinModule extends AndroidAppModule with KotlinModule { outer =>

override def sources: T[Seq[PathRef]] =
super.sources() :+ PathRef(millSourcePath / "src/main/kotlin")
super[AndroidAppModule].sources() :+ PathRef(millSourcePath / "src/main/kotlin")

override def kotlincOptions = super.kotlincOptions() ++ {
if (androidEnableCompose()) {
Expand Down Expand Up @@ -54,7 +54,7 @@ trait AndroidAppKotlinModule extends AndroidAppModule with KotlinModule { outer

trait AndroidAppKotlinTests extends AndroidAppTests with KotlinTests {
override def sources: T[Seq[PathRef]] =
super.sources() :+ PathRef(outer.millSourcePath / "src/test/kotlin")
super[AndroidAppTests].sources() ++ Seq(PathRef(outer.millSourcePath / "src/test/kotlin"))
}

trait AndroidAppKotlinInstrumentedTests extends AndroidAppKotlinModule
Expand All @@ -64,6 +64,8 @@ trait AndroidAppKotlinModule extends AndroidAppModule with KotlinModule { outer
override final def androidSdkModule = outer.androidSdkModule

override def sources: T[Seq[PathRef]] =
super.sources() :+ PathRef(outer.millSourcePath / "src/androidTest/kotlin")
super[AndroidAppInstrumentedTests].sources() :+ PathRef(
outer.millSourcePath / "src/androidTest/kotlin"
)
}
}
2 changes: 1 addition & 1 deletion scalalib/src/mill/javalib/android/AndroidAppBundle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ trait AndroidAppBundle extends AndroidAppModule with JavaModule {
*/
def androidBundleZip: T[PathRef] = Task {
val dexFile = androidDex().path
val resFile = androidResources().path / "res.apk"
val resFile = androidResources()._1.path / "res.apk"
val baseDir = Task.dest / "base"
val appDir = Task.dest / "app"

Expand Down
105 changes: 81 additions & 24 deletions scalalib/src/mill/javalib/android/AndroidAppModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package mill.javalib.android
import coursier.Repository
import mill._
import mill.scalalib._
import mill.api.{Logger, PathRef}
import mill.define.ModuleRef
import mill.api.{Logger, PathRef, internal}
import mill.define.{ModuleRef, Task}
import mill.scalalib.bsp.BspBuildTarget
import mill.testrunner.TestResult
import mill.util.Jvm
import os.RelPath
Expand Down Expand Up @@ -266,9 +267,20 @@ trait AndroidAppModule extends JavaModule {
*/
override def resources: T[Seq[PathRef]] = Task {
val libResFolders = androidUnpackArchives().flatMap(_.resources)
super.resources() ++ libResFolders :+ PathRef(millSourcePath / "src/main/res")
libResFolders :+ PathRef(millSourcePath / "src/main/res")
}

@internal
override def bspCompileClasspath: T[Agg[UnresolvedPath]] = Task {
compileClasspath().map(_.path).map(UnresolvedPath.ResolvedPath(_))
}

@internal
override def bspBuildTarget: BspBuildTarget = super.bspBuildTarget.copy(
baseDirectory = Some(millSourcePath / "src/main"),
tags = Seq("application")
)

/**
* Replaces AAR files in classpath with their extracted JARs.
*/
Expand All @@ -278,29 +290,42 @@ trait AndroidAppModule extends JavaModule {
super.resolvedRunIvyDeps().filter(_.path.ext == "jar")
// TODO process metadata shipped with Android libs. It can have some rules with Target SDK, for example.
// TODO support baseline profiles shipped with Android libs.
super.compileClasspath().filter(_.path.ext == "jar") ++ jarFiles
super.compileClasspath().filter(_.path.ext != "aar") ++ jarFiles
}

/**
* Specifies AAPT options for Android resource compilation.
*/
def androidAaptOptions: T[Seq[String]] = Task { Seq("--auto-add-overlay") }

def androidTransitiveResources: Target[Seq[PathRef]] = Task {
T.traverse(transitiveModuleCompileModuleDeps) { m =>
Task.Anon(m.resources())
}().flatten
}

/**
* Compiles Android resources and generates `R.java` and `res.apk`.
*
* @return [[PathRef]] to the directory with application's `R.java` and `res.apk`.
* @return [[(PathRef, Seq[PathRef]]] to the directory with application's `R.java` and `res.apk` and a list
* of the zip files containing the resource compiled files
*
* For more details on the aapt2 tool, refer to:
* [[https://developer.android.com/tools/aapt2 aapt Documentation]]
*/
def androidResources: T[PathRef] = Task {
def androidResources: T[(PathRef, Seq[PathRef])] = Task {
val rClassDir = T.dest / rClassDirName
val compiledResDir = T.dest / compiledResourcesDirName
os.makeDir(compiledResDir)
val compiledResources = collection.mutable.Buffer[os.Path]()

for (resDir <- resources().map(_.path).filter(os.exists)) {
val transitiveResources = androidTransitiveResources().map(_.path).filter(os.exists)

val localResources = resources().map(_.path).filter(os.exists)

val allResources = localResources ++ transitiveResources

for (resDir <- allResources) {
val segmentsSeq = resDir.segments.toSeq
val libraryName = segmentsSeq.dropRight(1).last
// if not directory, but file, it should have one of the possible extensions: .flata, .zip, .jar, .jack,
Expand Down Expand Up @@ -382,7 +407,7 @@ trait AndroidAppModule extends JavaModule {

os.call(appLinkArgs)

PathRef(T.dest)
(PathRef(T.dest), compiledResources.toSeq.map(PathRef(_)))
}

/**
Expand All @@ -397,7 +422,7 @@ trait AndroidAppModule extends JavaModule {
* the Android resource generation step.
*/
override def generatedSources: T[Seq[PathRef]] = Task {
super.generatedSources() ++ Seq(androidResources()) ++ Seq(androidLibsRClasses())
Seq(PathRef(androidResources()._1.path / rClassDirName)) ++ androidLibsRClasses()
}

/**
Expand All @@ -406,12 +431,20 @@ trait AndroidAppModule extends JavaModule {
* @return os.Path to the Generated DEX File Directory
*/
def androidDex: T[PathRef] = Task {

val inheritedClassFiles = compileClasspath().map(_.path).filter(os.isDir)
.flatMap(os.walk(_))
.filter(os.isFile)
.filter(_.ext == "class")
.map(_.toString())

val appCompiledFiles = os.walk(compile().classes.path)
.filter(_.ext == "class")
.map(_.toString)
.map(_.toString) ++ inheritedClassFiles

val libsJarFiles = compileClasspath()
.filter(_ != androidSdkModule().androidJarPath())
.filter(_.path.ext == "jar")
.map(_.path.toString())

val proguardFile = T.dest / "proguard-rules.pro"
Expand All @@ -421,13 +454,14 @@ trait AndroidAppModule extends JavaModule {
.flatMap(_.proguardRules)
.map(p => os.read(p.path))
.appendedAll(mainDexPlatformRules)
.appended(os.read(androidResources().path / "main-dex-rules.pro"))
.appended(os.read(androidResources()._1.path / "main-dex-rules.pro"))
.mkString("\n")
os.write(proguardFile, knownProguardRules)

val d8ArgsBuilder = Seq.newBuilder[String]

d8ArgsBuilder += androidSdkModule().d8Path().path.toString

if (androidIsDebug()) {
d8ArgsBuilder += "--debug"
} else {
Expand Down Expand Up @@ -462,7 +496,7 @@ trait AndroidAppModule extends JavaModule {
def androidUnsignedApk: T[PathRef] = Task {
val unsignedApk = Task.dest / "app.unsigned.apk"

os.copy(androidResources().path / "res.apk", unsignedApk)
os.copy(androidResources()._1.path / "res.apk", unsignedApk)
val dexFiles = os.walk(androidDex().path)
.filter(_.ext == "dex")
.map(os.zip.ZipSource.fromPath)
Expand Down Expand Up @@ -650,29 +684,33 @@ trait AndroidAppModule extends JavaModule {
PathRef(signedApk)
}

def androidLibsRClasses: T[PathRef] = Task {
def androidLibsRClasses: T[Seq[PathRef]] = Task {
// TODO do this better
// So we have application R.java class generated by aapt2 link, which includes IDs of app resources + libs resources.
// But we also need to have R.java classes for libraries. The process below is quite hacky and inefficient, because:
// * it will generate R.java for the library even library has no resources declared
// * R.java will have not only resource ID from this library, but from other libraries as well. They should be stripped.
val mainRClassPath = os.walk(androidResources().path / rClassDirName)
val rClassDir = androidResources()._1.path / rClassDirName
val mainRClassPath = os.walk(rClassDir)
.find(_.last == "R.java")
.get

val mainRClass = os.read(mainRClassPath)
val libsPackages = androidUnpackArchives()
.flatMap(_.manifest)
.map(f => ((XML.loadFile(f.path.toIO) \\ "manifest").head \ "@package").head.toString())

for (libPackage <- libsPackages) {
val libRClassPath = T.dest / libPackage.split('.') / "R.java"
os.write(
val libClasses: Seq[PathRef] = for {
libPackage <- libsPackages
libRClassPath = T.dest / libPackage.split('.') / "R.java"
_ = os.write(
libRClassPath,
mainRClass.replaceAll("package .+;", s"package $libPackage;"),
createFolders = true
)
}
PathRef(T.dest)
} yield PathRef(libRClassPath)

libClasses :+ PathRef(mainRClassPath)
}

/**
Expand Down Expand Up @@ -1007,15 +1045,23 @@ trait AndroidAppModule extends JavaModule {
trait AndroidAppTests extends JavaTests {
private def testPath = parent.millSourcePath / "src/test"

override def sources: T[Seq[PathRef]] = parent.sources() :+ PathRef(testPath / "java")
override def sources: T[Seq[PathRef]] = Seq(PathRef(testPath / "java"))

override def resources: T[Seq[PathRef]] = Task.Sources(Seq(PathRef(testPath / "res")))

override def bspBuildTarget: BspBuildTarget = super.bspBuildTarget.copy(
baseDirectory = Some(testPath),
canTest = true
)

override def resources: T[Seq[PathRef]] = parent.resources() :+ PathRef(testPath / "res")
}

trait AndroidAppInstrumentedTests extends AndroidAppModule with AndroidTestModule {
private def androidMainSourcePath = parent.millSourcePath
private def androidTestPath = androidMainSourcePath / "src/androidTest"

override def moduleDeps: Seq[JavaModule] = Seq(parent)

override def androidCompileSdk: T[Int] = parent.androidCompileSdk
override def androidMinSdk: T[Int] = parent.androidMinSdk
override def androidTargetSdk: T[Int] = parent.androidTargetSdk
Expand All @@ -1030,11 +1076,15 @@ trait AndroidAppModule extends JavaModule {

override def androidEmulatorPort: String = parent.androidEmulatorPort

override def sources: T[Seq[PathRef]] = parent.sources() :+ PathRef(androidTestPath / "java")
override def sources: T[Seq[PathRef]] = Seq(PathRef(androidTestPath / "java"))

/** The resources in res directories of both main source and androidTest sources */
override def resources: T[Seq[PathRef]] =
super.resources() ++ parent.resources() :+ PathRef(androidTestPath / "res")
override def resources: T[Seq[PathRef]] = Task.Sources {
val libResFolders = androidUnpackArchives().flatMap(_.resources)
libResFolders ++ Seq(PathRef(androidTestPath / "res"))
}

override def generatedSources: T[Seq[PathRef]] = Task.Sources(Seq.empty[PathRef])

/* TODO on debug work, an AndroidManifest.xml with debug and instrumentation settings
* will need to be created. Then this needs to point to the location of that debug
Expand Down Expand Up @@ -1095,6 +1145,13 @@ trait AndroidAppModule extends JavaModule {

/** Builds the apk including the integration tests (e.g. from androidTest) */
def androidInstantApk: T[PathRef] = androidApk

@internal
override def bspBuildTarget: BspBuildTarget = super[AndroidTestModule].bspBuildTarget.copy(
baseDirectory = Some(androidTestPath),
canRun = false
)

}

}
Expand Down
2 changes: 1 addition & 1 deletion scalalib/src/mill/javalib/android/AndroidTestModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import mill.T
import mill.scalalib.TestModule

@mill.api.experimental
trait AndroidTestModule extends TestModule
trait AndroidTestModule extends TestModule {}

@mill.api.experimental
object AndroidTestModule {
Expand Down

0 comments on commit 6b50855

Please sign in to comment.