Output:
Chunk ID: 2a19e5
Wall time: 0.3351 seconds
Process exited with code 0
Original token count: 3149
Output:
import org.gradle.api.Task
import org.gradle.api.file.RelativePath
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 testVariant = if (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"

android {
    namespace = "com.yorvana"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.yorvana"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0.0"

        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",
            )
            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<com.android.build.api.dsl.ManagedVirtualDevice>("pixel2api33") {
                    device = "Pixel 2"
                    apiLevel = 33
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("phonePixel6api34") {
                    device = "Pixel 6"
                    apiLevel = 34
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("tablet7Nexus7api34") {
                    device = "Nexus 7"
                    apiLevel = 34
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().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 {
    // 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<com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask>().configureEach {
        val deviceDirName =
            when {
                name.contains("phonePixel6api34", ignoreCase = true) -> "phonePixel6api34"
                name.contains("tablet7Nexus7api34", ignoreCase = true) -> "tablet7Nexus7api34"
                name.contains("tablet10PixelTabletApi34", ignoreCase = true) -> "tablet10PixelTabletApi34"
                name.contains("pixel2api33", ignoreCase = true) -> "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<String, String>

        val myTask = this
        val taskName = name
        argsProperty.putAll(
            provider {
                val map = mutableMapOf<String, String>()
                // We only return arguments if the task is actually executing. This prevents
                // aliasing/merging issues when AGP shares the underlying MapProperty object across tasks.
                if (myTask.state.executing) {
                    when {
                        taskName.contains("phonePixel6api34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("tablet7Nexus7api34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("tablet10PixelTabletApi34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("pixel2api33", ignoreCase = true) -> {
                            map["notPackage"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = smokeDeviceOutputDir
                        }
                    }
                }
                map
            },
        )
    }
}

tasks.register<Copy>("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<out Task>? = null,
): TaskProvider<Copy> {
    tasks.named<Task>(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>("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.


Output:
Chunk ID: d33337
Wall time: 0.3894 seconds
Process exited with code 0
Original token count: 3153
Output:
.github/workflows/baseline-reminder.yml
.github/workflows/ci-label.yml
.github/workflows/ci.yml
.github/workflows/deploy-play-store.yml
.github/workflows/semgrep.yml
.github/workflows/smoke-label.yml
.github/workflows/smoke.yml
app/src/androidTest/assets/screenshots-fixture/categories.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/records/2024-03-12-oil-change.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/records/2024-06-04-brake-pads.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/records/2024-09-21-tyre-rotation.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/records/2025-01-18-mot.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/records/2025-04-02-battery.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/records/2025-07-15-coolant-flush.json
app/src/androidTest/assets/screenshots-fixture/vehicles/2019-honda-civic/vehicle.json
app/src/androidTest/assets/test_receipt.jpg
app/src/androidTest/java/com/yorvana/SmokeTest.kt
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt
app/src/androidTest/java/com/yorvana/testsupport/ComposeWaitHelpers.kt
app/src/androidTest/java/com/yorvana/testsupport/DemoMode.kt
app/src/androidTest/java/com/yorvana/testsupport/ScreenshotConstants.kt
app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt
app/src/test/java/com/yorvana/LocalOnly.kt
app/src/test/java/com/yorvana/MainActivityTest.kt
app/src/test/java/com/yorvana/TestYorvanaApplication.kt
app/src/test/java/com/yorvana/YorvanaApplicationTest.kt
app/src/test/java/com/yorvana/data/billing/FakeBillingManager.kt
app/src/test/java/com/yorvana/data/preferences/AppPreferencesImplTest.kt
app/src/test/java/com/yorvana/data/repository/CategoryRepositoryImplTest.kt
app/src/test/java/com/yorvana/data/repository/DataIntegrationIT.kt
app/src/test/java/com/yorvana/data/repository/RecordRepositoryImplTest.kt
app/src/test/java/com/yorvana/data/repository/VehicleRepositoryImplTest.kt
app/src/test/java/com/yorvana/data/repository/WorkflowIntegrationIT.kt
app/src/test/java/com/yorvana/data/storage/MigrationHelperTest.kt
app/src/test/java/com/yorvana/data/storage/VaultStorageImplIT.kt
app/src/test/java/com/yorvana/data/storage/VaultStorageImplTest.kt
app/src/test/java/com/yorvana/domain/AppGateTest.kt
app/src/test/java/com/yorvana/ui/components/ComponentScreenshotTest.kt
app/src/test/java/com/yorvana/ui/components/PaywallInfoSheetScreenshotTest.kt
app/src/test/java/com/yorvana/ui/components/PaywallScaffoldScreenshotTest.kt
app/src/test/java/com/yorvana/ui/components/PaywallStatusCardScreenshotTest.kt
app/src/test/java/com/yorvana/ui/components/RequiredLabelTest.kt
app/src/test/java/com/yorvana/ui/components/UpgradeDialogScreenshotTest.kt
app/src/test/java/com/yorvana/ui/components/UpgradeDialogTest.kt
app/src/test/java/com/yorvana/ui/components/snapshots/AttachmentChip_deletable.png
app/src/test/java/com/yorvana/ui/components/snapshots/AttachmentChip_readOnly.png
app/src/test/java/com/yorvana/ui/components/snapshots/AttachmentThumbnail_noUri.png
app/src/test/java/com/yorvana/ui/components/snapshots/ChromeLikeMenu.png
app/src/test/java/com/yorvana/ui/components/snapshots/ConfirmDeleteDialog.png
app/src/test/java/com/yorvana/ui/components/snapshots/CurrencyPickerDialog.png
app/src/test/java/com/yorvana/ui/components/snapshots/CustomCurrencyDialog.png
app/src/test/java/com/yorvana/ui/components/snapshots/EmptyStateView_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/EmptyStateView_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/ErrorListItem_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/MakePickerDialog.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallInfoSheet_pending_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallInfoSheet_pending_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallInfoSheet_readOnly_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallInfoSheet_readOnly_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallScaffold_noSheet_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallScaffold_noSheet_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallStatusCard_pending_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallStatusCard_pending_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallStatusCard_readOnly_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/PaywallStatusCard_readOnly_light.png
app/src/test/java/com/yorvana/ui/components/snapshots/RequiredLabel.png
app/src/test/java/com/yorvana/ui/components/snapshots/RequiredLabel_default.png
app/src/test/java/com/yorvana/ui/components/snapshots/UpgradeDialog_dark.png
app/src/test/java/com/yorvana/ui/components/snapshots/UpgradeDialog_light.png
app/src/test/java/com/yorvana/ui/navigation/SetupScreenTest.kt
app/src/test/java/com/yorvana/ui/navigation/SetupScreenshotTest.kt
app/src/test/java/com/yorvana/ui/navigation/snapshots/SetupScreen_step1_folder.png
app/src/test/java/com/yorvana/ui/navigation/snapshots/SetupScreen_step2_sentry_off.png
app/src/test/java/com/yorvana/ui/navigation/snapshots/SetupScreen_step2_sentry_on.png
app/src/test/java/com/yorvana/ui/navigation/snapshots/SetupScreen_step2_sentry_privacy_dialog.png
app/src/test/java/com/yorvana/ui/navigation/snapshots/SetupScreen_step2_with_topbar.png
app/src/test/java/com/yorvana/ui/records/AddEditRecordScreenIT.kt
app/src/test/java/com/yorvana/ui/records/AddEditRecordScreenTest.kt
app/src/test/java/com/yorvana/ui/records/AddEditRecordScreenshotTest.kt
app/src/test/java/com/yorvana/ui/records/AddEditRecordViewModelTest.kt
app/src/test/java/com/yorvana/ui/records/ImageViewerScreenshotTest.kt
app/src/test/java/com/yorvana/ui/records/RecordDetailScreenIT.kt
app/src/test/java/com/yorvana/ui/records/RecordDetailScreenshotTest.kt
app/src/test/java/com/yorvana/ui/records/RecordDetailViewModelIT.kt
app/src/test/java/com/yorvana/ui/records/RecordDetailViewModelTest.kt
app/src/test/java/com/yorvana/ui/records/VehicleDetailScreenIT.kt
app/src/test/java/com/yorvana/ui/records/VehicleDetailScreenTest.kt
app/src/test/java/com/yorvana/ui/records/VehicleDetailScreenshotTest.kt
app/src/test/java/com/yorvana/ui/records/VehicleDetailViewModelTest.kt
app/src/test/java/com/yorvana/ui/records/snapshots/AddEditRecordScreen_add_dark.png
app/src/test/java/com/yorvana/ui/records/snapshots/AddEditRecordScreen_add_light.png
app/src/test/java/com/yorvana/ui/records/snapshots/AddEditRecordScreen_custom_task_dialog.png
app/src/test/java/com/yorvana/ui/records/snapshots/AddEditRecordScreen_edit_dark.png
app/src/test/java/com/yorvana/ui/records/snapshots/AddEditRecordScreen_edit_light.png
app/src/test/java/com/yorvana/ui/records/snapshots/ImageViewerScreen.png
app/src/test/java/com/yorvana/ui/records/snapshots/RecordDetailScreen_dark.png
app/src/test/java/com/yorvana/ui/records/snapshots/RecordDetailScreen_light.png
app/src/test/java/com/yorvana/ui/records/snapshots/VehicleDetailScreen_dark.png
app/src/test/java/com/yorvana/ui/records/snapshots/VehicleDetailScreen_light.png
app/src/test/java/com/yorvana/ui/records/snapshots/VehicleDetailScreen_menu_dark.png
app/src/test/java/com/yorvana/ui/records/snapshots/VehicleDetailScreen_menu_light.png
app/src/test/java/com/yorvana/ui/reportbug/ReportBugScreenTest.kt
app/src/test/java/com/yorvana/ui/reportbug/ReportBugScreenshotTest.kt
app/src/test/java/com/yorvana/ui/reportbug/ReportBugViewModelTest.kt
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_device_info_dialog_light.png
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_disclosure_light.png
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_empty_light.png
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_filled_dark.png
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_invalid_email_dark.png
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_invalid_email_light.png
app/src/test/java/com/yorvana/ui/reportbug/snapshots/ReportBugScreen_submitting_light.png
app/src/test/java/com/yorvana/ui/settings/CategoriesScreenIT.kt
app/src/test/java/com/yorvana/ui/settings/CategoriesScreenTest.kt
app/src/test/java/com/yorvana/ui/settings/CategoriesScreenshotTest.kt
app/src/test/java/com/yorvana/ui/settings/CategoriesViewModelTest.kt
app/src/test/java/com/yorvana/ui/settings/SettingsScreenIT.kt
app/src/test/java/com/yorvana/ui/settings/SettingsScreenshotTest.kt
app/src/test/java/com/yorvana/ui/settings/SettingsViewModelIT.kt
app/src/test/java/com/yorvana/ui/settings/SettingsViewModelTest.kt
app/src/test/java/com/yorvana/ui/settings/snapshots/CategoriesScreen_add_dialog.png
app/src/test/java/com/yorvana/ui/settings/snapshots/CategoriesScreen_dark.png
app/src/test/java/com/yorvana/ui/settings/snapshots/CategoriesScreen_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_conflict_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_crash_reporting_on_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_dark.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_debug_crash_section_dark.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_debug_crash_section_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_existing_vault_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_premium_dark.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_premium_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_privacy_dialog.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_privacy_section_light.png
app/src/test/java/com/yorvana/ui/settings/snapshots/SettingsScreen_validating_light.png
app/src/test/java/com/yorvana/ui/util/BaseScreenshotTest.kt
app/src/test/java/com/yorvana/ui/util/ContextExtensionsTest.kt
app/src/test/java/com/yorvana/ui/util/EmailLaunchRequestTest.kt
app/src/test/java/com/yorvana/ui/util/ViewModelDependenciesExt.kt
app/src/test/java/com/yorvana/ui/util/ViewModelFactoryTest.kt
app/src/test/java/com/yorvana/ui/vehicles/AddEditVehicleScreenIT.kt
app/src/test/java/com/yorvana/ui/vehicles/AddEditVehicleScreenTest.kt
app/src/test/java/com/yorvana/ui/vehicles/AddEditVehicleScreenshotTest.kt
app/src/test/java/com/yorvana/ui/vehicles/AddEditVehicleViewModelIT.kt
app/src/test/java/com/yorvana/ui/vehicles/AddEditVehicleViewModelTest.kt
app/src/test/java/com/yorvana/ui/vehicles/VehicleListConsentDialogTest.kt
app/src/test/java/com/yorvana/ui/vehicles/VehicleListScreenIT.kt
app/src/test/java/com/yorvana/ui/vehicles/VehicleListScreenshotTest.kt
app/src/test/java/com/yorvana/ui/vehicles/VehicleListViewModelTest.kt
app/src/test/java/com/yorvana/ui/vehicles/snapshots/AddEditVehicleScreen_add_dark.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/AddEditVehicleScreen_add_light.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/AddEditVehicleScreen_edit_dark.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/AddEditVehicleScreen_edit_light.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/VehicleListScreen_empty_dark.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/VehicleListScreen_empty_light.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/VehicleListScreen_list_dark.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/VehicleListScreen_list_light.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/VehicleListScreen_readOnly_dark.png
app/src/test/java/com/yorvana/ui/vehicles/snapshots/VehicleListScreen_readOnly_light.png
app/src/test/java/com/yorvana/util/CrashReporterTest.kt
app/src/test/java/com/yorvana/util/DateFormatterTest.kt
app/src/test/java/com/yorvana/util/FileProviderHelperTest.kt
app/src/test/java/com/yorvana/util/SentryWrapperTest.kt
app/src/test/java/com/yorvana/util/UuidGeneratorTest.kt
app/src/test/java/com/yorvana/util/VaultPathHelperTest.kt
docs/adr-011-navigation-transitions.md
docs/adr.md
docs/dependabot-workflow.md
docs/plan-integration-tests-restructure.md
docs/plan-paywall.md
docs/plan-regression-tests.md
docs/plan-smoke-tests.md
docs/privacy-policy.md
docs/sentry-setup.md
docs/specs.md


Output:
Chunk ID: 24ca37
Wall time: 0.3515 seconds
Process exited with code 0
Original token count: 619
Output:
# GMD smoke tests on CI.
#
# GitHub-hosted `ubuntu-latest` runners expose `/dev/kvm` for CPU acceleration
# (the "Enable KVM" step installs a world-writable `0666` udev rule, which is
# a common approach when running GMD tests on ephemeral Ubuntu runners to
# ensure the emulator process has necessary permissions).
# However, they have no GPU/Vulkan driver — so the GPU mode is forced to
# `swiftshader_indirect`. Note: swiftshader is documented to crash on Linux
# kernel >= 6.17 (see TESTING_SETUP.md). GitHub-hosted runners are currently
# on kernel ~6.8, but if that changes this workflow will need a different
# harness.
#
# To keep Free-tier Actions minutes in budget, this workflow does not auto-trigger.
# It runs only when the `smoke` label is added to a PR (via smoke-label.yml) or
# when manually invoked from the Actions tab (workflow_dispatch).
name: Smoke
permissions:
  contents: read

on:
  workflow_dispatch:
  workflow_call:

jobs:
  smoke:
    name: GMD Smoke Tests
    runs-on: ubuntu-latest
    timeout-minutes: 45
    concurrency:
      group: smoke-${{ github.ref }}
      cancel-in-progress: true
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Set up JDK 17
        uses: actions/setup-java@v5
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/master' }}
          cache-cleanup: on-success

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run GMD Smoke Tests
        run: ./gradlew pixel2api33Check -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect

      - name: Upload Managed Device Reports
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: gmd-test-reports
          path: |
            app/build/reports/androidTests/managedDevice/
            app/build/outputs/androidTest-results/managedDevice/
            app/build/outputs/managed_device_android_test_additional_output/
          if-no-files-found: warn
          retention-days: 7


Output:
Chunk ID: 42d52c
Wall time: 0.3852 seconds
Process exited with code 0
Original token count: 132
Output:
# Manual label trigger for smoke tests.
#
# This workflow is a lightweight, one-shot manual trigger. It consumes 0 runner
# minutes for unrelated PRs or unrelated labels because the job guard (if:) is
# evaluated before a runner is provisioned. It only runs when the 'smoke' label
# is explicitly applied.
name: Smoke (Manual Label)
permissions:
  contents: read
  pull-requests: read

on:
  pull_request:
    types: [labeled]

jobs:
  smoke:
    if: github.event.label.name == 'smoke'
    uses: ./.github/workflows/smoke.yml


sed -n '1,260p' app/src/androidTest/java/com/yorvana/SmokeTest.kt


sed -n '1,240p' app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt


sed -n '1,240p' app/src/androidTest/java/com/yorvana/testsupport/ComposeWaitHelpers.kt


sed -n '1,220p' docs/plan-regression-tests.md


Output:
Chunk ID: aa4e3e
Wall time: 0.3774 seconds
Process exited with code 0
Original token count: 2498
Output:
package com.yorvana

import android.util.Log
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasStateDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.yorvana.data.model.Vehicle
import com.yorvana.data.preferences.DebugBillingOverrideMode
import com.yorvana.testsupport.awaitTagGone
import com.yorvana.testsupport.awaitTagVisible
import com.yorvana.testsupport.awaitText
import com.yorvana.testsupport.installFreshVault
import com.yorvana.testsupport.resetYorvanaState
import com.yorvana.ui.TestTags
import com.yorvana.util.DateFormatter
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID

/**
 * Smoke tests for Yorvana.
 *
 * These tests run on a real Android device (or GMD) and cover the primary user journeys
 * including filesystem access (SAF), camera integration, and the paywall.
 */
@RunWith(AndroidJUnit4::class)
class SmokeTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Before
    fun setUp() {
        composeTestRule.resetYorvanaState()
    }

    @After
    fun tearDown() {
        composeTestRule.resetYorvanaState()
    }

    private fun step(
        name: String,
        description: String,
    ) {
        Log.d("SmokeTest", "STEP: $name - $description")
    }

    private fun configureVaultAndNavigateToGarage() {
        composeTestRule.installFreshVault()
    }

    private fun navigateToAddVehicle() {
        composeTestRule.onNodeWithTag(TestTags.ADD_VEHICLE_FAB).performClick()
        composeTestRule.waitForIdle()
    }

    private fun navigateToSettings() {
        composeTestRule.awaitTagVisible(TestTags.SETTINGS_BUTTON)
        composeTestRule.onNodeWithTag(TestTags.SETTINGS_BUTTON).performClick()
        composeTestRule.waitForIdle()
    }

    private fun assertScreenTitle(titleResId: Int) {
        val expectedTitle = composeTestRule.activity.getString(titleResId)
        composeTestRule.onNodeWithText(expectedTitle).assertIsDisplayed()
    }

    private fun setBillingOverride(mode: DebugBillingOverrideMode) {
        step("setBillingOverride", "setting mode to $mode")
        val app = composeTestRule.activity.application as YorvanaApplication
        runBlocking {
            app.debugBillingOverride?.setMode(mode)
        }
        composeTestRule.waitForIdle()
    }

    /**
     * Seeds a vehicle directly via repository to bypass the "Add Vehicle" UI gate.
     * Note: Does not populate denormalized fields (recordCount, lastServiceDate)
     * as they are not needed for these smoke scenarios.
     */
    private fun addVehicleForTesting(nickname: String) {
        step("addVehicleForTesting", "adding $nickname")
        val app = composeTestRule.activity.application as YorvanaApplication
        val vehicle =
            Vehicle(
                id = UUID.randomUUID().toString(),
                nickname = nickname,
                createdAt = DateFormatter.nowIso(),
            )
        runBlocking {
            app.vehicleRepository.saveVehicle(vehicle)
        }
        composeTestRule.waitForIdle()
        composeTestRule.awaitText(nickname)
    }

    @Test
    fun s01_firstLaunch_showsWelcome() {
        step("S01", "verifying welcome screen")
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.setup_title)).assertIsDisplayed()
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.setup_choose_folder)).assertIsDisplayed()
    }

    @Test
    fun s03_addVehicle_savesCorrectly() {
        configureVaultAndNavigateToGarage()
        step("S03", "adding new vehicle")
        navigateToAddVehicle()

        composeTestRule.onNodeWithTag(TestTags.VEHICLE_NICKNAME_FIELD).performTextInput("Test Car")
        composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick()

        step("S03", "verifying vehicle in list")
        composeTestRule.awaitText("Test Car")
        composeTestRule.onNodeWithText("Test Car").assertIsDisplayed()
    }

    @Test
    fun s05_addRecord_savesCorrectly() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Record Car")

        step("S05", "navigating to vehicle detail")
        composeTestRule
            .onNode(hasText("Record Car") and hasClickAction())
            .performScrollTo()
            .performClick()
        composeTestRule.awaitTagVisible(TestTags.ADD_RECORD_FAB)

        step("S05", "adding new record")
        composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB).performClick()
        composeTestRule.awaitTagVisible(TestTags.RECORD_ODOMETER_FIELD)

        composeTestRule.onNodeWithTag(TestTags.RECORD_ODOMETER_FIELD).performTextInput("12345")
        composeTestRule.onNodeWithTag(TestTags.PERFORMER_FIELD).performScrollTo().performTextInput("Self")
        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick()

        step("S05", "verifying record in list")
        composeTestRule.waitUntil(15000) {
            composeTestRule.onAllNodesWithText("12345 km", substring = true).fetchSemanticsNodes().isNotEmpty()
        }
        composeTestRule.onNodeWithText("12345 km", substring = true).assertIsDisplayed()
    }

    @Test
    fun s07_addAttachment_showsPickerButton() {
        // This test is currently flaky on GMD due to intent stubbing issues with SAF
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Attachment Car")

        step("S07", "navigating to add record")
        composeTestRule
            .onNode(hasText("Attachment Car") and hasClickAction())
            .performScrollTo()
            .performClick()
        composeTestRule.awaitTagVisible(TestTags.ADD_RECORD_FAB)
        composeTestRule.onNodeWithTag(TestTags.ADD_RECORD_FAB).performClick()

        step("S07", "verifying attachment button is present")
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.attachment_pick_file)).assertIsDisplayed()
    }

    @Test
    fun s11_navigateToSettings_works() {
        configureVaultAndNavigateToGarage()
        step("S11", "navigating to settings")
        navigateToSettings()
        assertScreenTitle(R.string.settings_title)
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.settings_odometer_unit)).assertIsDisplayed()
    }

    @Test
    fun s13_editVehicle_updatesList() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Old Name")

        step("S13", "editing vehicle")
        composeTestRule.onNodeWithText("Old Name").performScrollTo().performClick()
        composeTestRule.onNodeWithTag(TestTags.MORE_OPTIONS_BUTTON).performClick()
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.vehicle_edit)).performClick()

        composeTestRule.onNodeWithTag(TestTags.VEHICLE_NICKNAME_FIELD).performTextReplacement("New Name")
        composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick()

        step("S13", "verifying update")
        composeTestRule.awaitText("New Name")
        composeTestRule.onNodeWithText("New Name").assertIsDisplayed()
    }

    @Test
    fun s16_deleteVehicle_returnsToGarage() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("To Delete")

        step("S16", "deleting vehicle")
        composeTestRule.onNodeWithText("To Delete").performScrollTo().performClick()
        composeTestRule.onNodeWithTag(TestTags.MORE_OPTIONS_BUTTON).performClick()
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.vehicle_delete)).performClick()
        composeTestRule.onNodeWithTag(TestTags.CONFIRM_DELETE_BUTTON).performClick()

        step("S16", "verifying empty garage")
        assertScreenTitle(R.string.my_garage)
        composeTestRule.onNodeWithText("To Delete").assertDoesNotExist()
    }

    @Test
    fun s17_backNavigation_works() {
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("Nav Car")

        step("S17", "navigating deep")
        composeTestRule.onNodeWithText("Nav Car").performScrollTo().performClick()
        composeTestRule.awaitTagVisible(TestTags.ADD_RECORD_FAB)

        step("S17", "going back")
        pressBack()
        assertScreenTitle(R.string.my_garage)
    }

    private fun pressBack() {
        InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(android.view.KeyEvent.KEYCODE_BACK)
        composeTestRule.waitForIdle()
    }

    @Test
    fun upgradeDialogOnFreeTier() {
        val activity = composeTestRule.activity
        step("S18", "configuring vault and seeding 1 vehicle directly")
        configureVaultAndNavigateToGarage()
        addVehicleForTesting("S18 Car")

        step("S18", "forcing free tier and attempting to add vehicle")
        setBillingOverride(DebugBillingOverrideMode.FORCE_FREE)
        navigateToAddVehicle()

        step("S18", "verifying paywall sheet is displayed")
        composeTestRule.awaitTagVisible(TestTags.PAYWALL_INFO_SHEET)


Output:
Chunk ID: 2743b7
Wall time: 0.3257 seconds
Process exited with code 0
Original token count: 362
Output:
package com.yorvana.testsupport

import android.net.Uri
import com.yorvana.R
import com.yorvana.YorvanaApplication
import com.yorvana.data.preferences.CURRENT_CONSENT_VERSION
import com.yorvana.data.preferences.DebugBillingOverrideMode
import com.yorvana.util.CrashReporter
import kotlinx.coroutines.runBlocking
import java.io.File

fun MainActivityComposeRule.resetYorvanaState() {
    val app = activity.application as YorvanaApplication
    runBlocking {
        app.vaultStorage.clearVault()
        app.preferences.setVaultUri(null)
        app.preferences.setCrashReportingConsentPromptShownVersion(0)
        app.preferences.setCrashReportingEnabled(false)
        CrashReporter.clear(app)
        app.debugBillingOverride?.setMode(DebugBillingOverrideMode.NONE)
    }
}

fun MainActivityComposeRule.installFreshVault(
    vaultName: String = "test_vault",
    seed: ((File) -> Unit)? = null,
) {
    val app = activity.application as YorvanaApplication
    val vaultFile = File(activity.filesDir, vaultName)
    vaultFile.deleteRecursively()
    vaultFile.mkdirs()
    seed?.invoke(vaultFile)

    val uri = Uri.fromFile(vaultFile)
    runBlocking {
        app.preferences.setCrashReportingConsentPromptShownVersion(CURRENT_CONSENT_VERSION)
        app.preferences.setVaultUri(uri.toString())
        app.applyVaultUri(uri.toString())
    }
    waitForIdle()
    awaitText(activity.getString(R.string.my_garage), timeoutMillis = 15_000)
}


Output:
Chunk ID: 0736f4
Wall time: 0.3433 seconds
Process exited with code 0
Original token count: 265
Output:
package com.yorvana.testsupport

import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.yorvana.MainActivity

typealias MainActivityComposeRule = AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>

fun MainActivityComposeRule.awaitTagVisible(
    tag: String,
    timeoutMillis: Long = 15_000,
) {
    waitUntil(timeoutMillis) {
        onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
    }
}

fun MainActivityComposeRule.awaitTagGone(
    tag: String,
    timeoutMillis: Long = 15_000,
) {
    waitUntil(timeoutMillis) {
        onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
    }
}

fun MainActivityComposeRule.awaitText(
    text: String,
    timeoutMillis: Long = 15_000,
    substring: Boolean = false,
) {
    waitUntil(timeoutMillis) {
        onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty()
    }
}


Output:
Chunk ID: 96a497
Wall time: 0.3402 seconds
Process exited with code 0
Original token count: 10167
Output:
Total output lines: 220

# Full Regression Test Plan

> **Status:** Planning. No tests implemented yet — see [§4 Phased implementation](#4-phased-implementation).
>
> **Authoritative source:** issues #114–#120 own the per-phase contracts. This document has been reconciled against them as of 2026-05-22 (#114 deliverable 9); where future review iterations on those issues add detail, the issue text wins.
>
> **Companion docs:** [plan-smoke-tests.md](plan-smoke-tests.md) (existing PR-gated smoke suite), [specs.md](specs.md) (ACs the regression suite covers), [TESTING_SETUP.md](../TESTING_SETUP.md) (GMD / Robolectric infrastructure).

## 1. Goals & principles

- **Catch real-device bugs before release** — SAF, camera, FileProvider, billing, lifecycle, perf. Things JVM/Robolectric structurally can't catch.
- **Functional regression, not duplication** — don't re-test what `*Test.kt` / `*IT.kt` / `*ScreenshotTest.kt` already cover deterministically on JVM. The regression suite owns *behavior that depends on the real OS*.
- **Independent tests, deterministic setup** — every scenario resets DataStore + vault state (extend existing `resetYorvanaState` / `installFreshVault`). No accumulated state across tests.
- **Annotation-tiered, single source tree** — keep tests in `app/src/androidTest`, add custom marker annotations so PR-gated vs nightly is an AndroidJUnitRunner `annotation` filter, not a separate codebase.
- **Flake budget: zero on PR-gated tier, ≤1% on nightly** — quarantine, don't ignore. Flaky tests get a `@Quarantined` marker annotation; PR-gated and core regression tasks pass `notAnnotation=com.yorvana.testsupport.tiers.Quarantined` so these tests are excluded from gating, while the nightly `regressionFullCheck` task does *not* set `notAnnotation` so they still execute and report.

## 2. Technical approach

Stay on the existing stack — `AndroidJUnit4` + `createAndroidComposeRule<MainActivity>()` + GMD Pixel 2 API 33 — and add the missing pieces:

| Concern | Tool | Where it lives |
|---|---|---|
| Real SAF picker, real file picker, real camera | **UiAutomator** (`UiDevice`) | `androidTest/.../regression/system/` |
| Process death | `ActivityScenario.recreate()` + `Instrumentation.callActivityOn{SaveInstanceState,Pause}` + kill via `am` | `regression/lifecycle/` |
| Configuration changes | `composeTestRule.activityRule.scenario.recreate()` + `Configuration` overrides | `regression/lifecycle/` |
| Google Play Billing sandbox | Test APK built from a non-debug build type signed with the same upload key as the Play internal track (the app module currently defaults instrumentation to `testBuildType = "debug"` per `app/build.gradle.kts:68`, which produces a debug-signed APK Play won't recognise as the internal-track artifact) + license tester accounts on a **Play-enabled device** (Play Store system image — `google_apis_playstore`, not the AOSP-only GMD pool; see [§5 Billing sandbox device path](#5-billing-sandbox-device-path)); gated by the **multi-precondition `Assume.assumeTrue` skip guard** from #118 (`BILLING_SANDBOX_ACCOUNT`, signing material, selected device path, signed-in Play account, and a `BillingClient.queryProductDetailsAsync(premium_lifetime)` non-empty result) so any missing prerequisite skips cleanly with a specific assumption message — *not* a narrow `BILLING_SANDBOX_ACCOUNT`-only gate | `regression/billing/` |
| Performance NFRs | **Macrobenchmark** (`androidx.benchmark`) in a separate module, running on its own dedicated GMD entry registered inside `:macrobenchmark` (a Pixel 2 API 33 entry matching the app's GMD, generating a task like `pixel2api33BenchmarkAndroidTest`). Not the app's smoke `pixel2api33` device, and not `connected*AndroidTest` (which would target ad-hoc connected devices, which CI doesn't have). Numbers from a swiftshader/angle emulator are advisory regression signal, not authoritative perf measurements. | new `:macrobenchmark` module |
| Fixture data | Bundle only a **minimal** asset set (one small PDF, one small JPG, the `sample-vault/` snapshot) in `app/src/androidTest/assets/`. Per #114 deliverable 6, large datasets (20 vehicles, 200 records, attachment-heavy vaults) are **generated in-process** by Phase 2/4/6 helpers rather than checked in as snapshots, so the repo doesn't carry binary asset bloat. | bundled in `app/src/androidTest/assets/` and copied to a known device path in `@Before` (matches smoke plan's recommendation; self-contained on GMD, no `adb` round-trip) |

### Test tiers (custom marker annotations + AndroidJUnitRunner filters)

The project is on JUnit 4 with `AndroidJUnitRunner` (see `app/build.gradle.kts`), which does **not** support a `tag` filter — it filters via `annotation` / `notAnnotation` / `package` / `class` arguments. Define a small set of runtime-retained marker annotations in `app/src/androidTest/.../testsupport/tiers/` (`Smoke`, `Regression`, `RegressionFull`, `BillingSandbox`, `Quarantined`) and apply them at the test-method or class level.

All five tier markers carry `@java.lang.annotation.Inherited` so AndroidJUnitRunner's `Class.getAnnotation()` filter and the Phase 1 annotation-misuse scanner walk the same scope at superclass-class level. `@Inherited` only affects class-level annotation queries — method-level annotations are never inherited in Java/Kotlin regardless of the meta-annotation, so the scanner does not invent inheritance the runtime filter cannot honor.

| Tier | Marker | Runs | Budget |
|---|---|---|---|
| Smoke | `@Smoke` *(existing scenarios re-annotated)* | PR (label-gated, unchanged) | <2 min |
| Regression — core | `@Regression` | PR (label-gated, new) + nightly | ~15 min |
| Regression — full | `@RegressionFull` | Nightly only | ~45–60 min |
| Regression — sandbox | `@BillingSandbox` | Nightly with creds | adds ~10 min |
| Macrobenchmark | separate module | Nightly | ~15 min |

New Gradle tasks (marker classes live in `com.yorvana.testsupport.tiers.*`; AndroidJUnitRunner's `annotation` / `notAnnotation` arguments need the fully-qualified class name, so the `-P...` examples below are spelled in full for copy-paste fidelity):

- **`regressionCheck`** — PR/core tier. Single `pixel2api33DebugAndroidTest` invocation with `-Pandroid.testInstrumentationRunnerArguments.annotation=com.yorvana.testsupport.tiers.Regression -Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.yorvana.testsupport.tiers.Quarantined`. Excludes `RegressionFull` and `BillingSandbox` (neither carries `@Regression`).
- **`regressionFullCheck`** — Nightly. Aggregator task that depends on the sibling tasks below, plus eventually the macrobenchmark module. It does *not* `dependsOn(regressionCheck)`, because that task's `notAnnotation=...Quarantined` filter would otherwise hide quarantined core tests from nightly too (contradicting §1's quarantine policy). **Phase 1 (#114) wires only the first two siblings** — `regressionFullSandboxCheck` is deferred to **Phase 5 (#118)** because it depends on a Play-enabled GMD entry and a non-debug `staging` build type that don't exist yet; the `:macrobenchmark` `dependsOn` is deferred to **Phase 6 (#119)**.
  - Mechanism: because `-Pandroid.testInstrumentationRunnerArguments.*` is build-wide (set on the root `Project` before AGP reads it) and the same GMD task can only execute once per Gradle invocation, the three filter passes cannot be expressed by re-running one task with different `-P` values inside a single build. Instead each pass is its own task. Two viable shapes; Phase 1 will pick one:
    - **(a) `GradleBuild` child invocations** — register three `GradleBuild` tasks under `regressionFullCheck`, each setting `startParameter.projectProperties` for its own runner-argument set and `tasks = ["pixel2api33DebugAndroidTest"]` (or the Play device task for sandbox). Each child invocation gets a fresh Gradle build, so `-P` values do not collide.
    - **(b) Variant- or device-scoped tasks** — declare extra GMD device entries (or a custom test-only variant) whose `instrumentationRunnerArguments` are set in `build.gradle.kts` rather than at the CLI, so each generated `*DebugAndroidTest` task carries its own baked-in filter. `regressionFullCheck` then plain-`dependsOn`s those tasks.
  - Logical tiers wired in (under whichever shape is chosen):
    - `regressionFullNightlyCoreCheck` — `pixel2api33DebugAndroidTest` with `annotation=com.yorvana.testsupport.tiers.Regression` and **no** `notAnnotation`, so flaky `@Regression @Quarantined` tests still execute and report.
    - `regressionFullExtendedCheck` — `pixel2api33DebugAndroidTest` with `annotation=com.yorvana.testsupport.tiers.RegressionFull`.
    - `regressionFullSandboxCheck` — runs `@BillingSandbox` against the Play-enabled device path from §5, using a test APK built from a non-debug build type signed with the same upload key as the Play internal track (e.g. a `staging` `testBuildType` distinct from `debug`/`release` and outside the screenshot-build branch in `app/build.gradle.kts:43`). The concrete task is therefore something like `pixel2api33PlayStagingAndroidTest`, *not* `pixel2api33PlayDebugAndroidTest` — a debug-signed APK is the wrong artifact for real Play Billing. Annotation filter: `annotation=com.yorvana.testsupport.tiers.BillingSandbox`; tier selection is in-build only — runtime skip is owned by the **multi-precondition `Assume.assumeTrue` skip guard** from #118 (`BILLING_SANDBOX_ACCOUNT`, signing material, selected device path, signed-in Play account, and a non-empty `BillingClient.queryProductDetailsAsync(premium_lifetime)` result), *not* a narrow `BILLING_SANDBOX_ACCOUNT`-only gate. If §5 picks the external-workflow option instead, this aggregator entry is omitted (the sandbox tier runs in `billing-sandbox.yml`, not under `regressionFullCheck`).
  - None of the three set `notAnnotation`. The `:macrobenchmark` module's GMD-generated benchmark task (e.g. `:macrobenchmark:pixel2api33BenchmarkAndroidTest`, from the dedicated GMD entry registered in `:macrobenchmark` per Phase 6 and the §2 table) is wired as an additional `dependsOn` to keep "core + full + sandbox + benchmark" in one entry point. It is deliberately *not* `:macrobenchmark:connectedBenchmarkAndroidTest` — `connected*AndroidTest` targets ad-hoc currently-connected devices that CI doesn't have, so the nightly aggregate could otherwise pass without ever running the benchmark tier. (Separate tiers rather than a comma list because AndroidJUnitRunner's `annotation` argument takes a single class, and the sandbox tier needs a different device target anyway.)
- **Smoke** — the existing PR-gated path. Today the `pixel2api33` device's `instrumentationRunnerArguments` in `app/build.gradle.kts` only sets `notPackage=com.yorvana.screenshots`, and `.github/workflows/smoke.yml` runs plain `./gradlew pixel2api33Check`. Once regression tests start landing in the same `app/src/androidTest` tree, that filter would sweep them into smoke and blow the <2 min budget. **Phase 1 (#114) isolates smoke via a positive filter** — `annotation=com.yorvana.testsupport.tiers.Smoke` is added to the smoke device's `instrumentationRunnerArguments` alongside the existing `notPackage=com.yorvana.screenshots`, and `@Smoke` annotation of every existing smoke test is a hard Phase 1 prerequisite. The negative-filter alternative (`notAnnotation=Regression,...`) was rejected because any future unannotated `androidTest` class would default into smoke and silently blow the budget. The change lives in `app/build.gradle.kts` (smoke device's runner args) so `smoke.yml`'s `./gradlew pixel2api33Check` invocation stays unchanged. The runner-args injection must be **scoped by exact device/task-name match** (not `contains("pixel2api33")`) so a future `pixel2api33PlayStagingDebugAndroidTest` introduced in Phase 5 does **not** inherit the smoke `annotation=Smoke` filter.

## 3. Test plan — coverage map

Mapped to [specs.md](specs.md) ACs. The principle: every AC has either a JVM test (existing) **or** a regression scenario, with regression earning its place by needing real OS behavior.

### 3.1 Vehicle CRUD (FR-V1..V4)

- **R-V01** — Add vehicle with all optional fields filled, kill process, relaunch → vehicle present with all fields preserved on disk (vault JSON readable).
- **R-V02** — Duplicate VIN guard: add vehicle with VIN, try to add another with same VIN → blocked with error.
- **R-V03** — Field length / range validation negative paths: nickname >50 chars, year=1899, year=2101 → save blocked, error visible.
- **R-V04** — Edit vehicle, rotate device mid-edit → form state preserved (rememberSaveable).
- **R-V05** — Delete vehicle with 50+ records + 20 attachments → all files removed from the **file-backed vault tree** (assertions read the `installFreshVault` root directly, not `DocumentFile`). Real SAF tree-URI coverage of this scenario is deferred to Phase 3 (#116).
- **R-V06** — External vault edit: pre-seed the **file-backed vault tree** with an extra vehicle file directly (no `DocumentFile`), relaunch app → vehicle appears in list (catches cache-staleness bugs). Real SAF tree-URI coverage is deferred to Phase 3 (#116).
- **R-V07** — VIN copy-to-clipboard: trigger the copy action on a vehicle with a VIN, read back via `ClipboardManager.primaryClip` and assert the text matches (real OS clipboard, not a mocked manager).

### 3.2 Records (FR-R1..R4)

- **R-R01** — Add record with all fields, kill app mid-form → on relaunch, no partial record (transactional save).
- **R-R02** — Odometer/cost boundary validation (0, max, max+1, decimals).
- **R-R03** — 200-record list **correctness only**: scroll reaches first and last records, item order is stable, no crash or state loss. Frame-timing / jank measurement belongs to **M-03** (Phase 6 #119) — no `FrameTimingMetric` / Macrobenchmark code lands in Phase 2.
- **R-R04** — Delete record → confirm attachment files are gone from disk.

### 3.3 Attachments (FR-A1, FR-A2) — *the big real-OS bucket*

The app uses `ActivityResultContracts.TakePicture()` with a `FileProvider` output URI and **does not declare or request the `CAMERA` runtime permission**. Camera scenarios here therefore do not assert a runtime camera permission dialog and do not run `pm revoke android.permission.CAMERA`; the capture failure path is driven by cancelled/failed capture, not permission denial. External-viewer assertions follow a "resolver-presence gate": assert `ACTION_VIEW` launches; only assert external viewer UI when `packageManager.resolveActivity(...) != null` on the GMD image.

- **R-A01** — Real SAF file picker via UiAutomator: pick a PDF (bundled in `androidTest/assets` and copied into `MediaStore` Downloads by the fixture `TestRule` under a unique display name per scenario, cleaned up in `@After`), save record, kill app, relaunch → attachment persists, URI resolves, `ACTION_VIEW` fires; assert external UI only if a handler resolves.
- **R-A02** — Real camera capture via UiAutomator using the real `TakePicture()` + `FileProvider` flow, photo persists as JPG in vault. No `CAMERA` permission dialog assertion.
- **R-A03** — Image viewer gestures (pinch zoom, double-tap zoom toggle, pan with bounds, rotation-state contract). **Owned by Phase 4 (#117)** under a UI-gestures sub-section — gestures don't depend on real OS surfaces, and Phase 4 ships the production `IMAGE_VIEWER_SURFACE` / `IMAGE_VIEWER_IMAGE` test tags and the user-observable assertion stance (no reads of private viewer scale/offset state).
- **R-A04** — Non-image attachment → system viewer launches. Same resolver-presence gate as R-A01.
- **R-A05** — Remove attachment with confirmation, save → file is deleted from disk (via `DocumentFile`).
- **R-A06** — Vault folder change mid-session, attachments still resolvable. Scoped to attachment-URI resolution; the move-vs-start-fresh contract is R-S02's responsibility — both scenarios ship.
- **R-A07** — **Cancelled / failed capture recovery** (replaces the previous camera-permission-denial framing, which was invalid against the app's no-`CAMERA`-permission contract): trigger the capture flow, cancel the system camera (or simulate `RESULT_CANCELED`) → app surfaces a clear non-blocking error state, no partial JPG left in the vault, retry succeeds.

### 3.4 Vault / SAF (FR-D1)

- **R-D01** — Fresh setup: drive real SAF folder picker via UiAutomator, pick a folder, complete setup.
- **R-D02** — Pre-existing vault: point at a folder with sample data → vehicles + records load.
- **R-D03** — Vault permission revoked externally (clear persistable URI perms) → app shows recoverable error state, not crash.
- **R-D04** — Malformed `vehicle.json` in vault → app skips that vehicle and logs, doesn't crash the list.
- **R-D05** — Migration: pre-seed an old-format vault, verify `MigrationHelper` upgrades it. **Caveat:** `MigrationHelper` today only passes v1 through and logs unknown versions, so R-D05 only has meaningful content once a concrete legacy schema fixture (`regression-fixtures/legacy-vault-v0/`) and an "expected upgraded shape" reference are landed. Per #116, Phase 3 either ships those fixtures or removes R-D05 from that phase and reopens when a real legacy schema is in flight.

### 3.5 Categories (FR-C1, FR-C2)

- **R-C01** — Default categories present on fresh install.
- **R-C02** — Add custom category → appears in selector across vehicles → persists across app kill.
- **R-C03** — Delete custom category in use by records → confirm prompt mentions affected record count.

### 3.6 Settings (FR-S1..S3)

- **R-S01** — Toggle odometer unit → existing records re-render with new unit (already in smoke partially; add the "with records present" path).
- **R-S02** — Change vault folder via Settings against the **real SAF folder picker**. Three asserted paths per ADR-005 (full contract owned by Phase 3 #116):
  1. **Move path** — pre-seed source vault (1 vehicle + 1 record + 1 attachment), pick new tree URI, choose **Move** → progress indicator shown, byte-identical copy verified at destination, source tree deleted only after verification, active vault pointer flips, reopen boots into new tree.
  2. **Start-fresh path** — same starting state, choose **Start fresh** → active vault pointer flips, source tree left untouched byte-for-byte, new tree starts empty, reopen boots into empty new tree.
  3. **Abort/failure path** — induce a write failure **after copy has begun** (e.g. ENOSPC via `sm set-virtual-disk`) → abort, source tree intact byte-for-byte, active vault pointer unchanged, UI surfaces a clear error. Pre-conditions that block the move from starting (destination-not-empty) are out of scope — already covered by `VaultStorageImplIT.moveVaultTo_should_fail_when_destination_is_not_empty`.
- **R-S03** — Crash reporting opt-in persists across kill + restart (extension of existing `s21`).
- **R-S04** — Custom currency persistence: set a custom (non-list) currency code via `CustomCurrencyDialog`, kill app, relaunch → code is rendered on existing record cost lines and used as the default for new records (exercises DataStore round-trip for a user-supplied string).

### 3.7 Paywall (FR-P1..P6) — sandbox-dependent

- **R-P01** — Free tier, attempt 2nd vehicle → upgrade dialog. **Already covered** by `SmokeTest.upgradeDialogOnFreeTier` ("S18", `app/src/androidTest/java/com/yorvana/SmokeTest.kt:249`). No new regression test in any phase; if a kill+restore variant is wanted, it ships as a follow-up.
- **R-P02** — Read-only mode on free tier across screens. **Already covered** by `SmokeTest.readOnlyBannerAcrossScreens` ("S19", `SmokeTest.kt:271`). No new regression test in any phase.
- **R-P03** — *Sandbox*: complete real test purchase via Play Billing. Full ADR-012 contract asserted by Phase 5 (#118): paywall sheet → Play Billing dialog → license-tester purchase → `isP…167 tokens truncated…` transition. Idempotent against the owned product — the nightly-repeatable sandbox check.
- **R-P05** — **Offline cached premium** (not sandbox-dependent — owned by Phase 2 #115 as `@Regression`). Invariant at the offline assertion block: `DebugBillingOverrideMode == NONE` **and** `billingDerivedPremium == null`, so `isPremium` resolves only through `cachedPremium` per `billingPremium ?: cachedPremium ?: false` (`BillingManagerImpl.kt:94` / `:102`). Two seeding paths in `@Before`:
  1. **Preferred** — seed the cache via the existing production API `AppPreferencesStore.setIsPremiumCached(true)` (`AppPreferences.kt:27` / `AppPreferencesImpl.kt:70`); no test-only `AppPreferences` surface introduced and `billingDerivedPremium` is never touched.
  2. **Alternative** — `DebugBillingActions.simulatePurchaseSuccess()`, but then the test **must** kill-and-relaunch (or otherwise recreate `BillingManagerImpl`) before going offline so `billingDerivedPremium` resets to `null`. Same-process offline assertion after `simulatePurchaseSuccess()` without a recreate is explicitly forbidden — it would pass via `billingPremium`, not `cachedPremium`.

  Offline mechanism is **device-level** (`svc wifi disable` + `svc data disable`, or airplane mode if reliable on the GMD image) — no app-level fake connectivity. `@After` restores connectivity unconditionally. Skip with `Assume.assumeTrue` if the GMD image disallows the chosen control.

### 3.8 Lifecycle & robustness (no specific AC, but bug-rich)

- **R-L01** — Rotate device on every screen (parametrized). **Persistent state contract** (asserted): any field in `rememberSaveable` or a ViewModel `StateFlow` survives rotation — form drafts (vehicle edit, record edit) included. **Transient state contract** (out of scope unless lifted to saveable in the same PR): plain `remember` state (open menus, snackbars, transient dialogs). Per-screen test KDoc documents which class each surface falls under.
- **R-L02** — Process death on every screen. Two explicit helpers (no implicit mixing):
  - `ProcessDeath.recreate(activityRule)` → `activityRule.scenario.recreate()` (state-preservation use, restores from `onSaveInstanceState` bundle only).
  - `ProcessDeath.kill()` → `UiDevice.pressHome()` → `am kill com.yorvana`, relaunch via launcher intent. **Do not use `force-stop`** — different launcher/task semantics produce a cold start that masks navigation-stack restore bugs.

  Default contract for this phase: **route restoration + persisted data only**. Unsaved form drafts are covered by R-V01 / R-R01, not here.
- **R-L03** — Low-memory simulation (`am send-trim-memory`) → no crash.
- **R-L04** — Back-press exhaustively from deep screens.
- **R-L05** — **Launcher re-entry** while on a deep screen (renamed from "deep-link / intent re-entry"). The app does not declare any app-deep-link `<intent-filter>` today, so this scenario asserts task-affinity / `singleTask` behavior: launching from the launcher with an existing task on a deep screen returns to that screen. Real deep-link routing is out of scope and requires manifest + `MainActivity` work in a separate issue.
- **R-L06** — Disk full on save. Assertion split by vault backend because the two paths give different guarantees:
  - **File-backed (`VaultNode.Local`)** — production prerequisite: same-directory `vehicle.json.tmp` + `fsync` + `java.nio.file.Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)` (minSdk 26). Any `File.renameTo()` fallback must check the boolean return and treat `false` as a failed save. Test asserts the existing JSON is **byte-identical** to its pre-write contents after an induced ENOSPC.
  - **SAF (`VaultNode.Remote`)** — production prerequisite: documented staged-write protocol (write `.tmp` via SAF, verify length + checksum, promote via delete-existing + rename, orphan-`.tmp` recovery on next open). `DocumentFile.renameTo()` is **not** a guaranteed atomic primitive across SAF providers, so the test asserts a **narrower** contract: clear error, active vault pointer unchanged, no orphan `.tmp` past the next open, retry-after-free-space succeeds. The byte-identical-pre-write assertion does **not** apply to the SAF path.

  If either production change is descoped, the corresponding backend narrows to "clear error, no crash, recoverable retry" in this PR's test KDoc (no silent assertion drop).
- **R-L07** — Concurrent external edit. Scoped to **backgrounded edit + resume**: background via `UiDevice.pressHome()`, mutate `vehicle.json` (or `records/{uuid}.json`) externally via `DocumentFile`, resume → list/detail UI reflects the new value without crash. Requires a production-side refresh-on-resume hook (e.g. `onStart` repository invalidate); if neither that nor pull-to-refresh exists, Phase 4 either lands the hook or narrows R-L07 to cold-start. Foreground-while-running mutation (`FileObserver` against a SAF tree) is out of scope this phase.

### 3.9 NFRs (NFR-1, NFR-6) — Macrobenchmark module

- **M-01** — Cold start (`StartupTimingMetric`): assert P50 ≤ 2000ms.
- **M-02** — Vehicle list scroll FPS with 20 vehicles (`FrameTimingMetric`).
- **M-03** — Record list scroll FPS with 200 records.
- **M-04** — Navigation transition duration garage→detail→record. Either `TraceSectionMetric` against production `androidx.tracing` sections (`nav.garage_to_detail`, `nav.detail_to_record`) whose **span covers the destination composition + transition animation frames, not just the synchronous nav event** (wrapping only the click handler / back-stack mutation would measure command latency, not perceived transition), **or** a `FrameTimingMetric` over a scripted nav journey if a reliable transition-work span can't be established. Phase 6 (#119) picks one and documents the choice in the module README.

## 4. Phased implementation

### Phase 1 — Infrastructure (1–2 days) — issue **#114**

- Add custom marker annotations (`@Smoke`, `@Regression`, `@RegressionFull`, `@BillingSandbox`, `@Quarantined`) under `testsupport/tiers/`, **all five carrying `@java.lang.annotation.Inherited`** so the runtime filter and the scanner walk superclass-class annotations the same way.
- Wire `regressionCheck` and `regressionFullCheck` Gradle tasks per §2 using a **single chosen mechanism** (either `GradleBuild` child invocations with `startParameter.projectProperties`, or variant/device-scoped tasks with baked-in `instrumentationRunnerArguments`) — same shape for both, documented in the PR description. `regressionFullCheck` in Phase 1 wires **two siblings** (`regressionFullNightlyCoreCheck`, `regressionFullExtendedCheck`); `regressionFullSandboxCheck` is deferred to Phase 5 and the `:macrobenchmark` dependency to Phase 6.
- **Smoke isolation via positive filter**: update the `pixel2api33` device's `instrumentationRunnerArguments` to add `annotation=com.yorvana.testsupport.tiers.Smoke` alongside `notPackage=com.yorvana.screenshots`; annotate every existing smoke test with `@Smoke` (hard prerequisite). The injection must be scoped by exact device/task-name match so a future `pixel2api33PlayStagingDebugAndroidTest` from Phase 5 does not inherit the smoke filter. `.github/workflows/smoke.yml` keeps invoking `./gradlew pixel2api33Check` unchanged.
- Create only the `testsupport/tiers/` package under `androidTest/` for Phase 1 — feature subpackages (`vehicles/`, `records/`, …) are created lazily by the phase adding the first scenario in each area.
- Add UiAutomator helpers in `testsupport/`: `SystemPicker.pickFile()`, `SystemPicker.pickFolder()`, `Camera.captureAndAccept()`, `Permissions.acceptIfShown()` (stubs acceptable, API surface settled).
- Bundle a **minimal** fixture set (one small PDF, one small JPG) in `app/src/androidTest/assets/` and the `@Before` / `TestRule` helper that copies them to the device-side location (Downloads via `MediaStore` for picker-visible files, app cache for app-internal reads). No large-vault snapshots — later phases generate large datasets in-process.
- Add the **annotation-misuse guardrail** (source-level scanner under `src/test` OR Gradle bytecode task scanning `compileDebugAndroidTestKotlin` output via ASM). Lookup model: method-level → class-level → superclass class-level (the inheritance step is valid only because of `@Inherited`). Quarantine-pairing rule, forward-compatibility with annotation arguments (`@Quarantined(since,issue,reason)`, `@ScenarioId`), parameterized-test handling, and four negative-test fixtures (zero tiers, two tiers, orphan quarantine, inherited-tier sanity that pins scanner / runtime-filter agreement).
- Add the nightly workflow pair: `regression.yml` (`workflow_call` + `workflow_dispatch` + `schedule`, runs `./gradlew regressionCheck` and `regressionFullCheck` on the cron path, mirrors `smoke.yml`'s KVM setup, forces `swiftshader_indirect`, uploads managed-device reports) and `regression-label.yml` (`pull_request: [labeled]`, gated on `regression` label).

### Phase 2 — Vehicle + Record + Categories + Settings regression (3–4 days) — issue **#115**

- Write R-V01..07, R-R01..04, R-C01..03, R-S01 / R-S03 / R-S04, plus the new `@Regression` scenario **R-P05** (offline cached premium; not sandbox-dependent — see §3.7). R-S02 is **deferred to Phase 3** because it needs the real SAF picker.
- R-P01 and R-P02 are **not** re-implemented — they are explicitly attributed to existing smoke tests `SmokeTest.upgradeDialogOnFreeTier` and `SmokeTest.readOnlyBannerAcrossScreens`.
- Shared helpers in `testsupport/`: `killAndRelaunch()`, `rotateMidEdit(composeRule)`, repository-layer seeding helpers (no UI-driven 50-record fixtures), `Network.setOffline()` / `Network.setOnline()` (device-level `svc wifi disable` / `svc data disable` for R-P05; `@After`-restore unconditional; `Assume.assumeTrue` skip if the GMD image disallows the control).
- These have the best bug-catch-per-engineering-hour ratio and surface a lot of edge cases (validation, boundaries, vault round-trip) that aren't currently tested on real OS.

### Phase 3 — Real SAF + file/camera + R-S02 (3–5 days) — issue **#116**

- Build the UiAutomator + real-SAF helpers; write R-D01..05, R-S02, R-A01/02/04/05/06/07. R-A03 is **routed to Phase 4** (UI gestures don't need real OS surfaces).
- New `testsupport/` primitives: `RealSafVault.create()` / `useExisting(treeUri)` (real tree URI with persistable permission, seeded via `DocumentFile`), `Camera.captureAndAccept()` (no permission dialog — app does not declare `CAMERA`), `ExternalViewer.assertLaunchOrSkipUi(intent)` (resolver-presence gate for R-A01 / R-A04). `installFreshVault()` stays file-backed and is not used for this phase's real-SAF scenarios.
- Fixture acceptance: tiny PDF asset (<20 KB, valid `%PDF-1.4`, documented SHA-256), `MediaStore` Downloads insertion under unique display names per scenario with `@After` cleanup, R-D05 legacy fixture (or R-D05 dropped from this phase).
- `@Quarantined` policy defined here (inherited by later phases): flake ≥1× in 20 nightly runs; core/PR tasks exclude via `notAnnotation`; nightly includes; on quarantine open a tracking issue with 14-day review; **30 consecutive green nightly runs** required to de-quarantine (Phase 7 #120 is the authority — if it revises the threshold, update here in the same PR).
- Expect flakes — pin documents UI package, build retry helper, screenshot-on-failure (extend GMD `additional-test-output` already wired up).

### Phase 4 — Lifecycle + process death + R-A03 (2 days) — issue **#117**

- R-L01..07 and **R-A03** (image viewer gestures, routed here because gestures don't depend on real OS surfaces).
- Parametrize R-L01 / R-L02 across the screen list; long parametrized runs carry `@RegressionFull`, one canonical rotation + one canonical kill stay in `@Regression`.
- Helpers under `testsupport/lifecycle/`: `ProcessDeath.recreate` (state-preserve) and `ProcessDeath.kill` (hard kill via `am kill`, **never** `force-stop`). Gesture helpers under `testsupport/gestures/`: `Gestures.pinch / .doubleTap / .swipe` — distinct from Phase 3's `testsupport/system/`.
- R-L06 production prerequisites are **per-backend** (see §3.8): file-backed `Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)` with checked `renameTo` fallback (any `false` return treated as failed save), SAF documented staged-write protocol with orphan-`.tmp` recovery. Either backend can narrow to "clear error, no crash, recoverable retry" if its production change is descoped, with the narrowing documented in the test KDoc.
- R-A03 needs production test tags `TestTags.IMAGE_VIEWER_SURFACE` and `TestTags.IMAGE_VIEWER_IMAGE`, locating-only (do **not** expose scale/offset/matrix state via semantics). Assertions are user-observable (rendered bounds via `boundsInRoot`), never reads of private viewer state. Rotation-state contract documented in the test KDoc (preserve or reset — either acceptable).

### Phase 5 — Billing sandbox (2–3 days, separable) — issue **#118**, see [§5](#5-billing-sandbox-device-path) for the device path

- Pick the device path: **Option 1** (Play-enabled GMD entry `pixel2api33Play` + `staging` build type → `pixel2api33PlayStagingAndroidTest`), or **Option 2** (external `billing-sandbox.yml` workflow running against a real Play device / Firebase Test Lab physical device, sandbox tier outside `regressionFullCheck`).
- Pick an **entitlement repeatability strategy** for the non-consumable `premium_lifetime` product: (a) disposable tester-account pool, (b) manual refund/revoke + ad-hoc invocation, or (c) split contract (R-P03 once-per-release manual; R-P04 nightly). Documented in PR description and test KDoc — without this R-P03 silently degrades to a restore on the second run.
- **Multi-precondition skip guard** as the first step of every scenario: `Assume.assumeTrue` checks `BILLING_SANDBOX_ACCOUNT`, signing material, selected device path, signed-in Play account, and a `BillingClient.queryProductDetailsAsync(premium_lifetime)` returning a non-empty list. Any failure skips with a specific assumption message. Replaces the previous narrow `BILLING_SANDBOX_ACCOUNT`-only guard.
- Publish a Play Console / CI runbook (e.g. `docs/billing-sandbox.md`) covering package registration, license-tester setup, internal-track signed APK at a `versionCode` ≥ the test artifact, CI secrets checklist, and manual refund/revoke steps. Reviewers reject the PR if missing.
- R-P03 / R-P04 ship per §3.7's full contracts (ADR-012 auto-navigate, explicit fresh-install definition for restore).

### Phase 6 — Macrobenchmark module (2 days) — issue **#119**

- New `:macrobenchmark` module (`com.android.test` + `androidx.benchmark:benchmark-macro-junit4`), M-01..04. Baseline thresholds calibrated over 5 runs; alert on >20% regression.
- Register a dedicated GMD entry inside `:macrobenchmark` so AGP generates `:macrobenchmark:pixel2api33BenchmarkAndroidTest`; wire that exact task into `regressionFullCheck`'s `dependsOn` **as a sibling outside any Phase 1 `GradleBuild` child invocation** so app-module instrumentation args don't leak into the benchmark run. Do **not** depend on `:macrobenchmark:connectedBenchmarkAndroidTest`.
- **Prerequisites that must land before benchmarks are meaningful:**
  - **A. Production trace sections for M-04.** Add `androidx.tracing` spans (`nav.garage_to_detail`, `nav.detail_to_record`) covering destination composition + transition animation frames (not the synchronous nav event), OR switch M-04 to `FrameTimingMetric` over a scripted journey. `TraceSectionMetric` against a missing or event-only span is treated as the same failure.
  - **B. Stable test tags.** `TestTags.VEHICLE_LIST`, `TestTags.vehicleItem(id)`, `TestTags.RECORD_LIST` wired in production composables, plus `Modifier.semantics { testTagsAsResourceId = true }` applied to the Compose hierarchy hosting them so `By.res(...)` in `:macrobenchmark` resolves.
  - **C. Cross-module fixture ownership.** Either (a) extract seeding into a shared `:testfixtures` module, (b) expose a benchmark-variant-only test hook (`ContentProvider` / `ActivityAction`) **not gated on `BuildConfig.DEBUG`** (the benchmark variant is non-debuggable; gate on the `benchmark` build type instead), or (c) intentional duplication with sync requirement.
  - **D. Benchmark build variant.** `targetProjectPath = ":app"`, dedicated `benchmark` build type (`debuggable = false`, `signingConfig = signingConfigs.debug`, `matchingFallbacks = listOf("release")`, `<profileable shell="true" />` on `:app`'s manifest); `androidx.benchmark.suppressErrors` only for known-acceptable conditions (e.g. `EMULATOR`); macrobenchmark JSON output uploaded as a CI artifact on every run.
- **Threshold policy** documented in the module README and enforced: **hard-fail** on relative regression > 20% against a committed 5-run baseline; absolute NFR targets (e.g. M-01 P50 ≤ 2000ms) are **advisory only** (warn + artifact, never red-X CI). Swiftshader absolute numbers are not authoritative; authoritative perf still requires a physical-device run outside this aggregate.

### Phase 7 — Hardening & nightly bake (ongoing) — issue **#120**

- **Quarantine metadata.** Promote `@Quarantined` to carry `since: String` (ISO-8601), `issue: Int`, `reason: String` — or maintain a sidecar registry (`quarantine-registry.json`) keyed by fully-qualified test name with a JVM-test guard against divergence. Existing usages migrated in the same PR.
- **Orphan-quarantine guard.** Every `@Quarantined` test must also carry one of `@Regression` / `@RegressionFull` / `@BillingSandbox`; CI guard fails the build on orphans.
- **Stable scenario IDs.** Adopt spec IDs (`R-A01`, `M-03`, …) as the dashboard's stable key via `@ScenarioId("R-A01")` on the test method or a naming convention (`fun R_A01_…()`). Parameterized variants suffix and roll up.
- **Flake-rate semantics** documented explicitly (passed-on-retry counts as flake; `Assume.assumeTrue` skips excluded from denominator; `@Ignore` excluded entirely; infrastructure failures recorded separately; benchmark failures categorised separately; cancellations excluded). Applied in the aggregator math.
- **Durable history.** Pick one of (a) committed dashboard file (`docs/regression-dashboard.md` + `docs/regression-history.json` via auto-PR / orphan branch), (b) retained JSON artifacts + GitHub API, or (c) external store. Default recommendation: (a) for the published dashboard, (b) as the raw input the aggregator reads.
- **Workflow permissions** declared explicitly per chosen source — `contents: write` + `pull-requests: write` on the dashboard-writing job, `actions: read` for prior-run lookup, `issues: write` for flake-comment posting; the main test-running job in `regression.yml` stays `contents: read`.
- **Promotion playbook** (`docs/regression-promotion-playbook.md`): promote on 100 consecutive nightly passes on the stable scenario ID at runtime < 30s with no infra noise; demote on any nightly failure not reproduced as a real regression within 24h or runtime drift > 50%; de-quarantine threshold is owned by **Phase 7 #120** (30 consecutive green nightly runs); Phase 3 #116's quarantine policy tracks that value, and if changed it changes in #120 first.
- **Tier audit pass.** Either a real promotion/demotion is executed, **or** the PR explicitly documents "no eligible candidate yet" with the dashboard linked as proof — the acceptance is *not* "at least one test promoted/demoted" (no artificial churn).

## 5. Billing sandbox device path

The existing GMD pool in `app/build.gradle.kts` is AOSP-only (`systemImageSource = "aosp"` on every entry). Real Google Play Billing flows require a device with Google Play Services and the Play Store app present, which AOSP images do not ship. The plan therefore needs an explicit, separate device path for `@BillingSandbox` tests — *not* shoehorned into the AOSP `pixel2api33` device. Two acceptable options:


The repo already has a reconciled regression plan note, so deliverable 9 may be smaller than the issue suggests unless we find stale spots later. The current build uses reflective GMD task argument injection and a broad `contains("pixel2api33")` match, which is the main implementation hazard the plan needs to pin down.


sed -n '1,260p' gradle/libs.versions.toml


find app/src/androidTest/java -type f -name '*.kt' -maxdepth 10 -print | sort


rg "Parameterized|RunWith|@Test|@Ignore|Quarantined|Regression|Smoke" app/src/test app/src/androidTest -n


Output:
Chunk ID: 87f051
Wall time: 0.3235 seconds
Process exited with code 0
Original token count: 2565
Output:
import org.gradle.api.Task
import org.gradle.api.file.RelativePath
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 testVariant = if (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"

android {
    namespace = "com.yorvana"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.yorvana"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "1.0.0"

        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",
            )
            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<com.android.build.api.dsl.ManagedVirtualDevice>("pixel2api33") {
                    device = "Pixel 2"
                    apiLevel = 33
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("phonePixel6api34") {
                    device = "Pixel 6"
                    apiLevel = 34
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("tablet7Nexus7api34") {
                    device = "Nexus 7"
                    apiLevel = 34
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().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 {
    // 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<com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask>().configureEach {
        val deviceDirName =
            when {
                name.contains("phonePixel6api34", ignoreCase = true) -> "phonePixel6api34"
                name.contains("tablet7Nexus7api34", ignoreCase = true) -> "tablet7Nexus7api34"
                name.contains("tablet10PixelTabletApi34", ignoreCase = true) -> "tablet10PixelTabletApi34"
                name.contains("pixel2api33", ignoreCase = true) -> "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<String, String>

        val myTask = this
        val taskName = name
        argsProperty.putAll(
            provider {
                val map = mutableMapOf<String, String>()
                // We only return arguments if the task is actually executing. This prevents
                // aliasing/merging issues when AGP shares the underlying MapProperty object across tasks.
                if (myTask.state.executing) {
                    when {
                        taskName.contains("phonePixel6api34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("tablet7Nexus7api34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("tablet10PixelTabletApi34", ignoreCase = true) -> {
                            map["package"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                        }
                        taskName.contains("pixel2api33", ignoreCase = true) -> {
                            map["notPackage"] = screenshotTestPackage
                            map["additionalTestOutputDir"] = smokeDeviceOutputDir
                        }
                    }
                }
                map
            },
        )
    }
}

tasks.register<Copy>("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)


Output:
Chunk ID: 87c7be
Wall time: 0.3884 seconds
Process exited with code 0
Original token count: 1091
Output:
[versions]
ktlint = "14.2.0"
detekt = "1.23.8"
git-hooks = "0.0.2"
mockk = "1.14.9"
truth = "1.4.5"
turbine = "1.2.1"
agp = "9.2.1"
kotlin = "2.3.21"
paparazzi = "1.3.5"
roborazzi = "1.11.0"
compose-bom = "2026.05.01"
navigation3 = "1.1.2"
lifecycle = "2.10.0"
datastore = "1.2.1"
kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0"
coil = "2.7.0"
documentfile = "1.1.0"
core-ktx = "1.18.0"
activity-compose = "1.13.0"
robolectric = "4.16.1"
billing = "9.0.0"
sentry = "8.41.0"

[libraries]
sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
junit = { module = "junit:junit", version = "4.13.2" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
androidx-test-ext = { module = "androidx.test.ext:junit", version = "1.3.0" }
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit = { module = "io.github.takahirom.roborazzi:roborazzi-junit", version.ref = "roborazzi" }
uiautomator = { module = "androidx.test.uiautomator:uiautomator", version = "2.3.0" }
espresso-intents = { module = "androidx.test.espresso:espresso-intents", version = "3.7.0" }
google-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "billing" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
git-hooks = { id = "com.github.jakemarsden.git-hooks", version.ref = "git-hooks" }


Output:
Chunk ID: aea4eb
Wall time: 0.3919 seconds
Process exited with code 0
Original token count: 117
Output:
app/src/androidTest/java/com/yorvana/SmokeTest.kt
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotBase.kt
app/src/androidTest/java/com/yorvana/screenshots/StoreScreenshotTest.kt
app/src/androidTest/java/com/yorvana/testsupport/ComposeWaitHelpers.kt
app/src/androidTest/java/com/yorvana/testsupport/DemoMode.kt
app/src/androidTest/java/com/yorvana/testsupport/ScreenshotConstants.kt
app/src/androidTest/java/com/yorvana/testsupport/VaultTestSetup.kt