Skip to content

Commit 98a0dbb

Browse files
committed
refactor(plugin): migrate app versioning logic to custom Gradle plugin
1 parent 587081c commit 98a0dbb

7 files changed

Lines changed: 202 additions & 164 deletions

File tree

app-k9mail/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
plugins {
22
id(ThunderbirdPlugins.App.androidCompose)
33
alias(libs.plugins.dependency.guard)
4-
id("thunderbird.app.version.info")
54
alias(libs.plugins.tb.app.badging)
5+
alias(libs.plugins.tb.app.versioning)
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
@@ -1,8 +1,8 @@
11
plugins {
22
id(ThunderbirdPlugins.App.androidCompose)
33
alias(libs.plugins.dependency.guard)
4-
id("thunderbird.app.version.info")
54
alias(libs.plugins.tb.app.badging)
5+
alias(libs.plugins.tb.app.versioning)
66
}
77

88
val testCoverageEnabled = hasProperty("testCoverageEnabled")
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package net.thunderbird.gradle.plugin.app.versioning
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.file.RegularFileProperty
5+
import org.gradle.api.provider.Property
6+
import org.gradle.api.tasks.Input
7+
import org.gradle.api.tasks.InputFiles
8+
import org.gradle.api.tasks.Optional
9+
import org.gradle.api.tasks.OutputFile
10+
import org.gradle.api.tasks.PathSensitive
11+
import org.gradle.api.tasks.PathSensitivity
12+
import org.gradle.api.tasks.TaskAction
13+
14+
abstract class PrintVersionInfoTask : DefaultTask() {
15+
@get:Input
16+
abstract val applicationId: Property<String>
17+
18+
@get:Input
19+
abstract val applicationLabel: Property<String>
20+
21+
@get:Input
22+
abstract val versionCode: Property<Int>
23+
24+
@get:Input
25+
abstract val versionName: Property<String>
26+
27+
@get:Input
28+
abstract val versionNameSuffix: Property<String>
29+
30+
@get:OutputFile
31+
@get:Optional
32+
abstract val outputFile: RegularFileProperty
33+
34+
@get:InputFiles
35+
@get:Optional
36+
@get:PathSensitive(PathSensitivity.RELATIVE)
37+
abstract val stringsXmlFile: RegularFileProperty
38+
39+
init {
40+
outputs.upToDateWhen { false } // This forces Gradle to always re-run the task
41+
}
42+
43+
@TaskAction
44+
fun printVersionInfo() {
45+
val output = """
46+
APPLICATION_ID=${applicationId.get()}
47+
APPLICATION_LABEL=${applicationLabel.get()}
48+
VERSION_CODE=${versionCode.get()}
49+
VERSION_NAME=${versionName.get()}
50+
VERSION_NAME_SUFFIX=${versionNameSuffix.get()}
51+
FULL_VERSION_NAME=${versionName.get()}${versionNameSuffix.get()}
52+
""".trimIndent()
53+
54+
println(output)
55+
56+
if (outputFile.isPresent) {
57+
outputFile.get().asFile.writeText(output + "\n")
58+
}
59+
}
60+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package net.thunderbird.gradle.plugin.app.versioning
2+
3+
data class VersionInfo(
4+
val versionCode: Int,
5+
val versionName: String,
6+
val versionNameSuffix: String,
7+
)
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package net.thunderbird.gradle.plugin.app.versioning
2+
3+
import com.android.build.api.artifact.SingleArtifact
4+
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
5+
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
6+
import com.android.build.api.variant.ApplicationVariant
7+
import com.android.build.api.variant.Variant
8+
import java.io.File
9+
import javax.xml.parsers.DocumentBuilderFactory
10+
import javax.xml.xpath.XPathConstants
11+
import javax.xml.xpath.XPathFactory
12+
import org.gradle.api.Plugin
13+
import org.gradle.api.Project
14+
import org.gradle.api.provider.Provider
15+
import org.gradle.kotlin.dsl.assign
16+
import org.gradle.kotlin.dsl.configure
17+
import org.gradle.kotlin.dsl.register
18+
19+
class VersioningPlugin : Plugin<Project> {
20+
override fun apply(target: Project) {
21+
with(target) {
22+
with(pluginManager) {
23+
apply("com.android.application")
24+
}
25+
26+
configureVersioning()
27+
}
28+
}
29+
30+
private fun Project.configureVersioning() {
31+
extensions.configure<ApplicationAndroidComponentsExtension> {
32+
onVariants { variant ->
33+
val variantName = variant.name.capitalized()
34+
val printVersionInfoTaskName = "printVersionInfo$variantName"
35+
36+
tasks.register<PrintVersionInfoTask>(printVersionInfoTaskName) {
37+
val versionInfo = getVersionInfo(variant).get()
38+
39+
applicationId = variant.applicationId
40+
applicationLabel = getApplicationLabel(variant)
41+
versionCode = versionInfo.versionCode
42+
versionName = versionInfo.versionName
43+
versionNameSuffix = versionInfo.versionNameSuffix
44+
45+
// Set outputFile only if provided via -PoutputFile=...
46+
project.findProperty("outputFile")?.toString()?.let { path ->
47+
outputFile.set(File(path))
48+
}
49+
}
50+
}
51+
}
52+
}
53+
54+
/**
55+
* Get version information for the given variant.
56+
*/
57+
private fun Project.getVersionInfo(variant: ApplicationVariant): Provider<VersionInfo> {
58+
return provider {
59+
val flavorNames = variant.productFlavors.map { it.second }
60+
val androidExtension = extensions.findByType(BaseAppModuleExtension::class.java)
61+
val flavor = androidExtension?.productFlavors?.find { it.name in flavorNames }
62+
val builtType = androidExtension?.buildTypes?.find { it.name == variant.buildType }
63+
64+
val versionCode = flavor?.versionCode ?: androidExtension?.defaultConfig?.versionCode ?: 0
65+
val versionName = flavor?.versionName ?: androidExtension?.defaultConfig?.versionName ?: "unknown"
66+
val versionNameSuffix = builtType?.versionNameSuffix.orEmpty()
67+
68+
VersionInfo(
69+
versionCode = versionCode,
70+
versionName = versionName,
71+
versionNameSuffix = versionNameSuffix,
72+
)
73+
}
74+
}
75+
76+
private fun Project.getApplicationLabel(variant: Variant): Provider<String> {
77+
val mergedManifest = variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
78+
79+
return providers.zip(mergedManifest, provider { variant }) { mergedManifest, _ ->
80+
val labelRaw = readManifestApplicationLabel(mergedManifest.asFile) ?: return@zip "Unknown"
81+
82+
// Return raw label if not a resource string
83+
val match = STRING_RESOURCE_REGEX.matchEntire(labelRaw.trim()) ?: return@zip labelRaw
84+
val resourceName = match.groupValues[1]
85+
86+
val resourceDirs = variant.sources.res?.all?.get()?.filter { it.isNotEmpty() }?.flatten() ?: emptyList()
87+
88+
val resolvedApplicationLabel = resourceDirs
89+
.map { it.asFile }
90+
.mapNotNull { dir -> File(dir, "values/strings.xml").takeIf { it.exists() } }
91+
.firstNotNullOfOrNull { stringResourceFile -> readStringResource(stringResourceFile, resourceName) }
92+
93+
resolvedApplicationLabel ?: "Unknown"
94+
}
95+
}
96+
97+
private fun readManifestApplicationLabel(manifest: File): String? {
98+
val document = DocumentBuilderFactory.newInstance()
99+
.apply { isNamespaceAware = true }
100+
.newDocumentBuilder()
101+
.parse(manifest)
102+
103+
val apps = document.getElementsByTagName("application")
104+
if (apps.length == 0) return null
105+
106+
val appElement = apps.item(0)
107+
return appElement.attributes?.getNamedItemNS("http://schemas.android.com/apk/res/android", "label")?.nodeValue
108+
?: appElement.attributes?.getNamedItem("android:label")?.nodeValue
109+
?: appElement.attributes?.getNamedItem("label")?.nodeValue
110+
}
111+
112+
/**
113+
* Parses stringResourceFile to extract `<string name="resourceName">...</string>`
114+
*/
115+
private fun readStringResource(stringResourceFile: File, resourceName: String): String? {
116+
val xmlDocument = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringResourceFile)
117+
val xPath = XPathFactory.newInstance().newXPath()
118+
val expression = "/resources/string[@name='$resourceName']/text()"
119+
val value = xPath.evaluate(expression, xmlDocument, XPathConstants.STRING) as String
120+
return value.trim().takeIf { it.isNotEmpty() }
121+
}
122+
123+
private fun String.capitalized() = replaceFirstChar {
124+
if (it.isLowerCase()) it.titlecase() else it.toString()
125+
}
126+
127+
private companion object {
128+
val STRING_RESOURCE_REGEX = "^@string/([A-Za-z0-9_]+)$".toRegex()
129+
}
130+
}
131+
132+

build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts

Lines changed: 0 additions & 162 deletions
This file was deleted.

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ dev-mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
138138

139139
# Build plugins
140140
tb-app-badging = { id = "net.thunderbird.gradle.plugin.app.badging" }
141+
tb-app-versioning = { id = "net.thunderbird.gradle.plugin.app.versioning" }
141142
tb-quality-code-coverage = { id = "net.thunderbird.gradle.plugin.quality.coverage" }
142143

143144
[libraries]

0 commit comments

Comments
 (0)