Input:
*** Begin Patch
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/tiers/Smoke.kt
+package com.yorvana.testsupport.tiers
+
+import java.lang.annotation.Inherited
+
+@Inherited
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+annotation class Smoke
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/tiers/Regression.kt
+package com.yorvana.testsupport.tiers
+
+import java.lang.annotation.Inherited
+
+@Inherited
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+annotation class Regression
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/tiers/RegressionFull.kt
+package com.yorvana.testsupport.tiers
+
+import java.lang.annotation.Inherited
+
+@Inherited
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+annotation class RegressionFull
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/tiers/BillingSandbox.kt
+package com.yorvana.testsupport.tiers
+
+import java.lang.annotation.Inherited
+
+@Inherited
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+annotation class BillingSandbox
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/tiers/Quarantined.kt
+package com.yorvana.testsupport.tiers
+
+import java.lang.annotation.Inherited
+
+@Inherited
+@Retention(AnnotationRetention.RUNTIME)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+annotation class Quarantined
*** Update File: app/src/androidTest/java/com/yorvana/SmokeTest.kt
@@
 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.testsupport.tiers.Smoke
 import com.yorvana.ui.TestTags
@@
  * including filesystem access (SAF), camera integration, and the paywall.
  */
+@Smoke
 @RunWith(AndroidJUnit4::class)
 class SmokeTest {
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt
+package com.yorvana.testsupport
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+
+object SystemPicker {
+    private const val TIMEOUT_MILLIS = 10_000L
+
+    fun pickFile(
+        device: UiDevice,
+        displayName: String,
+    ) {
+        clickText(device, displayName)
+    }
+
+    fun pickFolder(
+        device: UiDevice,
+        displayName: String,
+    ) {
+        clickText(device, displayName)
+        clickText(device, "Use this folder")
+        Permissions.acceptIfShown(device)
+    }
+
+    private fun clickText(
+        device: UiDevice,
+        text: String,
+    ) {
+        val objectByText = device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
+            ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
+            ?: error("System picker item not found: $text")
+        objectByText.click()
+    }
+}
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/Camera.kt
+package com.yorvana.testsupport
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+
+object Camera {
+    private const val TIMEOUT_MILLIS = 10_000L
+
+    fun captureAndAccept(device: UiDevice) {
+        val shutter =
+            device.wait(Until.findObject(By.res("com.android.camera2:id/shutter_button")), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.descContains("Shutter")), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.descContains("Take photo")), TIMEOUT_MILLIS)
+                ?: error("Camera shutter control not found")
+        shutter.click()
+
+        val done =
+            device.wait(Until.findObject(By.res("com.android.camera2:id/done_button")), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.descContains("Done")), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.textMatches("(?i)(OK|Use photo|Done)")), TIMEOUT_MILLIS)
+        done?.click()
+    }
+}
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/Permissions.kt
+package com.yorvana.testsupport
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+
+object Permissions {
+    private const val TIMEOUT_MILLIS = 2_000L
+
+    fun acceptIfShown(device: UiDevice): Boolean {
+        val allow =
+            device.wait(Until.findObject(By.textMatches("(?i)(Allow|OK)")), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.res("com.android.permissioncontroller:id/permission_allow_button")), TIMEOUT_MILLIS)
+                ?: device.wait(
+                    Until.findObject(By.res("com.android.permissioncontroller:id/permission_allow_foreground_only_button")),
+                    TIMEOUT_MILLIS,
+                )
+        allow?.click()
+        return allow != null
+    }
+}
*** Add File: app/src/androidTest/java/com/yorvana/testsupport/FixtureAssetsRule.kt
+package com.yorvana.testsupport
+
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.rules.ExternalResource
+import java.io.File
+
+class FixtureAssetsRule : ExternalResource() {
+    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+    private val resolver = context.contentResolver
+    private val mediaStoreUris = mutableListOf<Uri>()
+    private val cacheFiles = mutableListOf<File>()
+
+    override fun after() {
+        mediaStoreUris.forEach { resolver.delete(it, null, null) }
+        cacheFiles.forEach { it.delete() }
+        mediaStoreUris.clear()
+        cacheFiles.clear()
+    }
+
+    fun copyToCache(assetName: String): File {
+        val target = File(context.cacheDir, assetName)
+        context.assets.open(assetName).use { input ->
+            target.outputStream().use { output -> input.copyTo(output) }
+        }
+        cacheFiles += target
+        return target
+    }
+
+    fun copyToDownloads(
+        assetName: String,
+        displayName: String = assetName,
+        mimeType: String = mimeTypeFor(assetName),
+    ): Uri {
+        val values =
+            ContentValues().apply {
+                put(MediaStore.Downloads.DISPLAY_NAME, displayName)
+                put(MediaStore.Downloads.MIME_TYPE, mimeType)
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                    put(MediaStore.Downloads.IS_PENDING, 1)
+                }
+            }
+        val uri =
+            resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
+                ?: error("Unable to create Downloads fixture: $displayName")
+        context.assets.open(assetName).use { input ->
+            resolver.openOutputStream(uri)?.use { output -> input.copyTo(output) }
+                ?: error("Unable to write Downloads fixture: $displayName")
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            resolver.update(
+                uri,
+                ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) },
+                null,
+                null,
+            )
+        }
+        mediaStoreUris += uri
+        return uri
+    }
+
+    private fun mimeTypeFor(assetName: String): String =
+        when (assetName.substringAfterLast('.', missingDelimiterValue = "").lowercase()) {
+            "jpg", "jpeg" -> "image/jpeg"
+            "pdf" -> "application/pdf"
+            else -> "application/octet-stream"
+        }
+}
*** Add File: app/src/androidTest/assets/test_receipt.pdf
+%PDF-1.4
+1 0 obj
+<< /Type /Catalog /Pages 2 0 R >>
+endobj
+2 0 obj
+<< /Type /Pages /Kids [3 0 R] /Count 1 >>
+endobj
+3 0 obj
+<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>
+endobj
+4 0 obj
+<< /Length 44 >>
+stream
+BT
+/F1 12 Tf
+24 120 Td
+(Yorvana test receipt) Tj
+ET
+endstream
+endobj
+xref
+0 5
+0000000000 65535 f
+0000000009 00000 n
+0000000058 00000 n
+0000000115 00000 n
+0000000202 00000 n
+trailer
+<< /Root 1 0 R /Size 5 >>
+startxref
+295
+%%EOF
*** End Patch


Success. Updated the following files:
A app/src/androidTest/java/com/yorvana/testsupport/tiers/Smoke.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/Regression.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/RegressionFull.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/BillingSandbox.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/Quarantined.kt
A app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt
A app/src/androidTest/java/com/yorvana/testsupport/Camera.kt
A app/src/androidTest/java/com/yorvana/testsupport/Permissions.kt
A app/src/androidTest/java/com/yorvana/testsupport/FixtureAssetsRule.kt
A app/src/androidTest/assets/test_receipt.pdf
M app/src/androidTest/java/com/yorvana/SmokeTest.kt

package com.yorvana.testsupport.tiers

import java.lang.annotation.Inherited

@Inherited
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class Smoke

package com.yorvana.testsupport

import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until

object SystemPicker {
    private const val TIMEOUT_MILLIS = 10_000L

    fun pickFile(
        device: UiDevice,
        displayName: String,
    ) {
        clickText(device, displayName)
    }

    fun pickFolder(
        device: UiDevice,
        displayName: String,
    ) {
        clickText(device, displayName)
        clickText(device, "Use this folder")
        Permissions.acceptIfShown(device)
    }

    private fun clickText(
        device: UiDevice,
        text: String,
    ) {
        val objectByText = device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
            ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
            ?: error("System picker item not found: $text")
        objectByText.click()
    }
}

package com.yorvana.testsupport.tiers

import java.lang.annotation.Inherited

@Inherited
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class Quarantined

package com.yorvana.testsupport.tiers

import java.lang.annotation.Inherited

@Inherited
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class RegressionFull

package com.yorvana.testsupport.tiers

import java.lang.annotation.Inherited

@Inherited
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class BillingSandbox

package com.yorvana.testsupport

import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until

object Camera {
    private const val TIMEOUT_MILLIS = 10_000L

    fun captureAndAccept(device: UiDevice) {
        val shutter =
            device.wait(Until.findObject(By.res("com.android.camera2:id/shutter_button")), TIMEOUT_MILLIS)
                ?: device.wait(Until.findObject(By.descContains("Shutter")), TIMEOUT_MILLIS)
                ?: device.wait(Until.findObject(By.descContains("Take photo")), TIMEOUT_MILLIS)
                ?: error("Camera shutter control not found")
        shutter.click()

        val done =
            device.wait(Until.findObject(By.res("com.android.camera2:id/done_button")), TIMEOUT_MILLIS)
                ?: device.wait(Until.findObject(By.descContains("Done")), TIMEOUT_MILLIS)
                ?: device.wait(Until.findObject(By.textMatches("(?i)(OK|Use photo|Done)")), TIMEOUT_MILLIS)
        done?.click()
    }
}

