Managing Build File Names in Large Multi-Module Android Projects

Managing Build File Names in Large Multi-Module Android Projects

If you're working on a large Android project with many modules, you've likely noticed that the build files can become difficult to manage and find, especially as the project continues to grow. This is namely due to the fact that as you add more modules, you will be adding more and more build.gradle.kts files (or build.gradle if you haven't migrated yet). However, there's a simple solution to keep these build files organized: name them according to their respective module names. This article will walk you through how you can achieve this by writing some custom code in your settings.gradle.kts

The Traditional Challenge

Android's default build system, Gradle, creates a build file for each module in the project, typically named build.gradle or build.gradle.kts for Kotlin. This convention is straightforward for smaller projects, but in larger, more complex projects with nested modules, locating the correct build file can become cumbersome.

The Solution

To simplify this process, we introduce the includeProject function. This function allows us to include a project into the build by its name and specifies an optional file path.

Here's how it works:

/**
 * Include module within the current project. Allows for specifying an optional file path
 */
fun includeProject(name: String, filePath: String? = null) {
    settings.include(name)
    val project = project(name)
    project.configureProjectDir(filePath)
    project.configureBuildFileName(name)
}

In the includeProject function, we have two parameters: name and an optional filepath. The name argument is used to include the module and then to obtain the project. The project's directory and build file are then configured. The optional parameter for specifying a custom file path can be included when you have a module name that does not follow the file system.

The configureProjectDir function sets the project directory and checks if the directory exists and is indeed a directory (and not a file). If not, a GradleException will be thrown and the build will fail to sync.

/**
 * Configures the project directory
 */
fun ProjectDescriptor.configureProjectDir(filePath: String? = null) {
    if (filePath != null) {
        projectDir = File(rootDir, filePath)
    }
    if (!projectDir.exists()) {
        throw GradleException("Path: $projectDir does not exist. Cannot include project: $name")
    }
    if (!projectDir.isDirectory) {
        throw GradleException("Path: $projectDir is a file instead of a directory. Cannot include project: $name")
    }
}

Meanwhile, the configureBuildFileName function is the heart of what we are wanting to accomplsh. It sets the build file name based on the module name and checks if the file exists. If not, again, a GradleException is thrown.

/**
 * Searches for all of the relevant module names we want allowed. 
 * For example, if we have a file structure:
 * 
 * ├── grandparent
 *    ├── parent
 *       ├── module
 *
 * where `grandparent` and `parent` are just folders and not logical modules, and `module` is a Gradle module,
 * we'd want to allow the following entries for `module`
 * 
 * 1. includeProject(":module")
 * 2. includeProject(":parent-module")
 * 3. includeProject(":grandparent-module")
 * 
 * In slightly more realistic example, let's say you have the following file structure:
 * ├── repository
 *    ├── fake
 *    ├── api
 *    ├── impl
 * 
 * We would want to allow the following names for including the 3 logical modules here, fake, api, and impl:
 * 
 * 1. includeProject(":repository-fake")
 * 2. includeProject(":repository-api")
 * 3. includeProject(":repository-impl")
 */
fun ProjectDescriptor.configureBuildFileName(projectName: String) {
    val name = projectName.substringAfterLast(":")
    val thirdToLastIndex = lastOrdinalIndexOf(projectName, ':', 3)
    val secondToLastIndex = lastOrdinalIndexOf(projectName, ':', 2)
    val lastIndex = lastOrdinalIndexOf(projectName, ':', 1)
    val directParentModule = projectName.substring(secondToLastIndex, lastIndex).trim(':')
    val thirdLevelParentModule = projectName.substring(thirdToLastIndex, lastIndex).trim(':').replace(":","-")

    val filesToCheck = listOf(
        "$name.gradle.kts",
        "$directParentModule-$name.gradle.kts",
        "$thirdLevelParentModule-$name.gradle.kts",
        // any other files you wish to be named
        // potentially still allow for build.gradle.kts
    )

    filesToCheck.firstOrNull {
        buildFileName = it
        buildFile.exists()
    } ?: throw GradleException("None of the following build files exist: ${filesToCheck.joinToString(", ")}. Cannot include project: $name")
}

/**
 * Finds the n-th last index within a String
 */
fun lastOrdinalIndexOf(string: String, searchChar: Char, ordinal: Int): Int {
    val numberOfOccurrences = string.count { it == searchChar }

    if(numberOfOccurrences <= ordinal) {
        return 0
    }
    var found = 0
    var index = string.length
    do {
        index = string.lastIndexOf(searchChar, index - 1)
        if (index < 0) {
            return index
        }
        found++
    } while (found < ordinal)
    return index
}

This function also handles different naming formats based on the module's hierarchy. So, for instance, if we have a file structure like this:

├── repository
   ├── fake
   ├── api
   ├── impl

Where fake, api, and impl are all logical modules, we can now include them in our Android project in the following way:

includeProject(":repository-fake")
includeProject(":repository-api")
includeProject(":repository-impl")

This flexibility allows you to maintain a naming pattern that matches your project's module hierarchy.

Wrapping Up

The includeProject function can be a huge timesaver in managing large, multi-module projects. Not only does it ensure that your project adheres to a logical, hierarchical naming convention, but it also reduces the risk of errors from manually entering project names and paths.

By enforcing a more systematic naming convention for your build files, you can navigate and manage your codebase more efficiently, especially when your project continues to scale. As a result, you'll spend less time searching for the correct build files and more time on what matters most - writing quality code for your Android application.