Output:
Chunk ID: 0eb77c
Wall time: 0.3509 seconds
Process exited with code 0
Original token count: 3699
Output:
package com.yorvana.ui.vehicles

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.DirectionsCar
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.yorvana.R
import com.yorvana.YorvanaApplication
import com.yorvana.ui.TestTags
import com.yorvana.ui.components.ConfirmDeleteDialog
import com.yorvana.ui.components.DismissablePendingDialog
import com.yorvana.ui.components.EmptyStateView
import com.yorvana.ui.components.ExpandablePrivacyDisclosure
import com.yorvana.ui.components.PaywallScaffold
import com.yorvana.ui.components.PaywallStatusCard
import com.yorvana.ui.components.shimmerBrush
import com.yorvana.ui.navigation.AddVehicle
import com.yorvana.ui.navigation.Settings
import com.yorvana.ui.navigation.Setup
import com.yorvana.ui.navigation.VehicleDetail
import com.yorvana.ui.theme.appTopAppBarColors
import com.yorvana.ui.util.ViewModelFactory

@Composable
private fun LazyListState.isScrollingUp(): Boolean {
    var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
    var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
    return remember(this) {
        derivedStateOf {
            if (previousIndex != firstVisibleItemIndex) {
                (previousIndex > firstVisibleItemIndex).also { previousIndex = firstVisibleItemIndex }
            } else {
                (previousScrollOffset >= firstVisibleItemScrollOffset).also {
                    previousScrollOffset = firstVisibleItemScrollOffset
                }
            }
        }
    }.value
}

private val LargeCorner = 24.dp
private val SmallCorner = 4.dp