%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
24 120 Td
(Yorvana test receipt) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000202 00000 n
trailer
<< /Root 1 0 R /Size 5 >>
startxref
295
%%EOF

@@ -30,2 +30,3 @@
 import com.yorvana.testsupport.resetYorvanaState
+import com.yorvana.testsupport.tiers.Smoke
 import com.yorvana.ui.TestTags
@@ -46,2 +47,3 @@
  */
+@Smoke
 @RunWith(AndroidJUnit4::class)

package com.yorvana.testsupport

import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.rules.ExternalResource
import java.io.File

class FixtureAssetsRule : ExternalResource() {
    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
    private val resolver = context.contentResolver
    private val mediaStoreUris = mutableListOf<Uri>()
    private val cacheFiles = mutableListOf<File>()

    override fun after() {
        mediaStoreUris.forEach { resolver.delete(it, null, null) }
        cacheFiles.forEach { it.delete() }
        mediaStoreUris.clear()
        cacheFiles.clear()
    }

    fun copyToCache(assetName: String): File {
        val target = File(context.cacheDir, assetName)
        context.assets.open(assetName).use { input ->
            target.outputStream().use { output -> input.copyTo(output) }
        }
        cacheFiles += target
        return target
    }

    fun copyToDownloads(
        assetName: String,
        displayName: String = assetName,
        mimeType: String = mimeTypeFor(assetName),
    ): Uri {
        val values =
            ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, displayName)
                put(MediaStore.Downloads.MIME_TYPE, mimeType)
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    put(MediaStore.Downloads.IS_PENDING, 1)
                }
            }
        val uri =
            resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
                ?: error("Unable to create Downloads fixture: $displayName")
        context.assets.open(assetName).use { input ->
            resolver.openOutputStream(uri)?.use { output -> input.copyTo(output) }
                ?: error("Unable to write Downloads fixture: $displayName")
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            resolver.update(
                uri,
                ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) },
                null,
                null,
            )
        }
        mediaStoreUris += uri
        return uri
    }

    private fun mimeTypeFor(assetName: String): String =
        when (assetName.substringAfterLast('.', missingDelimiterValue = "").lowercase()) {
            "jpg", "jpeg" -> "image/jpeg"
            "pdf" -> "application/pdf"
            else -> "application/octet-stream"
        }
}

package com.yorvana.testsupport

import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until

object Permissions {
    private const val TIMEOUT_MILLIS = 2_000L

    fun acceptIfShown(device: UiDevice): Boolean {
        val allow =
            device.wait(Until.findObject(By.textMatches("(?i)(Allow|OK)")), TIMEOUT_MILLIS)
                ?: device.wait(Until.findObject(By.res("com.android.permissioncontroller:id/permission_allow_button")), TIMEOUT_MILLIS)
                ?: device.wait(
                    Until.findObject(By.res("com.android.permissioncontroller:id/permission_allow_foreground_only_button")),
                    TIMEOUT_MILLIS,
                )
        allow?.click()
        return allow != null
    }
}


Output:
Exit code: 0
Wall time: 9.8 seconds
Output:
Success. Updated the following files:
A app/src/androidTest/java/com/yorvana/testsupport/tiers/Smoke.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/Regression.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/RegressionFull.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/BillingSandbox.kt
A app/src/androidTest/java/com/yorvana/testsupport/tiers/Quarantined.kt
A app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt
A app/src/androidTest/java/com/yorvana/testsupport/Camera.kt
A app/src/androidTest/java/com/yorvana/testsupport/Permissions.kt
A app/src/androidTest/java/com/yorvana/testsupport/FixtureAssetsRule.kt
A app/src/androidTest/assets/test_receipt.pdf
M app/src/androidTest/java/com/yorvana/SmokeTest.kt


The first batch is in: tier annotations, `@Smoke` on the existing smoke suite, UiAutomator helper APIs, a fixture rule, and a tiny PDF fixture. Next I’m changing the Gradle wiring so smoke and regression can share the same Pixel 2 managed-device task without annotation arguments colliding.


Add tier annotations, smoke marker, fixtures, and test support helpers

Wire Gradle smoke/regression tasks safely

Add source-level annotation scanner and JVM tests

Add regression workflows and reconcile docs


Output: Plan updated


Input:
*** Begin Patch
*** Update File: app/build.gradle.kts
@@
 import org.gradle.api.Task
 import org.gradle.api.file.RelativePath
+import org.gradle.api.tasks.GradleBuild
 import org.gradle.api.tasks.TaskProvider
 import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
 import org.gradle.testing.jacoco.tasks.JacocoReport
@@
 val screenshotTestPackage = "com.yorvana.screenshots"
 val screenshotDeviceOutputDir = "/sdcard/test-outputs/screenshots"
 val smokeDeviceOutputDir = "/sdcard/test-outputs/smoke"
+val smokeAnnotation = "com.yorvana.testsupport.tiers.Smoke"
+val regressionAnnotation = "com.yorvana.testsupport.tiers.Regression"
+val regressionFullAnnotation = "com.yorvana.testsupport.tiers.RegressionFull"
+val quarantinedAnnotation = "com.yorvana.testsupport.tiers.Quarantined"
+val regressionDeviceTaskName = "pixel2api33DebugAndroidTest"
@@
 afterEvaluate {
@@
     tasks.withType<com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask>().configureEach {
+        fun isTaskForDevice(
+            taskName: String,
+            deviceName: String,
+        ): Boolean = taskName.equals("$deviceName${testVariantCap}AndroidTest", ignoreCase = true)
+
         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"
+                isTaskForDevice(name, "phonePixel6api34") -> "phonePixel6api34"
+                isTaskForDevice(name, "tablet7Nexus7api34") -> "tablet7Nexus7api34"
+                isTaskForDevice(name, "tablet10PixelTabletApi34") -> "tablet10PixelTabletApi34"
+                isTaskForDevice(name, "pixel2api33") -> "pixel2api33"
                 else -> null
             }
@@
         @Suppress("UNCHECKED_CAST")
         val argsProperty = getArgsMethod.invoke(testData) as org.gradle.api.provider.MapProperty<String, String>

         val myTask = this
         val taskName = name
+        val isExplicitAnnotationRun = providers.gradleProperty("android.testInstrumentationRunnerArguments.annotation").isPresent
         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) -> {
+                        isTaskForDevice(taskName, "phonePixel6api34") -> {
                             map["package"] = screenshotTestPackage
                             map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                         }
-                        taskName.contains("tablet7Nexus7api34", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "tablet7Nexus7api34") -> {
                             map["package"] = screenshotTestPackage
                             map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                         }
-                        taskName.contains("tablet10PixelTabletApi34", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "tablet10PixelTabletApi34") -> {
                             map["package"] = screenshotTestPackage
                             map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                         }
-                        taskName.contains("pixel2api33", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "pixel2api33") -> {
+                            // Keep this exact AOSP smoke task match. A future Play/staging billing
+                            // task must not inherit the smoke annotation filter.
+                            if (!isExplicitAnnotationRun) {
+                                map["annotation"] = smokeAnnotation
+                            }
                             map["notPackage"] = screenshotTestPackage
                             map["additionalTestOutputDir"] = smokeDeviceOutputDir
                         }
@@
     }
 }
+
+fun registerRegressionDeviceTask(
+    taskName: String,
+    descriptionText: String,
+    runnerArguments: Map<String, String>,
+): TaskProvider<GradleBuild> =
+    tasks.register<GradleBuild>(taskName) {
+        group = "Verification"
+        description = descriptionText
+        tasks = listOf(regressionDeviceTaskName)
+        startParameter.projectProperties.putAll(
+            gradle.startParameter.projectProperties +
+                runnerArguments.mapKeys { (key, _) -> "android.testInstrumentationRunnerArguments.$key" },
+        )
+    }
+
+val regressionCheck =
+    registerRegressionDeviceTask(
+        taskName = "regressionCheck",
+        descriptionText = "Runs core regression androidTests on the Pixel 2 API 33 managed device.",
+        runnerArguments =
+            mapOf(
+                "annotation" to regressionAnnotation,
+                "notAnnotation" to quarantinedAnnotation,
+            ),
+    )
+
+val regressionFullNightlyCoreCheck =
+    registerRegressionDeviceTask(
+        taskName = "regressionFullNightlyCoreCheck",
+        descriptionText = "Runs all core regression androidTests, including quarantined tests.",
+        runnerArguments = mapOf("annotation" to regressionAnnotation),
+    )
+
+val regressionFullExtendedCheck =
+    registerRegressionDeviceTask(
+        taskName = "regressionFullExtendedCheck",
+        descriptionText = "Runs full regression androidTests on the Pixel 2 API 33 managed device.",
+        runnerArguments = mapOf("annotation" to regressionFullAnnotation),
+    )
+
+tasks.register("regressionFullCheck") {
+    group = "Verification"
+    description = "Runs Phase 1 full regression checks; sandbox and macrobenchmark siblings are deferred."
+    dependsOn(regressionFullNightlyCoreCheck, regressionFullExtendedCheck)
+}
*** End Patch


