Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ streamProject {
// Exclude file patterns from Spotless formatting (default: empty)
excludePatterns = setOf("**/generated/**")
}

coverage {
// Modules to include in coverage analysis (default: empty)
includedModules = setOf("some-module", "some-ui-module")

// Additional Kover exclusion patterns for classes/packages (default: empty)
koverClassExclusions = listOf("*SomeClass", "io.getstream.some.package.*")

// Additional Sonar coverage exclusion patterns for file paths (default: empty)
sonarCoverageExclusions = listOf("**/io/getstream/some/package/**")
}
}
```

Expand Down
6 changes: 4 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ kotlin = "2.0.21"
detekt = "1.23.8"
spotless = "8.0.0"
kotlinDokka = "2.0.0"
gradlePluginPublish = "2.0.0"
mavenPublish = "0.34.0"
sonarqube = "6.0.1.5171"
kover = "0.9.3"

[libraries]
android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
spotless-gradle-plugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" }
sonarqube-gradle-plugin = { group = "org.sonarsource.scanner.gradle", name = "sonarqube-gradle-plugin", version.ref = "sonarqube" }
kover-gradle-plugin = { group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
dokka = { id = "org.jetbrains.dokka-javadoc", version.ref = "kotlinDokka" }
gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "gradlePluginPublish" }
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
2 changes: 2 additions & 0 deletions plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ dependencies {
compileOnly(libs.android.gradle.plugin)
compileOnly(libs.kotlin.gradle.plugin)
implementation(libs.spotless.gradle.plugin)
implementation(libs.sonarqube.gradle.plugin)
implementation(libs.kover.gradle.plugin)
}

val repoId = "GetStream/stream-build-conventions-android"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package io.getstream.android

import io.getstream.android.coverage.CoverageOptions
import io.getstream.android.spotless.SpotlessOptions
import javax.inject.Inject
import org.gradle.api.Action
Expand All @@ -23,6 +24,7 @@ import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.kotlin.dsl.create
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.newInstance
import org.gradle.kotlin.dsl.property

/**
Expand All @@ -38,10 +40,16 @@ constructor(project: Project, objects: ObjectFactory) {
objects.property<String>().convention(project.provider { project.rootProject.name })

/** Spotless formatting configuration */
val spotless: SpotlessOptions = objects.newInstance(SpotlessOptions::class.java)
val spotless: SpotlessOptions = objects.newInstance<SpotlessOptions>()

/** Configure Spotless formatting */
fun spotless(action: Action<SpotlessOptions>) = action.execute(spotless)

/** Code coverage configuration */
val coverage: CoverageOptions = objects.newInstance<CoverageOptions>()

/** Configure code coverage */
fun coverage(action: Action<CoverageOptions>) = action.execute(coverage)
}

internal fun Project.createProjectExtension(): StreamProjectExtension =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package io.getstream.android
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.dsl.TestExtension
import io.getstream.android.coverage.configureCoverageModule
import io.getstream.android.coverage.configureCoverageRoot
import io.getstream.android.spotless.configureSpotless
import org.gradle.api.Plugin
import org.gradle.api.Project
Expand All @@ -34,6 +36,7 @@ class RootConventionPlugin : Plugin<Project> {
}

createProjectExtension()
configureCoverageRoot()
}
}
}
Expand All @@ -46,6 +49,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
configureAndroid<ApplicationExtension>()
configureKotlin()
configureSpotless()
configureCoverageModule()
}
}
}
Expand All @@ -58,6 +62,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
configureAndroid<LibraryExtension>()
configureKotlin()
configureSpotless()
configureCoverageModule()
}
}
}
Expand All @@ -82,6 +87,7 @@ class JavaLibraryConventionPlugin : Plugin<Project> {
configureJava()
configureKotlin()
configureSpotless()
configureCoverageModule()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.getstream.android.coverage

import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.LibraryExtension
import io.getstream.android.StreamProjectExtension
import io.getstream.android.requireStreamProjectExtension
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.sonarqube.gradle.SonarExtension

private object SonarConstants {
const val HOST_URL = "https://sonarcloud.io"
const val ORGANIZATION = "getstream"

val EXCLUSIONS =
listOf(
"**/test/**",
"**/androidTest/**",
"**/R.class",
"**/R2.class",
"**/R$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
)
}

object KoverConstants {
const val VARIANT_NAME = "coverage"
const val VARIANT_SUFFIX = "Coverage"
const val TEST_TASK = "test$VARIANT_SUFFIX"
val CLASS_EXCLUSIONS = listOf("*R", "*R$*", "*BuildConfig", "*Manifest*", "*Composable*")
val ANNOTATION_EXCLUSIONS = arrayOf("androidx.compose.ui.tooling.preview.Preview")
}

internal fun Project.configureCoverageRoot() {
pluginManager.apply("org.sonarqube")

afterEvaluate {
val projectExtension = requireStreamProjectExtension()
val includedModules = projectExtension.coverage.includedModules.get()

configureKover(projectExtension.coverage, isRoot = true)
setupKoverDependencyOnModules(includedModules)
configureSonar(projectExtension)
registerAggregatedCoverageTask(includedModules)
}
}

private fun Project.configureSonar(extension: StreamProjectExtension) {
val repositoryName = extension.repositoryName.get()
val exclusions = buildList {
addAll(SonarConstants.EXCLUSIONS)
addAll(extension.coverage.sonarCoverageExclusions.get())
}

extensions.configure<SonarExtension> {
properties {
property("sonar.host.url", SonarConstants.HOST_URL)
property("sonar.token", System.getenv("SONAR_TOKEN"))
property("sonar.organization", SonarConstants.ORGANIZATION)
property("sonar.projectKey", "GetStream_$repositoryName")
property("sonar.projectName", repositoryName)
property("sonar.java.coveragePlugin", "jacoco")
property("sonar.sourceEncoding", "UTF-8")
property("sonar.java.binaries", "$rootDir/**/build/tmp/kotlin-classes/debug")
property("sonar.coverage.exclusions", exclusions)
property(
"sonar.coverage.jacoco.xmlReportPaths",
layout.buildDirectory
.file("/reports/kover/report${KoverConstants.VARIANT_SUFFIX}.xml")
.get(),
)
}
}
}

private fun Project.setupKoverDependencyOnModules(includedModules: Set<String>) {
subprojects.forEach { subproject ->
if (subproject.name in includedModules) {
dependencies.add("kover", subproject)
}
}
}

internal fun Project.configureCoverageModule() {
val coverageOptions = requireStreamProjectExtension().coverage

// Only configure coverage for included modules
if (name !in coverageOptions.includedModules.get()) {
return
}

pluginManager.apply("org.sonarqube")

// Configure Android test coverage if this is an Android module
pluginManager.withPlugin("com.android.library") { configureAndroid<LibraryExtension>() }
pluginManager.withPlugin("com.android.application") { configureAndroid<ApplicationExtension>() }

configureKover(coverageOptions, isRoot = false)
registerModuleCoverageTask()
}

private fun Project.registerAggregatedCoverageTask(includedModules: Set<String>) {
tasks.register(KoverConstants.TEST_TASK) {
group = "verification"
description = "Run all tests in all modules and generate merged coverage report"

// Depend on all module-specific testCoverage tasks
val coverageModuleTasks =
subprojects
.filter { it.name in includedModules }
.map { ":${it.name}:${KoverConstants.TEST_TASK}" }
dependsOn(coverageModuleTasks)

finalizedBy(
"koverXmlReport${KoverConstants.VARIANT_SUFFIX}",
"koverHtmlReport${KoverConstants.VARIANT_SUFFIX}",
)
}
}

private fun Project.registerModuleCoverageTask() {
// Determine the appropriate test task based on module type and plugins
val hasPaparazziPlugin = pluginManager.hasPlugin("app.cash.paparazzi")
val hasAndroidPlugin =
pluginManager.hasPlugin("com.android.library") ||
pluginManager.hasPlugin("com.android.application")

val testTaskName =
when {
hasPaparazziPlugin -> "verifyPaparazziDebug"
hasAndroidPlugin -> "testDebugUnitTest"
else -> "test"
}

tasks.register(KoverConstants.TEST_TASK) {
group = "verification"
description = "Run module-specific tests"
dependsOn(testTaskName)
}
}

private inline fun <reified E : CommonExtension<*, *, *, *, *, *>> Project.configureAndroid() {
extensions.configure<E> {
buildTypes {
getByName("debug") {
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
}
}
}
}

private fun Project.configureKover(options: CoverageOptions, isRoot: Boolean) {
pluginManager.apply("org.jetbrains.kotlinx.kover")

extensions.configure<KoverProjectExtension> {
// Create custom variant in each project (including root) for coverage aggregation
currentProject {
createVariant(KoverConstants.VARIANT_NAME) {
if (isRoot) {
// Root variant is empty, it just aggregates from dependencies
} else {
add("jvm", "debug", optional = true)
}
}
}

reports {
verify.warningInsteadOfFailure.set(true)

filters.excludes {
classes(KoverConstants.CLASS_EXCLUSIONS)
classes(options.koverClassExclusions.get())

annotatedBy(*KoverConstants.ANNOTATION_EXCLUSIONS)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.getstream.android.coverage

import javax.inject.Inject
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.SetProperty
import org.gradle.kotlin.dsl.listProperty
import org.gradle.kotlin.dsl.setProperty

abstract class CoverageOptions @Inject constructor(objects: ObjectFactory) {
/** Modules to include in coverage analysis. Default: none */
val includedModules: SetProperty<String> = objects.setProperty<String>().convention(emptySet())

/**
* Additional Kover exclusion patterns beyond the defaults. Default exclusions include tests,
* generated code, etc. Expected patterns matching classes/packages, e.g. "*SomeClass",
* "io.getstream.some.package.*"
*/
val koverClassExclusions: ListProperty<String> =
objects.listProperty<String>().convention(emptyList())

/**
* Additional Sonar coverage exclusion patterns beyond the defaults. Default exclusions include
* tests, generated code, etc. Expected patterns matching file paths, e.g.
* "&#42;&#42;/io/getstream/some/package/&#42;&#42;"
*/
val sonarCoverageExclusions: ListProperty<String> =
objects.listProperty<String>().convention(emptyList())
}
Loading