Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

78
build-plugin/README.md Normal file
View file

@ -0,0 +1,78 @@
# Build plugins
The `build-plugin` folder defines Gradle build plugins, used as single source of truth for the project configuration.
This helps to avoid duplicated build script setups and provides a central location for all common build logic.
## Background
We use Gradle's
[sharing build logic in a multi-repo setup](https://docs.gradle.org/current/samples/sample_publishing_convention_plugins.html)
to create common configuration. It allows usage of `xyz.gradle.kts` files, that are then automatically converted to
Gradle Plugins.
The `build-plugin` is used as included build in the root `settings.gradle.kts` and provides all
included `xyz.gradle.kts` as plugins under their `xyz` name to the whole project.
The plugins should try to accomplish single responsibility and leave one-off configuration to the
module's `build.gradle.kts`.
## Convention plugins
- `thunderbird.app.android` - Configures common options for Android apps
- `thunderbird.app.android.compose` - Configures common options for Jetpack Compose, based
on `thunderbird.app.android`
- `thunderbird.library.android` - Configures common options for Android libraries
- `thunderbird.library.android.compose` - Configures common options for Jetpack Compose, based
on `thunderbird.library.android`
- `thunderbird.library.jvm` - Configures common options for JVM libraries
## Supportive plugins
- `thunderbird.dependency.check` - [Gradle Versions: Gradle plugin to discover dependency updates](https://github.com/ben-manes/gradle-versions-plugin)
- Use `./gradlew dependencyUpdates` to generate a dependency update report
- `thunderbird.quality.detekt` - [Detekt - Static code analysis for Kotlin](https://detekt.dev/)
- Use `./gradlew detekt` to check for any issue and `./gradlew detektBaseline` in case you can't fix the reported
issue.
- `thunderbird.quality.spotless` - [Spotless - Code formatter](https://github.com/diffplug/spotless)
with [Ktlint - Kotlin linter and formatter](https://pinterest.github.io/ktlint/)
- Use `./gradlew spotlessCheck` to check for any issue and `./gradlew spotlessApply` to format your code
- `thunderbird.quality.badging` - [Android Badging Check Plugin](https://github.com/android/nowinandroid/blob/main/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt)
- Use `./gradlew generate{VariantName}Badging` to generate badging file
- Use `./gradlew check{VariantName}Badging` to validate allowed badging
- Use `./gradlew update{VariantName}Badging` to update allowed badging
## Add new build plugin
Create a `thunderbird.xyz.gradle.kts` file, while `xyz` describes the new plugin.
If you need to access dependencies that are not yet defined in `build-plugin/build.gradle.kts` you have to:
1. Add the dependency to the version catalog `gradle/libs.versions.toml`
2. Then add it to `build-plugin/build.gradle.kts`.
1. In case of a plugin dependency use `implementation(plugin(libs.plugins.YOUR_PLUGIN_DEPENDENCY))`.
2. Otherwise `implementation(libs.YOUR_DEPENDENCY))`.
When done, add the plugin to `build-plugin/src/main/kotlin/ThunderbirdPlugins.kt`
Then apply the plugin to any subproject it should be used with:
```
plugins {
id(ThunderbirdPlugins.xyz)
}
```
If the plugin is meant for the root `build.gradle.kts`, you can't use `ThunderbirdPlugins`, as it's not available to
the `plugins` block. Instead use:
```
plugins {
id("thunderbird.xyz")
}
```
## Acknowledgments
- [Herding Elephants | Square Corner Blog](https://developer.squareup.com/blog/herding-elephants/)
- [Idiomatic Gradle: How do I idiomatically structure a large build with Gradle](https://github.com/jjohannes/idiomatic-gradle#idiomatic-build-logic-structure)

View file

@ -0,0 +1,35 @@
plugins {
`kotlin-dsl`
}
dependencies {
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
implementation(plugin(libs.plugins.kotlin.android))
implementation(plugin(libs.plugins.kotlin.jvm))
implementation(plugin(libs.plugins.kotlin.multiplatform))
implementation(plugin(libs.plugins.kotlin.parcelize))
implementation(plugin(libs.plugins.kotlin.serialization))
implementation(plugin(libs.plugins.android.application))
implementation(plugin(libs.plugins.android.library))
implementation(plugin(libs.plugins.compose))
implementation(plugin(libs.plugins.jetbrains.compose))
implementation(plugin(libs.plugins.dependency.check))
implementation(plugin(libs.plugins.detekt))
implementation(plugin(libs.plugins.spotless))
implementation(libs.diff.utils)
compileOnly(libs.android.tools.common)
// This defines the used Kotlin version for all Plugin dependencies
// and ensures that transitive dependencies are aligned on one version.
implementation(platform(libs.kotlin.gradle.bom))
}
fun plugin(provider: Provider<PluginDependency>) = with(provider.get()) {
"$pluginId:$pluginId.gradle.plugin:$version"
}

View file

@ -0,0 +1,21 @@
pluginManagement {
repositories {
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
versionCatalogs.create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
rootProject.name = "build-plugin"

View file

@ -0,0 +1,72 @@
import com.android.build.api.dsl.CommonExtension
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.Project
import org.gradle.api.artifacts.dsl.DependencyHandler
internal fun CommonExtension<*, *, *, *, *, *>.configureSharedConfig(project: Project) {
compileSdk = ThunderbirdProjectConfig.Android.sdkCompile
defaultConfig {
compileSdk = ThunderbirdProjectConfig.Android.sdkCompile
minSdk = ThunderbirdProjectConfig.Android.sdkMin
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
compileOptions {
sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
}
lint {
warningsAsErrors = false
abortOnError = true
checkDependencies = true
lintConfig = project.file("${project.rootProject.projectDir}/config/lint/lint.xml")
baseline = project.file("${project.rootProject.projectDir}/config/lint/android-lint-baseline.xml")
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
packaging {
resources {
excludes += listOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/DEPENDENCIES",
"/META-INF/LICENSE",
"/META-INF/LICENSE.txt",
"/META-INF/NOTICE",
"/META-INF/NOTICE.txt",
"/META-INF/README",
"/META-INF/README.md",
"/META-INF/CHANGES",
"/LICENSE.txt",
)
}
}
}
internal fun CommonExtension<*, *, *, *, *, *>.configureSharedComposeConfig(libs: LibrariesForLibs) {
buildFeatures {
compose = true
}
}
internal fun DependencyHandler.configureSharedComposeDependencies(libs: LibrariesForLibs) {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.bundles.shared.jvm.android.compose)
debugImplementation(libs.bundles.shared.jvm.android.compose.debug)
testImplementation(libs.bundles.shared.jvm.test.compose)
androidTestImplementation(libs.bundles.shared.jvm.androidtest.compose)
}

View file

@ -0,0 +1,14 @@
import org.gradle.api.artifacts.Dependency
import org.gradle.api.artifacts.dsl.DependencyHandler
internal fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? =
add("implementation", dependencyNotation)
internal fun DependencyHandler.debugImplementation(dependencyNotation: Any): Dependency? =
add("debugImplementation", dependencyNotation)
internal fun DependencyHandler.testImplementation(dependencyNotation: Any): Dependency? =
add("testImplementation", dependencyNotation)
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any): Dependency? =
add("androidTestImplementation", dependencyNotation)

View file

@ -0,0 +1,11 @@
import org.gradle.api.Project
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
fun Project.configureKotlinJavaCompatibility() {
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget)
}
}
}

View file

@ -0,0 +1,10 @@
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.Project
import org.gradle.kotlin.dsl.DependencyHandlerScope
import org.gradle.kotlin.dsl.getByName
internal val Project.libs: LibrariesForLibs
get() = extensions.getByName<LibrariesForLibs>("libs")
internal fun Project.dependencies(configuration: DependencyHandlerScope.() -> Unit) =
DependencyHandlerScope.of(dependencies).configuration()

View file

@ -0,0 +1,91 @@
import com.android.build.api.dsl.ApkSigningConfig
import java.io.FileInputStream
import java.util.Properties
import org.gradle.api.NamedDomainObjectContainer
import org.gradle.api.Project
private const val SIGNING_FOLDER = ".signing"
private const val SIGNING_FILE_ENDING = ".signing.properties"
private const val UPLOAD_FILE_ENDING = ".upload.properties"
private const val PROPERTY_STORE_FILE = "storeFile"
private const val PROPERTY_STORE_PASSWORD = "storePassword"
private const val PROPERTY_KEY_ALIAS = "keyAlias"
private const val PROPERTY_KEY_PASSWORD = "keyPassword"
/**
* Creates an [ApkSigningConfig] for the given signing type.
*
* The signing properties are read from a file in the `.signing` folder in the project root directory.
* File names are expected to be in the format `$app.$type.signing.properties` or `$app.$type.upload.properties`.
*
* The file should contain the following properties:
* - `$app.$type.storeFile`
* - `$app.$type.storePassword`
* - `$app.$type.keyAlias`
* - `$app.$type.keyPassword`
*
* @param project the project to create the signing config for
* @param signingType the signing type to create the signing config for
* @param isUpload whether the upload or signing config is used
*/
fun NamedDomainObjectContainer<out ApkSigningConfig>.createSigningConfig(
project: Project,
signingType: SigningType,
isUpload: Boolean = true,
) {
val properties = project.readSigningProperties(signingType, isUpload)
if (properties.hasSigningConfig(signingType)) {
create(signingType.type) {
storeFile = project.file(properties.getSigningProperty(signingType, PROPERTY_STORE_FILE))
storePassword = properties.getSigningProperty(signingType, PROPERTY_STORE_PASSWORD)
keyAlias = properties.getSigningProperty(signingType, PROPERTY_KEY_ALIAS)
keyPassword = properties.getSigningProperty(signingType, PROPERTY_KEY_PASSWORD)
}
} else {
project.logger.warn("Signing config not created for ${signingType.type}")
}
}
/**
* Returns the [ApkSigningConfig] for the given signing type.
*
* @param signingType the signing type to get the signing config for
*/
fun NamedDomainObjectContainer<out ApkSigningConfig>.getByType(signingType: SigningType): ApkSigningConfig? {
return findByName(signingType.type)
}
private fun Project.readSigningProperties(signingType: SigningType, isUpload: Boolean) = Properties().apply {
val signingPropertiesFile = if (isUpload) {
rootProject.file("$SIGNING_FOLDER/${signingType.id}$UPLOAD_FILE_ENDING")
} else {
rootProject.file("$SIGNING_FOLDER/${signingType.id}$SIGNING_FILE_ENDING")
}
if (signingPropertiesFile.exists()) {
FileInputStream(signingPropertiesFile).use { inputStream ->
load(inputStream)
}
} else {
logger.warn("Signing properties file not found: $signingPropertiesFile")
}
}
private fun Properties.hasSigningConfig(signingType: SigningType): Boolean {
return isNotEmpty() &&
containsKey(signingType, PROPERTY_STORE_FILE) &&
containsKey(signingType, PROPERTY_STORE_PASSWORD) &&
containsKey(signingType, PROPERTY_KEY_ALIAS) &&
containsKey(signingType, PROPERTY_KEY_PASSWORD)
}
private fun Properties.containsKey(signingType: SigningType, key: String): Boolean {
return containsKey("${signingType.id}.$key")
}
private fun Properties.getSigningProperty(signingType: SigningType, key: String): String {
return getProperty("${signingType.id}.$key")
?: throw IllegalArgumentException("Missing property: ${signingType.type}.$key")
}

View file

@ -0,0 +1,10 @@
enum class SigningType(
val app: String,
val type: String,
val id: String = "$app.$type",
) {
K9_RELEASE(app = "k9", type = "release"),
TB_RELEASE(app = "tb", type = "release"),
TB_BETA(app = "tb", type = "beta"),
TB_DAILY(app = "tb", type = "daily"),
}

View file

@ -0,0 +1,11 @@
val kotlinEditorConfigOverride = mapOf(
"ktlint_code_style" to "intellij_idea",
"ktlint_ignore_back_ticked_identifier" to "true",
"ktlint_function_naming_ignore_when_annotated_with" to "Composable",
"ktlint_standard_class-signature" to "disabled",
"ktlint_standard_function-expression-body" to "disabled",
"ktlint_standard_function-signature" to "disabled",
"ktlint_standard_parameter-list-spacing" to "disabled",
"ktlint_standard_property-naming" to "disabled",
)

View file

@ -0,0 +1,20 @@
object ThunderbirdPlugins {
object App {
const val android = "thunderbird.app.android"
const val androidCompose = "thunderbird.app.android.compose"
const val jvm = "thunderbird.app.jvm"
const val kmpAndroidCompose = "thunderbird.app.kmp.android.compose"
}
object Library {
const val android = "thunderbird.library.android"
const val androidCompose = "thunderbird.library.android.compose"
const val jvm = "thunderbird.library.jvm"
const val kmp = "thunderbird.library.kmp"
const val kmpCompose = "thunderbird.library.kmp.compose"
}
}

View file

@ -0,0 +1,19 @@
import org.gradle.api.JavaVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
object ThunderbirdProjectConfig {
object Android {
const val sdkMin = 21
// Only needed for application
const val sdkTarget = 35
const val sdkCompile = 35
}
object Compiler {
val javaCompatibility = JavaVersion.VERSION_11
val jvmTarget = JvmTarget.JVM_11
val javaVersion = JavaVersion.VERSION_11
}
}

View file

@ -0,0 +1,26 @@
plugins {
id("thunderbird.app.android")
id("org.jetbrains.kotlin.plugin.compose")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
android {
configureSharedComposeConfig(libs)
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
dependencies {
configureSharedComposeDependencies(libs)
implementation(libs.androidx.activity.compose)
}

View file

@ -0,0 +1,42 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
android {
configureSharedConfig(project)
defaultConfig {
targetSdk = ThunderbirdProjectConfig.Android.sdkTarget
}
buildFeatures {
buildConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString()
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
dependencies {
coreLibraryDesugaring(libs.android.desugar.nio)
implementation(platform(libs.kotlin.bom))
implementation(platform(libs.koin.bom))
implementation(libs.bundles.shared.jvm.android.app)
testImplementation(libs.bundles.shared.jvm.test)
}

View file

@ -0,0 +1,21 @@
plugins {
id("application")
id("org.jetbrains.kotlin.jvm")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
java {
sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
}
configureKotlinJavaCompatibility()
dependencies {
implementation(platform(libs.kotlin.bom))
implementation(platform(libs.koin.bom))
implementation(libs.bundles.shared.jvm.main)
testImplementation(libs.bundles.shared.jvm.test)
}

View file

@ -0,0 +1,163 @@
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
androidComponents {
onVariants { variant ->
val variantName = variant.name.capitalized()
val printVersionInfoTaskName = "printVersionInfo$variantName"
tasks.register<PrintVersionInfo>(printVersionInfoTaskName) {
applicationId.set(variant.applicationId)
applicationLabel.set(getApplicationLabel(variant))
versionCode.set(getVersionCode(variant))
versionName.set(getVersionName(variant))
versionNameSuffix.set(getVersionNameSuffix(variant))
// Set outputFile only if provided via -PoutputFile=...
project.findProperty("outputFile")?.toString()?.let { path ->
outputFile.set(File(path))
}
// Set the `strings.xml` file for the variant to track changes
findStringsXmlForVariant(variant)?.let { stringsFile ->
stringsXmlFile.set(project.layout.projectDirectory.file(stringsFile.path))
}
}
}
}
private fun String.capitalized() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
abstract class PrintVersionInfo : DefaultTask() {
@get:Input
abstract val applicationId: Property<String>
@get:Input
abstract val applicationLabel: Property<String>
@get:Input
abstract val versionCode: Property<Int>
@get:Input
abstract val versionName: Property<String>
@get:Input
abstract val versionNameSuffix: Property<String>
@get:OutputFile
@get:Optional
abstract val outputFile: RegularFileProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val stringsXmlFile: RegularFileProperty
init {
outputs.upToDateWhen { false } // This forces Gradle to always re-run the task
}
@TaskAction
fun printVersionInfo() {
val output = """
APPLICATION_ID=${applicationId.get()}
APPLICATION_LABEL=${applicationLabel.get()}
VERSION_CODE=${versionCode.get()}
VERSION_NAME=${versionName.get()}
VERSION_NAME_SUFFIX=${versionNameSuffix.get()}
FULL_VERSION_NAME=${versionName.get()}${versionNameSuffix.get()}
""".trimIndent()
println(output)
if (outputFile.isPresent) {
outputFile.get().asFile.writeText(output + "\n")
}
}
}
/**
* Finds the correct `strings.xml` for the given variant.
*/
private fun findStringsXmlForVariant(variant: com.android.build.api.variant.Variant): File? {
val targetBuildType = variant.buildType ?: return null
val sourceSets = android.sourceSets
// Try to find the strings.xml for the specific build type
val buildTypeSource = sourceSets.findByName(targetBuildType)?.res?.srcDirs?.firstOrNull()
val stringsXmlFile = buildTypeSource?.resolve("values/strings.xml")
if (stringsXmlFile?.exists() == true) {
return stringsXmlFile
}
// Fallback to the `main` source set
val mainSourceSet = sourceSets.findByName("main")?.res?.srcDirs?.firstOrNull()
return mainSourceSet?.resolve("values/strings.xml")?.takeIf { it.exists() }
}
/**
* Extracts `APPLICATION_LABEL` from `strings.xml`
*/
private fun getApplicationLabel(variant: com.android.build.api.variant.Variant): Provider<String> {
return project.provider {
findStringsXmlForVariant(variant)?.let {
extractAppName(it)
} ?: "Unknown"
}
}
/**
* Parses `strings.xml` to extract `<string name="app_name">...</string>`
*/
private fun extractAppName(stringsXmlFile: File): String {
val xmlDocument = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringsXmlFile)
val xPath = XPathFactory.newInstance().newXPath()
val expression = "/resources/string[@name='app_name']/text()"
return xPath.evaluate(expression, xmlDocument, XPathConstants.STRING) as String
}
/**
* Extracts the `VERSION_CODE` from product flavors
*/
private fun getVersionCode(variant: com.android.build.api.variant.Variant): Int {
val flavorNames = variant.productFlavors.map { it.second }
val androidExtension =
project.extensions.findByType(com.android.build.gradle.internal.dsl.BaseAppModuleExtension::class.java)
val flavor = androidExtension?.productFlavors?.find { it.name in flavorNames }
return flavor?.versionCode ?: androidExtension?.defaultConfig?.versionCode ?: 0
}
/**
* Extracts the `VERSION_NAME` from product flavors
*/
private fun getVersionName(variant: com.android.build.api.variant.Variant): String {
val flavorNames = variant.productFlavors.map { it.second }
val androidExtension = project.extensions.findByType(
com.android.build.gradle.internal.dsl.BaseAppModuleExtension::class.java,
)
val flavor = androidExtension?.productFlavors?.find { it.name in flavorNames }
return flavor?.versionName ?: androidExtension?.defaultConfig?.versionName ?: "unknown"
}
/**
* Extracts the `VERSION_NAME_SUFFIX` from build types
*/
private fun getVersionNameSuffix(variant: com.android.build.api.variant.Variant): String {
val buildTypeName = variant.buildType ?: return ""
val androidExtension =
project.extensions.findByType(com.android.build.gradle.internal.dsl.BaseAppModuleExtension::class.java)
val buildType = androidExtension?.buildTypes?.find { it.name == buildTypeName }
return buildType?.versionNameSuffix ?: ""
}

View file

@ -0,0 +1,18 @@
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
plugins {
id("com.github.ben-manes.versions")
}
tasks.withType<DependencyUpdatesTask> {
rejectVersionIf {
isNonStable(candidate.version) && !isNonStable(currentVersion)
}
}
fun isNonStable(version: String): Boolean {
val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) }
val regex = "^[\\d,.v-]+(-r)?$".toRegex()
val isStable = stableKeyword || regex.matches(version)
return isStable.not()
}

View file

@ -0,0 +1,22 @@
plugins {
id("thunderbird.library.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
android {
configureSharedComposeConfig(libs)
}
androidComponents {
beforeVariants(selector().withBuildType("release")) { variantBuilder ->
variantBuilder.enableUnitTest = false
variantBuilder.enableAndroidTest = false
}
}
dependencies {
configureSharedComposeDependencies(libs)
}

View file

@ -0,0 +1,42 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
android {
configureSharedConfig(project)
buildFeatures {
buildConfig = false
}
kotlinOptions {
jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString()
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
kotlin {
sourceSets.all {
compilerOptions {
freeCompilerArgs.add("-Xwhen-guards")
}
}
}
dependencies {
implementation(platform(libs.kotlin.bom))
implementation(platform(libs.koin.bom))
implementation(libs.bundles.shared.jvm.main)
implementation(libs.bundles.shared.jvm.android)
testImplementation(libs.bundles.shared.jvm.test)
}

View file

@ -0,0 +1,30 @@
import org.gradle.jvm.tasks.Jar
plugins {
`java-library`
id("org.jetbrains.kotlin.jvm")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
java {
sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
}
tasks.withType<Jar> {
// We want to avoid ending up with multiple JARs having the same name, e.g. "common.jar".
// To do this, we use the modified project path as base name, e.g. ":core:common" -> "core.common".
val projectDotPath = project.path.split(":").filter { it.isNotEmpty() }.joinToString(separator = ".")
archiveBaseName.set(projectDotPath)
}
configureKotlinJavaCompatibility()
dependencies {
implementation(platform(libs.kotlin.bom))
implementation(platform(libs.koin.bom))
implementation(libs.bundles.shared.jvm.main)
testImplementation(libs.bundles.shared.jvm.test)
}

View file

@ -0,0 +1,63 @@
plugins {
id("com.android.library")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.multiplatform")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget)
}
}
jvm {
compilerOptions {
jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget)
}
}
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.kotlin.bom))
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.bundles.shared.kmp.common)
implementation(libs.bundles.shared.kmp.compose)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
}
commonTest.dependencies {
implementation(libs.bundles.shared.kmp.common.test)
}
androidMain.dependencies {
implementation(libs.bundles.shared.kmp.android)
implementation(libs.bundles.shared.kmp.compose.android)
implementation(compose.preview)
}
}
}
android {
compileSdk = ThunderbirdProjectConfig.Android.sdkCompile
defaultConfig {
minSdk = ThunderbirdProjectConfig.Android.sdkMin
}
compileOptions {
sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
}
configureSharedComposeConfig(libs)
}

View file

@ -0,0 +1,50 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.multiplatform")
id("org.jetbrains.kotlin.plugin.serialization")
id("thunderbird.quality.detekt.typed")
id("thunderbird.quality.spotless")
}
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget)
}
}
jvm {
compilerOptions {
jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget)
}
}
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.kotlin.bom))
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.bundles.shared.kmp.common)
}
commonTest.dependencies {
implementation(libs.bundles.shared.kmp.common.test)
}
androidMain.dependencies {
implementation(libs.bundles.shared.kmp.android)
}
}
}
android {
compileSdk = ThunderbirdProjectConfig.Android.sdkCompile
defaultConfig {
minSdk = ThunderbirdProjectConfig.Android.sdkMin
}
compileOptions {
sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility
}
}