Success. Updated the following files:
M app/build.gradle.kts

@@ -2,2 +2,3 @@
 import org.gradle.api.file.RelativePath
+import org.gradle.api.tasks.GradleBuild
 import org.gradle.api.tasks.TaskProvider
@@ -49,2 +50,7 @@
 val smokeDeviceOutputDir = "/sdcard/test-outputs/smoke"
+val smokeAnnotation = "com.yorvana.testsupport.tiers.Smoke"
+val regressionAnnotation = "com.yorvana.testsupport.tiers.Regression"
+val regressionFullAnnotation = "com.yorvana.testsupport.tiers.RegressionFull"
+val quarantinedAnnotation = "com.yorvana.testsupport.tiers.Quarantined"
+val regressionDeviceTaskName = "pixel2api33DebugAndroidTest"

@@ -194,8 +200,13 @@
     tasks.withType<com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask>().configureEach {
+        fun isTaskForDevice(
+            taskName: String,
+            deviceName: String,
+        ): Boolean = taskName.equals("$deviceName${testVariantCap}AndroidTest", ignoreCase = true)
+
         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"
+                isTaskForDevice(name, "phonePixel6api34") -> "phonePixel6api34"
+                isTaskForDevice(name, "tablet7Nexus7api34") -> "tablet7Nexus7api34"
+                isTaskForDevice(name, "tablet10PixelTabletApi34") -> "tablet10PixelTabletApi34"
+                isTaskForDevice(name, "pixel2api33") -> "pixel2api33"
                 else -> null
@@ -221,2 +232,3 @@
         val taskName = name
+        val isExplicitAnnotationRun = providers.gradleProperty("android.testInstrumentationRunnerArguments.annotation").isPresent
         argsProperty.putAll(
@@ -228,3 +240,3 @@
                     when {
-                        taskName.contains("phonePixel6api34", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "phonePixel6api34") -> {
                             map["package"] = screenshotTestPackage
@@ -232,3 +244,3 @@
                         }
-                        taskName.contains("tablet7Nexus7api34", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "tablet7Nexus7api34") -> {
                             map["package"] = screenshotTestPackage
@@ -236,3 +248,3 @@
                         }
-                        taskName.contains("tablet10PixelTabletApi34", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "tablet10PixelTabletApi34") -> {
                             map["package"] = screenshotTestPackage
@@ -240,3 +252,8 @@
                         }
-                        taskName.contains("pixel2api33", ignoreCase = true) -> {
+                        isTaskForDevice(taskName, "pixel2api33") -> {
+                            // Keep this exact AOSP smoke task match. A future Play/staging billing
+                            // task must not inherit the smoke annotation filter.
+                            if (!isExplicitAnnotationRun) {
+                                map["annotation"] = smokeAnnotation
+                            }
                             map["notPackage"] = screenshotTestPackage
@@ -252,2 +269,48 @@

+fun registerRegressionDeviceTask(
+    taskName: String,
+    descriptionText: String,
+    runnerArguments: Map<String, String>,
+): TaskProvider<GradleBuild> =
+    tasks.register<GradleBuild>(taskName) {
+        group = "Verification"
+        description = descriptionText
+        tasks = listOf(regressionDeviceTaskName)
+        startParameter.projectProperties.putAll(
+            gradle.startParameter.projectProperties +
+                runnerArguments.mapKeys { (key, _) -> "android.testInstrumentationRunnerArguments.$key" },
+        )
+    }
+
+val regressionCheck =
+    registerRegressionDeviceTask(
+        taskName = "regressionCheck",
+        descriptionText = "Runs core regression androidTests on the Pixel 2 API 33 managed device.",
+        runnerArguments =
+            mapOf(
+                "annotation" to regressionAnnotation,
+                "notAnnotation" to quarantinedAnnotation,
+            ),
+    )
+
+val regressionFullNightlyCoreCheck =
+    registerRegressionDeviceTask(
+        taskName = "regressionFullNightlyCoreCheck",
+        descriptionText = "Runs all core regression androidTests, including quarantined tests.",
+        runnerArguments = mapOf("annotation" to regressionAnnotation),
+    )
+
+val regressionFullExtendedCheck =
+    registerRegressionDeviceTask(
+        taskName = "regressionFullExtendedCheck",
+        descriptionText = "Runs full regression androidTests on the Pixel 2 API 33 managed device.",
+        runnerArguments = mapOf("annotation" to regressionFullAnnotation),
+    )
+
+tasks.register("regressionFullCheck") {
+    group = "Verification"
+    description = "Runs Phase 1 full regression checks; sandbox and macrobenchmark siblings are deferred."
+    dependsOn(regressionFullNightlyCoreCheck, regressionFullExtendedCheck)
+}
+
 tasks.register<Copy>("generateGmdCoverage") {


Output:
Exit code: 0
Wall time: 0.9 seconds
Output:
Success. Updated the following files:
M app/build.gradle.kts


The Gradle patch now defines the regression tasks and narrows the managed-device matching to exact generated task names. I’m moving on to the scanner; I’ll keep it source-level and test-owned so it can validate future `androidTest` regression files without trying to reflect across source-set classpaths.


Output: Plan updated


Input:
*** Begin Patch
*** Add File: app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt
+package com.yorvana.testsupport
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import java.io.File
+
+class RegressionTierScannerTest {
+    @Test
+    fun androidTestRegressionSourcesHaveValidTierAnnotations() {
+        val regressionRoot = File("src/androidTest/java/com/yorvana/regression")
+
+        val violations = RegressionTierScanner.scanDirectory(regressionRoot)
+
+        assertThat(violations).isEmpty()
+    }
+
+    @Test
+    fun zeroTierFixtureFails() {
+        val violations =
+            scanFixture(
+                """
+                package com.yorvana.regression
+
+                import org.junit.Test
+
+                class MissingTierTest {
+                    @Test
+                    fun runsWithoutTier() = Unit
+                }
+                """,
+            )
+
+        assertThat(violations.single()).contains("has 0 tier annotations")
+    }
+
+    @Test
+    fun twoTierFixtureFails() {
+        val violations =
+            scanFixture(
+                """
+                package com.yorvana.regression
+
+                import com.yorvana.testsupport.tiers.Regression
+                import com.yorvana.testsupport.tiers.RegressionFull
+                import org.junit.Test
+
+                class TooManyTiersTest {
+                    @Regression
+                    @RegressionFull
+                    @Test
+                    fun runsWithTwoTiers() = Unit
+                }
+                """,
+            )
+
+        assertThat(violations.single()).contains("has 2 tier annotations")
+    }
+
+    @Test
+    fun orphanQuarantineFixtureFails() {
+        val violations =
+            scanFixture(
+                """
+                package com.yorvana.regression
+
+                import com.yorvana.testsupport.tiers.Quarantined
+                import org.junit.Test
+
+                class OrphanQuarantineTest {
+                    @Quarantined
+                    @Test
+                    fun quarantinedWithoutTier() = Unit
+                }
+                """,
+            )
+
+        assertThat(violations.single()).contains("is quarantined without a tier")
+    }
+
+    @Test
+    fun inheritedClassTierFixturePasses() {
+        val violations =
+            scanFixture(
+                """
+                package com.yorvana.regression
+
+                import com.yorvana.testsupport.tiers.Regression
+                import org.junit.Test
+
+                @Regression
+                open class RegressionBase
+
+                class InheritedTierTest : RegressionBase() {
+                    @Test
+                    fun inheritsClassTier() = Unit
+                }
+                """,
+            )
+
+        assertThat(violations).isEmpty()
+    }
+
+    @Test
+    fun annotationArgumentsAndUnknownSiblingsAreAccepted() {
+        val violations =
+            scanFixture(
+                """
+                package com.yorvana.regression
+
+                import com.yorvana.testsupport.tiers.Regression
+                import com.yorvana.testsupport.tiers.Quarantined
+                import org.junit.Test
+
+                annotation class ScenarioId(val value: String)
+
+                class ForwardCompatibleTest {
+                    @Regression
+                    @Quarantined(since = "2026-05-22", issue = 120, reason = "fixture")
+                    @ScenarioId("R-A01")
+                    @Test
+                    fun acceptsArgumentsAndUnknownSibling() = Unit
+                }
+                """,
+            )
+
+        assertThat(violations).isEmpty()
+    }
+
+    private fun scanFixture(source: String): List<String> =
+        RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
+}
+
+private object RegressionTierScanner {
+    private val tierNames = setOf("Regression", "RegressionFull", "BillingSandbox")
+    private const val quarantineName = "Quarantined"
+
+    fun scanDirectory(root: File): List<String> {
+        if (!root.exists()) return emptyList()
+        val sources =
+            root
+                .walkTopDown()
+                .filter { it.isFile && it.extension == "kt" }
+                .associate { it.path to it.readText() }
+        return scanSources(sources)
+    }
+
+    fun scanSources(sources: Map<String, String>): List<String> {
+        val classes = sources.flatMap { (path, source) -> parseSource(path, source) }
+        val bySimpleName = classes.associateBy { it.simpleName }
+        val violations = mutableListOf<String>()
+
+        classes.forEach { testClass ->
+            testClass.testMethods.forEach { method ->
+                val scopes =
+                    listOf(
+                        AnnotationScope("method", method.annotations),
+                        AnnotationScope("class", testClass.annotations),
+                    ) +
+                        inheritedClassScopes(testClass, bySimpleName)
+                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in tierNames } > 0 }
+                val tierCount = effectiveTierScope?.annotations?.count { it in tierNames } ?: 0
+                val effectiveQuarantineScope = scopes.firstOrNull { quarantineName in it.annotations }
+
+                if (tierCount != 1) {
+                    violations += "${testClass.fqName}.${method.name} has $tierCount tier annotations"
+                }
+                if (effectiveQuarantineScope != null && effectiveQuarantineScope != effectiveTierScope) {
+                    violations +=
+                        "${testClass.fqName}.${method.name} is quarantined without a tier at the same effective scope"
+                }
+            }
+        }
+
+        return violations
+    }
+
+    private fun inheritedClassScopes(
+        testClass: ParsedClass,
+        bySimpleName: Map<String, ParsedClass>,
+    ): List<AnnotationScope> {
+        val scopes = mutableListOf<AnnotationScope>()
+        var currentSuperName = testClass.superClassName
+        val seen = mutableSetOf<String>()
+        while (currentSuperName != null && seen.add(currentSuperName)) {
+            val superClass = bySimpleName[currentSuperName] ?: break
+            scopes += AnnotationScope("inherited class", superClass.annotations)
+            currentSuperName = superClass.superClassName
+        }
+        return scopes
+    }
+
+    private fun parseSource(
+        path: String,
+        source: String,
+    ): List<ParsedClass> {
+        val packageName = Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
+            .find(source)
+            ?.groupValues
+            ?.get(1)
+            .orEmpty()
+        val classes = mutableListOf<ParsedClass>()
+        var currentClass: ParsedClass? = null
+        var pendingAnnotations = emptySet<String>()
+
+        source.lineSequence().forEach { rawLine ->
+            val line = rawLine.trim()
+            val annotations = extractAnnotations(line)
+            if (annotations.isNotEmpty()) {
+                pendingAnnotations = pendingAnnotations + annotations
+            }
+
+            val classMatch = Regex("""\bclass\s+([A-Za-z_]\w*)\s*(?::\s*([A-Za-z_][\w.]*))?""").find(line)
+            if (classMatch != null) {
+                val simpleName = classMatch.groupValues[1]
+                val superClassName =
+                    classMatch.groupValues
+                        .getOrNull(2)
+                        ?.takeIf { it.isNotBlank() }
+                        ?.substringAfterLast('.')
+                currentClass =
+                    ParsedClass(
+                        sourcePath = path,
+                        packageName = packageName,
+                        simpleName = simpleName,
+                        superClassName = superClassName,
+                        annotations = pendingAnnotations,
+                        testMethods = mutableListOf(),
+                    )
+                classes += currentClass
+                pendingAnnotations = emptySet()
+                return@forEach
+            }
+
+            val methodMatch = Regex("""\bfun\s+([A-Za-z_]\w*)\s*\(""").find(line)
+            if (methodMatch != null) {
+                if ("Test" in pendingAnnotations) {
+                    currentClass?.testMethods?.add(
+                        ParsedMethod(
+                            name = methodMatch.groupValues[1],
+                            annotations = pendingAnnotations,
+                        ),
+                    )
+                }
+                pendingAnnotations = emptySet()
+            }
+        }
+
+        return classes
+    }
+
+    private fun extractAnnotations(line: String): Set<String> =
+        Regex("""@([A-Za-z_][\w.]*)""")
+            .findAll(line)
+            .map { it.groupValues[1].substringAfterLast('.') }
+            .toSet()
+
+    private data class ParsedClass(
+        val sourcePath: String,
+        val packageName: String,
+        val simpleName: String,
+        val superClassName: String?,
+        val annotations: Set<String>,
+        val testMethods: MutableList<ParsedMethod>,
+    ) {
+        val fqName: String = listOf(packageName, simpleName).filter { it.isNotBlank() }.joinToString(".")
+    }
+
+    private data class ParsedMethod(
+        val name: String,
+        val annotations: Set<String>,
+    )
+
+    private data class AnnotationScope(
+        val label: String,
+        val annotations: Set<String>,
+    )
+}
*** End Patch


Success. Updated the following files:
A app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt

package com.yorvana.testsupport

import com.google.common.truth.Truth.assertThat
import org.junit.Test
import java.io.File

class RegressionTierScannerTest {
    @Test
    fun androidTestRegressionSourcesHaveValidTierAnnotations() {
        val regressionRoot = File("src/androidTest/java/com/yorvana/regression")

        val violations = RegressionTierScanner.scanDirectory(regressionRoot)

        assertThat(violations).isEmpty()
    }

    @Test
    fun zeroTierFixtureFails() {
        val violations =
            scanFixture(
                """
                package com.yorvana.regression

                import org.junit.Test

                class MissingTierTest {
                    @Test
                    fun runsWithoutTier() = Unit
                }
                """,
            )

        assertThat(violations.single()).contains("has 0 tier annotations")
    }

    @Test
    fun twoTierFixtureFails() {
        val violations =
            scanFixture(
                """
                package com.yorvana.regression

                import com.yorvana.testsupport.tiers.Regression
                import com.yorvana.testsupport.tiers.RegressionFull
                import org.junit.Test

                class TooManyTiersTest {
                    @Regression
                    @RegressionFull
                    @Test
                    fun runsWithTwoTiers() = Unit
                }
                """,
            )

        assertThat(violations.single()).contains("has 2 tier annotations")
    }

    @Test
    fun orphanQuarantineFixtureFails() {
        val violations =
            scanFixture(
                """
                package com.yorvana.regression

                import com.yorvana.testsupport.tiers.Quarantined
                import org.junit.Test

                class OrphanQuarantineTest {
                    @Quarantined
                    @Test
                    fun quarantinedWithoutTier() = Unit
                }
                """,
            )

        assertThat(violations.single()).contains("is quarantined without a tier")
    }

    @Test
    fun inheritedClassTierFixturePasses() {
        val violations =
            scanFixture(
                """
                package com.yorvana.regression

                import com.yorvana.testsupport.tiers.Regression
                import org.junit.Test

                @Regression
                open class RegressionBase

                class InheritedTierTest : RegressionBase() {
                    @Test
                    fun inheritsClassTier() = Unit
                }
                """,
            )

        assertThat(violations).isEmpty()
    }

    @Test
    fun annotationArgumentsAndUnknownSiblingsAreAccepted() {
        val violations =
            scanFixture(
                """
                package com.yorvana.regression

                import com.yorvana.testsupport.tiers.Regression
                import com.yorvana.testsupport.tiers.Quarantined
                import org.junit.Test

                annotation class ScenarioId(val value: String)

                class ForwardCompatibleTest {
                    @Regression
                    @Quarantined(since = "2026-05-22", issue = 120, reason = "fixture")
                    @ScenarioId("R-A01")
                    @Test
                    fun acceptsArgumentsAndUnknownSibling() = Unit
                }
                """,
            )

        assertThat(violations).isEmpty()
    }

    private fun scanFixture(source: String): List<String> =
        RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
}

private object RegressionTierScanner {
    private val tierNames = setOf("Regression", "RegressionFull", "BillingSandbox")
    private const val quarantineName = "Quarantined"

    fun scanDirectory(root: File): List<String> {
        if (!root.exists()) return emptyList()
        val sources =
            root
                .walkTopDown()
                .filter { it.isFile && it.extension == "kt" }
                .associate { it.path to it.readText() }
        return scanSources(sources)
    }

    fun scanSources(sources: Map<String, String>): List<String> {
        val classes = sources.flatMap { (path, source) -> parseSource(path, source) }
        val bySimpleName = classes.associateBy { it.simpleName }
        val violations = mutableListOf<String>()

        classes.forEach { testClass ->
            testClass.testMethods.forEach { method ->
                val scopes =
                    listOf(
                        AnnotationScope("method", method.annotations),
                        AnnotationScope("class", testClass.annotations),
                    ) +
                        inheritedClassScopes(testClass, bySimpleName)
                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in tierNames } > 0 }
                val tierCount = effectiveTierScope?.annotations?.count { it in tierNames } ?: 0
                val effectiveQuarantineScope = scopes.firstOrNull { quarantineName in it.annotations }

                if (tierCount != 1) {
                    violations += "${testClass.fqName}.${method.name} has $tierCount tier annotations"
                }
                if (effectiveQuarantineScope != null && effectiveQuarantineScope != effectiveTierScope) {
                    violations +=
                        "${testClass.fqName}.${method.name} is quarantined without a tier at the same effective scope"
                }
            }
        }

        return violations
    }

    private fun inheritedClassScopes(
        testClass: ParsedClass,
        bySimpleName: Map<String, ParsedClass>,
    ): List<AnnotationScope> {
        val scopes = mutableListOf<AnnotationScope>()
        var currentSuperName = testClass.superClassName
        val seen = mutableSetOf<String>()
        while (currentSuperName != null && seen.add(currentSuperName)) {
            val superClass = bySimpleName[currentSuperName] ?: break
            scopes += AnnotationScope("inherited class", superClass.annotations)
            currentSuperName = superClass.superClassName
        }
        return scopes
    }

    private fun parseSource(
        path: String,
        source: String,
    ): List<ParsedClass> {
        val packageName = Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
            .find(source)
            ?.groupValues
            ?.get(1)
            .orEmpty()
        val classes = mutableListOf<ParsedClass>()
        var currentClass: ParsedClass? = null
        var pendingAnnotations = emptySet<String>()

        source.lineSequence().forEach { rawLine ->
            val line = rawLine.trim()
            val annotations = extractAnnotations(line)
            if (annotations.isNotEmpty()) {
                pendingAnnotations = pendingAnnotations + annotations
            }

            val classMatch = Regex("""\bclass\s+([A-Za-z_]\w*)\s*(?::\s*([A-Za-z_][\w.]*))?""").find(line)
            if (classMatch != null) {
                val simpleName = classMatch.groupValues[1]
                val superClassName =
                    classMatch.groupValues
                        .getOrNull(2)
                        ?.takeIf { it.isNotBlank() }
                        ?.substringAfterLast('.')
                currentClass =
                    ParsedClass(
                        sourcePath = path,
                        packageName = packageName,
                        simpleName = simpleName,
                        superClassName = superClassName,
                        annotations = pendingAnnotations,
                        testMethods = mutableListOf(),
                    )
                classes += currentClass
                pendingAnnotations = emptySet()
                return@forEach
            }

            val methodMatch = Regex("""\bfun\s+([A-Za-z_]\w*)\s*\(""").find(line)
            if (methodMatch != null) {
                if ("Test" in pendingAnnotations) {
                    currentClass?.testMethods?.add(
                        ParsedMethod(
                            name = methodMatch.groupValues[1],
                            annotations = pendingAnnotations,
                        ),
                    )
                }
                pendingAnnotations = emptySet()
            }
        }

        return classes
    }

    private fun extractAnnotations(line: String): Set<String> =
        Regex("""@([A-Za-z_][\w.]*)""")
            .findAll(line)
            .map { it.groupValues[1].substringAfterLast('.') }
            .toSet()

    private data class ParsedClass(
        val sourcePath: String,
        val packageName: String,
        val simpleName: String,
        val superClassName: String?,
        val annotations: Set<String>,
        val testMethods: MutableList<ParsedMethod>,
    ) {
        val fqName: String = listOf(packageName, simpleName).filter { it.isNotBlank() }.joinToString(".")
    }

    private data class ParsedMethod(
        val name: String,
        val annotations: Set<String>,
    )

    private data class AnnotationScope(
        val label: String,
        val annotations: Set<String>,
    )
}


Output:
Exit code: 0
Wall time: 1.4 seconds
Output:
Success. Updated the following files:
A app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt


The scanner and its contract tests are in place, including inherited superclass tier handling and forward-compatible annotation syntax. I’m adding the regression workflow pair now and correcting the remaining doc line that still mentioned a `sample-vault` fixture in Phase 1.


Output: Plan updated


Input:
*** Begin Patch
*** Add File: .github/workflows/regression.yml
+# GMD regression tests on CI.
+#
+# This mirrors the smoke workflow's emulator setup but keeps PR execution
+# opt-in through regression-label.yml. Nightly scheduled runs execute the
+# Phase 1 full aggregate; label/manual/reusable calls execute the core tier.
+name: Regression
+permissions:
+  contents: read
+
+on:
+  workflow_dispatch:
+  workflow_call:
+  schedule:
+    - cron: '17 8 * * *'
+
+jobs:
+  regression:
+    name: GMD Regression Tests
+    runs-on: ubuntu-latest
+    timeout-minutes: 90
+    concurrency:
+      group: regression-${{ 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 Regression Check
+        if: github.event_name != 'schedule'
+        run: ./gradlew regressionCheck -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect
+
+      - name: Run Nightly Regression Checks
+        if: github.event_name == 'schedule'
+        run: ./gradlew regressionCheck regressionFullCheck -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect
+
+      - name: Upload Managed Device Reports
+        if: failure()
+        uses: actions/upload-artifact@v7
+        with:
+          name: gmd-regression-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
*** Add File: .github/workflows/regression-label.yml
+# Manual label trigger for regression tests.
+#
+# This keeps regression PR runs opt-in, matching the smoke label workflow.
+name: Regression (Manual Label)
+permissions:
+  contents: read
+  pull-requests: read
+
+on:
+  pull_request:
+    types: [labeled]
+
+jobs:
+  regression:
+    if: github.event.label.name == 'regression'
+    uses: ./.github/workflows/regression.yml
*** Update File: docs/plan-regression-tests.md
@@
-| 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) |
+| Fixture data | Bundle only a **minimal** asset set (one small PDF and one small JPG) 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) |
*** End Patch


Success. Updated the following files:
A .github/workflows/regression.yml
A .github/workflows/regression-label.yml
M docs/plan-regression-tests.md

# Manual label trigger for regression tests.
#
# This keeps regression PR runs opt-in, matching the smoke label workflow.
name: Regression (Manual Label)
permissions:
  contents: read
  pull-requests: read

on:
  pull_request:
    types: [labeled]

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

@@ -27,3 +27,3 @@
 | 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) |
+| Fixture data | Bundle only a **minimal** asset set (one small PDF and one small JPG) 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) |

