import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.gradle.testing.jacoco.tasks.JacocoReport import java.io.FileInputStream import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose.compiler) alias(libs.plugins.roborazzi) alias(libs.plugins.ktlint) alias(libs.plugins.detekt) jacoco } jacoco { toolVersion = "0.8.11" } val keystorePropertiesFile = rootProject.file("app/keystore.properties") val keystoreProperties = Properties() if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } val isCoverageEnabled = project.hasProperty("coverage") || gradle.startParameter.taskNames.any { it.contains("verifyWithCoverage", ignoreCase = true) || it.contains("generateGmdCoverage", ignoreCase = true) } android { namespace = "com.servicebook" compileSdk = 36 defaultConfig { applicationId = "com.servicebook" minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["additionalTestOutputDir"] = "/sdcard/test-outputs/smoke" vectorDrawables { useSupportLibrary = true } } signingConfigs { create("release") { storeFile = rootProject.file("app/${keystoreProperties["storeFile"] ?: System.getenv("KEYSTORE_FILE") ?: ""}") storePassword = (keystoreProperties["storePassword"] ?: System.getenv("KEYSTORE_PASSWORD") ?: "").toString() keyAlias = (keystoreProperties["keyAlias"] ?: System.getenv("KEY_ALIAS") ?: "").toString() keyPassword = (keystoreProperties["keyPassword"] ?: System.getenv("KEY_PASSWORD") ?: "").toString() } } buildTypes { debug { enableAndroidTestCoverage = isCoverageEnabled } release { // Only attach the release signing config when a keystore is actually // configured. Lets PR CI run assembleRelease for R8 mapping verification // without signing secrets, and lets local devs produce an unsigned release // APK for inspection. The deploy workflow sets KEYSTORE_FILE so signing // still applies on real releases. if (keystorePropertiesFile.exists() || !System.getenv("KEYSTORE_FILE").isNullOrEmpty() ) { signingConfig = signingConfigs.getByName("release") } isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } } buildFeatures { compose = true buildConfig = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "META-INF/LICENSE.md" excludes += "META-INF/LICENSE-notice.md" } } testOptions { unitTests { isIncludeAndroidResources = true } managedDevices { allDevices { create("pixel2api33") { device = "Pixel 2" apiLevel = 33 systemImageSource = "aosp" } } } } lint { abortOnError = true warningsAsErrors = false xmlReport = true htmlReport = true baseline = file("lint-baseline.xml") } } tasks.withType().configureEach { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } detekt { config.setFrom(rootProject.file("config/detekt/detekt.yml")) buildUponDefaultConfig = true autoCorrect = true } tasks.named("check") { dependsOn("lintDebug") } tasks.register("generateGmdCoverage") { group = "Verification" description = "Runs GMD tests and copies the coverage file to the baseline directory." dependsOn("pixel2api33DebugAndroidTest") from(layout.buildDirectory.dir("outputs/managed_device_code_coverage/debug/pixel2api33")) { include("coverage.ec") } into(layout.projectDirectory.dir("coverage-baselines")) rename { "gmd_smoke.ec" } includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.FAIL doLast { val destFile = layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec").asFile if (!destFile.exists()) { throw GradleException("Failed to generate GMD coverage. Expected file not found: $destFile") } } } tasks.register("verifyWithCoverage") { group = "Verification" description = "Runs JVM unit tests and generates a combined coverage report using JaCoCo with the static GMD baseline." dependsOn("testDebugUnitTest") reports { xml.required.set(true) html.required.set(true) } val classFilter = listOf( "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", "**/*_ViewBinding*.*", "**/*_MembersInjector*.*", "**/*_Factory*.*", "**/*_Provide*Factory*.*", "**/*ExtensionsKt*.*", // ComposableSingletons are compiler-generated classes for @Composable lambdas // that are often static and difficult to cover in unit tests, creating noise. "**/*ComposableSingletons*.*", ) val debugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(classFilter) } val javaTree = fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) { exclude(classFilter) } classDirectories.setFrom(files(debugTree, javaTree)) sourceDirectories.setFrom( files( "src/main/java", "src/main/kotlin", "src/debug/java", "src/debug/kotlin", ), ) // Collect all execution data from both unit tests and instrumented tests // Using specific roots to avoid implicit dependency issues with other tasks' outputs in the build folder val executionDataFiles = files( fileTree(layout.buildDirectory.dir("outputs/unit_test_code_coverage")) { include("**/*.exec") }, layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec"), fileTree(layout.buildDirectory.dir("jacoco")) { include("**/*.exec") }, ) executionData.setFrom(executionDataFiles) doFirst { val lfsBaseline = layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec").asFile if (!lfsBaseline.exists()) { throw GradleException( "Required coverage baseline is missing: ${lfsBaseline.path}. " + "Run 'git lfs pull' to fetch tracked baselines or './gradlew generateGmdCoverage' to regenerate it.", ) } if (lfsBaseline.length() < 1000) { val header = lfsBaseline.inputStream().use { it.readNBytes(100) }.toString(Charsets.UTF_8) if (header.startsWith("version https://git-lfs.github.com/spec/v1")) { throw GradleException("Coverage baseline is a Git LFS pointer. Please run 'git lfs pull' to fetch the actual file.") } } val existingFiles = executionDataFiles.files.filter { it.exists() } val dynamicExecutionFiles = existingFiles.filter { it.name != "gmd_smoke.ec" } if (dynamicExecutionFiles.isEmpty()) { throw GradleException( "No dynamic JaCoCo execution data files were found. " + "Ensure tests were executed with coverage enabled (e.g., via -Pcoverage).\n" + "Searched in:\n" + "- ${layout.buildDirectory.dir("outputs/unit_test_code_coverage").get()}\n" + "- ${layout.buildDirectory.dir("jacoco").get()}", ) } } } tasks.withType().configureEach { configure { isEnabled = isCoverageEnabled // Required for Robolectric isIncludeNoLocationClasses = true excludes = listOf("jdk.internal.*") } if (name.contains("Release")) { enabled = false } systemProperty("robolectric.graphicsMode", "NATIVE") // Exclude tests tagged @Category(LocalOnly::class) from automated Gradle runs. // These tests are intended to be run directly from the IDE. // Note: This project uses JUnit 4 exclusively. If a Jupiter runner is ever added, // useJUnit { } will need to be scoped to only JUnit 4 tasks to avoid overriding // Jupiter configuration. useJUnit { excludeCategories("com.servicebook.LocalOnly") } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.documentfile) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) implementation(libs.navigation3.runtime) implementation(libs.navigation3.ui) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.runtime.compose) implementation(libs.datastore.preferences) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) implementation(libs.coil.compose) implementation(libs.google.billing) debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.truth) testImplementation(libs.turbine) testImplementation(libs.robolectric) testImplementation(libs.compose.ui.test) testImplementation(libs.androidx.test.ext) testImplementation(libs.roborazzi) testImplementation(libs.roborazzi.compose) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.compose.ui.test) androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.truth) androidTestImplementation(libs.turbine) androidTestImplementation(libs.roborazzi) androidTestImplementation(libs.roborazzi.compose) androidTestImplementation(libs.uiautomator) androidTestImplementation(libs.espresso.intents) }