gradlekotlin-multiplatform

Kotlin multiplatform: Accessing build variables in code


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...


Solution

  • 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.

    Generating source code

    There's a couple of Gradle utils that can be used to generate code from build properties.

    1. TextResourceFactory can create files dynamically.
    2. Tasks that create output files can be used as inputs (example).

    It's also important to be mindful of lazy configuration. The file should only be generated when it is necessary.

    BuildConfig generator task

    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.

    Defining a generated file

    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!

    enter image description here

    // ./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.

    1. I don't want to run this task manually. How can I make Gradle run it automatically?
    2. Gradle doesn't recognise ./build/generated-src/kotlin/ as a source directory.

    We can fix both in one go!

    Linking the task to the source set

    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!

    Future improvements

    Generating the source during IDEA sync

    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.

    Dynamic property

    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/"))
    }
    
    Dynamic properties

    What if you want to generate a file from multiple properties? In this case, I would either