# GMD regression tests on CI.
#
# This mirrors the smoke workflow's emulator setup but keeps PR execution
# opt-in through regression-label.yml. Nightly scheduled runs execute the
# Phase 1 full aggregate; label/manual/reusable calls execute the core tier.
name: Regression
permissions:
  contents: read

on:
  workflow_dispatch:
  workflow_call:
  schedule:
    - cron: '17 8 * * *'

jobs:
  regression:
    name: GMD Regression Tests
    runs-on: ubuntu-latest
    timeout-minutes: 90
    concurrency:
      group: regression-${{ 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 Regression Check
        if: github.event_name != 'schedule'
        run: ./gradlew regressionCheck -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect

      - name: Run Nightly Regression Checks
        if: github.event_name == 'schedule'
        run: ./gradlew regressionCheck regressionFullCheck -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect

      - name: Upload Managed Device Reports
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: gmd-regression-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:
Exit code: 0
Wall time: 2.5 seconds
Output:
Success. Updated the following files:
A .github/workflows/regression.yml
A .github/workflows/regression-label.yml
M docs/plan-regression-tests.md


The workflow pair and doc reconciliation are done. I’m starting verification with the JVM test task first because it will compile the Gradle script enough to catch bad task wiring and will exercise the new source scanner.


Output: Plan updated


Output:
Chunk ID: efbcd1
Wall time: 30.0011 seconds
Process running with session ID 10747
Original token count: 448
Output:
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off
Starting a Gradle Daemon, 2 busy and 1 incompatible Daemons could not be reused, use --status for details

> Configure project :app
WARNING: The option setting 'android.testoptions.manageddevices.emulator.gpu=host' is experimental.

> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:generateDebugBuildConfig UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:bundleDebugClassesToRuntimeJar UP-TO-DATE
> Task :app:bundleDebugClassesToCompileJar UP-TO-DATE
> Task :app:preDebugUnitTestBuild UP-TO-DATE
> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:packageDebugUnitTestForUnitTest UP-TO-DATE
> Task :app:processDebugUnitTestManifest UP-TO-DATE
> Task :app:generateDebugUnitTestConfig UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:restoreOutputDirRoborazzi SKIPPED


Output:
Chunk ID: 725c0c
Wall time: 30.0016 seconds
Process running with session ID 10747
Original token count: 273
Output:
> Task :app:compileDebugUnitTestKotlin
> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE
> Task :app:processDebugUnitTestJavaRes
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off

> Task :app:testDebugUnitTest

OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended

> Task :app:testDebugUnitTest

RegressionTierScannerTest > orphanQuarantineFixtureFails FAILED
    java.lang.IllegalArgumentException at RegressionTierScannerTest.kt:77

OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended


The first JVM run found a scanner test expectation issue rather than a production failure: orphan quarantine correctly reports both “missing tier” and “orphan quarantine,” while the test expected only one violation. I’ll adjust that contract test to assert the quarantine violation is present.


Output:
Chunk ID: aae129
Wall time: 10.0004 seconds
Process running with session ID 10747
Original token count: 33
Output:
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended


Output:
Chunk ID: 362cae
Wall time: 30.0010 seconds
Process running with session ID 10747
Original token count: 0
Output:


Output:
Chunk ID: 49040d
Wall time: 2.6151 seconds
Process exited with code 1
Original token count: 271
Output:

> Task :app:testDebugUnitTest

452 tests completed, 1 failed

> Task :app:testDebugUnitTest FAILED
> Task :app:finalizeTestRoborazziDebug SKIPPED

[Incubating] Problems report is available at: file:///home/Messier82/projects/service-book/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:testDebugUnitTest'.
> There were failing tests. See the results at: file:///home/Messier82/projects/service-book/app/build/test-results/testDebugUnitTest/

* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).

Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/9.5.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD FAILED in 1m 54s
30 actionable tasks: 3 executed, 27 up-to-date


Input:
*** Begin Patch
*** Update File: app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt
@@
-        assertThat(violations.single()).contains("is quarantined without a tier")
+        assertThat(violations).contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier has 0 tier annotations")
+        assertThat(violations)
+            .contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier is quarantined without a tier at the same effective scope")
*** End Patch


Success. Updated the following files:
M app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt

@@ -76,3 +76,5 @@

-        assertThat(violations.single()).contains("is quarantined without a tier")
+        assertThat(violations).contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier has 0 tier annotations")
+        assertThat(violations)
+            .contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier is quarantined without a tier at the same effective scope")
     }