View file

@ -0,0 +1,253 @@
import com.android.SdkConstants
import com.android.build.api.artifact.SingleArtifact
import com.github.difflib.text.DiffRow
import com.github.difflib.text.DiffRowGenerator
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
/**
* This is a Gradle plugin that adds a task to generate the badging of the APKs and a task to check that the
* generated badging is the same as the golden badging.
*
* This is taken from [nowinandroid](https://github.com/android/nowinandroid) and follows recommendations from
* [Prevent regressions with CI and badging](https://android-developers.googleblog.com/2023/12/increase-your-apps-availability-across-device-types.html).
*/
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
val variantsToCheck = listOf("release", "beta", "daily")
androidComponents {
onVariants { variant ->
if (variantsToCheck.any { variant.name.contains(it, ignoreCase = true) }) {
val capitalizedVariantName = variant.name.capitalized()
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
val generateBadging = tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {
apk.set(variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE))
aapt2Executable.set(
File(
android.sdkDirectory,
"${SdkConstants.FD_BUILD_TOOLS}/" +
"${android.buildToolsVersion}/" +
SdkConstants.FN_AAPT2,
),
)
badging.set(
project.layout.buildDirectory.file(
"outputs/badging/${variant.name}/${variant.name}-badging.txt",
),
)
}
val updateBadgingTaskName = "update${capitalizedVariantName}Badging"
tasks.register<Copy>(updateBadgingTaskName) {
from(generateBadging.get().badging)
into(project.layout.projectDirectory.dir("badging"))
}
val checkBadgingTaskName = "check${capitalizedVariantName}Badging"
val goldenBadgingPath = project.layout.projectDirectory.file("badging/${variant.name}-badging.txt")
tasks.register<CheckBadgingTask>(checkBadgingTaskName) {
if (goldenBadgingPath.asFile.exists()) {
goldenBadging.set(goldenBadgingPath)
}
generatedBadging.set(
generateBadging.get().badging,
)
this.updateBadgingTaskName.set(updateBadgingTaskName)
output.set(
project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"),
)
}
tasks.named("build") {
dependsOn(checkBadgingTaskName)
}
}
}
}
private fun String.capitalized() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
@CacheableTask
abstract class GenerateBadgingTask : DefaultTask() {
@get:OutputFile
abstract val badging: RegularFileProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFile
abstract val apk: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val aapt2Executable: RegularFileProperty
@get:Inject
abstract val execOperations: ExecOperations
@TaskAction
fun taskAction() {
val outputStream = ByteArrayOutputStream()
execOperations.exec {
commandLine(
aapt2Executable.get().asFile.absolutePath,
"dump",
"badging",
apk.get().asFile.absolutePath,
)
standardOutput = outputStream
}
badging.asFile.get().writeText(cleanBadgingContent(outputStream) + "\n")
}
private fun cleanBadgingContent(outputStream: ByteArrayOutputStream): String {
return ByteArrayInputStream(outputStream.toByteArray()).bufferedReader().use { reader ->
reader.lineSequence().map { line ->
line.cleanBadgingLine()
}.sorted().joinToString("\n")
}
}
private fun String.cleanBadgingLine(): String {
return if (startsWith("package:")) {
replace(Regex("versionName='[^']*'"), "")
.replace(Regex("versionCode='[^']*'"), "")
.replace(Regex("\\s+"), " ")
.trim()
} else if (trim().startsWith("uses-feature-not-required:")) {
trim()
} else {
this
}
}
}
@CacheableTask
abstract class CheckBadgingTask : DefaultTask() {
// In order for the task to be up-to-date when the inputs have not changed,
// the task must declare an output, even if it's not used. Tasks with no
// output are always run regardless of whether the inputs changed
@get:OutputDirectory
abstract val output: DirectoryProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:Optional
@get:InputFile
abstract val goldenBadging: RegularFileProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFile
abstract val generatedBadging: RegularFileProperty
@get:Input
abstract val updateBadgingTaskName: Property<String>
override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP
@TaskAction
fun taskAction() {
if (goldenBadging.isPresent.not()) {
printlnColor(
ANSI_YELLOW,
"Golden badging file does not exist!" +
" If this is the first time running this task," +
" run ./gradlew ${updateBadgingTaskName.get()}",
)
return
}
val goldenBadgingContent = goldenBadging.get().asFile.readText()
val generatedBadgingContent = generatedBadging.get().asFile.readText()
if (goldenBadgingContent == generatedBadgingContent) {
printlnColor(ANSI_YELLOW, "Generated badging is the same as golden badging!")
return
}
val diff = performDiff(goldenBadgingContent, generatedBadgingContent)
printDiff(diff)
throw GradleException(
"""
Generated badging is different from golden badging!
If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}
""".trimIndent(),
)
}
private fun performDiff(goldenBadgingContent: String, generatedBadgingContent: String): String {
val generator: DiffRowGenerator = DiffRowGenerator.create()
.showInlineDiffs(true)
.mergeOriginalRevised(true)
.inlineDiffByWord(true)
.oldTag { _ -> "" }
.newTag { _ -> "" }
.build()
return generator.generateDiffRows(
goldenBadgingContent.lines(),
generatedBadgingContent.lines(),
).filter { row -> row.tag != DiffRow.Tag.EQUAL }
.joinToString("\n") { row ->
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
when (row.tag) {
DiffRow.Tag.INSERT -> {
"+ ${row.newLine}"
}
DiffRow.Tag.DELETE -> {
"- ${row.oldLine}"
}
DiffRow.Tag.CHANGE -> {
"+ ${row.newLine}"
"- ${row.oldLine}"
}
DiffRow.Tag.EQUAL -> ""
}
}
}
private fun printDiff(diff: String) {
printlnColor("", null)
printlnColor(ANSI_YELLOW, "Badging diff:")
diff.lines().forEach { line ->
val ansiColor = if (line.startsWith("+")) {
ANSI_GREEN
} else if (line.startsWith("-")) {
ANSI_RED
} else {
null
}
printlnColor(line, ansiColor)
}
}
private fun printlnColor(text: String, ansiColor: String?) {
println(
if (ansiColor != null) {
ansiColor + text + ANSI_RESET
} else {
text
},
)
}
private companion object {
const val ANSI_RESET = "\u001B[0m"
const val ANSI_RED = "\u001B[31m"
const val ANSI_GREEN = "\u001B[32m"
const val ANSI_YELLOW = "\u001B[33m"
}
}

