import org.gradle.api.Task import org.gradle.api.file.RelativePath import org.gradle.api.tasks.GradleBuild import org.gradle.api.tasks.TaskProvider 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.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 localPropertiesFile = rootProject.file("local.properties") val localProperties = Properties() if (localPropertiesFile.exists()) { localProperties.load(FileInputStream(localPropertiesFile)) } val isCoverageEnabled = project.hasProperty("coverage") || gradle.startParameter.taskNames.any { it.contains("verifyWithCoverage", ignoreCase = true) || it.contains("generateGmdCoverage", ignoreCase = true) } val isScreenshotBuild = project.hasProperty("screenshotBuild") val isBillingSandboxBuild = project.hasProperty("billingSandboxBuild") val testVariant = when { isBillingSandboxBuild -> "staging" isScreenshotBuild -> "release" else -> "debug" } val testVariantCap = testVariant.replaceFirstChar { it.uppercase() } // Configuration for Play Store screenshot generation. val screenshotTestPackage = "com.yorvana.screenshots" val screenshotDeviceOutputDir = "/sdcard/test-outputs/screenshots" val smokeDeviceOutputDir = "/sdcard/test-outputs/smoke" val smokeAnnotation = "com.yorvana.testsupport.tiers.Smoke" val regressionAnnotation = "com.yorvana.testsupport.tiers.Regression" val regressionFullAnnotation = "com.yorvana.testsupport.tiers.RegressionFull" val quarantinedAnnotation = "com.yorvana.testsupport.tiers.Quarantined" val regressionDeviceTaskName = "pixel2api33DebugAndroidTest" android { namespace = "com.yorvana" compileSdk = 36 defaultConfig { applicationId = "com.yorvana" minSdk = 26 targetSdk = 36 versionCode = 2 versionName = "1.0.0-rc.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } testBuildType = testVariant 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 val dsnDebug = (localProperties.getProperty("sentry.dsn.debug") ?: "") .replace("\\", "\\\\") .replace("\"", "\\\"") buildConfigField("String", "SENTRY_DSN", "\"$dsnDebug\"") } 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", ) ndk { debugSymbolLevel = "SYMBOL_TABLE" } val dsnRelease = (localProperties.getProperty("sentry.dsn.release") ?: "") .replace("\\", "\\\\") .replace("\"", "\\\"") buildConfigField("String", "SENTRY_DSN", "\"$dsnRelease\"") } create("staging") { initWith(getByName("release")) matchingFallbacks += listOf("release") isMinifyEnabled = false if (keystorePropertiesFile.exists() || !System.getenv("KEYSTORE_FILE").isNullOrEmpty() ) { signingConfig = signingConfigs.getByName("release") } val dsnRelease = (localProperties.getProperty("sentry.dsn.release") ?: "") .replace("\\", "\\\\") .replace("\"", "\\\"") buildConfigField("String", "SENTRY_DSN", "\"$dsnRelease\"") } } 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" testedAbi = "x86_64" } create("phonePixel6api34") { device = "Pixel 6" apiLevel = 34 systemImageSource = "aosp" testedAbi = "x86_64" } create("tablet7Nexus7api34") { device = "Nexus 7" apiLevel = 34 systemImageSource = "aosp" testedAbi = "x86_64" } create("tablet10PixelTabletApi34") { device = "Pixel Tablet" apiLevel = 34 systemImageSource = "aosp" testedAbi = "x86_64" } } } } 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") } afterEvaluate { val requestedTaskNames = gradle.startParameter.taskNames.map { it.substringAfterLast(":") } val screenshotDeviceNames = setOf("phonePixel6api34", "tablet7Nexus7api34", "tablet10PixelTabletApi34") val isStoreScreenshotRun = requestedTaskNames.any { requestedTaskName -> requestedTaskName == "generateStoreScreenshots" || requestedTaskName.endsWith("StoreScreenshots") } val isSmokeCoverageRun = requestedTaskNames.any { it == "generateGmdCoverage" } // Note: This reflective block and the use of afterEvaluate make the build incompatible with // the Configuration Cache (CC) for managed device test tasks. ManagedDeviceInstrumentationTestTask // cannot be serialized when configured reflectively in afterEvaluate. We accept this trade-off // to enable per-device instrumentation arguments which AGP 8.x does not yet expose via a public DSL. tasks.withType().configureEach { fun isTaskForDevice( taskName: String, deviceName: String, ): Boolean = taskName.equals("$deviceName${testVariantCap}AndroidTest", ignoreCase = true) fun isRequestedForDevice(deviceName: String): Boolean = requestedTaskNames.any { requestedTaskName -> requestedTaskName.equals("$deviceName${testVariantCap}AndroidTest", ignoreCase = true) || requestedTaskName.equals("${deviceName}Check", ignoreCase = true) } || (deviceName == "pixel2api33" && isSmokeCoverageRun) || (deviceName in screenshotDeviceNames && isStoreScreenshotRun) val deviceDirName = when { isTaskForDevice(name, "phonePixel6api34") -> "phonePixel6api34" isTaskForDevice(name, "tablet7Nexus7api34") -> "tablet7Nexus7api34" isTaskForDevice(name, "tablet10PixelTabletApi34") -> "tablet10PixelTabletApi34" isTaskForDevice(name, "pixel2api33") -> "pixel2api33" else -> null } if (deviceDirName != null) { // Set the host-side directory where GMD will pull files from. getAdditionalTestOutputDir().set( layout.buildDirectory.dir("outputs/managed_device_android_test_additional_output/$testVariant/$deviceDirName"), ) } // AGP does not provide a public DSL to set per-ManagedVirtualDevice instrumentation arguments. // We use reflection to reach into the internal testData property and its instrumentationRunnerArguments map. val getTestDataMethod = javaClass.getMethod("getTestData") val testDataProperty = getTestDataMethod.invoke(this) as org.gradle.api.provider.Property<*> val testData = testDataProperty.get() val getArgsMethod = testData.javaClass.getMethod("getInstrumentationRunnerArguments") @Suppress("UNCHECKED_CAST") val argsProperty = getArgsMethod.invoke(testData) as org.gradle.api.provider.MapProperty val myTask = this val taskName = name // Intentionally captured at configuration time: direct invocations and child GradleBuild // regression tasks each configure a fresh build with their own -P runner arguments. val isExplicitAnnotationRun = providers.gradleProperty("android.testInstrumentationRunnerArguments.annotation").isPresent argsProperty.putAll( provider { val map = mutableMapOf() // ManagedDeviceInstrumentationTestTask instances can share AGP's underlying // runner argument map during configuration. Only the device task requested by // this Gradle invocation contributes arguments, while regression GradleBuild // children pass their explicit annotation filters in their own invocation. if (!isRequestedForDevice(deviceDirName.orEmpty())) { return@provider map } when { isTaskForDevice(taskName, "phonePixel6api34") -> { map["package"] = screenshotTestPackage map["additionalTestOutputDir"] = screenshotDeviceOutputDir } isTaskForDevice(taskName, "tablet7Nexus7api34") -> { map["package"] = screenshotTestPackage map["additionalTestOutputDir"] = screenshotDeviceOutputDir } isTaskForDevice(taskName, "tablet10PixelTabletApi34") -> { map["package"] = screenshotTestPackage map["additionalTestOutputDir"] = screenshotDeviceOutputDir } isTaskForDevice(taskName, "pixel2api33") -> { // Keep this exact AOSP smoke task match. A future Play/staging billing // task must not inherit the smoke annotation filter. if (!isExplicitAnnotationRun) { map["annotation"] = smokeAnnotation } map["notPackage"] = screenshotTestPackage map["additionalTestOutputDir"] = smokeDeviceOutputDir } } map }, ) } } fun registerRegressionDeviceTask( taskName: String, descriptionText: String, runnerArguments: Map, ): TaskProvider = tasks.register(taskName) { group = "Verification" description = descriptionText dir = rootProject.projectDir tasks = listOf(":app:$regressionDeviceTaskName") // Regression aggregate tasks own their runner filters; these values intentionally // override any outer -Pandroid.testInstrumentationRunnerArguments.annotation settings. startParameter.projectProperties.putAll( gradle.startParameter.projectProperties + runnerArguments.mapKeys { (key, _) -> "android.testInstrumentationRunnerArguments.$key" }, ) } val regressionCheck = registerRegressionDeviceTask( taskName = "regressionCheck", descriptionText = "Runs core regression androidTests on the Pixel 2 API 33 managed device.", runnerArguments = mapOf( "annotation" to regressionAnnotation, "notAnnotation" to quarantinedAnnotation, ), ) val regressionFullCoreCheck = registerRegressionDeviceTask( taskName = "regressionFullCoreCheck", descriptionText = "Runs all core regression androidTests, including quarantined tests.", runnerArguments = mapOf("annotation" to regressionAnnotation), ) val regressionFullExtendedCheck = registerRegressionDeviceTask( taskName = "regressionFullExtendedCheck", descriptionText = "Runs full regression androidTests on the Pixel 2 API 33 managed device.", runnerArguments = mapOf("annotation" to regressionFullAnnotation), ) tasks.register("regressionFullCheck") { group = "Verification" description = "Runs Phase 1 full regression checks; sandbox and macrobenchmark siblings are deferred." dependsOn(regressionFullCoreCheck, regressionFullExtendedCheck) } tasks.register("generateGmdCoverage") { group = "Verification" description = "Runs GMD tests and copies the coverage file to the baseline directory." // Coverage is always collected from the debug variant of the smoke device // regardless of the current -PscreenshotBuild setting. val smokeTask = "pixel2api33DebugAndroidTest" dependsOn(smokeTask) 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") } } } val storeScreenshotFiles = listOf( "screen_01_vehicle_list.png", "screen_02_vehicle_detail.png", "screen_03_add_record.png", "screen_04_categories.png", "screen_05_settings_privacy.png", "screen_06_paywall_dialog.png", ) val storeScreenshotsRoot = rootProject.layout.projectDirectory.dir("distribution/store-assets/screenshots") val storeAdditionalOutputRoot = layout.buildDirectory.dir("outputs/managed_device_android_test_additional_output/$testVariant") fun registerStoreScreenshotCopy( taskPrefix: String, deviceTaskName: String, outputDirectory: String, previousCopyTask: TaskProvider? = null, ): TaskProvider { tasks.named(deviceTaskName).configure { previousCopyTask?.let { mustRunAfter(it) } // We inject a doFirst block to wipe the additional output root before every run. // This ensures a clean state for the subsequent copy task, but has the side-effect // of clearing previous outputs even when running a single device test for debugging. doFirst { delete(storeAdditionalOutputRoot) } } return tasks.register("copy${taskPrefix}StoreScreenshots") { previousCopyTask?.let { dependsOn(it) } dependsOn(deviceTaskName) into(storeScreenshotsRoot.dir(outputDirectory)) includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.FAIL doFirst { delete(storeScreenshotsRoot.dir(outputDirectory)) } from(storeAdditionalOutputRoot) { // AGP sometimes aliases instrumentation arguments across GMD tasks, // leading to screenshots being saved in the wrong bucket subfolder on the host. // We use a wildcard to grab any screenshot found in the device's output root. include("**/$outputDirectory/screen_*.png") eachFile { relativePath = RelativePath(true, name) } } doLast { val missing = storeScreenshotFiles.mapNotNull { fileName -> val file = storeScreenshotsRoot.file("$outputDirectory/$fileName").asFile if (file.exists()) null else file } if (missing.isNotEmpty()) { throw GradleException( "Missing generated store screenshots for $outputDirectory:\n" + missing.joinToString(separator = "\n") { "- ${it.path}" }, ) } } } } afterEvaluate { val copyPhoneStoreScreenshots = registerStoreScreenshotCopy( taskPrefix = "Phone", deviceTaskName = "phonePixel6api34${testVariantCap}AndroidTest", outputDirectory = "phone", ) val copyTablet7StoreScreenshots = registerStoreScreenshotCopy( taskPrefix = "Tablet7", deviceTaskName = "tablet7Nexus7api34${testVariantCap}AndroidTest", outputDirectory = "tablet-7", previousCopyTask = copyPhoneStoreScreenshots, ) val copyTablet10StoreScreenshots = registerStoreScreenshotCopy( taskPrefix = "Tablet10", deviceTaskName = "tablet10PixelTabletApi34${testVariantCap}AndroidTest", outputDirectory = "tablet-10", previousCopyTask = copyTablet7StoreScreenshots, ) tasks.register("generateStoreScreenshots") { group = "Verification" description = "Runs store screenshot tests on managed devices and copies PNGs into distribution assets." dependsOn(copyTablet10StoreScreenshots) } } tasks.register("verifyWithCoverage") { group = "Verification" description = "Runs JVM unit tests and generates a combined coverage report using JaCoCo with the static GMD baseline." // Unit tests are variant-agnostic; we always use debug for coverage consistency 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()}", ) } } } val printBillingSandboxSkipReasons = tasks.register("printBillingSandboxSkipReasons") { group = "Verification" description = "Prints billing sandbox assumption skip reasons from connected staging androidTest results." onlyIf { isBillingSandboxBuild } doLast { val resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/staging").get().asFile if (!resultsDir.exists()) { logger.lifecycle("Billing sandbox skip reasons: no connected staging results found at ${resultsDir.path}") return@doLast } val reasons = resultsDir .walkTopDown() .filter { it.isFile && it.name == "test-result.textproto" } .flatMap { resultFile -> resultFile .readLines() .asSequence() .filter { it.contains("AssumptionViolatedException") && it.contains("error_message:") } .map { line -> line .substringAfter("error_message: \"") .substringBeforeLast("\"") .replace("\\n", "\n") .replace("\\\"", "\"") .lineSequence() .firstOrNull { it.contains("AssumptionViolatedException") } .orEmpty() .substringAfter("AssumptionViolatedException: ") .trim() } }.filter { it.isNotBlank() } .distinct() .toList() if (reasons.isEmpty()) { logger.lifecycle("Billing sandbox skip reasons: none found in connected staging results.") } else { logger.lifecycle("") logger.lifecycle("Billing sandbox skipped preconditions:") reasons.forEach { reason -> logger.lifecycle("- $reason") } logger.lifecycle("") } } } tasks.matching { it.name == "connectedStagingAndroidTest" }.configureEach { finalizedBy(printBillingSandboxSkipReasons) } tasks.withType().configureEach { if (isCoverageEnabled) { configure { isEnabled = true // Required for Robolectric isIncludeNoLocationClasses = true excludes = listOf("jdk.internal.*") } } else { configure { isEnabled = false } } val defaultForks = (Runtime.getRuntime().availableProcessors() / 4).coerceAtLeast(2) maxParallelForks = (project.findProperty("testForks") as String?)?.toInt() ?: defaultForks forkEvery = 0L minHeapSize = "512m" maxHeapSize = "1536m" jvmArgs = listOf( "-XX:+UseG1GC", "-XX:MaxMetaspaceSize=512m", "-Dfile.encoding=UTF-8", ) 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. if (name.contains("Release")) { enabled = false } useJUnit { excludeCategories("com.yorvana.LocalOnly") } reports { html.required.set(System.getenv("CI") != null) junitXml.required.set(true) } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.documentfile) implementation(libs.sentry.android) 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.viewmodel.savedstate) 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) }