Output:
Exit code: 0
Wall time: 0.8 seconds
Output:
Success. Updated the following files:
M app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt


./gradlew testDebugUnitTest --tests com.yorvana.testsupport.RegressionTierScannerTest


Output:
Chunk ID: 1c30ea
Wall time: 6.9982 seconds
Process exited with code 0
Original token count: 674
Output:
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off

> Configure project :app
WARNING: The option setting 'android.testoptions.manageddevices.emulator.gpu=host' is experimental.

> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:generateDebugBuildConfig UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:processDebugNavigationResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:generateDebugRFile UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:compileDebugNavigationResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:bundleDebugClassesToRuntimeJar UP-TO-DATE
> Task :app:bundleDebugClassesToCompileJar UP-TO-DATE
> Task :app:preDebugUnitTestBuild UP-TO-DATE
> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:packageDebugUnitTestForUnitTest UP-TO-DATE
> Task :app:processDebugUnitTestManifest UP-TO-DATE
> Task :app:generateDebugUnitTestConfig UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:restoreOutputDirRoborazzi SKIPPED
> Task :app:compileDebugUnitTestKotlin
> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE
> Task :app:processDebugUnitTestJavaRes UP-TO-DATE

> Task :app:testDebugUnitTest
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off

> Task :app:finalizeTestRoborazziDebug SKIPPED

