From be0bb4f749e4504dede22da983a112e82b78be4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 26 Jan 2026 18:29:41 +0100 Subject: [PATCH 1/8] refactor: replace testApi with testImplementation in core build script --- legacy/core/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index b4abeef6dd9..4f676cf282f 100644 --- a/legacy/core/build.gradle.kts +++ b/legacy/core/build.gradle.kts @@ -50,8 +50,8 @@ dependencies { implementation(projects.feature.mail.message.list.api) implementation(projects.feature.mail.message.reader.api) - testApi(projects.core.testing) - testApi(projects.core.android.testing) + testImplementation(projects.core.testing) + testImplementation(projects.core.android.testing) testImplementation(projects.core.logging.testing) testImplementation(projects.feature.telemetry.noop) testImplementation(projects.mail.testing) From 9f6d47bb168068060d0c07e53e2384b80031b8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 26 Jan 2026 18:31:08 +0100 Subject: [PATCH 2/8] refactor: replace `disableC2CompilerForRobolectric` with direct testOptions configuration --- .../src/main/kotlin/RobolectricUtils.kt | 23 ------------------- .../kotlin/thunderbird.app.android.gradle.kts | 20 +++++++++++++++- .../thunderbird.library.android.gradle.kts | 20 +++++++++++++++- 3 files changed, 38 insertions(+), 25 deletions(-) delete mode 100644 build-plugin/src/main/kotlin/RobolectricUtils.kt diff --git a/build-plugin/src/main/kotlin/RobolectricUtils.kt b/build-plugin/src/main/kotlin/RobolectricUtils.kt deleted file mode 100644 index 315ae444786..00000000000 --- a/build-plugin/src/main/kotlin/RobolectricUtils.kt +++ /dev/null @@ -1,23 +0,0 @@ -import com.android.build.gradle.BaseExtension - -/** - * Disables the C2 compiler for Robolectric tests. - * - * This is a workaround for a known issue where Robolectric tests can fail on JDK 17+ - * with a "failed to compile" error. The issue is related to the Tiered Compilation in the JVM, - * specifically the C2 (server) compiler. Disabling C2 forces the JVM to use the C1 (client) - * compiler, which avoids the problem. - * - * The official workaround uses `-XX:+TieredCompilation -XX:TieredStopAtLevel=1`, but just - * `-XX:TieredStopAtLevel=3` seems to work. In case the flakiness still happens, we can - * use the workaround mentioned in the issue. - * - * See: https://github.com/robolectric/robolectric/issues/3202 - */ -fun BaseExtension.disableC2CompilerForRobolectric() { - testOptions { - unitTests.all { - it.jvmArgs("-XX:TieredStopAtLevel=3") - } - } -} diff --git a/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts index 295ada62504..cb5031f2258 100644 --- a/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts @@ -26,7 +26,25 @@ android { includeInBundle = false } - disableC2CompilerForRobolectric() + /** + * Disables the C2 compiler for Robolectric tests. + * + * This is a workaround for a known issue where Robolectric tests can fail on JDK 17+ + * with a "failed to compile" error. The issue is related to the Tiered Compilation in the JVM, + * specifically the C2 (server) compiler. Disabling C2 forces the JVM to use the C1 (client) + * compiler, which avoids the problem. + * + * The official workaround uses `-XX:+TieredCompilation -XX:TieredStopAtLevel=1`, but just + * `-XX:TieredStopAtLevel=3` seems to work. In case the flakiness still happens, we can + * use the workaround mentioned in the issue. + * + * See: https://github.com/robolectric/robolectric/issues/3202 + */ + testOptions { + unitTests.all { + it.jvmArgs("-XX:TieredStopAtLevel=3") + } + } } kotlin { diff --git a/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts index 8b831de793d..770726cd3e5 100644 --- a/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts @@ -13,7 +13,25 @@ android { buildConfig = false } - disableC2CompilerForRobolectric() + /** + * Disables the C2 compiler for Robolectric tests. + * + * This is a workaround for a known issue where Robolectric tests can fail on JDK 17+ + * with a "failed to compile" error. The issue is related to the Tiered Compilation in the JVM, + * specifically the C2 (server) compiler. Disabling C2 forces the JVM to use the C1 (client) + * compiler, which avoids the problem. + * + * The official workaround uses `-XX:+TieredCompilation -XX:TieredStopAtLevel=1`, but just + * `-XX:TieredStopAtLevel=3` seems to work. In case the flakiness still happens, we can + * use the workaround mentioned in the issue. + * + * See: https://github.com/robolectric/robolectric/issues/3202 + */ + testOptions { + unitTests.all { + it.jvmArgs("-XX:TieredStopAtLevel=3") + } + } } kotlin { From 7f24f6ccd4e41429084a6aba41827fc29734fa55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 26 Jan 2026 18:34:31 +0100 Subject: [PATCH 3/8] refactor: inline shared config and compose setup into module build files --- .../src/main/kotlin/AndroidExtension.kt | 66 ------------------- ...thunderbird.app.android.compose.gradle.kts | 18 ++++- .../kotlin/thunderbird.app.android.gradle.kts | 33 +++++++++- ...derbird.library.android.compose.gradle.kts | 16 ++++- .../thunderbird.library.android.gradle.kts | 39 ++++++++++- 5 files changed, 99 insertions(+), 73 deletions(-) delete mode 100644 build-plugin/src/main/kotlin/AndroidExtension.kt diff --git a/build-plugin/src/main/kotlin/AndroidExtension.kt b/build-plugin/src/main/kotlin/AndroidExtension.kt deleted file mode 100644 index ea8962c0b43..00000000000 --- a/build-plugin/src/main/kotlin/AndroidExtension.kt +++ /dev/null @@ -1,66 +0,0 @@ -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") - checkReleaseBuilds = System.getenv("CI_CHECK_RELEASE_BUILDS")?.toBoolean() ?: 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) -} diff --git a/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts index c43d35406b2..20ce6326fae 100644 --- a/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts @@ -7,8 +7,6 @@ plugins { } android { - configureSharedComposeConfig(libs) - buildTypes { release { isMinifyEnabled = false @@ -18,10 +16,24 @@ android { ) } } + + buildFeatures { + compose = true + } } dependencies { - configureSharedComposeDependencies(libs) + 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) implementation(libs.androidx.activity.compose) } diff --git a/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts index cb5031f2258..8d6e1f3ecd2 100644 --- a/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts @@ -7,10 +7,14 @@ plugins { } android { - configureSharedConfig(project) + compileSdk = ThunderbirdProjectConfig.Android.sdkCompile defaultConfig { + minSdk = ThunderbirdProjectConfig.Android.sdkMin targetSdk = ThunderbirdProjectConfig.Android.sdkTarget + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true } buildFeatures { @@ -19,6 +23,8 @@ android { compileOptions { isCoreLibraryDesugaringEnabled = true + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility } dependenciesInfo { @@ -26,6 +32,31 @@ android { includeInBundle = false } + lint { + warningsAsErrors = false + abortOnError = true + checkDependencies = true + lintConfig = project.file("${project.rootProject.projectDir}/config/lint/lint.xml") + checkReleaseBuilds = System.getenv("CI_CHECK_RELEASE_BUILDS")?.toBoolean() ?: 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", + ) + } + } + /** * Disables the C2 compiler for Robolectric tests. * diff --git a/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts index d2d64dedf64..ae91e83856b 100644 --- a/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts @@ -10,7 +10,9 @@ plugins { } android { - configureSharedComposeConfig(libs) + buildFeatures { + compose = true + } } androidComponents { @@ -21,5 +23,15 @@ androidComponents { } dependencies { - configureSharedComposeDependencies(libs) + 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) } diff --git a/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts index 770726cd3e5..cd9f6c4cdd8 100644 --- a/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts @@ -7,12 +7,49 @@ plugins { } android { - configureSharedConfig(project) + compileSdk = ThunderbirdProjectConfig.Android.sdkCompile + + defaultConfig { + minSdk = ThunderbirdProjectConfig.Android.sdkMin + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + compileOptions { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + } buildFeatures { buildConfig = false } + lint { + warningsAsErrors = false + abortOnError = true + checkDependencies = true + lintConfig = project.file("${project.rootProject.projectDir}/config/lint/lint.xml") + checkReleaseBuilds = System.getenv("CI_CHECK_RELEASE_BUILDS")?.toBoolean() ?: 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", + ) + } + } + /** * Disables the C2 compiler for Robolectric tests. * From cb57a9c3b22ef1f249c6ceaed961ab2e075d2748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 26 Jan 2026 18:38:33 +0100 Subject: [PATCH 4/8] chore: remove unused `kotlin-android` plugin --- build-plugin/build.gradle.kts | 1 - .../src/main/kotlin/thunderbird.app.version.info.gradle.kts | 1 - .../src/main/kotlin/thunderbird.quality.badging.gradle.kts | 1 - build.gradle.kts | 1 - gradle/libs.versions.toml | 1 - 5 files changed, 5 deletions(-) diff --git a/build-plugin/build.gradle.kts b/build-plugin/build.gradle.kts index fd493a89980..188d617a15f 100644 --- a/build-plugin/build.gradle.kts +++ b/build-plugin/build.gradle.kts @@ -5,7 +5,6 @@ plugins { 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)) diff --git a/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts index 084db7cf728..3b6153919aa 100644 --- a/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts @@ -4,7 +4,6 @@ import javax.xml.xpath.XPathFactory plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") } androidComponents { diff --git a/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts index c134335d5b4..baf0fc59f73 100644 --- a/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts @@ -15,7 +15,6 @@ import java.io.ByteArrayOutputStream plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") } val variantsToCheck = listOf("release", "beta", "daily") diff --git a/build.gradle.kts b/build.gradle.kts index f576637daa6..2b160e3990d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.android.lint) apply false alias(libs.plugins.android.test) apply false alias(libs.plugins.compose) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.parcelize) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77b63b64fe6..586596248aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -127,7 +127,6 @@ dependency-check = { id = "com.github.ben-manes.versions", version.ref = "depend dependency-guard = { id = "com.dropbox.dependency-guard", version.ref = "dependencyGuardPlugin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detektPlugin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinBom" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinBom" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinBom" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinBom" } From 587081cc2285e6baf63e4c7418bd08346d6df1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Mon, 26 Jan 2026 18:43:31 +0100 Subject: [PATCH 5/8] refactor(plugin): migrate app badging logic to custom Gradle plugin --- app-k9mail/build.gradle.kts | 2 +- app-thunderbird/build.gradle.kts | 2 +- build-plugin/build.gradle.kts | 1 - .../net/thunderbird/gradle/plugin/Libs.kt | 8 + .../plugin/app/badging/BadgingPlugin.kt | 75 ++++++ .../plugin/app/badging/CheckBadgingTask.kt | 140 ++++++++++ .../plugin/app/badging/GenerateBadgingTask.kt | 69 +++++ .../thunderbird.quality.badging.gradle.kts | 252 ------------------ gradle/libs.versions.toml | 1 + 9 files changed, 295 insertions(+), 255 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/Libs.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/BadgingPlugin.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/CheckBadgingTask.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/GenerateBadgingTask.kt delete mode 100644 build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts diff --git a/app-k9mail/build.gradle.kts b/app-k9mail/build.gradle.kts index e954e31e5ec..346c02edcf7 100644 --- a/app-k9mail/build.gradle.kts +++ b/app-k9mail/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id(ThunderbirdPlugins.App.androidCompose) alias(libs.plugins.dependency.guard) id("thunderbird.app.version.info") - id("thunderbird.quality.badging") + alias(libs.plugins.tb.app.badging) } val testCoverageEnabled = hasProperty("testCoverageEnabled") diff --git a/app-thunderbird/build.gradle.kts b/app-thunderbird/build.gradle.kts index 4535b27261a..e7952907adc 100644 --- a/app-thunderbird/build.gradle.kts +++ b/app-thunderbird/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id(ThunderbirdPlugins.App.androidCompose) alias(libs.plugins.dependency.guard) id("thunderbird.app.version.info") - id("thunderbird.quality.badging") + alias(libs.plugins.tb.app.badging) } val testCoverageEnabled = hasProperty("testCoverageEnabled") diff --git a/build-plugin/build.gradle.kts b/build-plugin/build.gradle.kts index 188d617a15f..9aec446f833 100644 --- a/build-plugin/build.gradle.kts +++ b/build-plugin/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { // Make custom plugins in ":plugin" available to precompiled convention plugins by classpath implementation(project(":plugin")) - implementation(libs.diff.utils) compileOnly(libs.android.tools.common) // This defines the used Kotlin version for all Plugin dependencies diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/Libs.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/Libs.kt new file mode 100644 index 00000000000..914119e4fa0 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/Libs.kt @@ -0,0 +1,8 @@ +package net.thunderbird.gradle.plugin + +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Project +import org.gradle.kotlin.dsl.getByName + +val Project.libs + get(): LibrariesForLibs = extensions.getByName("libs") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/BadgingPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/BadgingPlugin.kt new file mode 100644 index 00000000000..9a93c3f5da8 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/BadgingPlugin.kt @@ -0,0 +1,75 @@ +package net.thunderbird.gradle.plugin.app.badging + +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.Aapt2 +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import org.gradle.api.tasks.Copy +import org.gradle.kotlin.dsl.register + +private val variantsToCheck = listOf("release", "beta", "daily") + +/** + * 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 modified 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). + */ +class BadgingPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.application") + } + + configureBadging() + } + } + + private fun Project.configureBadging() { + extensions.configure { + 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(generateBadgingTaskName) { + apk = variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE) + aapt2Executable = this@configure.sdkComponents.aapt2.flatMap(Aapt2::executable) + badging = project.layout.buildDirectory.file( + "outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt", + ) + } + + val updateBadgingTaskName = "update${capitalizedVariantName}Badging" + tasks.register(updateBadgingTaskName) { + from(generateBadging.map(GenerateBadgingTask::badging)) + into(project.layout.projectDirectory.dir("badging")) + } + + val checkBadgingTaskName = "check${capitalizedVariantName}Badging" + tasks.register(checkBadgingTaskName) { + goldenBadging = project.layout.projectDirectory.file("badging/${variant.name}-badging.txt") + + generatedBadging.set(generateBadging.flatMap(GenerateBadgingTask::badging)) + + this.updateBadgingTaskName = updateBadgingTaskName + + output = project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName") + } + + tasks.named("build") { + dependsOn(checkBadgingTaskName) + } + } + } + } + } +} + +private fun String.capitalized() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/CheckBadgingTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/CheckBadgingTask.kt new file mode 100644 index 00000000000..b356346d3cf --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/CheckBadgingTask.kt @@ -0,0 +1,140 @@ +package net.thunderbird.gradle.plugin.app.badging + +import com.github.difflib.text.DiffRow +import com.github.difflib.text.DiffRowGenerator +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.language.base.plugins.LifecycleBasePlugin + +@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 + + 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" + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/GenerateBadgingTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/GenerateBadgingTask.kt new file mode 100644 index 00000000000..2f9428f575b --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/badging/GenerateBadgingTask.kt @@ -0,0 +1,69 @@ +package net.thunderbird.gradle.plugin.app.badging + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.inject.Inject +import kotlin.io.writeText +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations + +@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 + } + } +} diff --git a/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts deleted file mode 100644 index baf0fc59f73..00000000000 --- a/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts +++ /dev/null @@ -1,252 +0,0 @@ -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") -} - -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(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(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(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 - - 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" - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 586596248aa..b16cb5b0906 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,6 +137,7 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPlugin" } dev-mokkery = { id = "dev.mokkery", version.ref = "mokkery" } # Build plugins +tb-app-badging = { id = "net.thunderbird.gradle.plugin.app.badging" } tb-quality-code-coverage = { id = "net.thunderbird.gradle.plugin.quality.coverage" } [libraries] From 98a0dbbb5ebe14618d243e06cd548b0909b551a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Tue, 27 Jan 2026 11:34:33 +0100 Subject: [PATCH 6/8] refactor(plugin): migrate app versioning logic to custom Gradle plugin --- app-k9mail/build.gradle.kts | 2 +- app-thunderbird/build.gradle.kts | 2 +- .../app/versioning/PrintVersionInfoTask.kt | 60 +++++++ .../plugin/app/versioning/VersionInfo.kt | 7 + .../plugin/app/versioning/VersioningPlugin.kt | 132 ++++++++++++++ .../thunderbird.app.version.info.gradle.kts | 162 ------------------ gradle/libs.versions.toml | 1 + 7 files changed, 202 insertions(+), 164 deletions(-) create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/PrintVersionInfoTask.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersionInfo.kt create mode 100644 build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersioningPlugin.kt delete mode 100644 build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts diff --git a/app-k9mail/build.gradle.kts b/app-k9mail/build.gradle.kts index 346c02edcf7..2e2668cb1e2 100644 --- a/app-k9mail/build.gradle.kts +++ b/app-k9mail/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id(ThunderbirdPlugins.App.androidCompose) alias(libs.plugins.dependency.guard) - id("thunderbird.app.version.info") alias(libs.plugins.tb.app.badging) + alias(libs.plugins.tb.app.versioning) } val testCoverageEnabled = hasProperty("testCoverageEnabled") diff --git a/app-thunderbird/build.gradle.kts b/app-thunderbird/build.gradle.kts index e7952907adc..325fbd2171a 100644 --- a/app-thunderbird/build.gradle.kts +++ b/app-thunderbird/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id(ThunderbirdPlugins.App.androidCompose) alias(libs.plugins.dependency.guard) - id("thunderbird.app.version.info") alias(libs.plugins.tb.app.badging) + alias(libs.plugins.tb.app.versioning) } val testCoverageEnabled = hasProperty("testCoverageEnabled") diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/PrintVersionInfoTask.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/PrintVersionInfoTask.kt new file mode 100644 index 00000000000..2a2427b9cf2 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/PrintVersionInfoTask.kt @@ -0,0 +1,60 @@ +package net.thunderbird.gradle.plugin.app.versioning + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +abstract class PrintVersionInfoTask : DefaultTask() { + @get:Input + abstract val applicationId: Property + + @get:Input + abstract val applicationLabel: Property + + @get:Input + abstract val versionCode: Property + + @get:Input + abstract val versionName: Property + + @get:Input + abstract val versionNameSuffix: Property + + @get:OutputFile + @get:Optional + abstract val outputFile: RegularFileProperty + + @get:InputFiles + @get:Optional + @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") + } + } +} diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersionInfo.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersionInfo.kt new file mode 100644 index 00000000000..cec741f84d5 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersionInfo.kt @@ -0,0 +1,7 @@ +package net.thunderbird.gradle.plugin.app.versioning + +data class VersionInfo( + val versionCode: Int, + val versionName: String, + val versionNameSuffix: String, +) diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersioningPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersioningPlugin.kt new file mode 100644 index 00000000000..2070d31b226 --- /dev/null +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/app/versioning/VersioningPlugin.kt @@ -0,0 +1,132 @@ +package net.thunderbird.gradle.plugin.app.versioning + +import com.android.build.api.artifact.SingleArtifact +import com.android.build.gradle.internal.dsl.BaseAppModuleExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.ApplicationVariant +import com.android.build.api.variant.Variant +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register + +class VersioningPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.application") + } + + configureVersioning() + } + } + + private fun Project.configureVersioning() { + extensions.configure { + onVariants { variant -> + val variantName = variant.name.capitalized() + val printVersionInfoTaskName = "printVersionInfo$variantName" + + tasks.register(printVersionInfoTaskName) { + val versionInfo = getVersionInfo(variant).get() + + applicationId = variant.applicationId + applicationLabel = getApplicationLabel(variant) + versionCode = versionInfo.versionCode + versionName = versionInfo.versionName + versionNameSuffix = versionInfo.versionNameSuffix + + // Set outputFile only if provided via -PoutputFile=... + project.findProperty("outputFile")?.toString()?.let { path -> + outputFile.set(File(path)) + } + } + } + } + } + + /** + * Get version information for the given variant. + */ + private fun Project.getVersionInfo(variant: ApplicationVariant): Provider { + return provider { + val flavorNames = variant.productFlavors.map { it.second } + val androidExtension = extensions.findByType(BaseAppModuleExtension::class.java) + val flavor = androidExtension?.productFlavors?.find { it.name in flavorNames } + val builtType = androidExtension?.buildTypes?.find { it.name == variant.buildType } + + val versionCode = flavor?.versionCode ?: androidExtension?.defaultConfig?.versionCode ?: 0 + val versionName = flavor?.versionName ?: androidExtension?.defaultConfig?.versionName ?: "unknown" + val versionNameSuffix = builtType?.versionNameSuffix.orEmpty() + + VersionInfo( + versionCode = versionCode, + versionName = versionName, + versionNameSuffix = versionNameSuffix, + ) + } + } + + private fun Project.getApplicationLabel(variant: Variant): Provider { + val mergedManifest = variant.artifacts.get(SingleArtifact.MERGED_MANIFEST) + + return providers.zip(mergedManifest, provider { variant }) { mergedManifest, _ -> + val labelRaw = readManifestApplicationLabel(mergedManifest.asFile) ?: return@zip "Unknown" + + // Return raw label if not a resource string + val match = STRING_RESOURCE_REGEX.matchEntire(labelRaw.trim()) ?: return@zip labelRaw + val resourceName = match.groupValues[1] + + val resourceDirs = variant.sources.res?.all?.get()?.filter { it.isNotEmpty() }?.flatten() ?: emptyList() + + val resolvedApplicationLabel = resourceDirs + .map { it.asFile } + .mapNotNull { dir -> File(dir, "values/strings.xml").takeIf { it.exists() } } + .firstNotNullOfOrNull { stringResourceFile -> readStringResource(stringResourceFile, resourceName) } + + resolvedApplicationLabel ?: "Unknown" + } + } + + private fun readManifestApplicationLabel(manifest: File): String? { + val document = DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = true } + .newDocumentBuilder() + .parse(manifest) + + val apps = document.getElementsByTagName("application") + if (apps.length == 0) return null + + val appElement = apps.item(0) + return appElement.attributes?.getNamedItemNS("http://schemas.android.com/apk/res/android", "label")?.nodeValue + ?: appElement.attributes?.getNamedItem("android:label")?.nodeValue + ?: appElement.attributes?.getNamedItem("label")?.nodeValue + } + + /** + * Parses stringResourceFile to extract `...` + */ + private fun readStringResource(stringResourceFile: File, resourceName: String): String? { + val xmlDocument = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringResourceFile) + val xPath = XPathFactory.newInstance().newXPath() + val expression = "/resources/string[@name='$resourceName']/text()" + val value = xPath.evaluate(expression, xmlDocument, XPathConstants.STRING) as String + return value.trim().takeIf { it.isNotEmpty() } + } + + private fun String.capitalized() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() + } + + private companion object { + val STRING_RESOURCE_REGEX = "^@string/([A-Za-z0-9_]+)$".toRegex() + } +} + + diff --git a/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts deleted file mode 100644 index 3b6153919aa..00000000000 --- a/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts +++ /dev/null @@ -1,162 +0,0 @@ -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.xpath.XPathConstants -import javax.xml.xpath.XPathFactory - -plugins { - id("com.android.application") -} - -androidComponents { - onVariants { variant -> - val variantName = variant.name.capitalized() - val printVersionInfoTaskName = "printVersionInfo$variantName" - tasks.register(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 - - @get:Input - abstract val applicationLabel: Property - - @get:Input - abstract val versionCode: Property - - @get:Input - abstract val versionName: Property - - @get:Input - abstract val versionNameSuffix: Property - - @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 { - return project.provider { - findStringsXmlForVariant(variant)?.let { - extractAppName(it) - } ?: "Unknown" - } -} - -/** - * Parses `strings.xml` to extract `...` - */ -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 ?: "" -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b16cb5b0906..331f2bc1417 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -138,6 +138,7 @@ dev-mokkery = { id = "dev.mokkery", version.ref = "mokkery" } # Build plugins tb-app-badging = { id = "net.thunderbird.gradle.plugin.app.badging" } +tb-app-versioning = { id = "net.thunderbird.gradle.plugin.app.versioning" } tb-quality-code-coverage = { id = "net.thunderbird.gradle.plugin.quality.coverage" } [libraries] From 1ad5e58c3bf077bf3f9b07c6157a4e735804d9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Wed, 28 Jan 2026 12:38:05 +0100 Subject: [PATCH 7/8] chore(build): make minification configurable via CI property -Pci=true --- .github/workflows/build-android.yml | 4 +-- app-k9mail/build.gradle.kts | 5 +++- app-thunderbird/build.gradle.kts | 41 +++++++++++++++-------------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 335649404b5..f21b2a8f871 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -69,7 +69,7 @@ jobs: uses: ./.github/actions/setup-gradle - name: Build K9 application - run: ./gradlew :app-k9mail:assemble + run: ./gradlew :app-k9mail:assemble -Pci=true - name: Check K9 Badging run: | @@ -93,7 +93,7 @@ jobs: uses: ./.github/actions/setup-gradle - name: Build Thunderbird application - run: ./gradlew :app-thunderbird:assemble + run: ./gradlew :app-thunderbird:assemble -Pci=true - name: Check Thunderbird Badging run: | diff --git a/app-k9mail/build.gradle.kts b/app-k9mail/build.gradle.kts index 2e2668cb1e2..502d2086d8a 100644 --- a/app-k9mail/build.gradle.kts +++ b/app-k9mail/build.gradle.kts @@ -85,10 +85,13 @@ android { } buildTypes { + val isCI = project.findProperty("ci") == "true" release { signingConfig = signingConfigs.getByType(SigningType.K9_RELEASE) - isMinifyEnabled = true + isMinifyEnabled = !isCI + isShrinkResources = !isCI + proguardFiles( getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro", diff --git a/app-thunderbird/build.gradle.kts b/app-thunderbird/build.gradle.kts index 325fbd2171a..5cd3ab07c40 100644 --- a/app-thunderbird/build.gradle.kts +++ b/app-thunderbird/build.gradle.kts @@ -88,25 +88,12 @@ android { } buildTypes { - debug { - applicationIdSuffix = ".debug" - versionNameSuffix = "-SNAPSHOT" - - enableUnitTestCoverage = testCoverageEnabled - enableAndroidTestCoverage = testCoverageEnabled - - isMinifyEnabled = false - isShrinkResources = false - isDebuggable = true - - buildConfigField("String", "GLEAN_RELEASE_CHANNEL", "null") - } - + val isCI = project.findProperty("ci") == "true" release { signingConfig = signingConfigs.getByType(SigningType.TB_RELEASE) - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = !isCI + isShrinkResources = !isCI isDebuggable = false proguardFiles( @@ -123,8 +110,8 @@ android { applicationIdSuffix = ".beta" versionNameSuffix = "b0" - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = !isCI + isShrinkResources = !isCI isDebuggable = false matchingFallbacks += listOf("release") @@ -143,8 +130,8 @@ android { applicationIdSuffix = ".daily" versionNameSuffix = "a1" - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = !isCI + isShrinkResources = !isCI isDebuggable = false matchingFallbacks += listOf("release") @@ -157,6 +144,20 @@ android { // See https://bugzilla.mozilla.org/show_bug.cgi?id=1918151 buildConfigField("String", "GLEAN_RELEASE_CHANNEL", "\"nightly\"") } + + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-SNAPSHOT" + + enableUnitTestCoverage = testCoverageEnabled + enableAndroidTestCoverage = testCoverageEnabled + + isMinifyEnabled = false + isShrinkResources = false + isDebuggable = true + + buildConfigField("String", "GLEAN_RELEASE_CHANNEL", "null") + } } flavorDimensions += listOf("app") From 44d6d6ba3403edf91cc7a9d4352b0c605ab49f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Wed, 4 Feb 2026 12:24:15 +0100 Subject: [PATCH 8/8] refactor(build): remove unnecessary compose build feature configuration --- .../main/kotlin/thunderbird.app.android.compose.gradle.kts | 4 ---- .../kotlin/thunderbird.library.android.compose.gradle.kts | 7 +------ 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts index 20ce6326fae..1e606fa0b64 100644 --- a/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts @@ -16,10 +16,6 @@ android { ) } } - - buildFeatures { - compose = true - } } dependencies { diff --git a/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts index ae91e83856b..5ea33ea1539 100644 --- a/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts +++ b/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts @@ -9,14 +9,9 @@ plugins { id("thunderbird.quality.spotless") } -android { - buildFeatures { - compose = true - } -} - androidComponents { beforeVariants(selector().withBuildType("release")) { variantBuilder -> + @Suppress("UnstableApiUsage") variantBuilder.hostTests[HostTestBuilder.UNIT_TEST_TYPE]?.enable = false variantBuilder.enableAndroidTest = false }