Airin 🎋
Airin is a tool for the automated migration of Gradle Android projects to Bazel.
Overview
To facilitate the migration of Android apps to Bazel, Airin provides a Gradle plugin that upon configuration,
analyzes the Gradle project structure and replicates it with Bazel by generating the corresponding Bazel files.
To enable Starlark code generation in Kotlin, Airin is bundled with Pendant,
an open-source declarative and type-safe Starlark code generator.
You can learn more about Airin and the design behind it in a blog post at Turo Engineering.
Installation
Apply Airin Gradle plugin in your root build.gradle.kts file.
plugins {
id("io.morfly.airin.android") version "x.y.z"
}

Configuration
Next, in the same build.gradle.kts file, use the airin extension to configure the plugin and adjust it specifically
for your project.
airin {
targets += setOf(":app")
register<AndroidLibraryModule> {
include<JetpackComposeFeature>()
include<HiltFeature>()
include<ParcelizeFeature>()
...
}
register<JvmLibraryModule>()
register<RootModule> {
include<AndroidToolchainFeature>()
}
}
A few things are happening in the script above:
- An
app module and all its dependencies will be migrated to Bazel.
- Modules classified as Android library, JVM library and root module will be migrated to Bazel.
- If any Android library module optionally uses technologies like Jetpack Compose, Hilt or Parcelize, they will be reflected in Bazel too.
Continue reading the documentation to find more details about components and plugin configuration.
Migration
Finally, after the plugin is configured, the migration to Bazel is triggered with a corresponding Gradle task.
./gradlew app:migrateToBazel --no-configure-on-demand
Airin plugin needs to analyze the project dependency graph during the configuration phase.
Therefore, configuration on demand must be disabled when running the migration.
Gradle plugin
Configuration options
To configure Airin Gradle plugin use airin extension in the root build.gradle.kts file.
Gradle tasks
migrateToBazel - registered for each migration target that is explicitly specified in airin plugin extension. Triggers the migration for the module, its direct and transitive dependencies and a root module.
migrateProjectToBazel - registered for all dependencies of migration targets. Triggers migration only for this module.
migrateRootToBazelFor*** - registered for a root project and complements migration for specific migration target, where the is a name of a migration target. E.g. .
Module components
Module component is responsible for generating Bazel files for specific types of modules.
Every module component is an abstract class that extends the ModuleComponent base class and implements 2 functions, canProcess and onInvoke.
It is declared in buildSrc, or any other type of module that is included in the classpath of your build configuration.
abstract class AndroidLibraryModule : ModuleComponent {
override fun canProcess(project: Project): Boolean {...}
override fun ModuleContext.onInvoke(module: GradleModule) {...}
}
canProcess is invoked during the Gradle Configuration phase and is aimed to filter Gradle modules to which this component is applicable. Only one module component can be selected for every module in the codebase.
onInvoke is invoked during the Gradle Execution phase and contains the main logic of the component the purpose of which is to generate Bazel files for the module.
The easiest way to determine the module type is by examining its applied plugins. For example, an Android library module in Gradle typically relies upon the com.android.library plugin.
abstract class AndroidLibraryModule : ModuleComponent {
override fun canProcess(project: Project): Boolean =
project.plugins.hasPlugin("com.android.library")
}
Generating Bazel files
The main responsibility of module components is generating Bazel files.
To do this, Airin leverages Pendant, a Kotlin DSL that provides a declarative API for generating Starlark code. This is done in onInvoke.
abstract class AndroidLibraryModule : ModuleComponent {
override fun ModuleContext.onInvoke(module: GradleModule) {
val file = BUILD.bazel {
...
}
generate(file)
}
}
You can use various builders for different types of Bazel types.
val build = BUILD { ... }
val buildWithExt = BUILD.bazel { ... }
val workspace = WORKSPACE { ... }
val workspaceWithExt = WORKSPACE.bazel { ... }
val mavenDependencies = "maven_dependencies".bzl { ... }
To write the file in the file system, use generate call. By default, the file is generated in the same directory as the currently processed module.
Additionally, you can use relativePath to specify a subdirectory for a generated file.
generate(build)
generate(mavenDependencies, relativePath = "third_party")
The actual content of a generated Starlark file is built using Pendant.
override fun ModuleContext.onInvoke(module: GradleModule) {
val file = BUILD.bazel {
load("@io_bazel_rules_kotlin//kotlin:android.bzl", "kt_android_library")
kt_android_library {
name = module.name
srcs = glob("src/main/**/*.kt")
custom_package = module.androidMetadata?.packageName
manifest = "src/main/AndroidManifest.xml"
resource_files = glob("src/main/res/**")
}
}
generate(file)
}
You can find an in-depth overview of Pendant on GitHub and in the talk at droidcon NYC 2022.
Dependencies
A Bazel target can possess various types of dependencies, each represented by different function parameters.
kt_android_library(
...
deps = [...],
exports = [...],
plugins = [...],
)
A GradleModule instance provides dependencies mapped per configuration, represented with an argument name. To designate dependencies in the generated code, the = function (enclosed in backticks) is used to represent an argument passed to a function.
kt_android_library {
...
for ((config, deps) in module.dependencies) {
config `=` deps.map { it.asBazelLabel().toString() }
}
}
As a result, the following Starlark code is generated.
kt_android_library(
...
plugins = [...],
deps = [...],
exports = [...],
)
Feature components
Feature component is responsible for contributing to Bazel files generated by a related module component based on a specific build feature.
Every feature component is an abstract class that extends the FeatureComponent base class and implements 2 functions, canProcess and onInvoke.
It is declared in buildSrc, or any other type of module that is included in the classpath of your build configuration.
abstract class JetpackComposeFeature : FeatureComponent() {
override fun canProcess(project: Project): Boolean {...}
override fun FeatureContext.onInvoke(module: GradleModule) {...}
}
canProcess is invoked during the Gradle Configuration phase and is aimed to filter Gradle modules to which this component is applicable.
onInvoke is invoked during the Gradle Execution phase and contains the main logic of the component. Its purpose is to modify Bazel files generated by a related module component as well as manage the dependencies of the module.
Dependency overrides
When migrating Gradle modules to Bazel, an obvious task is to preserve a correct dependency graph including internal module dependencies and third-party artifacts. As it turns out, the same module might have a different set of dependencies in Gradle and Bazel.
To address such scenarios, feature components offer dependency override API.
onDependency(MavenCoordinates("com.google.dagger", "hilt-android")) {
overrideWith(BazelLabel(path = "", target = "hilt-android"))
}
In addition, you can ignore certain Gradle dependencies in Bazel by leaving the onDependency block empty.
onDependency(MavenCoordinates("com.google.dagger", "hilt-android")) {
}
Refer to the example of HiltFeature to learn more.
Configuration overrides
When setting up a Gradle module, it involves not only specifying dependencies but also assigning them to a specific configuration, providing instructions to Gradle on how to treat each dependency.
dependencies {
implementation(...)
api(...)
ksp(...)
...
}
In Bazel, targets are declared using function calls. As an analogue to Gradle configurations, we use specific function parameters for various types of dependencies.
kt_android_library(
...
deps = [...],
exports = [...],
plugins = [...],
)
Similar to dependency overrides, feature components also allow the overriding of configurations. In the example below, all implementation dependencies will be declared as deps in Bazel.
onConfiguration("implementation") {
overrideWith("deps")
}
For exporting transitive dependencies, deps and exports are used as an equivalent to Gradle’s api configuration.
onConfiguration("api") {
overrideWith("deps")
overrideWith("exports")
}
Refer to the example of ArtifactMappingFeature to learn more.
File modifiers
Beyond handling dependencies, feature components can also make contributions to the Bazel files generated by module components.
Let’s revisit the code snippet from the AndroidLibraryModule component that we used in the module components section, but this time, let’s make a slight update to it.
val file = BUILD.bazel {
_id = "build_file"
load("@io_bazel_rules_kotlin//kotlin:android.bzl", "kt_android_library")
kt_android_library {
_id = "android_library_target"
name = module.name
srcs = glob("src/main/**/*.kt")
custom_package = module.androidMetadata?.packageName
manifest = "src/main/AndroidManifest.xml"
resource_files = glob("src/main/res/**")
for ((config, deps) in module.dependencies) {
config `=` deps.map { it.asBazelLabel().toString() }
}
}
}
A notable addition here is the introduction of _id fields. These can be defined within any code block enclosed by curly brackets {}. Once defined, you gain the flexibility to edit the contents of these code blocks externally.
Let’s modify the contents of the generated Bazel file using our feature component. To achieve this, within the onInvoke function, use the onContext call, specifying the type of the context to be modified, along with its identifier.
onContext<BuildContext>(id = "build_file") {
`package`(default_visibility = list["//visibility:public"])
}
onContext<KtAndroidLibraryContext>(id = "android_library_target") {
enable_data_binding = true
}
As a result, when the AndroidLibraryModule component is invoked, it will incorporate all the modifications, including the added enable_data_binding argument, as well as the top-level package function call.
load("@io_bazel_rules_kotlin//kotlin:android.bzl", "kt_android_library")
load("@rules_jvm_external//:defs.bzl", "artifact")
kt_android_library(
name = "my-library",
srcs = glob(["src/main/**/*.kt"]),
custom_package = "com.turo.mylibrary",
manifest = "src/main/AndroidManifest.xml",
resource_files = glob(["src/main/res/**"]),
deps = [...],
enable_data_binding = True,
)
package(default_visibility = ["//visibility:public"])
Refer to the example of JetpackComposeFeature to learn more.
Shared components
The purpose of shared components is to enable feature components to contribute into multiple module components.
There could be multiple types of shared components.
- Shared module component — receives contributions from every shared feature component, even if it is not directly included in it.
- Shared feature component — contributes to every shared module component.
- Top-level feature component — does not belong to any module component specifically and contributes to all module components, even non-shared ones.
This is how they are declared in a Gradle plugin.
airin {
register<AndroidLibraryModule> {
include<HiltFeature> { shared = true }
}
register<RootModule> { shared = true }
include<AllPublicFeature>()
}
Here is what's happening in the example above.
HiltFeature - feature component that configures Hilt for a Bazel module.
- Contributes to because they are directly connected. Includes Hilt in Bazel scripts in each Android library module.
Refer to examples of AndroidLibraryModule,
HiltFeature
and RootModule to learn more.
Properties
Both module and feature components offer an API for declaring properties, that serve as arguments that allow additional customization when configuring airin plugin.
airin {
register<RootModule> {
include<AndroidToolchainFeature> {
rulesKotlinVersion = "1.8.1"
rulesKotlinSha = "a630cda9fdb4f56cf2dc20a4bf873765c41cf00e9379e8d59cd07b24730f4fde"
}
}
}
To declare a property in a custom component use property delegate and provide a default value.
abstract class AndroidToolchainFeature : FeatureComponent() {
val rulesKotlinVersion: String by property(default = "1.8.1")
val rulesKotlinSha: String by property(default = "a630cda9fdb4f56cf2dc20a4bf873765c41cf00e9379e8d59cd07b24730f4fde")
override fun FeatureContext.onInvoke(module: GradleModule) {
...
}
}
Refer to the example of AndroidToolchainFeature to learn more.
Default properties
There are a few default properties that are available in each component.
shared - makes a component shared, as described in shared components section.
ignored - excludes the component from the migration process.
priority - applied only to module components and defines the priority of the component. It complements ComponentConflictResolution.UsePriority, so that a component with a highest priority is select for a module in case of a conflict.
Shared properties
Regular properties are defined during the Gradle configuration phase and are common in all modules that use a certain component.
Shared properties, on the other hand, are used during the Gradle execution phase and allow sharing the data between related module and shared components within a single module.
abstract class HiltFeature : FeatureComponent() {
override fun FeatureContext.onInvoke(module: GradleModule) {
sharedProperties["myProperty"] = "value"
}
}
abstract class AndroidLibraryModule : FeatureComponent() {
override fun FeatureContext.onInvoke(module: GradleModule) {
val myProperty = sharedProperties["myProperty"] as String
}
}
The components are invoked following the order specified in airin extension, so that for each module, feature components are invoked prior to the module component.
Decorators
Decorators allow extracting an additional information about Gradle modules that will automatically decorate instances of GradleModule in
onInvoke calls of each module and feature component during the migration.
By default, io.morfly.airin.android Gradle plugin employs AndroidModuleDecorator.
Its purpose is to provide additional information about modules extracted from Android Gradle plugin, such as package name, enabled build features, etc.
The simple example of a module decorator could be found below.
open class AndroidModuleDecorator : GradleModuleDecorator {
override fun GradleModule.decorate(project: ) {
(!project.plugins.hasPlugin())
androidMetadata = AndroidMetadata(
namespace = extensions.findByType(CommonExtension::.java)?.namespace
)
.properties[] = androidMetadata
}
}
After the decorator is declared, all that is left is to apply it in the airin plugin configuration.
airin {
decorateWith<AndroidModuleDecorator>()
}
As a result, you can extract this data in your custom components.
abstract class MyFeatureComponent : FeatureComponent() {
override fun FeatureContext.onInvoke(module: GradleModule) {
val myData = module.properties["androidMetadata"]
}
}
Refer to the example of AndroidModuleDecorator to learn more.
License
Copyright 2023 Pavlo Stavytskyi.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance the License.
You may obtain a copy of the License at
https:
Unless applicable law agreed to writing, software
distributed under the License distributed an BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express implied.
See the License the specific language governing permissions
limitations under the License.