View file

@ -0,0 +1,47 @@
import io.gitlab.arturbosch.detekt.Detekt
import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask
import io.gitlab.arturbosch.detekt.extensions.DetektExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
plugins {
id("io.gitlab.arturbosch.detekt")
}
configure<DetektExtension> {
config.setFrom(project.rootProject.files("config/detekt/detekt.yml"))
val name = project.path.replace(":", "-").replace("/", "-")
baseline = project.rootProject.file("config/detekt/detekt-baseline$name.xml")
ignoredBuildTypes = listOf("release")
}
tasks.withType<Detekt>().configureEach {
jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString()
exclude(defaultExcludes)
reports {
html.required.set(true)
sarif.required.set(true)
xml.required.set(true)
}
}
tasks.withType<DetektCreateBaselineTask>().configureEach {
jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString()
exclude(defaultExcludes)
}
dependencies {
detektPlugins(libs.detekt.plugin.compose)
}
val defaultExcludes = listOf(
"**/.gradle/**",
"**/.idea/**",
"**/build/**",
".github/**",
"gradle/**",
)

View file

@ -0,0 +1,48 @@
import com.diffplug.gradle.spotless.SpotlessExtension
plugins {
id("com.diffplug.spotless")
}
configure<SpotlessExtension> {
kotlin {
target(
"src/*/java/*.kt",
"src/*/kotlin/*.kt",
"src/*/java/**/*.kt",
"src/*/kotlin/**/*.kt",
)
ktlint(libs.versions.ktlint.get())
.setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig")
.editorConfigOverride(kotlinEditorConfigOverride)
}
kotlinGradle {
target(
"*.gradle.kts",
)
ktlint(libs.versions.ktlint.get())
.setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig")
.editorConfigOverride(
mapOf(
"ktlint_code_style" to "intellij_idea",
"ktlint_standard_function-expression-body" to "disabled",
"ktlint_standard_function-signature" to "disabled",
),
)
}
flexmark {
target(
"*.md",
)
flexmark()
}
format("misc") {
target(".gitignore")
trimTrailingWhitespace()
}
}

View file

@ -0,0 +1,50 @@
import com.diffplug.gradle.spotless.SpotlessExtension
plugins {
id("com.diffplug.spotless")
}
configure<SpotlessExtension> {
kotlin {
target(
"build-plugin/src/*/kotlin/*.kt",
"build-plugin/src/*/kotlin/**/*.kt",
)
ktlint(libs.versions.ktlint.get())
.setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig")
.editorConfigOverride(kotlinEditorConfigOverride)
}
kotlinGradle {
target(
"*.gradle.kts",
"build-plugin/*.gradle.kts",
"build-plugin/src/*/kotlin/*.kts",
"build-plugin/src/*/kotlin/**/*.kts",
)
ktlint(libs.versions.ktlint.get())
.setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig")
.editorConfigOverride(
mapOf(
"ktlint_code_style" to "intellij_idea",
"ktlint_standard_function-expression-body" to "disabled",
"ktlint_standard_function-signature" to "disabled",
),
)
}
flexmark {
target(
"*.md",
"docs/*.md",
"docs/**/*.md",
)
flexmark()
}
format("misc") {
target(".gitignore")
trimTrailingWhitespace()
}
}