private fun cardShape(
    isFirst: Boolean,
    isLast: Boolean,
): RoundedCornerShape =
    when {
        isFirst && isLast -> RoundedCornerShape(LargeCorner)
        isFirst -> RoundedCornerShape(topStart = LargeCorner, topEnd = LargeCorner, bottomStart = SmallCorner, bottomEnd = SmallCorner)
        isLast -> RoundedCornerShape(topStart = SmallCorner, topEnd = SmallCorner, bottomStart = LargeCorner, bottomEnd = LargeCorner)
        else -> RoundedCornerShape(SmallCorner)
    }

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VehicleListScreen(
    backStack: NavBackStack<NavKey>,
    viewModel: VehicleListViewModel =
        viewModel(
            factory = ViewModelFactory(LocalContext.current.applicationContext as YorvanaApplication),
        ),
    listState: LazyListState = rememberLazyListState(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(state.isLoading, state.isReadOnly) {
        if (!state.isLoading && state.isReadOnly) {
            listState.scrollToItem(0)
        }
    }
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
    val snackbarHostState = remember { SnackbarHostState() }
    val disabledDescription = stringResource(R.string.disabled_upgrade_description)

    LaunchedEffect(Unit) {
        viewModel.effects.collect { effect ->
            when (effect) {
                VehicleListEffect.NavigateToAddVehicle ->
                    backStack.add(AddVehicle)
                is VehicleListEffect.NavigateToVehicle ->
                    backStack.add(VehicleDetail(vehicleId = effect.vehicleId))
                VehicleListEffect.NavigateToSettings ->
                    backStack.add(Settings)
                VehicleListEffect.NavigateToSetup -> {
                    backStack.clear()
                    backStack.add(Setup)
                }
            }
        }
    }

    if (state.confirmDeleteVehicleId != null) {
        val vehicle = state.vehicles.find { it.id == state.confirmDeleteVehicleId }
        ConfirmDeleteDialog(
            message =
                stringResource(
                    R.string.vehicle_delete_confirm,
                    vehicle?.nickname ?: "",
                ),
            onConfirm = { viewModel.onEvent(VehicleListEvent.ConfirmDeleteVehicle) },
            onDismiss = { viewModel.onEvent(VehicleListEvent.DismissDeleteConfirm) },
        )
    }

    // Show dismissable pending dialog for at-cap + pending (not read-only) state
    DismissablePendingDialog(visible = state.isPremiumPending && !state.isReadOnly)

    if (state.showConsentDialog) {
        AlertDialog(
            onDismissRequest = { viewModel.onEvent(VehicleListEvent.DismissConsentDialog) },
            title = { Text(stringResource(R.string.consent_dialog_title)) },
            text = {
                ExpandablePrivacyDisclosure()
            },
            confirmButton = {
                TextButton(onClick = { viewModel.onEvent(VehicleListEvent.ConsentEnabled) }) {
                    Text(stringResource(R.string.consent_dialog_enable))
                }
            },
            dismissButton = {
                TextButton(onClick = { viewModel.onEvent(VehicleListEvent.ConsentNotNow) }) {
                    Text(stringResource(R.string.consent_dialog_not_now))
                }
            },
            modifier = Modifier.testTag(TestTags.CONSENT_DIALOG),
        )
    }

    PaywallScaffold(
        showPaywallSheet = state.showPaywallSheet,
        isPremiumPending = state.isPremiumPending,
        onDismissPaywallSheet = { viewModel.onEvent(VehicleListEvent.DismissPaywallSheet) },
        snackbarHostState = snackbarHostState,
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            LargeTopAppBar(
                title = {
                    Text(
                        stringResource(R.string.my_garage),
                        modifier = Modifier.testTag(TestTags.SCREEN_TITLE),
                    )
                },
                actions = {
                    IconButton(
                        onClick = { viewModel.onEvent(VehicleListEvent.OpenSettings) },
                        modifier = Modifier.testTag(TestTags.SETTINGS_BUTTON),
                    ) {
                        Icon(Icons.Outlined.Settings, contentDescription = stringResource(R.string.settings_title))
                    }
                },
                scrollBehavior = scrollBehavior,
                colors = appTopAppBarColors(),
            )
        },
        floatingActionButton = {
            val isExpanded = listState.firstVisibleItemIndex == 0 || listState.isScrollingUp()
            if (state.isReadOnly) {
                ExtendedFloatingActionButton(
                    onClick = { viewModel.onEvent(VehicleListEvent.BlockedWriteAttempted) },
                    expanded = isExpanded,
                    icon = { Icon(Icons.Default.Add, contentDescription = null) },
                    text = { Text(stringResource(R.string.vehicle_add)) },
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    contentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.38f),
                    modifier =
                        Modifier
                            .testTag(TestTags.ADD_VEHICLE_FAB)
                            .semantics {
                                stateDescription = disabledDescription
                                role = Role.Button
                            },
                )
            } else {
                ExtendedFloatingActionButton(
                    onClick = { viewModel.onEvent(VehicleListEvent.AddVehicle) },
                    expanded = isExpanded,
                    icon = { Icon(Icons.Default.Add, contentDescription = null) },
                    text = { Text(stringResource(R.string.vehicle_add)) },
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
                    modifier = Modifier.testTag(TestTags.ADD_VEHICLE_FAB),
                )
            }
        },
    ) { padding ->
        if (state.vehicles.isEmpty() && !state.isLoading) {
            EmptyStateView(
                icon = Icons.Outlined.DirectionsCar,
                message = stringResource(R.string.vehicle_empty),
                modifier = Modifier.padding(padding),
            )
        } else {
            LazyColumn(
                state = listState,
                modifier =
                    Modifier
                        .fillMaxSize()
                        .padding(padding),
                contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
            ) {
                if (state.isLoading) {
                    itemsIndexed(List(4) { it }) { index, _ ->
                        VehicleCardSkeleton(
                            shape = cardShape(isFirst = index == 0, isLast = index == 3),
                            showDivider = false,
                        )
                        if (index < 3) {
                            Spacer(Modifier.height(2.dp))
                        }
                    }
                } else {
                    if (state.isReadOnly) {
                        item("paywall_status_card") {
                            Spacer(Modifier.height(8.dp))
                            PaywallStatusCard(
                                isPendingPurchase = state.isPremiumPending,
                                onUpgradeClick = { viewModel.onEvent(VehicleListEvent.BlockedWriteAttempted) },
                            )
                            Spacer(Modifier.height(8.dp))
                        }
                    }
                    val total = state.vehicles.size
                    itemsIndexed(state.vehicles, key = { _, item -> item.id }) { index, item ->
                        val shape = cardShape(isFirst = index == 0, isLast = index == total - 1)
                        val dismissState = rememberSwipeToDismissBoxState()
                        LaunchedEffect(dismissState.currentValue) {
                            if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) {
                                viewModel.onEvent(VehicleListEvent.RequestDeleteVehicle(item.id))
                            }
                        }
                        LaunchedEffect(state.confirmDeleteVehicleId) {
                            if (state.confirmDeleteVehicleId != item.id &&
                                dismissState.currentValue == SwipeToDismissBoxValue.EndToStart
                            ) {
                                dismissState.reset()
                            }
                        }
                        SwipeToDismissBox(
                            state = dismissState,
                            enableDismissFromStartToEnd = false,
                            enableDismissFromEndToStart = !state.isReadOnly,
                            backgroundContent = {
                                Box(
                                    modifier =
                                        Modifier
                                            .fillMaxSize()
                                            .background(MaterialTheme.colorScheme.errorContainer, shape)
                                            .padding(horizontal = 20.dp),
                                    contentAlignment = Alignment.CenterEnd,
                                ) {
                                    Icon(
                                        imageVector = Icons.Default.Delete,
                                        contentDescription = stringResource(R.string.vehicle_delete),
                                        tint = MaterialTheme.colorScheme.onErrorContainer,
                                    )
                                }
                            },
                        ) {
                            VehicleCard(
                                item = item,


Output:
Chunk ID: 5305b6
Wall time: 0.3334 seconds
Process exited with code 0
Original token count: 4301
Output:
package com.yorvana.ui.records

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.WaterDrop
import androidx.compose.material.icons.outlined.Build
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import com.yorvana.R
import com.yorvana.YorvanaApplication
import com.yorvana.domain.model.RecordWithCategory
import com.yorvana.ui.TestTags
import com.yorvana.ui.components.ChromeLikeMenu
import com.yorvana.ui.components.ChromeLikeMenuItem
import com.yorvana.ui.components.ConfirmDeleteDialog
import com.yorvana.ui.components.EmptyStateView
import com.yorvana.ui.components.ErrorListItem
import com.yorvana.ui.components.PaywallScaffold
import com.yorvana.ui.components.PaywallStatusCard
import com.yorvana.ui.components.TopBarBackButton
import com.yorvana.ui.components.shimmerBrush
import com.yorvana.ui.navigation.AddRecord
import com.yorvana.ui.navigation.EditVehicle
import com.yorvana.ui.navigation.RecordDetail
import com.yorvana.ui.theme.appTopAppBarColors
import com.yorvana.ui.util.ViewModelFactory
import com.yorvana.util.DateFormatter

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VehicleDetailScreen(
    vehicleId: String,
    backStack: NavBackStack<NavKey>,
    viewModel: VehicleDetailViewModel =
        viewModel(
            key = vehicleId,
            factory =
                ViewModelFactory(
                    application = LocalContext.current.applicationContext as YorvanaApplication,
                    extraParams = mapOf("vehicleId" to vehicleId),
                ),
        ),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }
    val disabledDescription = stringResource(R.string.disabled_upgrade_description)

    LaunchedEffect(state.error) {
        state.error?.let { errorMessage ->
            try {
                snackbarHostState.showSnackbar(errorMessage)
            } finally {
                viewModel.onEvent(VehicleDetailEvent.DismissError)
            }
        }
    }

    LaunchedEffect(Unit) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is VehicleDetailEffect.NavigateToAddRecord ->
                    backStack.add(AddRecord(vehicleId = effect.vehicleId))
                is VehicleDetailEffect.NavigateToRecord ->
                    backStack.add(RecordDetail(vehicleId = effect.vehicleId, recordId = effect.recordId))
                is VehicleDetailEffect.NavigateToEditVehicle ->
                    backStack.add(EditVehicle(vehicleId = effect.vehicleId))
                VehicleDetailEffect.NavigateBack -> backStack.removeLastOrNull()
            }
        }
    }

    if (state.confirmDeleteVehicle) {
        ConfirmDeleteDialog(
            message = stringResource(R.string.vehicle_delete_confirm, state.vehicle?.nickname ?: ""),
            onConfirm = { viewModel.onEvent(VehicleDetailEvent.ConfirmDeleteVehicle) },
            onDismiss = { viewModel.onEvent(VehicleDetailEvent.DismissDeleteVehicle) },
        )
    }

    if (state.confirmDeleteRecordId != null) {
        ConfirmDeleteDialog(
            message = stringResource(R.string.record_delete_confirm),
            onConfirm = { viewModel.onEvent(VehicleDetailEvent.ConfirmDeleteRecord) },
            onDismiss = { viewModel.onEvent(VehicleDetailEvent.DismissDeleteRecord) },
        )
    }

    var menuExpanded by remember { mutableStateOf(false) }

    PaywallScaffold(
        showPaywallSheet = state.showPaywallSheet,
        isPremiumPending = state.isPremiumPending,
        onDismissPaywallSheet = { viewModel.onEvent(VehicleDetailEvent.DismissPaywallSheet) },
        snackbarHostState = snackbarHostState,
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = state.vehicle?.nickname ?: "",
                        style = MaterialTheme.typography.titleLarge,
                        modifier = Modifier.testTag(TestTags.SCREEN_TITLE),
                    )
                },
                navigationIcon = {
                    TopBarBackButton(onClick = { backStack.removeLastOrNull() })
                },
                colors = appTopAppBarColors(),
                actions = {
                    Box {
                        IconButton(
                            onClick = { menuExpanded = true },
                            modifier = Modifier.testTag(TestTags.MORE_OPTIONS_BUTTON),
                        ) {
                            Icon(
                                imageVector = Icons.Default.MoreVert,
                                contentDescription = stringResource(R.string.action_more),
                            )
                        }
                        ChromeLikeMenu(
                            expanded = menuExpanded,
                            onDismissRequest = { menuExpanded = false },
                        ) {
                            // Menu items route to BlockedWriteAttempted when read-only, keeping
                            // the sheet as the single upgrade funnel. Visual disabled state is
                            // handled inside ChromeLikeMenuItem via enabled = !isReadOnly.
                            ChromeLikeMenuItem(
                                text = { Text(stringResource(R.string.vehicle_edit)) },
                                leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
                                onClick = {
                                    menuExpanded = false
                                    if (state.isReadOnly) {
                                        viewModel.onEvent(VehicleDetailEvent.BlockedWriteAttempted)
                                    } else {
                                        viewModel.onEvent(VehicleDetailEvent.EditVehicle)
                                    }
                                },
                                // Always enabled so it can receive clicks in read-only mode
                                enabled = true,
                                enabledLook = !state.isReadOnly,
                                modifier =
                                    if (state.isReadOnly) {
                                        Modifier.semantics {
                                            stateDescription = disabledDescription
                                            role = Role.Button
                                        }
                                    } else {
                                        Modifier
                                    },
                            )
                            ChromeLikeMenuItem(
                                text = { Text(stringResource(R.string.vehicle_delete)) },
                                leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
                                onClick = {
                                    menuExpanded = false
                                    if (state.isReadOnly) {
                                        viewModel.onEvent(VehicleDetailEvent.BlockedWriteAttempted)
                                    } else {
                                        viewModel.onEvent(VehicleDetailEvent.RequestDeleteVehicle)
                                    }
                                },
                                // Always enabled so it can receive clicks in read-only mode
                                enabled = true,
                                enabledLook = !state.isReadOnly,
                                modifier =
                                    if (state.isReadOnly) {
                                        Modifier.semantics {
                                            stateDescription = disabledDescription
                                            role = Role.Button
                                        }
                                    } else {
                                        Modifier
                                    },
                            )
                        }
                    }
                },
            )
        },
        floatingActionButton = {
            if (state.isReadOnly) {
                ExtendedFloatingActionButton(
                    onClick = { viewModel.onEvent(VehicleDetailEvent.BlockedWriteAttempted) },
                    icon = { Icon(Icons.Default.Add, contentDescription = null) },
                    text = { Text(stringResource(R.string.record_add_label)) },
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    contentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.38f),
                    modifier =
                        Modifier
                            .testTag(TestTags.ADD_RECORD_FAB)
                            .semantics {
                                stateDescription = disabledDescription
                                role = Role.Button
                            },
                )
            } else {
                ExtendedFloatingActionButton(
                    onClick = { viewModel.onEvent(VehicleDetailEvent.AddRecord) },
                    icon = { Icon(Icons.Default.Add, contentDescription = null) },
                    text = { Text(stringResource(R.string.record_add_label)) },
                    containerColor = MaterialTheme.colorScheme.primaryContainer,
                    contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
                    modifier = Modifier.testTag(TestTags.ADD_RECORD_FAB),
                )
            }
        },
    ) { padding ->
        Column(
            modifier =
                Modifier
                    .fillMaxSize()
                    .padding(padding),
        ) {
            if (state.isReadOnly) {
                PaywallStatusCard(
                    isPendingPurchase = state.isPremiumPending,
                    onUpgradeClick = { viewModel.onEvent(VehicleDetailEvent.BlockedWriteAttempted) },
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
                )
            }
            if (state.isLoading) {
                LazyColumn(
                    modifier =
                        Modifier
                            .fillMaxSize()
                            .weight(1f),
                    contentPadding = PaddingValues(vertical = 8.dp),
                    verticalArrangement = Arrangement.spacedBy(0.dp),
                ) {
                    items(4) { TimelineItemSkeleton() }
                }
            } else if (state.records.isEmpty()) {
                EmptyStateView(
                    icon = Icons.Outlined.Build,
                    message = stringResource(R.string.record_empty),
                    modifier =
                        Modifier
                            .fillMaxSize()
                            .weight(1f),
                )
            } else {
                val groupedRecords =
                    remember(state.records) {
                        state.records
                            .filter { !it.isError }
                            .groupBy { DateFormatter.toYear(it.record.date) }
                            .entries
                            .sortedByDescending { it.key }
                    }
                val errorRecords =
                    remember(state.records) {
                        state.records.filter { it.isError }
                    }
                val odometerUnit = state.vehicle?.odometerUnit?.label ?: "km"
                val outlineVariant = MaterialTheme.colorScheme.outlineVariant

                LazyColumn(
                    modifier =
                        Modifier
                            .fillMaxSize()
                            .weight(1f),
                ) {
                    groupedRecords.forEachIndexed { groupIndex, (year, items) ->
                        stickyHeader(key = "header_$year") {
                            YearHeader(year)
                        }
                        items.forEachIndexed { itemIndex, item ->
                            val isFirst = itemIndex == 0
                            val isLast = itemIndex == items.lastIndex
                            val isAbsoluteFirst = groupIndex == 0 && itemIndex == 0
                            item(key = item.record.id) {
                                TimelineListItem(
                                    item = item,
                                    odometerUnit = odometerUnit,
                                    isFirst = isFirst,
                                    isLast = isLast,
                                    isAbsoluteFirst = isAbsoluteFirst,
                                    outlineVariant = outlineVariant,
                                    onClick = { viewModel.onEvent(VehicleDetailEvent.SelectRecord(item.record.id)) },
                                    modifier = Modifier.testTag(TestTags.recordItem(item.record.id)),
                                )
                            }
                        }
                    }
                    errorRecords.forEach { item ->
                        item(key = item.record.id) {
                            ErrorListItem(errorMessage = item.rawError)
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun YearHeader(year: Int) {
    Text(
        text = year.toString(),
        style = MaterialTheme.typography.titleSmall,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        modifier =
            Modifier
                .fillMaxWidth()
                .background(MaterialTheme.colorScheme.surfaceContainerLow)
                .padding(horizontal = 16.dp, vertical = 8.dp),
    )
}

@Composable
private fun TimelineListItem(
    item: RecordWithCategory,
    odometerUnit: String,
    isFirst: Boolean,
    isLast: Boolean,
    isAbsoluteFirst: Boolean,
    outlineVariant: Color,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val onlySingleItem = isFirst && isLast


Output:
Chunk ID: 0fe430
Wall time: 0.3471 seconds
Process exited with code 0
Original token count: 1980
Output:
package com.yorvana.testsupport

import android.content.pm.ActivityInfo
import android.net.Uri
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.yorvana.R
import com.yorvana.YorvanaApplication
import com.yorvana.data.model.CategoriesFile
import com.yorvana.data.model.CustomCategory
import com.yorvana.data.model.OdometerUnit
import com.yorvana.data.model.ServiceRecord
import com.yorvana.data.model.Vehicle
import com.yorvana.data.preferences.DebugBillingOverrideMode
import com.yorvana.testsupport.lifecycle.hardKillAndRelaunch
import com.yorvana.util.DateFormatter
import com.yorvana.util.UuidGenerator
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import org.junit.Assume
import java.io.File

fun MainActivityComposeRule.killAndRelaunch(waitForText: String? = null) {
    hardKillAndRelaunch(this)

    // Handle crash dialog if present (non-blocking semantics check)
    val crashDialogTitle = "Previous crash detected"
    val hasCrashDialog =
        try {
            onAllNodesWithText(crashDialogTitle).fetchSemanticsNodes().isNotEmpty()
        } catch (_: Exception) {
            false
        }

    if (hasCrashDialog) {
        try {
            onNodeWithText("Dismiss").performClick()
            waitForIdle()
        } catch (_: Exception) {
        }
    }

    // Wait for the UI to be ready
    val targetContext = activity.applicationContext
    val expected = waitForText ?: targetContext.getString(R.string.my_garage)
    if (waitForText == null) {
        repeat(4) {
            if (onAllNodesWithText(expected).fetchSemanticsNodes().isNotEmpty()) {
                return@repeat
            }
            activityRule.scenario.onActivity { activity ->
                activity.onBackPressedDispatcher.onBackPressed()
            }
            waitForIdle()
        }
    }
    awaitText(expected, timeoutMillis = 120_000)
}

fun MainActivityComposeRule.rotateMidEdit() {
    activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
    activityRule.scenario.recreate()
    waitForIdle()
    activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
    activityRule.scenario.recreate()
    waitForIdle()
}

fun MainActivityComposeRule.app(): YorvanaApplication = activity.application as YorvanaApplication

fun MainActivityComposeRule.seedVehicle(
    id: String = UuidGenerator.generate(),
    nickname: String,
    make: String = "",
    model: String = "",
    year: Int? = null,
    vin: String? = null,
    licensePlate: String? = null,
    odometerUnit: OdometerUnit = OdometerUnit.KM,
): Vehicle {
    val vehicle =
        Vehicle(
            id = id,
            nickname = nickname,
            make = make,
            model = model,
            year = year,
            vin = vin,
            licensePlate = licensePlate,
            odometerUnit = odometerUnit,
            createdAt = DateFormatter.nowIso(),
        )
    runBlocking { app().vehicleRepository.saveVehicle(vehicle) }
    waitForIdle()
    return vehicle
}

fun MainActivityComposeRule.seedRecord(
    vehicleId: String,
    id: String = UuidGenerator.generate(),
    date: String = "2025-01-01",
    odometer: Int = 12_345,
    category: String = "oil-change",
    performer: String? = "Self",
    cost: Double? = null,
    currency: String? = null,
    attachments: List<String> = emptyList(),
): ServiceRecord {
    val record =
        ServiceRecord(
            id = id,
            vehicleId = vehicleId,
            date = date,
            odometer = odometer,
            category = category,
            performer = performer,
            cost = cost,
            currency = currency,
            attachments = attachments,
            createdAt = DateFormatter.nowIso(),
            updatedAt = DateFormatter.nowIso(),
        )
    runBlocking { app().recordRepository.saveRecord(record) }
    waitForIdle()
    return record
}

fun MainActivityComposeRule.seedCustomCategory(label: String): CustomCategory {
    val category =
        CustomCategory(
            id = UuidGenerator.generate(),
            label = label,
            createdAt = DateFormatter.nowIso(),
        )
    runBlocking {
        val current = app().vaultStorage.readCategories().getOrElse { CategoriesFile() }
        app().vaultStorage.writeCategories(current.copy(custom = current.custom + category)).getOrThrow()
    }
    return category
}

fun MainActivityComposeRule.seedAttachmentFile(
    vehicleId: String,
    recordId: String,
    filename: String,
    contents: String = "fixture attachment",
) {
    seedAttachmentBytes(vehicleId, recordId, filename, contents.toByteArray())
}

fun MainActivityComposeRule.seedAttachmentBytes(
    vehicleId: String,
    recordId: String,
    filename: String,
    contents: ByteArray,
) {
    val source = File(activity.cacheDir, filename)
    source.writeBytes(contents)
    runBlocking {
        app().recordRepository.addAttachment(
            vehicleId = vehicleId,
            recordId = recordId,
            sourceUri = Uri.fromFile(source),
            filename = filename,
        )
        // Also update the record metadata to include the attachment
        val record = app().recordRepository.getRecord(vehicleId, recordId)
        if (record != null && !record.attachments.contains(filename)) {
            app().recordRepository.saveRecord(record.copy(attachments = record.attachments + filename))
        }
    }
}

fun MainActivityComposeRule.setBillingOverride(mode: DebugBillingOverrideMode) {
    runBlocking { app().debugBillingOverride?.setMode(mode) }
    waitForIdle()
}

fun MainActivityComposeRule.seedCachedPremium() {
    runBlocking {
        app().preferences.setIsPremiumCached(true)
        app().preferences.setDebugBillingOverrideMode(DebugBillingOverrideMode.NONE)
    }
    waitForIdle()
}

fun MainActivityComposeRule.vaultRoot(vaultName: String = "test_vault"): File = File(activity.filesDir, vaultName)

fun MainActivityComposeRule.vehicleDir(vehicleId: String): File = File(vaultRoot(), "vehicles/$vehicleId")

fun MainActivityComposeRule.recordFile(
    vehicleId: String,
    recordId: String,
): File = File(vehicleDir(vehicleId), "records/$recordId.json")

fun MainActivityComposeRule.attachmentDir(
    vehicleId: String,
    recordId: String,
): File = File(vehicleDir(vehicleId), "records/$recordId")

inline fun <reified T> File.decodeJson(): T =
    Json {
        ignoreUnknownKeys = true
    }.decodeFromString<T>(readText())

class NetworkState(
    private val device: UiDevice,
) {
    private var changed = false

    fun setOffline() {
        val wifi = runCatching { device.executeShellCommand("svc wifi disable") }
        val data = runCatching { device.executeShellCommand("svc data disable") }
        val commandsAccepted =
            wifi.getOrNull().isAcceptedShellOutput() &&
                data.getOrNull().isAcceptedShellOutput()
        Assume.assumeTrue(
            "device image does not permit `svc wifi disable` / `svc data disable` from instrumentation",
            commandsAccepted,
        )
        changed = true
    }

    fun restore() {
        if (!changed) return
        runCatching { device.executeShellCommand("svc wifi enable") }
        runCatching { device.executeShellCommand("svc data enable") }
        changed = false
    }

    private fun String?.isAcceptedShellOutput(): Boolean {
        val output = this?.lowercase().orEmpty()
        return output.isBlank() ||
            listOf("permission denial", "not found", "exception", "inaccessible", "error").none(output::contains)
    }
}

fun networkState(): NetworkState = NetworkState(UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()))


Output:
Chunk ID: 75a1c9
Wall time: 0.3667 seconds
Process exited with code 0
Original token count: 2002
Output:
package com.yorvana.testsupport

import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import com.yorvana.data.model.ServiceRecord
import com.yorvana.data.model.Vehicle
import com.yorvana.util.DateFormatter
import com.yorvana.util.UuidGenerator
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import java.io.File
import java.io.InputStream
import java.io.OutputStream

class VaultFixture private constructor(
    private val context: Context,
    val uri: Uri,
) {
    val root: DocumentFile =
        if (uri.scheme == "file") {
            DocumentFile.fromFile(File(uri.path ?: error("Missing file path: $uri")))
        } else {
            DocumentFile.fromTreeUri(context, uri) ?: error("Unable to open tree URI: $uri")
        }

    fun seedSampleVault(
        vehicleId: String = UuidGenerator.generate(),
        recordId: String = UuidGenerator.generate(),
        vehicleName: String = "Fixture Car",
        attachmentName: String = "receipt.txt",
        attachmentBytes: ByteArray = "fixture attachment".toByteArray(),
    ): SeededVault {
        val vehicle =
            Vehicle(
                id = vehicleId,
                nickname = vehicleName,
                createdAt = DateFormatter.nowIso(),
                lastServiceDate = "2025-01-01",
                recordCount = 1,
            )
        val record =
            ServiceRecord(
                id = recordId,
                vehicleId = vehicleId,
                date = "2025-01-01",
                odometer = 12345,
                category = "oil-change",
                performer = "Self",
                attachments = listOf(attachmentName),
                createdAt = DateFormatter.nowIso(),
                updatedAt = DateFormatter.nowIso(),
            )

        writeText("vehicles/$vehicleId/vehicle.json", json.encodeToString(Vehicle.serializer(), vehicle))
        writeText("vehicles/$vehicleId/records/$recordId.json", json.encodeToString(ServiceRecord.serializer(), record))
        writeBytes("vehicles/$vehicleId/records/$recordId/$attachmentName", attachmentBytes)
        return SeededVault(vehicle, record, attachmentName, attachmentBytes)
    }

    fun writeMalformedVehicle(vehicleId: String) {
        writeText("vehicles/$vehicleId/vehicle.json", "{not valid json")
    }

    fun writeText(
        relativePath: String,
        contents: String,
    ) {
        writeBytes(relativePath, contents.toByteArray())
    }

    fun writeBytes(
        relativePath: String,
        bytes: ByteArray,
    ) {
        openOutputStream(relativePath).use { it.write(bytes) }
    }

    private fun openOutputStream(relativePath: String): OutputStream {
        if (uri.scheme == "file") {
            val file = File(uri.path!!, relativePath)
            file.parentFile?.mkdirs()
            android.util.Log.d("VaultFixture", "Writing local file: ${file.absolutePath}")
            return file.outputStream()
        } else {
            val file = fileFor(relativePath, create = true) ?: error("Unable to create $relativePath")
            android.util.Log.d("VaultFixture", "Writing remote file: ${file.uri}")
            return context.contentResolver.openOutputStream(file.uri, "rwt") ?: error("Unable to open output stream for ${file.uri}")
        }
    }

    private fun openInputStream(relativePath: String): InputStream =
        if (uri.scheme == "file") {
            File(uri.path!!, relativePath).inputStream()
        } else {
            context.contentResolver.openInputStream(
                fileFor(relativePath, create = false)?.uri ?: error("Missing $relativePath"),
            ) ?: error("Unable to read $relativePath")
        }

    fun exists(relativePath: String): Boolean =
        if (uri.scheme == "file") {
            File(uri.path!!, relativePath).exists()
        } else {
            fileFor(relativePath, create = false)?.exists() == true
        }

    fun readBytes(relativePath: String): ByteArray = openInputStream(relativePath).use { it.readBytes() }

    fun snapshot(): Map<String, ByteArray> {
        val files = linkedMapOf<String, ByteArray>()
        collectFilesRecursive(root, "", files)
        return files
    }

    fun assertSnapshotEquals(expected: Map<String, ByteArray>) {
        val actual = snapshot()
        assertThat(actual.keys).containsExactlyElementsIn(expected.keys)
        expected.forEach { (path, bytes) ->
            assertThat(actual[path]).isEqualTo(bytes)
        }
    }

    fun isEmpty(): Boolean = root.listFiles().isEmpty()

    fun deleteContents() {
        root.listFiles().forEach { it.deleteRecursively() }
    }

    private fun collectFilesRecursive(
        dir: DocumentFile,
        prefix: String,
        files: MutableMap<String, ByteArray>,
    ) {
        dir.listFiles().forEach { child ->
            val name = child.name ?: return@forEach
            val path = if (prefix.isBlank()) name else "$prefix/$name"
            if (child.isDirectory) {
                collectFilesRecursive(child, path, files)
            } else {
                files[path] = readBytes(path)
            }
        }
    }

    private fun fileFor(
        relativePath: String,
        create: Boolean,
    ): DocumentFile? {
        val parts = relativePath.split("/").filter { it.isNotBlank() }
        var current = root
        parts.dropLast(1).forEach { segment ->
            val next =
                current.findFile(segment)
                    ?: if (create) {
                        current.createDirectory(segment)
                    } else {
                        return null
                    }
            current = next ?: return null
        }
        val filename = parts.last()
        return current.findFile(filename)
            ?: if (create) current.createFile(mimeTypeFor(filename), filename) else null
    }

    private fun DocumentFile.deleteRecursively() {
        if (isDirectory) listFiles().forEach { it.deleteRecursively() }
        delete()
    }

    companion object {
        private val json =
            Json {
                ignoreUnknownKeys = true
                encodeDefaults = true
            }

        fun createLocal(name: String = "yorvana-vault-fixture-${UuidGenerator.generate()}"): VaultFixture {
            val context = InstrumentationRegistry.getInstrumentation().targetContext
            val externalFilesDir = context.getExternalFilesDir(null) ?: error("External files dir unavailable")
            val rootDir =
                File(externalFilesDir, name).apply {
                    if (exists()) deleteRecursively()
                    mkdirs()
                }
            return VaultFixture(context, Uri.fromFile(rootDir))
        }

        fun useExisting(uri: Uri): VaultFixture {
            val context = InstrumentationRegistry.getInstrumentation().targetContext
            takePersistablePermission(context, uri)
            return VaultFixture(context, uri)
        }

        fun active(): VaultFixture {
            val context = InstrumentationRegistry.getInstrumentation().targetContext
            val uriString =
                runBlocking {
                    (context.applicationContext as com.yorvana.YorvanaApplication)
                        .preferences
                        .preferences
                        .first()
                        .vaultUriString
                } ?: error("No active vault URI")
            return useExisting(Uri.parse(uriString))
        }

        private fun takePersistablePermission(
            context: Context,
            uri: Uri,
        ) {
            if (uri.scheme == "file") return
            runCatching {
                context.contentResolver.takePersistableUriPermission(
                    uri,


find .github/workflows -maxdepth 1 -type f -print


sed -n '1,260p' .github/workflows/regression.yml


sed -n '1,240p' .github/workflows/smoke.yml


Output:
Chunk ID: 5f81f2
Wall time: 0.3605 seconds
Process exited with code 0
Original token count: 85
Output:
.github/workflows/baseline-reminder.yml
.github/workflows/smoke-label.yml
.github/workflows/ci-label.yml
.github/workflows/semgrep.yml
.github/workflows/smoke.yml
.github/workflows/ci.yml
.github/workflows/deploy-play-store.yml
.github/workflows/regression-label.yml
.github/workflows/regression.yml
.github/workflows/billing-sandbox.yml


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

on:
  workflow_dispatch:
  workflow_call:

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

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

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

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

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

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

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


Output:
Chunk ID: 871e9e
Wall time: 0.3491 seconds
Process exited with code 0
Original token count: 107
Output:
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.compose.compiler) apply false
    alias(libs.plugins.roborazzi) apply false
    alias(libs.plugins.ktlint) apply false
    alias(libs.plugins.detekt) apply false
    alias(libs.plugins.git.hooks)
}

gitHooks {
    setHooks(mapOf("pre-commit" to "ktlintCheck detekt lintDebug"))
}


# Issue #119: Macrobenchmark Phase 6

## Summary
Implement Phase 6 as a new `:macrobenchmark` Android test module that benchmarks cold start, vehicle-list scroll, record-list scroll, and garage-to-detail-to-record navigation on a dedicated Pixel 2 API 33 GMD task. Use emulator results only as advisory absolute performance data, with CI hard-failing only on >20% relative regression against committed calibration baselines.

## Key Changes
- Add `:macrobenchmark` to `settings.gradle.kts`, add version-catalog aliases for `androidx.benchmark:benchmark-macro-junit4` and the Android test plugin, and create `macrobenchmark/build.gradle.kts` using `com.android.test`, `targetProjectPath = ":app"`, a non-debuggable `benchmark` build type, and a dedicated `pixel2api33` managed device that generates `:macrobenchmark:pixel2api33BenchmarkAndroidTest`.
- Add an `app` `benchmark` build type with `debuggable = false`, debug signing, `matchingFallbacks = ["release"]`, `BuildConfig.BENCHMARK = true`, and profileable manifest support only for the benchmark variant. Do not alter `release` or `staging` user-build behavior.
- Use fixture strategy **C(b)**: a benchmark-variant-only exported seed activity/action in `app/src/benchmark` that macrobenchmark invokes with `am start`. It seeds deterministic file-backed vaults, marks setup complete, and lands on the garage screen. Provide two fixture names: `large_20v` and `large_200r`.
- Add production selector support: `TestTags.VEHICLE_LIST`, `TestTags.RECORD_LIST`, keep/use `TestTags.vehicleItem(id)`, tag the vehicle and record `LazyColumn`s, and apply `semantics { testTagsAsResourceId = true }` at the app-root hierarchy that hosts `AppNavGraph`.
- Implement M-04 with `FrameTimingMetric` over a scripted navigation journey, not `TraceSectionMetric`. This avoids shipping misleading trace spans around Navigation 3 transitions unless a reliable transition-work span is added later.
- Add `macrobenchmark/README.md` documenting swiftshader caveats, fixture ownership, M-04 metric choice, tag-as-resource-id placement, baseline calibration, and fail/warn policy.
- Wire `:macrobenchmark:pixel2api33BenchmarkAndroidTest` into root `regressionFullCheck` as a normal sibling dependency beside the existing GradleBuild regression tasks. Do not put it inside those child invocations.
- Update `.github/workflows/regression.yml` artifact upload to include macrobenchmark JSON/results on every scheduled run outcome, success or failure.

## Benchmark Interfaces
- Add benchmark tests:
  - `M01StartupBenchmark`: `StartupTimingMetric`, cold start, advisory P50 target <= 2000ms.
  - `M02VehicleListScrollBenchmark`: seed `large_20v`, drive `By.res(TestTags.VEHICLE_LIST)`, measure `FrameTimingMetric`.
  - `M03RecordListScrollBenchmark`: seed `large_200r`, open the seeded vehicle, drive `By.res(TestTags.RECORD_LIST)`, measure `FrameTimingMetric`.
  - `M04NavigationBenchmark`: seed `large_200r`, navigate garage -> detail -> record with stable `By.res(...)` selectors, measure `FrameTimingMetric`.
- Commit a small baseline file under `macrobenchmark/baselines/` containing the 5-run calibrated metric values used by the threshold checker.
- Add benchmark-side threshold assertions/helper code that fails on relative regression >20% versus the committed baseline and logs absolute target misses as advisory output only.

## Test Plan
- Static/build checks: `./gradlew :app:compileBenchmarkKotlin :macrobenchmark:compileBenchmarkKotlin` or the closest generated compile tasks after module creation.
- Focused benchmark task: `./gradlew :macrobenchmark:pixel2api33BenchmarkAndroidTest -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect`.
- Aggregator: `./gradlew regressionFullCheck -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect` and confirm macrobenchmark runs as a sibling dependency.
- Quality gate: `./gradlew ktlintCheck detekt lintDebug`.
- Calibration: run the benchmark task 5 times on the same GMD shape, commit the baseline values, and note the calibration in the PR description.

## Assumptions
- Choose benchmark-variant seed activity over a shared test-fixtures module to keep the diff scoped and avoid duplicating app/androidTest helpers.
- Choose `FrameTimingMetric` for M-04 because current `AppNavGraph` does not expose a reliable span covering destination composition plus transition animation frames.
- Absolute emulator numbers remain advisory; only relative regression against committed baselines fails CI.


<proposed_plan>
# Issue #119: Macrobenchmark Phase 6

## Summary
Implement Phase 6 as a new `:macrobenchmark` Android test module that benchmarks cold start, vehicle-list scroll, record-list scroll, and garage-to-detail-to-record navigation on a dedicated Pixel 2 API 33 GMD task. Use emulator results only as advisory absolute performance data, with CI hard-failing only on >20% relative regression against committed calibration baselines.

## Key Changes
- Add `:macrobenchmark` to `settings.gradle.kts`, add version-catalog aliases for `androidx.benchmark:benchmark-macro-junit4` and the Android test plugin, and create `macrobenchmark/build.gradle.kts` using `com.android.test`, `targetProjectPath = ":app"`, a non-debuggable `benchmark` build type, and a dedicated `pixel2api33` managed device that generates `:macrobenchmark:pixel2api33BenchmarkAndroidTest`.
- Add an `app` `benchmark` build type with `debuggable = false`, debug signing, `matchingFallbacks = ["release"]`, `BuildConfig.BENCHMARK = true`, and profileable manifest support only for the benchmark variant. Do not alter `release` or `staging` user-build behavior.
- Use fixture strategy **C(b)**: a benchmark-variant-only exported seed activity/action in `app/src/benchmark` that macrobenchmark invokes with `am start`. It seeds deterministic file-backed vaults, marks setup complete, and lands on the garage screen. Provide two fixture names: `large_20v` and `large_200r`.
- Add production selector support: `TestTags.VEHICLE_LIST`, `TestTags.RECORD_LIST`, keep/use `TestTags.vehicleItem(id)`, tag the vehicle and record `LazyColumn`s, and apply `semantics { testTagsAsResourceId = true }` at the app-root hierarchy that hosts `AppNavGraph`.
- Implement M-04 with `FrameTimingMetric` over a scripted navigation journey, not `TraceSectionMetric`. This avoids shipping misleading trace spans around Navigation 3 transitions unless a reliable transition-work span is added later.
- Add `macrobenchmark/README.md` documenting swiftshader caveats, fixture ownership, M-04 metric choice, tag-as-resource-id placement, baseline calibration, and fail/warn policy.
- Wire `:macrobenchmark:pixel2api33BenchmarkAndroidTest` into root `regressionFullCheck` as a normal sibling dependency beside the existing GradleBuild regression tasks. Do not put it inside those child invocations.
- Update `.github/workflows/regression.yml` artifact upload to include macrobenchmark JSON/results on every scheduled run outcome, success or failure.

## Benchmark Interfaces
- Add benchmark tests:
  - `M01StartupBenchmark`: `StartupTimingMetric`, cold start, advisory P50 target <= 2000ms.
  - `M02VehicleListScrollBenchmark`: seed `large_20v`, drive `By.res(TestTags.VEHICLE_LIST)`, measure `FrameTimingMetric`.
  - `M03RecordListScrollBenchmark`: seed `large_200r`, open the seeded vehicle, drive `By.res(TestTags.RECORD_LIST)`, measure `FrameTimingMetric`.
  - `M04NavigationBenchmark`: seed `large_200r`, navigate garage -> detail -> record with stable `By.res(...)` selectors, measure `FrameTimingMetric`.
- Commit a small baseline file under `macrobenchmark/baselines/` containing the 5-run calibrated metric values used by the threshold checker.
- Add benchmark-side threshold assertions/helper code that fails on relative regression >20% versus the committed baseline and logs absolute target misses as advisory output only.

## Test Plan
- Static/build checks: `./gradlew :app:compileBenchmarkKotlin :macrobenchmark:compileBenchmarkKotlin` or the closest generated compile tasks after module creation.
- Focused benchmark task: `./gradlew :macrobenchmark:pixel2api33BenchmarkAndroidTest -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect`.
- Aggregator: `./gradlew regressionFullCheck -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect` and confirm macrobenchmark runs as a sibling dependency.
- Quality gate: `./gradlew ktlintCheck detekt lintDebug`.
- Calibration: run the benchmark task 5 times on the same GMD shape, commit the baseline values, and note the calibration in the PR description.

## Assumptions
- Choose benchmark-variant seed activity over a shared test-fixtures module to keep the diff scoped and avoid duplicating app/androidTest helpers.
- Choose `FrameTimingMetric` for M-04 because current `AppNavGraph` does not expose a reliable span covering destination composition plus transition animation frames.
- Absolute emulator numbers remain advisory; only relative regression against committed baselines fails CI.
</proposed_plan>

<oai-mem-citation>
<citation_entries>
MEMORY.md:121-135|note=[issue 119 belongs to regression planning phase sweep context]
MEMORY.md:87-90|note=[regression task shape and fixture setup constraints]
MEMORY.md:171-173|note=[issue text is authoritative and task filtering must be exact]
</citation_entries>
<rollout_ids>
019e4aed-ada7-7722-8582-bb7de1defd4b
019e4f78-019c-7872-b769-76cceb5a60d2
019e53d6-41f4-79d3-ad40-caedbf878c23
</rollout_ids>
</oai-mem-citation>


<collaboration_mode># Collaboration Mode: Default

You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.

Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.

## request_user_input availability

Use the `request_user_input` tool only when it is listed in the available tools for this turn.

In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
</collaboration_mode>


# Collaboration Mode: Default

You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.

Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.

## request_user_input availability

Use the `request_user_input` tool only when it is listed in the available tools for this turn.

In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.

Summary: auto


Implement the plan.


I’ll implement the Phase 6 plan now: add the benchmark variant/module, benchmark seed hook, stable selectors, CI artifact wiring, then run focused build checks.


rg -n "benchmark|profileable|testTagsAsResourceId|VEHICLE_LIST|RECORD_LIST|macrobenchmark" .


rg -n "android-test|com.android.test|benchmark-macro|androidx.benchmark|android-test" gradle/libs.versions.toml build.gradle.kts app/build.gradle.kts settings.gradle.kts


Output:
Chunk ID: 5d6bb1
Wall time: 0.3172 seconds
Process exited with code 0
Original token count: 0
Output:


Output:
Chunk ID: d00d53
Wall time: 0.3492 seconds
Process exited with code 0
Original token count: 1412
Output:
./docs/plan-regression-tests.md:27:| 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 |
./docs/plan-regression-tests.md:42:| Macrobenchmark | separate module | Nightly | ~15 min |
./docs/plan-regression-tests.md:47:- **`regressionFullCheck`** — Nightly. Aggregator task that depends on the sibling tasks below, plus eventually the macrobenchmark module. It does *not* `dependsOn(regressionCheck)`, because that task's `notAnnotation=...Quarantined` filter would otherwise hide quarantined core tests from nightly too (contradicting §1's quarantine policy). **Phase 1 (#114) wires only the first two siblings**. Phase 5 (#118) chose the external-device billing path, so the sandbox tier runs in `billing-sandbox.yml` rather than under `regressionFullCheck`; the `:macrobenchmark` `dependsOn` is deferred to **Phase 6 (#119)**.
./docs/plan-regression-tests.md:55:  - None of the three set `notAnnotation`. The `:macrobenchmark` module's GMD-generated benchmark task (e.g. `:macrobenchmark:pixel2api33BenchmarkAndroidTest`, from the dedicated GMD entry registered in `:macrobenchmark` per Phase 6 and the §2 table) is wired as an additional `dependsOn` to keep "core + full + sandbox + benchmark" in one entry point. It is deliberately *not* `:macrobenchmark:connectedBenchmarkAndroidTest` — `connected*AndroidTest` targets ad-hoc currently-connected devices that CI doesn't have, so the nightly aggregate could otherwise pass without ever running the benchmark tier. (Separate tiers rather than a comma list because AndroidJUnitRunner's `annotation` argument takes a single class, and the sandbox tier needs a different device target anyway.)
./docs/plan-regression-tests.md:76:- **R-R03** — 200-record list **correctness only**: scroll reaches first and last records, item order is stable, no crash or state loss. Frame-timing / jank measurement belongs to **M-03** (Phase 6 #119) — no `FrameTimingMetric` / Macrobenchmark code lands in Phase 2.
./docs/plan-regression-tests.md:145:### 3.9 NFRs (NFR-1, NFR-6) — Macrobenchmark module
./docs/plan-regression-tests.md:157:- Wire `regressionCheck` and `regressionFullCheck` Gradle tasks per §2 using a **single chosen mechanism** (either `GradleBuild` child invocations with `startParameter.projectProperties`, or variant/device-scoped tasks with baked-in `instrumentationRunnerArguments`) — same shape for both, documented in the PR description. `regressionFullCheck` in Phase 1 wires **two siblings** (`regressionFullCoreCheck`, `regressionFullExtendedCheck`); `regressionFullSandboxCheck` is deferred to Phase 5 and the `:macrobenchmark` dependency to Phase 6.
./docs/plan-regression-tests.md:196:### Phase 6 — Macrobenchmark module (2 days) — issue **#119**
./docs/plan-regression-tests.md:198:- New `:macrobenchmark` module (`com.android.test` + `androidx.benchmark:benchmark-macro-junit4`), M-01..04. Baseline thresholds calibrated over 5 runs; alert on >20% regression.
./docs/plan-regression-tests.md:199:- Register a dedicated GMD entry inside `:macrobenchmark` so AGP generates `:macrobenchmark:pixel2api33BenchmarkAndroidTest`; wire that exact task into `regressionFullCheck`'s `dependsOn` **as a sibling outside any Phase 1 `GradleBuild` child invocation** so app-module instrumentation args don't leak into the benchmark run. Do **not** depend on `:macrobenchmark:connectedBenchmarkAndroidTest`.
./docs/plan-regression-tests.md:200:- **Prerequisites that must land before benchmarks are meaningful:**
./docs/plan-regression-tests.md:202:  - **B. Stable test tags.** `TestTags.VEHICLE_LIST`, `TestTags.vehicleItem(id)`, `TestTags.RECORD_LIST` wired in production composables, plus `Modifier.semantics { testTagsAsResourceId = true }` applied to the Compose hierarchy hosting them so `By.res(...)` in `:macrobenchmark` resolves.
./docs/plan-regression-tests.md:203:  - **C. Cross-module fixture ownership.** Either (a) extract seeding into a shared `:testfixtures` module, (b) expose a benchmark-variant-only test hook (`ContentProvider` / `ActivityAction`) **not gated on `BuildConfig.DEBUG`** (the benchmark variant is non-debuggable; gate on the `benchmark` build type instead), or (c) intentional duplication with sync requirement.
./docs/plan-regression-tests.md:204:  - **D. Benchmark build variant.** `targetProjectPath = ":app"`, dedicated `benchmark` build type (`debuggable = false`, `signingConfig = signingConfigs.debug`, `matchingFallbacks = listOf("release")`, `<profileable shell="true" />` on `:app`'s manifest); `androidx.benchmark.suppressErrors` only for known-acceptable conditions (e.g. `EMULATOR`); macrobenchmark JSON output uploaded as a CI artifact on every run.
./docs/plan-regression-tests.md:212:- **Flake-rate semantics** documented explicitly (passed-on-retry counts as flake; `Assume.assumeTrue` skips excluded from denominator; `@Ignore` excluded entirely; infrastructure failures recorded separately; benchmark failures categorised separately; cancellations excluded). Applied in the aggregator math.
./app/build.gradle.kts:360:    description = "Runs Phase 1 full regression checks; sandbox and macrobenchmark siblings are deferred."


Output:
Chunk ID: 399527
Wall time: 0.3124 seconds
Process exited with code 1
Original token count: 0
Output:


The worktree is clean. I’m going to make the smallest complete implementation: use a benchmark-only activity hook for seeding, avoid production trace spans by using frame timing for navigation, and keep the benchmark task as a sibling of the existing regression GradleBuild tasks.


sed -n '1,180p' app/src/main/java/com/yorvana/data/model/Vehicle.kt


sed -n '1,220p' app/src/main/java/com/yorvana/data/model/ServiceRecord.kt


Output:
Chunk ID: 5ae342
Wall time: 0.3057 seconds
Process exited with code 0
Original token count: 2099
Output:
                    apiLevel = 34
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
                create<com.android.build.api.dsl.ManagedVirtualDevice>("tablet10PixelTabletApi34") {
                    device = "Pixel Tablet"
                    apiLevel = 34
                    systemImageSource = "aosp"
                    testedAbi = "x86_64"
                }
            }
        }
    }

    lint {
        abortOnError = true
        warningsAsErrors = false
        xmlReport = true
        htmlReport = true
        baseline = file("lint-baseline.xml")
    }
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    compilerOptions {
        jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
    }
}

detekt {
    config.setFrom(rootProject.file("config/detekt/detekt.yml"))
    buildUponDefaultConfig = true
    autoCorrect = true
}

tasks.named("check") {
    dependsOn("lintDebug")
}

afterEvaluate {
    val requestedTaskNames = gradle.startParameter.taskNames.map { it.substringAfterLast(":") }
    val screenshotDeviceNames = setOf("phonePixel6api34", "tablet7Nexus7api34", "tablet10PixelTabletApi34")
    val isStoreScreenshotRun =
        requestedTaskNames.any { requestedTaskName ->
            requestedTaskName == "generateStoreScreenshots" || requestedTaskName.endsWith("StoreScreenshots")
        }
    val isSmokeCoverageRun = requestedTaskNames.any { it == "generateGmdCoverage" }

    // Note: This reflective block and the use of afterEvaluate make the build incompatible with
    // the Configuration Cache (CC) for managed device test tasks. ManagedDeviceInstrumentationTestTask
    // cannot be serialized when configured reflectively in afterEvaluate. We accept this trade-off
    // to enable per-device instrumentation arguments which AGP 8.x does not yet expose via a public DSL.
    tasks.withType<com.android.build.gradle.internal.tasks.ManagedDeviceInstrumentationTestTask>().configureEach {
        fun isTaskForDevice(
            taskName: String,
            deviceName: String,
        ): Boolean = taskName.equals("$deviceName${testVariantCap}AndroidTest", ignoreCase = true)

        fun isRequestedForDevice(deviceName: String): Boolean =
            requestedTaskNames.any { requestedTaskName ->
                requestedTaskName.equals("$deviceName${testVariantCap}AndroidTest", ignoreCase = true) ||
                    requestedTaskName.equals("${deviceName}Check", ignoreCase = true)
            } ||
                (deviceName == "pixel2api33" && isSmokeCoverageRun) ||
                (deviceName in screenshotDeviceNames && isStoreScreenshotRun)

        val deviceDirName =
            when {
                isTaskForDevice(name, "phonePixel6api34") -> "phonePixel6api34"
                isTaskForDevice(name, "tablet7Nexus7api34") -> "tablet7Nexus7api34"
                isTaskForDevice(name, "tablet10PixelTabletApi34") -> "tablet10PixelTabletApi34"
                isTaskForDevice(name, "pixel2api33") -> "pixel2api33"
                else -> null
            }
        if (deviceDirName != null) {
            // Set the host-side directory where GMD will pull files from.
            getAdditionalTestOutputDir().set(
                layout.buildDirectory.dir("outputs/managed_device_android_test_additional_output/$testVariant/$deviceDirName"),
            )
        }

        // AGP does not provide a public DSL to set per-ManagedVirtualDevice instrumentation arguments.
        // We use reflection to reach into the internal testData property and its instrumentationRunnerArguments map.
        val getTestDataMethod = javaClass.getMethod("getTestData")
        val testDataProperty = getTestDataMethod.invoke(this) as org.gradle.api.provider.Property<*>
        val testData = testDataProperty.get()
        val getArgsMethod = testData.javaClass.getMethod("getInstrumentationRunnerArguments")

        @Suppress("UNCHECKED_CAST")
        val argsProperty = getArgsMethod.invoke(testData) as org.gradle.api.provider.MapProperty<String, String>

        val myTask = this
        val taskName = name
        // Intentionally captured at configuration time: direct invocations and child GradleBuild
        // regression tasks each configure a fresh build with their own -P runner arguments.
        val isExplicitAnnotationRun = providers.gradleProperty("android.testInstrumentationRunnerArguments.annotation").isPresent
        argsProperty.putAll(
            provider {
                val map = mutableMapOf<String, String>()
                // ManagedDeviceInstrumentationTestTask instances can share AGP's underlying
                // runner argument map during configuration. Only the device task requested by
                // this Gradle invocation contributes arguments, while regression GradleBuild
                // children pass their explicit annotation filters in their own invocation.
                if (!isRequestedForDevice(deviceDirName.orEmpty())) {
                    return@provider map
                }
                when {
                    isTaskForDevice(taskName, "phonePixel6api34") -> {
                        map["package"] = screenshotTestPackage
                        map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                    }
                    isTaskForDevice(taskName, "tablet7Nexus7api34") -> {
                        map["package"] = screenshotTestPackage
                        map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                    }
                    isTaskForDevice(taskName, "tablet10PixelTabletApi34") -> {
                        map["package"] = screenshotTestPackage
                        map["additionalTestOutputDir"] = screenshotDeviceOutputDir
                    }
                    isTaskForDevice(taskName, "pixel2api33") -> {
                        // Keep this exact AOSP smoke task match. A future Play/staging billing
                        // task must not inherit the smoke annotation filter.
                        if (!isExplicitAnnotationRun) {
                            map["annotation"] = smokeAnnotation
                        }
                        map["notPackage"] = screenshotTestPackage
                        map["additionalTestOutputDir"] = smokeDeviceOutputDir
                    }
                }
                map
            },
        )
    }
}

fun registerRegressionDeviceTask(
    taskName: String,
    descriptionText: String,
    runnerArguments: Map<String, String>,
): TaskProvider<GradleBuild> =
    tasks.register<GradleBuild>(taskName) {
        group = "Verification"
        description = descriptionText
        dir = rootProject.projectDir
        tasks = listOf(":app:$regressionDeviceTaskName")
        // Regression aggregate tasks own their runner filters; these values intentionally
        // override any outer -Pandroid.testInstrumentationRunnerArguments.annotation settings.
        startParameter.projectProperties.putAll(
            gradle.startParameter.projectProperties +
                runnerArguments.mapKeys { (key, _) -> "android.testInstrumentationRunnerArguments.$key" },
        )
    }

val regressionCheck =
    registerRegressionDeviceTask(
        taskName = "regressionCheck",
        descriptionText = "Runs core regression androidTests on the Pixel 2 API 33 managed device.",
        runnerArguments =
            mapOf(
                "annotation" to regressionAnnotation,
                "notAnnotation" to quarantinedAnnotation,
            ),
    )

val regressionFullCoreCheck =
    registerRegressionDeviceTask(
        taskName = "regressionFullCoreCheck",
        descriptionText = "Runs all core regression androidTests, including quarantined tests.",
        runnerArguments = mapOf("annotation" to regressionAnnotation),
    )

val regressionFullExtendedCheck =
    registerRegressionDeviceTask(
        taskName = "regressionFullExtendedCheck",
        descriptionText = "Runs full regression androidTests on the Pixel 2 API 33 managed device.",
        runnerArguments = mapOf("annotation" to regressionFullAnnotation),
    )

tasks.register("regressionFullCheck") {
    group = "Verification"
    description = "Runs Phase 1 full regression checks; sandbox and macrobenchmark siblings are deferred."


Output:
Chunk ID: 8709ca
Wall time: 0.3303 seconds
Process exited with code 0
Original token count: 2662
Output:
    description = "Runs Phase 1 full regression checks; sandbox and macrobenchmark siblings are deferred."
    dependsOn(regressionFullCoreCheck, regressionFullExtendedCheck)
}

tasks.register<Copy>("generateGmdCoverage") {
    group = "Verification"
    description = "Runs GMD tests and copies the coverage file to the baseline directory."

    // Coverage is always collected from the debug variant of the smoke device
    // regardless of the current -PscreenshotBuild setting.
    val smokeTask = "pixel2api33DebugAndroidTest"
    dependsOn(smokeTask)

    from(layout.buildDirectory.dir("outputs/managed_device_code_coverage/debug/pixel2api33")) {
        include("coverage.ec")
    }
    into(layout.projectDirectory.dir("coverage-baselines"))
    rename { "gmd_smoke.ec" }

    includeEmptyDirs = false
    duplicatesStrategy = DuplicatesStrategy.FAIL

    doLast {
        val destFile = layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec").asFile
        if (!destFile.exists()) {
            throw GradleException("Failed to generate GMD coverage. Expected file not found: $destFile")
        }
    }
}

val storeScreenshotFiles =
    listOf(
        "screen_01_vehicle_list.png",
        "screen_02_vehicle_detail.png",
        "screen_03_add_record.png",
        "screen_04_categories.png",
        "screen_05_settings_privacy.png",
        "screen_06_paywall_dialog.png",
    )
val storeScreenshotsRoot = rootProject.layout.projectDirectory.dir("distribution/store-assets/screenshots")
val storeAdditionalOutputRoot = layout.buildDirectory.dir("outputs/managed_device_android_test_additional_output/$testVariant")

fun registerStoreScreenshotCopy(
    taskPrefix: String,
    deviceTaskName: String,
    outputDirectory: String,
    previousCopyTask: TaskProvider<out Task>? = null,
): TaskProvider<Copy> {
    tasks.named<Task>(deviceTaskName).configure {
        previousCopyTask?.let { mustRunAfter(it) }
        // We inject a doFirst block to wipe the additional output root before every run.
        // This ensures a clean state for the subsequent copy task, but has the side-effect
        // of clearing previous outputs even when running a single device test for debugging.
        doFirst {
            delete(storeAdditionalOutputRoot)
        }
    }

    return tasks.register<Copy>("copy${taskPrefix}StoreScreenshots") {
        previousCopyTask?.let { dependsOn(it) }
        dependsOn(deviceTaskName)
        into(storeScreenshotsRoot.dir(outputDirectory))
        includeEmptyDirs = false
        duplicatesStrategy = DuplicatesStrategy.FAIL

        doFirst {
            delete(storeScreenshotsRoot.dir(outputDirectory))
        }

        from(storeAdditionalOutputRoot) {
            // AGP sometimes aliases instrumentation arguments across GMD tasks,
            // leading to screenshots being saved in the wrong bucket subfolder on the host.
            // We use a wildcard to grab any screenshot found in the device's output root.
            include("**/$outputDirectory/screen_*.png")
            eachFile {
                relativePath = RelativePath(true, name)
            }
        }

        doLast {
            val missing =
                storeScreenshotFiles.mapNotNull { fileName ->
                    val file = storeScreenshotsRoot.file("$outputDirectory/$fileName").asFile
                    if (file.exists()) null else file
                }
            if (missing.isNotEmpty()) {
                throw GradleException(
                    "Missing generated store screenshots for $outputDirectory:\n" +
                        missing.joinToString(separator = "\n") { "- ${it.path}" },
                )
            }
        }
    }
}

afterEvaluate {
    val copyPhoneStoreScreenshots =
        registerStoreScreenshotCopy(
            taskPrefix = "Phone",
            deviceTaskName = "phonePixel6api34${testVariantCap}AndroidTest",
            outputDirectory = "phone",
        )
    val copyTablet7StoreScreenshots =
        registerStoreScreenshotCopy(
            taskPrefix = "Tablet7",
            deviceTaskName = "tablet7Nexus7api34${testVariantCap}AndroidTest",
            outputDirectory = "tablet-7",
            previousCopyTask = copyPhoneStoreScreenshots,
        )
    val copyTablet10StoreScreenshots =
        registerStoreScreenshotCopy(
            taskPrefix = "Tablet10",
            deviceTaskName = "tablet10PixelTabletApi34${testVariantCap}AndroidTest",
            outputDirectory = "tablet-10",
            previousCopyTask = copyTablet7StoreScreenshots,
        )

    tasks.register("generateStoreScreenshots") {
        group = "Verification"
        description = "Runs store screenshot tests on managed devices and copies PNGs into distribution assets."

        dependsOn(copyTablet10StoreScreenshots)
    }
}

tasks.register<JacocoReport>("verifyWithCoverage") {
    group = "Verification"
    description = "Runs JVM unit tests and generates a combined coverage report using JaCoCo with the static GMD baseline."

    // Unit tests are variant-agnostic; we always use debug for coverage consistency
    dependsOn("testDebugUnitTest")

    reports {
        xml.required.set(true)
        html.required.set(true)
    }

    val classFilter =
        listOf(
            "**/R.class",
            "**/R$*.class",
            "**/BuildConfig.*",
            "**/Manifest*.*",
            "**/*Test*.*",
            "android/**/*.*",
            "**/*_ViewBinding*.*",
            "**/*_MembersInjector*.*",
            "**/*_Factory*.*",
            "**/*_Provide*Factory*.*",
            "**/*ExtensionsKt*.*",
            // ComposableSingletons are compiler-generated classes for @Composable lambdas
            // that are often static and difficult to cover in unit tests, creating noise.
            "**/*ComposableSingletons*.*",
        )

    val debugTree =
        fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) {
            exclude(classFilter)
        }
    val javaTree =
        fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) {
            exclude(classFilter)
        }

    classDirectories.setFrom(files(debugTree, javaTree))
    sourceDirectories.setFrom(
        files(
            "src/main/java",
            "src/main/kotlin",
            "src/debug/java",
            "src/debug/kotlin",
        ),
    )

    // Collect all execution data from both unit tests and instrumented tests
    // Using specific roots to avoid implicit dependency issues with other tasks' outputs in the build folder
    val executionDataFiles =
        files(
            fileTree(layout.buildDirectory.dir("outputs/unit_test_code_coverage")) { include("**/*.exec") },
            layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec"),
            fileTree(layout.buildDirectory.dir("jacoco")) { include("**/*.exec") },
        )

    executionData.setFrom(executionDataFiles)

    doFirst {
        val lfsBaseline = layout.projectDirectory.file("coverage-baselines/gmd_smoke.ec").asFile
        if (!lfsBaseline.exists()) {
            throw GradleException(
                "Required coverage baseline is missing: ${lfsBaseline.path}. " +
                    "Run 'git lfs pull' to fetch tracked baselines or './gradlew generateGmdCoverage' to regenerate it.",
            )
        }
        if (lfsBaseline.length() < 1000) {
            val header = lfsBaseline.inputStream().use { it.readNBytes(100) }.toString(Charsets.UTF_8)
            if (header.startsWith("version https://git-lfs.github.com/spec/v1")) {
                throw GradleException("Coverage baseline is a Git LFS pointer. Please run 'git lfs pull' to fetch the actual file.")
            }
        }

        val existingFiles = executionDataFiles.files.filter { it.exists() }
        val dynamicExecutionFiles = existingFiles.filter { it.name != "gmd_smoke.ec" }
        if (dynamicExecutionFiles.isEmpty()) {
            throw GradleException(
                "No dynamic JaCoCo execution data files were found. " +
                    "Ensure tests were executed with coverage enabled (e.g., via -Pcoverage).\n" +
                    "Searched in:\n" +
                    "- ${layout.buildDirectory.dir("outputs/unit_test_code_coverage").get()}\n" +
                    "- ${layout.buildDirectory.dir("jacoco").get()}",
            )
        }
    }
}

val printBillingSandboxSkipReasons =
    tasks.register("printBillingSandboxSkipReasons") {
        group = "Verification"
        description = "Prints billing sandbox assumption skip reasons from connected staging androidTest results."
        onlyIf { isBillingSandboxBuild }

        doLast {
            fun unescapeTextProtoString(value: String): String =
                value
                    .replace("\\n", "\n")
                    .replace("\\\"", "\"")
                    .replace("\\\\", "\\")

            fun extractTextProtoStringFields(
                text: String,
                fieldName: String,
            ): Sequence<String> =
                sequence {
                    var cursor = 0
                    val fieldToken = "$fieldName:"
                    while (cursor < text.length) {
                        val fieldIndex = text.indexOf(fieldToken, cursor)
                        if (fieldIndex == -1) return@sequence
                        var index = fieldIndex + fieldToken.length
                        val chunks = mutableListOf<String>()
                        while (index < text.length) {
                            while (index < text.length && text[index].isWhitespace()) index++
                            if (index >= text.length || text[index] != '"') break
                            index++
                            val chunk = StringBuilder()
                            var escaped = false
                            var closed = false
                            while (index < text.length) {
                                val char = text[index++]
                                if (escaped) {
                                    chunk.append('\\').append(char)
                                    escaped = false
                                } else if (char == '\\') {
                                    escaped = true
                                } else if (char == '"') {
                                    closed = true
                                    break
                                } else {
                                    chunk.append(char)
                                }
                            }
                            if (!closed) {


Output:
Chunk ID: 473180
Wall time: 0.3139 seconds
Process exited with code 0
Original token count: 144
Output:
package com.yorvana.data.model

import kotlinx.serialization.Serializable

@Serializable
data class Vehicle(
    val schemaVersion: Int = 1,
    val id: String,
    val nickname: String,
    val make: String = "",
    val model: String = "",
    val year: Int? = null,
    val vin: String? = null,
    val licensePlate: String? = null,
    val odometerUnit: OdometerUnit = OdometerUnit.KM,
    val createdAt: String,
    val lastServiceDate: String? = null,
    val recordCount: Int = 0,
)

enum class OdometerUnit(
    val label: String,
) {
    KM("km"),
    MI("mi"),
}


Output:
Chunk ID: f7bc19
Wall time: 0.3305 seconds
Process exited with code 0
Original token count: 170
Output:
package com.yorvana.data.model

import kotlinx.serialization.Serializable

@Serializable
data class ServiceRecord(
    val schemaVersion: Int = 1,
    val id: String,
    val vehicleId: String,
    // "yyyy-MM-dd"
    val date: String,
    val odometer: Int,
    // predefined slug or custom UUID
    val category: String,
    val performer: String? = null,
    val cost: Double? = null,
    // ISO 4217, e.g. "USD"
    val currency: String? = null,
    val notes: String? = null,
    // filenames relative to record folder
    val attachments: List<String> = emptyList(),
    val workItems: List<WorkItem> = emptyList(),
    val createdAt: String,
    val updatedAt: String,
)


sed -n '250,520p' app/src/main/java/com/yorvana/ui/vehicles/VehicleListScreen.kt


sed -n '300,560p' app/src/main/java/com/yorvana/ui/records/VehicleDetailScreen.kt


sed -n '1,220p' app/src/main/java/com/yorvana/data/model/Category.kt


rg -n "CategoriesFile|default categor|oil-change|oil" app/src/main/java app/src/test/java