I'm working on a Kotlin Multiplatform project which is an SDK providing functionality for iOS & Android applications.
In our build.gradle.kts
we have a couple of variables that we would like to access within the common code shared code between iOS and Android.
As an Android developer this is how I would usually do in an Android project:
android {
...
defaultConfig {
...
buildConfigField "String", "SOME_VARIABLE", '"' + SOME_VARIABLE_IN_GRADLE_FILES + '"'
...
}
...
}
And then i could access it in code:
val someVariable = BuildConfig.SOME_VARIABLE
How would one do to make something similar to work in a Kotlin Mulitplatform project, since BuildConfig
is not something that is recognised in the common shared code base.
After searching on this topic for a solution I have yet not found any relevant answers, however my googlefoo skills might not be enough...
The answer provided by @Evgeny will solve the problem, but I think this is a good example to learn how to solve this problem in pure Gradle. Even if you want to use a plugin, understanding how Gradle works can help with understand how to customise any plugin or Gradle behaviour.
There's a couple of Gradle utils that can be used to generate code from build properties.
TextResourceFactory
can create files dynamically.It's also important to be mindful of lazy configuration. The file should only be generated when it is necessary.
Tasks are how work is defined in Gradle, so let's make a task that will produce a file as output. Sync
is a good task type for this. Sync
will copy any number of files into a directory.
// build.gradle.kts
val buildConfigGenerator by tasks.registering(Sync::class) {
// from(...) // no input files yet
// the target directory
into(layout.buildDirectory.dir("generated-src/kotlin"))
}
If you run ./gradlew buildConfigGenerator
then the task will run, but the only thing that happens is that an empty directory ./build/generated-src/kotlin/
is created. So let's add a file as an input.
TextResouceFactory
is a little known tool that can create text files dynamically. (Including downloading text files from URLs, which can come in handy!)
Let's create a Kotlin file using it.
// build.gradle.kts
val buildConfigGenerator by tasks.registering(Sync::class) {
from(
resources.text.fromString(
"""
|package my.project
|
|object BuildConfig {
| const val PROJECT_VERSION = "${project.version}"
|}
|
""".trimMargin()
)
) {
rename { "BuildConfig.kt" } // set the file name
into("my/project/") // change the directory to match the package
}
into(layout.buildDirectory.dir("generated-src/kotlin/"))
}
Note that I had to rename the file (TextResourceFactory
will generate a random name), and put set the directory to match package my.project
.
Now if you run ./gradlew buildConfigGenerator
it will actually generate a file!
// ./build/generated-src/kotlin/BuildConfig.kt
package my.project
object BuildConfig {
const val PROJECT_VERSION = "0.0.1"
}
However, there are two more things to fix.
./build/generated-src/kotlin/
as a source directory.We can fix both in one go!
You can add new source directories to a Kotlin SourceSet via the Kotlin Multiplatform plugin DSL via kotlin.srcDir()
// build.gradle.kts
plugins {
kotlin("multiplatform") version "1.7.22"
}
kotlin {
sourceSets {
val commonMain by getting {
kotlin.srcDir(/* add the generate source directory here... */)
}
}
}
We could hard-code kotlin.srcDir(layout.buildDirectory.dir("generated-src/kotlin/"))
- but now we haven't told Gradle about our task! We would still have to run it manually.
Fortunately, we can have the best of both worlds. Thanks to Gradle's Provider API, a task can be converted into a file-provider.
// build.gradle.kts
kotlin {
sourceSets {
val commonMain by getting {
kotlin.srcDir(
// convert the task to a file-provider
buildConfigGenerator.map { it.destinationDir }
)
}
}
}
(Note that because buildConfigGenerator
was created using tasks.registering()
its type is TaskProvider
, the same won't be true of tasks created using tasks.creating()
.)
generatedSourceDirProvider
will lazily provide the generated directory, and because it was mapped from a task, Gradle knows that it needs to run the connected task whenever the commonMain
source set is used for compilation.
To test this, run ./gradlew clean
. The build directory is gone, including the generated file. Now run ./gradlew assemble
- and Gradle will automatically run generatedSourceDirProvider
. Magic!
It's a bit annoying that the source isn't generated when I first open the project in IntelliJ. I could add the gradle-idea-ext-plugin, and make IntelliJ trigger buildConfigGenerator
on a Gradle sync.
In this example, I hard-coded project.version
. But what if the project version is dynamic?
In this case, we need to use a provider, which can be mapped to a file.
// build.gradle.kts
val buildConfigGenerator by tasks.registering(Sync::class) {
// create a provider for the project version
val projectVersionProvider: Provider<String> = provider { project.version.toString() }
// map the project version into a file
val buildConfigFileContents: Provider<TextResource> =
projectVersionProvider.map { version ->
resources.text.fromString(
"""
|package my.project
|
|object BuildConfig {
| const val PROJECT_VERSION = "$version"
|}
|
""".trimMargin()
)
}
// Gradle accepts file providers as Sync inputs
from(buildConfigFileContents) {
rename { "BuildConfig.kt" }
into("my/project/")
}
into(layout.buildDirectory.dir("generated-src/kotlin/"))
}
What if you want to generate a file from multiple properties? In this case, I would either
MapProperty
, set all properties into it, and then (because MapProperty
is a Gradle provider) .map { }
the map into a file