Skip to content

Commit 587081c

Browse files
committed
refactor(plugin): migrate app badging logic to custom Gradle plugin
1 parent cb57a9c commit 587081c

9 files changed

Lines changed: 295 additions & 255 deletions

File tree

app-k9mail/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ plugins {
22
id(ThunderbirdPlugins.App.androidCompose)
33
alias(libs.plugins.dependency.guard)
44
id("thunderbird.app.version.info")
5-
id("thunderbird.quality.badging")
5+
alias(libs.plugins.tb.app.badging)
66
}
77

88
val testCoverageEnabled = hasProperty("testCoverageEnabled")

app-thunderbird/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ plugins {
22
id(ThunderbirdPlugins.App.androidCompose)
33
alias(libs.plugins.dependency.guard)
44
id("thunderbird.app.version.info")
5-
id("thunderbird.quality.badging")
5+
alias(libs.plugins.tb.app.badging)
66
}
77

88
val testCoverageEnabled = hasProperty("testCoverageEnabled")

build-plugin/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ dependencies {
2525
// Make custom plugins in ":plugin" available to precompiled convention plugins by classpath
2626
implementation(project(":plugin"))
2727

28-
implementation(libs.diff.utils)
2928
compileOnly(libs.android.tools.common)
3029

3130
// This defines the used Kotlin version for all Plugin dependencies
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.thunderbird.gradle.plugin
2+
3+
import org.gradle.accessors.dm.LibrariesForLibs
4+
import org.gradle.api.Project
5+
import org.gradle.kotlin.dsl.getByName
6+
7+
val Project.libs
8+
get(): LibrariesForLibs = extensions.getByName<LibrariesForLibs>("libs")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package net.thunderbird.gradle.plugin.app.badging
2+
3+
import com.android.build.api.artifact.SingleArtifact
4+
import com.android.build.api.variant.Aapt2
5+
import org.gradle.api.Plugin
6+
import org.gradle.api.Project
7+
import org.gradle.kotlin.dsl.assign
8+
import org.gradle.kotlin.dsl.configure
9+
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
10+
import org.gradle.api.tasks.Copy
11+
import org.gradle.kotlin.dsl.register
12+
13+
private val variantsToCheck = listOf("release", "beta", "daily")
14+
15+
/**
16+
* This is a Gradle plugin that adds a task to generate the badging of the APKs and a task to check that the
17+
* generated badging is the same as the golden badging.
18+
*
19+
* This is modified from [nowinandroid](https://github.com/android/nowinandroid) and follows recommendations from
20+
* [Prevent regressions with CI and badging](https://android-developers.googleblog.com/2023/12/increase-your-apps-availability-across-device-types.html).
21+
*/
22+
class BadgingPlugin : Plugin<Project> {
23+
override fun apply(target: Project) {
24+
with(target) {
25+
with(pluginManager) {
26+
apply("com.android.application")
27+
}
28+
29+
configureBadging()
30+
}
31+
}
32+
33+
private fun Project.configureBadging() {
34+
extensions.configure<ApplicationAndroidComponentsExtension> {
35+
onVariants { variant ->
36+
if (variantsToCheck.any { variant.name.contains(it, ignoreCase = true) }) {
37+
val capitalizedVariantName = variant.name.capitalized()
38+
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
39+
val generateBadging = tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {
40+
apk = variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE)
41+
aapt2Executable = this@configure.sdkComponents.aapt2.flatMap(Aapt2::executable)
42+
badging = project.layout.buildDirectory.file(
43+
"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt",
44+
)
45+
}
46+
47+
val updateBadgingTaskName = "update${capitalizedVariantName}Badging"
48+
tasks.register<Copy>(updateBadgingTaskName) {
49+
from(generateBadging.map(GenerateBadgingTask::badging))
50+
into(project.layout.projectDirectory.dir("badging"))
51+
}
52+
53+
val checkBadgingTaskName = "check${capitalizedVariantName}Badging"
54+
tasks.register<CheckBadgingTask>(checkBadgingTaskName) {
55+
goldenBadging = project.layout.projectDirectory.file("badging/${variant.name}-badging.txt")
56+
57+
generatedBadging.set(generateBadging.flatMap(GenerateBadgingTask::badging))
58+
59+
this.updateBadgingTaskName = updateBadgingTaskName
60+
61+
output = project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName")
62+
}
63+
64+
tasks.named("build") {
65+
dependsOn(checkBadgingTaskName)
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
73+
private fun String.capitalized() = replaceFirstChar {
74+
if (it.isLowerCase()) it.titlecase() else it.toString()
75+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package net.thunderbird.gradle.plugin.app.badging
2+
3+
import com.github.difflib.text.DiffRow
4+
import com.github.difflib.text.DiffRowGenerator
5+
import org.gradle.api.DefaultTask
6+
import org.gradle.api.GradleException
7+
import org.gradle.api.file.DirectoryProperty
8+
import org.gradle.api.file.RegularFileProperty
9+
import org.gradle.api.provider.Property
10+
import org.gradle.api.tasks.CacheableTask
11+
import org.gradle.api.tasks.Input
12+
import org.gradle.api.tasks.InputFile
13+
import org.gradle.api.tasks.Optional
14+
import org.gradle.api.tasks.OutputDirectory
15+
import org.gradle.api.tasks.PathSensitive
16+
import org.gradle.api.tasks.PathSensitivity
17+
import org.gradle.api.tasks.TaskAction
18+
import org.gradle.language.base.plugins.LifecycleBasePlugin
19+
20+
@CacheableTask
21+
abstract class CheckBadgingTask : DefaultTask() {
22+
23+
// In order for the task to be up-to-date when the inputs have not changed,
24+
// the task must declare an output, even if it's not used. Tasks with no
25+
// output are always run regardless of whether the inputs changed
26+
@get:OutputDirectory
27+
abstract val output: DirectoryProperty
28+
29+
@get:PathSensitive(PathSensitivity.RELATIVE)
30+
@get:Optional
31+
@get:InputFile
32+
abstract val goldenBadging: RegularFileProperty
33+
34+
@get:PathSensitive(PathSensitivity.RELATIVE)
35+
@get:InputFile
36+
abstract val generatedBadging: RegularFileProperty
37+
38+
@get:Input
39+
abstract val updateBadgingTaskName: Property<String>
40+
41+
override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP
42+
43+
@TaskAction
44+
fun taskAction() {
45+
if (goldenBadging.isPresent.not()) {
46+
printlnColor(
47+
ANSI_YELLOW,
48+
"Golden badging file does not exist!" +
49+
" If this is the first time running this task," +
50+
" run ./gradlew ${updateBadgingTaskName.get()}",
51+
)
52+
return
53+
}
54+
55+
val goldenBadgingContent = goldenBadging.get().asFile.readText()
56+
val generatedBadgingContent = generatedBadging.get().asFile.readText()
57+
if (goldenBadgingContent == generatedBadgingContent) {
58+
printlnColor(ANSI_YELLOW, "Generated badging is the same as golden badging!")
59+
return
60+
}
61+
62+
val diff = performDiff(goldenBadgingContent, generatedBadgingContent)
63+
printDiff(diff)
64+
65+
throw GradleException(
66+
"""
67+
Generated badging is different from golden badging!
68+
69+
If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}
70+
""".trimIndent(),
71+
)
72+
}
73+
74+
private fun performDiff(goldenBadgingContent: String, generatedBadgingContent: String): String {
75+
val generator: DiffRowGenerator = DiffRowGenerator.create()
76+
.showInlineDiffs(true)
77+
.mergeOriginalRevised(true)
78+
.inlineDiffByWord(true)
79+
.oldTag { _ -> "" }
80+
.newTag { _ -> "" }
81+
.build()
82+
83+
return generator.generateDiffRows(
84+
goldenBadgingContent.lines(),
85+
generatedBadgingContent.lines(),
86+
).filter { row -> row.tag != DiffRow.Tag.EQUAL }
87+
.joinToString("\n") { row ->
88+
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
89+
when (row.tag) {
90+
DiffRow.Tag.INSERT -> {
91+
"+ ${row.newLine}"
92+
}
93+
94+
DiffRow.Tag.DELETE -> {
95+
"- ${row.oldLine}"
96+
}
97+
98+
DiffRow.Tag.CHANGE -> {
99+
"+ ${row.newLine}"
100+
"- ${row.oldLine}"
101+
}
102+
103+
DiffRow.Tag.EQUAL -> ""
104+
}
105+
}
106+
}
107+
108+
private fun printDiff(diff: String) {
109+
printlnColor("", null)
110+
printlnColor(ANSI_YELLOW, "Badging diff:")
111+
112+
diff.lines().forEach { line ->
113+
val ansiColor = if (line.startsWith("+")) {
114+
ANSI_GREEN
115+
} else if (line.startsWith("-")) {
116+
ANSI_RED
117+
} else {
118+
null
119+
}
120+
printlnColor(line, ansiColor)
121+
}
122+
}
123+
124+
private fun printlnColor(text: String, ansiColor: String?) {
125+
println(
126+
if (ansiColor != null) {
127+
ansiColor + text + ANSI_RESET
128+
} else {
129+
text
130+
},
131+
)
132+
}
133+
134+
private companion object {
135+
const val ANSI_RESET = "\u001B[0m"
136+
const val ANSI_RED = "\u001B[31m"
137+
const val ANSI_GREEN = "\u001B[32m"
138+
const val ANSI_YELLOW = "\u001B[33m"
139+
}
140+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package net.thunderbird.gradle.plugin.app.badging
2+
3+
import java.io.ByteArrayInputStream
4+
import java.io.ByteArrayOutputStream
5+
import javax.inject.Inject
6+
import kotlin.io.writeText
7+
import org.gradle.api.DefaultTask
8+
import org.gradle.api.file.RegularFileProperty
9+
import org.gradle.api.tasks.CacheableTask
10+
import org.gradle.api.tasks.InputFile
11+
import org.gradle.api.tasks.OutputFile
12+
import org.gradle.api.tasks.PathSensitive
13+
import org.gradle.api.tasks.PathSensitivity
14+
import org.gradle.api.tasks.TaskAction
15+
import org.gradle.process.ExecOperations
16+
17+
@CacheableTask
18+
abstract class GenerateBadgingTask : DefaultTask() {
19+
@get:OutputFile
20+
abstract val badging: RegularFileProperty
21+
22+
@get:PathSensitive(PathSensitivity.RELATIVE)
23+
@get:InputFile
24+
abstract val apk: RegularFileProperty
25+
26+
@get:PathSensitive(PathSensitivity.NONE)
27+
@get:InputFile
28+
abstract val aapt2Executable: RegularFileProperty
29+
30+
@get:Inject
31+
abstract val execOperations: ExecOperations
32+
33+
@TaskAction
34+
fun taskAction() {
35+
val outputStream = ByteArrayOutputStream()
36+
execOperations.exec {
37+
commandLine(
38+
aapt2Executable.get().asFile.absolutePath,
39+
"dump",
40+
"badging",
41+
apk.get().asFile.absolutePath,
42+
)
43+
standardOutput = outputStream
44+
}
45+
46+
badging.asFile.get().writeText(cleanBadgingContent(outputStream) + "\n")
47+
}
48+
49+
private fun cleanBadgingContent(outputStream: ByteArrayOutputStream): String {
50+
return ByteArrayInputStream(outputStream.toByteArray()).bufferedReader().use { reader ->
51+
reader.lineSequence().map { line ->
52+
line.cleanBadgingLine()
53+
}.sorted().joinToString("\n")
54+
}
55+
}
56+
57+
private fun String.cleanBadgingLine(): String {
58+
return if (startsWith("package:")) {
59+
replace(Regex("versionName='[^']*'"), "")
60+
.replace(Regex("versionCode='[^']*'"), "")
61+
.replace(Regex("\\s+"), " ")
62+
.trim()
63+
} else if (trim().startsWith("uses-feature-not-required:")) {
64+
trim()
65+
} else {
66+
this
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)