[Incubating] Problems report is available at: file:///home/Messier82/projects/service-book/build/reports/problems/problems-report.html

Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/9.5.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD SUCCESSFUL in 7s
30 actionable tasks: 2 executed, 28 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.5.1/userguide/configuration_cache_enabling.html


The scanner-focused test now passes. I’m running ktlint next because the new helpers and Gradle script are the most likely places for style violations before broader verification.


Output:
Chunk ID: 310e68
Wall time: 6.9366 seconds
Process exited with code 1
Original token count: 1121
Output:
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off

> Configure project :app
WARNING: The option setting 'android.testoptions.manageddevices.emulator.gpu=host' is experimental.

> Task :app:loadKtlintReporters UP-TO-DATE
> Task :app:runKtlintCheckOverAndroidTestDebugSourceSet NO-SOURCE
> Task :app:ktlintAndroidTestDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverAndroidTestReleaseSourceSet NO-SOURCE
> Task :app:ktlintAndroidTestReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverDebugSourceSet NO-SOURCE
> Task :app:ktlintDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverMainSourceSet UP-TO-DATE
> Task :app:ktlintMainSourceSetCheck UP-TO-DATE
> Task :app:runKtlintCheckOverReleaseSourceSet NO-SOURCE
> Task :app:ktlintReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestDebugSourceSet NO-SOURCE
> Task :app:ktlintTestDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestFixturesDebugSourceSet NO-SOURCE
> Task :app:ktlintTestFixturesDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestFixturesReleaseSourceSet NO-SOURCE
> Task :app:ktlintTestFixturesReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestFixturesSourceSet NO-SOURCE
> Task :app:ktlintTestFixturesSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestReleaseSourceSet NO-SOURCE
> Task :app:ktlintTestReleaseSourceSetCheck SKIPPED

> Task :app:runKtlintCheckOverAndroidTestSourceSet
[0.004s][warning][os,container] Cgroup memory controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate
[0.004s][warning][os,container] Cgroup cpu controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate

> Task :app:runKtlintCheckOverKotlinScripts
[0.005s][warning][os,container] Cgroup memory controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate
[0.005s][warning][os,container] Cgroup cpu controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate

> Task :app:ktlintKotlinScriptCheck UP-TO-DATE

> Task :app:runKtlintCheckOverTestSourceSet
[0.004s][warning][os,container] Cgroup memory controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate
[0.004s][warning][os,container] Cgroup cpu controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate

> Task :app:ktlintAndroidTestSourceSetCheck FAILED
/home/Messier82/projects/service-book/app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt:30:28 A multiline expression should start on a new line

[Incubating] Problems report is available at: file:///home/Messier82/projects/service-book/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:ktlintAndroidTestSourceSetCheck' (registered by plugin 'org.jlleitschuh.gradle.ktlint').
> A failure occurred while executing org.jlleitschuh.gradle.ktlint.worker.ConsoleReportWorkAction
   > KtLint found code style violations. Please see the following reports:
     - /home/Messier82/projects/service-book/app/build/reports/ktlint/ktlintAndroidTestSourceSetCheck/ktlintAndroidTestSourceSetCheck.txt

* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.

Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/9.5.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD FAILED in 6s
8 actionable tasks: 4 executed, 4 up-to-date


nl -ba app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt | sed -n '1,80p'


Output:
Chunk ID: 2cd038
Wall time: 0.1183 seconds
Process exited with code 0
Original token count: 291
Output:
     1	package com.yorvana.testsupport
     2
     3	import androidx.test.uiautomator.By
     4	import androidx.test.uiautomator.UiDevice
     5	import androidx.test.uiautomator.Until
     6
     7	object SystemPicker {
     8	    private const val TIMEOUT_MILLIS = 10_000L
     9
    10	    fun pickFile(
    11	        device: UiDevice,
    12	        displayName: String,
    13	    ) {
    14	        clickText(device, displayName)
    15	    }
    16
    17	    fun pickFolder(
    18	        device: UiDevice,
    19	        displayName: String,
    20	    ) {
    21	        clickText(device, displayName)
    22	        clickText(device, "Use this folder")
    23	        Permissions.acceptIfShown(device)
    24	    }
    25
    26	    private fun clickText(
    27	        device: UiDevice,
    28	        text: String,
    29	    ) {
    30	        val objectByText = device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
    31	            ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
    32	            ?: error("System picker item not found: $text")
    33	        objectByText.click()
    34	    }
    35	}


ktlint flagged one wrapping style issue in `SystemPicker`. I’ll fix that and rerun the style check so any remaining violations surface in one pass.


Input:
*** Begin Patch
*** Update File: app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt
@@
-        val objectByText = device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
-            ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
-            ?: error("System picker item not found: $text")
+        val objectByText =
+            device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
+                ?: error("System picker item not found: $text")
*** End Patch


Success. Updated the following files:
M app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt

