1+ package ytemplate.android.build
2+
3+ import com.android.build.gradle.BaseExtension
4+ import groovy.xml.XmlSlurper
5+ import groovy.xml.slurpersupport.NodeChild
6+ import org.gradle.api.GradleException
7+ import org.gradle.api.Plugin
8+ import org.gradle.api.Project
9+ import org.gradle.configurationcache.extensions.capitalized
10+ import org.gradle.kotlin.dsl.dependencies
11+ import org.gradle.kotlin.dsl.extra
12+ import org.gradle.kotlin.dsl.register
13+ import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
14+ import org.gradle.testing.jacoco.tasks.JacocoReport
15+ import java.io.File
16+ import java.util.*
17+ import kotlin.math.roundToInt
18+
19+ class JacocoTestReportPlugin : Plugin <Project > {
20+ private val Project .android: BaseExtension
21+ get() = extensions.findByName(" android" ) as ? BaseExtension
22+ ? : error(" Not an Android module: $name " )
23+
24+ private val Project .jacoco: JacocoPluginExtension
25+ get() = extensions.findByName(" jacoco" ) as ? JacocoPluginExtension
26+ ? : error(" Not a Jacoco module: $name " )
27+ private val excludedFiles = mutableSetOf (
28+ // data binding
29+ " android/databinding/**/*.class" ,
30+ " **/android/databinding/*Binding.class" ,
31+ " **/android/databinding/*" ,
32+ " **/androidx/databinding/*" ,
33+ " **/BR.*" ,
34+ // android
35+ " **/R.class" ,
36+ " **/R$*.class" ,
37+ " **/BuildConfig.*" ,
38+ " **/Manifest*.*" ,
39+ " **/*Test*.*" ,
40+ " android/**/*.*" ,
41+ // butterKnife
42+ " **/*\$ ViewInjector*.*" ,
43+ " **/*\$ ViewBinder*.*" ,
44+ // dagger
45+ " **/*_MembersInjector.class" ,
46+ " **/Dagger*Component.class" ,
47+ " **/Dagger*Component\$ Builder.class" ,
48+ " **/*Module_*Factory.class" ,
49+ " **/di/module/*" ,
50+ " **/*_Factory*.*" ,
51+ " **/*Module*.*" ,
52+ " **/*Dagger*.*" ,
53+ " **/*Hilt*.*" ,
54+ // kotlin
55+ " **/*MapperImpl*.*" ,
56+ " **/*\$ ViewInjector*.*" ,
57+ " **/*\$ ViewBinder*.*" ,
58+ " **/BuildConfig.*" ,
59+ " **/*Component*.*" ,
60+ " **/*BR*.*" ,
61+ " **/Manifest*.*" ,
62+ " **/*\$ Lambda$*.*" ,
63+ " **/*Companion*.*" ,
64+ " **/*Module*.*" ,
65+ " **/*Dagger*.*" ,
66+ " **/*Hilt*.*" ,
67+ " **/*MembersInjector*.*" ,
68+ " **/*_MembersInjector.class" ,
69+ " **/*_Factory*.*" ,
70+ " **/*_Provide*Factory*.*" ,
71+ " **/*Extensions*.*" ,
72+ // sealed and data classes
73+ " **/*\$ Result.*" ,
74+ " **/*\$ Result$*.*"
75+ )
76+
77+ private val limits = mutableMapOf (
78+ " instruction" to 0.0 ,
79+ " branch" to 0.0 ,
80+ " line" to 0.0 ,
81+ " complexity" to 0.0 ,
82+ " method" to 0.0 ,
83+ " class" to 0.0
84+ )
85+
86+
87+ override fun apply (target : Project ) {
88+ with (target) {
89+ plugins.run {
90+ apply (" jacoco" )
91+ }
92+ extra.set(" limits" , limits)
93+ setupJacocoPlugin()
94+ dependencies {
95+ " implementation" (" org.jacoco:org.jacoco.core:0.8.7" )
96+ }
97+ }
98+
99+ }
100+
101+ private fun Project.setupJacocoPlugin () {
102+ val buildTypes = android.buildTypes.map { type -> type.name }
103+ var productFlavors = android.productFlavors.map { flavor -> flavor.name }
104+
105+ if (productFlavors.isEmpty()) {
106+ productFlavors = productFlavors + " "
107+ }
108+ productFlavors.forEach { flavorName ->
109+ buildTypes.forEach { buildTypeName ->
110+ val sourceName: String
111+ val sourcePath: String
112+ if (flavorName.isEmpty()) {
113+ sourceName = buildTypeName
114+ sourcePath = buildTypeName
115+ } else {
116+ sourceName = " ${flavorName}${buildTypeName.capitalized()} "
117+ sourcePath = " ${flavorName} /${buildTypeName} "
118+ }
119+ val testTaskName = " test${sourceName.capitalized()} UnitTest"
120+ addTestCoverageTask(testTaskName, sourceName, sourcePath, flavorName, buildTypeName)
121+ }
122+ }
123+ }
124+
125+ private fun Project.addTestCoverageTask (
126+ taskName : String ,
127+ sourceName : String ,
128+ sourcePath : String ,
129+ flavorName : String ,
130+ buildTypeName : String
131+ ) {
132+ tasks.register<JacocoReport >(" ${taskName} Coverage" , ) {
133+ dependsOn(taskName)
134+ group = " Reporting"
135+ description = " Generate test coverage reports on the ${sourceName.capitalized()} build"
136+ val javaDirectories =
137+ fileTree(" ${project.buildDir} /intermediates/classes/$sourcePath " ) {
138+ exclude(excludedFiles)
139+ }
140+ val kotlinDirectories = fileTree(" ${project.buildDir} /tmp/kotlin-classes/$sourcePath " ) {
141+ exclude(excludedFiles)
142+ }
143+ val coverageDirectories = listOf (
144+ " src/main/java" ,
145+ " src/$flavorName /java" ,
146+ " src/$buildTypeName /java" )
147+
148+ classDirectories.setFrom(files(javaDirectories, kotlinDirectories))
149+ additionalClassDirs.setFrom(files(coverageDirectories))
150+ sourceDirectories.setFrom(files(coverageDirectories))
151+
152+ reports {
153+ xml.required.set(true )
154+ html.required.set(true )
155+ }
156+ val executionDataFiles = fileTree(project.buildDir) {
157+ setIncludes(listOf (" **/${taskName} .exec" ))
158+ }
159+ executionData.setFrom(
160+ executionDataFiles.files
161+ )
162+ doLast {
163+ jacocoTestReport(" ${taskName} Coverage" )
164+ }
165+ }
166+ }
167+
168+ private fun Project.jacocoTestReport (taskName : String ) {
169+ val reportDir = jacoco.reportsDirectory.asFile.get()
170+ val report = file(" $reportDir /$taskName /${taskName} .xml" )
171+ logger.lifecycle(" Checking coverage results:$report " )
172+ val metrics = report.extractTestCoverage()
173+ val limits = project.extra[" limits" ] as Map <String , Double >
174+ val failures = metrics.filter { item ->
175+ item.value < limits[item.key]!!
176+ }.map { item ->
177+ " -${item.key} coverage is: ${item.value} %, minimum is ${limits[item.key]} %"
178+ }
179+ if (failures.isNotEmpty()) {
180+ logger.quiet(" ======Code coverage failed=========" )
181+ failures.forEach {
182+ logger.quiet(it)
183+ }
184+ logger.quiet(" ===========================================" )
185+ throw GradleException (" Code coverage failed" )
186+ } else {
187+ logger.quiet(" ======Code coverage success=========" )
188+ metrics.forEach {
189+ logger.quiet(" - ${it.key} coverage: ${it.value} " )
190+ }
191+ logger.quiet(" ===========================================" )
192+ }
193+ }
194+
195+ private fun File.extractTestCoverage (): Map <String , Double > {
196+ val xmlReader = XmlSlurper ().apply {
197+ setFeature(" http://apache.org/xml/features/nonvalidating/load-external-dtd" , false )
198+ setFeature(" http://apache.org/xml/features/disallow-doctype-decl" , false )
199+ }
200+ val counterNodes: List <NodeChild > = xmlReader.parse(this ).parent().children()
201+ .filter { (it as NodeChild ).name() == " counter" } as List <NodeChild >
202+ return counterNodes.associate { child ->
203+ val type = child.attributes()[" type" ].toString().toLowerCase(Locale .US )
204+ val covered = child.attributes()[" covered" ].toString().toDouble()
205+ val missed = child.attributes()[" missed" ].toString().toDouble()
206+ val percentage = ((covered / (covered + missed)) * 10000.0 ).roundToInt() / 100.0
207+ Pair (type, percentage)
208+ }
209+ }
210+ }
0 commit comments