@@ -29,5 +29,6 @@
     ) {
-        val objectByText = device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
-            ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
-            ?: error("System picker item not found: $text")
+        val objectByText =
+            device.wait(Until.findObject(By.text(text)), TIMEOUT_MILLIS)
+                ?: device.wait(Until.findObject(By.textContains(text)), TIMEOUT_MILLIS)
+                ?: error("System picker item not found: $text")
         objectByText.click()


Output:
Exit code: 0
Wall time: 0.8 seconds
Output:
Success. Updated the following files:
M app/src/androidTest/java/com/yorvana/testsupport/SystemPicker.kt


Output:
Chunk ID: cde3ac
Wall time: 3.4134 seconds
Process exited with code 1
Original token count: 1125
Output:
Picked up JAVA_TOOL_OPTIONS: -Xlog:os+container=off

> Configure project :app
WARNING: The option setting 'android.testoptions.manageddevices.emulator.gpu=host' is experimental.

> Task :app:loadKtlintReporters UP-TO-DATE
> Task :app:runKtlintCheckOverAndroidTestDebugSourceSet NO-SOURCE
> Task :app:ktlintAndroidTestDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverAndroidTestReleaseSourceSet NO-SOURCE
> Task :app:ktlintAndroidTestReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverDebugSourceSet NO-SOURCE
> Task :app:ktlintDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverKotlinScripts UP-TO-DATE
> Task :app:ktlintKotlinScriptCheck UP-TO-DATE
> Task :app:runKtlintCheckOverMainSourceSet UP-TO-DATE
> Task :app:ktlintMainSourceSetCheck UP-TO-DATE
> Task :app:runKtlintCheckOverReleaseSourceSet NO-SOURCE
> Task :app:ktlintReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestDebugSourceSet NO-SOURCE
> Task :app:ktlintTestDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestFixturesDebugSourceSet NO-SOURCE
> Task :app:ktlintTestFixturesDebugSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestFixturesReleaseSourceSet NO-SOURCE
> Task :app:ktlintTestFixturesReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestFixturesSourceSet NO-SOURCE
> Task :app:ktlintTestFixturesSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestReleaseSourceSet NO-SOURCE
> Task :app:ktlintTestReleaseSourceSetCheck SKIPPED
> Task :app:runKtlintCheckOverTestSourceSet UP-TO-DATE

> Task :app:ktlintTestSourceSetCheck FAILED
/home/Messier82/projects/service-book/app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt:79:23 Argument should be on a separate line (unless all arguments can fit a single line)
/home/Messier82/projects/service-book/app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt:79:141 Exceeded max line length (140) (cannot be auto-corrected)
/home/Messier82/projects/service-book/app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt:79:149 Missing newline before ")"
/home/Messier82/projects/service-book/app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt:131:60 First line of body expression fits on same line as function signature
/home/Messier82/projects/service-book/app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt:137:23 Property name should use the screaming snake case notation when the value can not be changed (cannot be auto-corrected)
/home/Messier82/projects/service-book/app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt:198:27 A multiline expression should start on a new line

> Task :app:runKtlintCheckOverAndroidTestSourceSet
[0.004s][warning][os,container] Cgroup memory controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate
[0.004s][warning][os,container] Cgroup cpu controller path at '/sys/fs/cgroup' seems to have moved to '/../../../app.slice/app-tmux.slice/tmux-spawn-48989792-44f6-435b-b502-206716986e06.scope', detected limits won't be accurate

[Incubating] Problems report is available at: file:///home/Messier82/projects/service-book/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:ktlintTestSourceSetCheck' (registered by plugin 'org.jlleitschuh.gradle.ktlint').
> A failure occurred while executing org.jlleitschuh.gradle.ktlint.worker.ConsoleReportWorkAction
   > KtLint found code style violations. Please see the following reports:
     - /home/Messier82/projects/service-book/app/build/reports/ktlint/ktlintTestSourceSetCheck/ktlintTestSourceSetCheck.txt

* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.

Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/9.5.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD FAILED in 3s
8 actionable tasks: 2 executed, 6 up-to-date


nl -ba app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt | sed -n '70,210p'


Output:
Chunk ID: c96a55
Wall time: 0.1169 seconds
Process exited with code 0
Original token count: 1565
Output:
    70	                    @Quarantined
    71	                    @Test
    72	                    fun quarantinedWithoutTier() = Unit
    73	                }
    74	                """,
    75	            )
    76
    77	        assertThat(violations).contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier has 0 tier annotations")
    78	        assertThat(violations)
    79	            .contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier is quarantined without a tier at the same effective scope")
    80	    }
    81
    82	    @Test
    83	    fun inheritedClassTierFixturePasses() {
    84	        val violations =
    85	            scanFixture(
    86	                """
    87	                package com.yorvana.regression
    88
    89	                import com.yorvana.testsupport.tiers.Regression
    90	                import org.junit.Test
    91
    92	                @Regression
    93	                open class RegressionBase
    94
    95	                class InheritedTierTest : RegressionBase() {
    96	                    @Test
    97	                    fun inheritsClassTier() = Unit
    98	                }
    99	                """,
   100	            )
   101
   102	        assertThat(violations).isEmpty()
   103	    }
   104
   105	    @Test
   106	    fun annotationArgumentsAndUnknownSiblingsAreAccepted() {
   107	        val violations =
   108	            scanFixture(
   109	                """
   110	                package com.yorvana.regression
   111
   112	                import com.yorvana.testsupport.tiers.Regression
   113	                import com.yorvana.testsupport.tiers.Quarantined
   114	                import org.junit.Test
   115
   116	                annotation class ScenarioId(val value: String)
   117
   118	                class ForwardCompatibleTest {
   119	                    @Regression
   120	                    @Quarantined(since = "2026-05-22", issue = 120, reason = "fixture")
   121	                    @ScenarioId("R-A01")
   122	                    @Test
   123	                    fun acceptsArgumentsAndUnknownSibling() = Unit
   124	                }
   125	                """,
   126	            )
   127
   128	        assertThat(violations).isEmpty()
   129	    }
   130
   131	    private fun scanFixture(source: String): List<String> =
   132	        RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
   133	}
   134
   135	private object RegressionTierScanner {
   136	    private val tierNames = setOf("Regression", "RegressionFull", "BillingSandbox")
   137	    private const val quarantineName = "Quarantined"
   138
   139	    fun scanDirectory(root: File): List<String> {
   140	        if (!root.exists()) return emptyList()
   141	        val sources =
   142	            root
   143	                .walkTopDown()
   144	                .filter { it.isFile && it.extension == "kt" }
   145	                .associate { it.path to it.readText() }
   146	        return scanSources(sources)
   147	    }
   148
   149	    fun scanSources(sources: Map<String, String>): List<String> {
   150	        val classes = sources.flatMap { (path, source) -> parseSource(path, source) }
   151	        val bySimpleName = classes.associateBy { it.simpleName }
   152	        val violations = mutableListOf<String>()
   153
   154	        classes.forEach { testClass ->
   155	            testClass.testMethods.forEach { method ->
   156	                val scopes =
   157	                    listOf(
   158	                        AnnotationScope("method", method.annotations),
   159	                        AnnotationScope("class", testClass.annotations),
   160	                    ) +
   161	                        inheritedClassScopes(testClass, bySimpleName)
   162	                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in tierNames } > 0 }
   163	                val tierCount = effectiveTierScope?.annotations?.count { it in tierNames } ?: 0
   164	                val effectiveQuarantineScope = scopes.firstOrNull { quarantineName in it.annotations }
   165
   166	                if (tierCount != 1) {
   167	                    violations += "${testClass.fqName}.${method.name} has $tierCount tier annotations"
   168	                }
   169	                if (effectiveQuarantineScope != null && effectiveQuarantineScope != effectiveTierScope) {
   170	                    violations +=
   171	                        "${testClass.fqName}.${method.name} is quarantined without a tier at the same effective scope"
   172	                }
   173	            }
   174	        }
   175
   176	        return violations
   177	    }
   178
   179	    private fun inheritedClassScopes(
   180	        testClass: ParsedClass,
   181	        bySimpleName: Map<String, ParsedClass>,
   182	    ): List<AnnotationScope> {
   183	        val scopes = mutableListOf<AnnotationScope>()
   184	        var currentSuperName = testClass.superClassName
   185	        val seen = mutableSetOf<String>()
   186	        while (currentSuperName != null && seen.add(currentSuperName)) {
   187	            val superClass = bySimpleName[currentSuperName] ?: break
   188	            scopes += AnnotationScope("inherited class", superClass.annotations)
   189	            currentSuperName = superClass.superClassName
   190	        }
   191	        return scopes
   192	    }
   193
   194	    private fun parseSource(
   195	        path: String,
   196	        source: String,
   197	    ): List<ParsedClass> {
   198	        val packageName = Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
   199	            .find(source)
   200	            ?.groupValues
   201	            ?.get(1)
   202	            .orEmpty()
   203	        val classes = mutableListOf<ParsedClass>()
   204	        var currentClass: ParsedClass? = null
   205	        var pendingAnnotations = emptySet<String>()
   206
   207	        source.lineSequence().forEach { rawLine ->
   208	            val line = rawLine.trim()
   209	            val annotations = extractAnnotations(line)
   210	            if (annotations.isNotEmpty()) {


nl -ba app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt | sed -n '210,310p'


Output:
Chunk ID: ecfce3
Wall time: 0.1230 seconds
Process exited with code 0
Original token count: 739
Output:
   210	            if (annotations.isNotEmpty()) {
   211	                pendingAnnotations = pendingAnnotations + annotations
   212	            }
   213
   214	            val classMatch = Regex("""\bclass\s+([A-Za-z_]\w*)\s*(?::\s*([A-Za-z_][\w.]*))?""").find(line)
   215	            if (classMatch != null) {
   216	                val simpleName = classMatch.groupValues[1]
   217	                val superClassName =
   218	                    classMatch.groupValues
   219	                        .getOrNull(2)
   220	                        ?.takeIf { it.isNotBlank() }
   221	                        ?.substringAfterLast('.')
   222	                currentClass =
   223	                    ParsedClass(
   224	                        sourcePath = path,
   225	                        packageName = packageName,
   226	                        simpleName = simpleName,
   227	                        superClassName = superClassName,
   228	                        annotations = pendingAnnotations,
   229	                        testMethods = mutableListOf(),
   230	                    )
   231	                classes += currentClass
   232	                pendingAnnotations = emptySet()
   233	                return@forEach
   234	            }
   235
   236	            val methodMatch = Regex("""\bfun\s+([A-Za-z_]\w*)\s*\(""").find(line)
   237	            if (methodMatch != null) {
   238	                if ("Test" in pendingAnnotations) {
   239	                    currentClass?.testMethods?.add(
   240	                        ParsedMethod(
   241	                            name = methodMatch.groupValues[1],
   242	                            annotations = pendingAnnotations,
   243	                        ),
   244	                    )
   245	                }
   246	                pendingAnnotations = emptySet()
   247	            }
   248	        }
   249
   250	        return classes
   251	    }
   252
   253	    private fun extractAnnotations(line: String): Set<String> =
   254	        Regex("""@([A-Za-z_][\w.]*)""")
   255	            .findAll(line)
   256	            .map { it.groupValues[1].substringAfterLast('.') }
   257	            .toSet()
   258
   259	    private data class ParsedClass(
   260	        val sourcePath: String,
   261	        val packageName: String,
   262	        val simpleName: String,
   263	        val superClassName: String?,
   264	        val annotations: Set<String>,
   265	        val testMethods: MutableList<ParsedMethod>,
   266	    ) {
   267	        val fqName: String = listOf(packageName, simpleName).filter { it.isNotBlank() }.joinToString(".")
   268	    }
   269
   270	    private data class ParsedMethod(
   271	        val name: String,
   272	        val annotations: Set<String>,
   273	    )
   274
   275	    private data class AnnotationScope(
   276	        val label: String,
   277	        val annotations: Set<String>,
   278	    )
   279	}


Input:
*** Begin Patch
*** Update File: app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt
@@
         assertThat(violations).contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier has 0 tier annotations")
         assertThat(violations)
-            .contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier is quarantined without a tier at the same effective scope")
+            .contains(
+                "com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier " +
+                    "is quarantined without a tier at the same effective scope",
+            )
@@
-    private fun scanFixture(source: String): List<String> =
-        RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
+    private fun scanFixture(source: String): List<String> = RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
 }

 private object RegressionTierScanner {
-    private val tierNames = setOf("Regression", "RegressionFull", "BillingSandbox")
-    private const val quarantineName = "Quarantined"
+    private val TIER_NAMES = setOf("Regression", "RegressionFull", "BillingSandbox")
+    private const val QUARANTINE_NAME = "Quarantined"
@@
-        val classes = sources.flatMap { (path, source) -> parseSource(path, source) }
+        val classes = sources.values.flatMap { source -> parseSource(source) }
@@
-                        AnnotationScope("method", method.annotations),
-                        AnnotationScope("class", testClass.annotations),
+                        AnnotationScope(method.annotations),
+                        AnnotationScope(testClass.annotations),
                     ) +
                         inheritedClassScopes(testClass, bySimpleName)
-                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in tierNames } > 0 }
-                val tierCount = effectiveTierScope?.annotations?.count { it in tierNames } ?: 0
-                val effectiveQuarantineScope = scopes.firstOrNull { quarantineName in it.annotations }
+                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in TIER_NAMES } > 0 }
+                val tierCount = effectiveTierScope?.annotations?.count { it in TIER_NAMES } ?: 0
+                val effectiveQuarantineScope = scopes.firstOrNull { QUARANTINE_NAME in it.annotations }
@@
             val superClass = bySimpleName[currentSuperName] ?: break
-            scopes += AnnotationScope("inherited class", superClass.annotations)
+            scopes += AnnotationScope(superClass.annotations)
             currentSuperName = superClass.superClassName
         }
@@
-    private fun parseSource(
-        path: String,
-        source: String,
-    ): List<ParsedClass> {
-        val packageName = Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
-            .find(source)
-            ?.groupValues
-            ?.get(1)
-            .orEmpty()
+    private fun parseSource(source: String): List<ParsedClass> {
+        val packageName =
+            Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
+                .find(source)
+                ?.groupValues
+                ?.get(1)
+                .orEmpty()
@@
                     ParsedClass(
-                        sourcePath = path,
                         packageName = packageName,
                         simpleName = simpleName,
@@
     private data class ParsedClass(
-        val sourcePath: String,
         val packageName: String,
         val simpleName: String,
@@

     private data class AnnotationScope(
-        val label: String,
         val annotations: Set<String>,
     )
 }
*** End Patch


@@ -78,3 +78,6 @@
         assertThat(violations)
-            .contains("com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier is quarantined without a tier at the same effective scope")
+            .contains(
+                "com.yorvana.regression.OrphanQuarantineTest.quarantinedWithoutTier " +
+                    "is quarantined without a tier at the same effective scope",
+            )
     }
@@ -130,4 +133,3 @@

-    private fun scanFixture(source: String): List<String> =
-        RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
+    private fun scanFixture(source: String): List<String> = RegressionTierScanner.scanSources(mapOf("Fixture.kt" to source.trimIndent()))
 }
@@ -135,4 +137,4 @@
 private object RegressionTierScanner {
-    private val tierNames = setOf("Regression", "RegressionFull", "BillingSandbox")
-    private const val quarantineName = "Quarantined"
+    private val TIER_NAMES = setOf("Regression", "RegressionFull", "BillingSandbox")
+    private const val QUARANTINE_NAME = "Quarantined"

@@ -149,3 +151,3 @@
     fun scanSources(sources: Map<String, String>): List<String> {
-        val classes = sources.flatMap { (path, source) -> parseSource(path, source) }
+        val classes = sources.values.flatMap { source -> parseSource(source) }
         val bySimpleName = classes.associateBy { it.simpleName }
@@ -157,9 +159,9 @@
                     listOf(
-                        AnnotationScope("method", method.annotations),
-                        AnnotationScope("class", testClass.annotations),
+                        AnnotationScope(method.annotations),
+                        AnnotationScope(testClass.annotations),
                     ) +
                         inheritedClassScopes(testClass, bySimpleName)
-                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in tierNames } > 0 }
-                val tierCount = effectiveTierScope?.annotations?.count { it in tierNames } ?: 0
-                val effectiveQuarantineScope = scopes.firstOrNull { quarantineName in it.annotations }
+                val effectiveTierScope = scopes.firstOrNull { scope -> scope.annotations.count { it in TIER_NAMES } > 0 }
+                val tierCount = effectiveTierScope?.annotations?.count { it in TIER_NAMES } ?: 0
+                val effectiveQuarantineScope = scopes.firstOrNull { QUARANTINE_NAME in it.annotations }

@@ -187,3 +189,3 @@
             val superClass = bySimpleName[currentSuperName] ?: break
-            scopes += AnnotationScope("inherited class", superClass.annotations)
+            scopes += AnnotationScope(superClass.annotations)
             currentSuperName = superClass.superClassName
@@ -193,11 +195,9 @@

-    private fun parseSource(
-        path: String,
-        source: String,
-    ): List<ParsedClass> {
-        val packageName = Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
-            .find(source)
-            ?.groupValues
-            ?.get(1)
-            .orEmpty()
+    private fun parseSource(source: String): List<ParsedClass> {
+        val packageName =
+            Regex("""^\s*package\s+([A-Za-z_][\w.]*)""", RegexOption.MULTILINE)
+                .find(source)
+                ?.groupValues
+                ?.get(1)
+                .orEmpty()
         val classes = mutableListOf<ParsedClass>()
@@ -223,3 +223,2 @@
                     ParsedClass(
-                        sourcePath = path,
                         packageName = packageName,
@@ -259,3 +258,2 @@
     private data class ParsedClass(
-        val sourcePath: String,
         val packageName: String,
@@ -275,3 +273,2 @@
     private data class AnnotationScope(
-        val label: String,
         val annotations: Set<String>,


Output:
Exit code: 0
Wall time: 0.8 seconds
Output:
Success. Updated the following files:
M app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt


awk 'length($0)>140 {print FNR ":" length($0) ":" $0}' app/src/test/java/com/yorvana/testsupport/RegressionTierScannerTest.kt app/build.gradle.kts app/src/androidTest/java/com/yorvana/testsupport/*.kt


Output:
Chunk ID: a5482d
Wall time: 0.1245 seconds
Process exited with code 0
Original token count: 0
Output: