Android overhaul: one CopyCategoryKeywords task in build-logic instead of a copy in :data and :core:fakes

The task class and its wiring were duplicated verbatim in both build scripts; bundleCategoryKeywords(resourcePath)
registers it once per module. Resource paths and inputs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:53:44 +02:00
parent 48fe119ae1
commit e4e6e5d792
11 changed files with 76 additions and 368 deletions
@@ -0,0 +1,59 @@
package se.hagstedt.kvitt.buildlogic
import com.android.build.api.variant.AndroidComponentsExtension
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFile
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
/** Copies the keyword table to [resourcePath] under [outputDir]. A missing table fails the build. */
abstract class CopyCategoryKeywords : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val table: RegularFileProperty
@get:Input
abstract val resourcePath: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun copy() {
val source = table.get().asFile
if (!source.isFile) throw GradleException("missing ${source.path}")
val root = outputDir.get().asFile
root.deleteRecursively()
val target = root.resolve(resourcePath.get())
target.parentFile.mkdirs()
source.copyTo(target)
}
}
/** `contracts/category-keywords.csv`, the keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6). */
fun Project.categoryKeywordsTable(): RegularFile = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv")
/**
* Bundles the keyword table as the Java resource [resourcePath] of every variant, copied from contracts/ at build time
* (never hand-copied). Used by `:data` and `:core:fakes`.
*/
fun Project.bundleCategoryKeywords(resourcePath: String) {
val copy = tasks.register("copyCategoryKeywords", CopyCategoryKeywords::class.java) {
description = "Copies contracts/category-keywords.csv into the main Java resources."
table.set(categoryKeywordsTable())
this.resourcePath.set(resourcePath)
outputDir.set(layout.buildDirectory.dir("generated/category-keywords"))
}
extensions.getByType(AndroidComponentsExtension::class.java).onVariants { variant ->
variant.sources.resources?.addGeneratedSourceDirectory(copy, CopyCategoryKeywords::outputDir)
}
}
+6 -37
View File
@@ -1,6 +1,8 @@
import se.hagstedt.kvitt.buildlogic.bundleCategoryKeywords
// :core:fakes — scenario-driven fakes of every repository interface of SPEC §14.2.4. Only for tests, screenshot tests
// and debug previews (the architecture check rejects it in release configurations). Computed values come from :domain.
// M7x add their scenarios under src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/ and nowhere else (SPEC §11.3).
// Each feature's scenarios live under src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/ (SPEC §11.3).
plugins {
id("kvitt.android.library")
}
@@ -11,39 +13,6 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
}
// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6) is bundled as the Java resource
// se/hagstedt/kvitt/fakes/category-keywords.csv, copied from contracts/category-keywords.csv at build time (never
// hand-copied). A missing table fails the build: the task's input file must exist.
abstract class CopyCategoryKeywords : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val table: RegularFileProperty
@get:Input
abstract val resourcePath: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun copy() {
val source = table.get().asFile
if (!source.isFile) throw GradleException("missing ${source.path}")
val root = outputDir.get().asFile
root.deleteRecursively()
val target = root.resolve(resourcePath.get())
target.parentFile.mkdirs()
source.copyTo(target)
}
}
val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv")
val copyCategoryKeywords = tasks.register<CopyCategoryKeywords>("copyCategoryKeywords") {
description = "Copies contracts/category-keywords.csv into the main Java resources."
table.set(categoryKeywordsTable)
resourcePath.set("se/hagstedt/kvitt/fakes/category-keywords.csv")
outputDir.set(layout.buildDirectory.dir("generated/category-keywords"))
}
androidComponents {
onVariants { variant -> variant.sources.resources?.addGeneratedSourceDirectory(copyCategoryKeywords, CopyCategoryKeywords::outputDir) }
}
// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6), bundled as the Java resource
// se/hagstedt/kvitt/fakes/category-keywords.csv.
bundleCategoryKeywords("se/hagstedt/kvitt/fakes/category-keywords.csv")
+11 -40
View File
@@ -1,6 +1,9 @@
import se.hagstedt.kvitt.buildlogic.bundleCategoryKeywords
import se.hagstedt.kvitt.buildlogic.categoryKeywordsTable
// :data — Room database, Ktor client, token store, sync engine, WorkManager workers, the real repositories and the APK
// updater (SPEC §6.2, §14.2, §14.4; owned by M5). Hilt modules here provide the implementations; :app (M8) binds the
// :core:model interfaces to them.
// updater (SPEC §6.2, §14.2, §14.4). Hilt modules here provide the implementations; :app binds the :core:model
// interfaces to them.
plugins {
id("kvitt.android.library")
id("kvitt.android.hilt")
@@ -41,48 +44,16 @@ dependencies {
// so a changed contract file re-runs the tests.
val contractsExamples = rootProject.layout.projectDirectory.dir("../contracts/examples")
val roomSchemas = layout.projectDirectory.dir("schemas")
val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv")
val keywordsTable = categoryKeywordsTable()
tasks.withType<Test>().configureEach {
inputs.dir(contractsExamples).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("contractsExamples")
inputs.dir(roomSchemas).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("roomSchemas")
systemProperty("kvitt.contracts.examples", contractsExamples.asFile.absolutePath)
systemProperty("kvitt.room.schemas", roomSchemas.asFile.absolutePath)
inputs.file(categoryKeywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords")
systemProperty("kvitt.contracts.keywords", categoryKeywordsTable.asFile.absolutePath)
inputs.file(keywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords")
systemProperty("kvitt.contracts.keywords", keywordsTable.asFile.absolutePath)
}
// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6) is bundled as the Java resource
// se/hagstedt/kvitt/data/category-keywords.csv, copied from contracts/category-keywords.csv at build time (never
// hand-copied). A missing table fails the build: the task's input file must exist.
abstract class CopyCategoryKeywords : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val table: RegularFileProperty
@get:Input
abstract val resourcePath: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun copy() {
val source = table.get().asFile
if (!source.isFile) throw GradleException("missing ${source.path}")
val root = outputDir.get().asFile
root.deleteRecursively()
val target = root.resolve(resourcePath.get())
target.parentFile.mkdirs()
source.copyTo(target)
}
}
val copyCategoryKeywords = tasks.register<CopyCategoryKeywords>("copyCategoryKeywords") {
description = "Copies contracts/category-keywords.csv into the main Java resources."
table.set(categoryKeywordsTable)
resourcePath.set("se/hagstedt/kvitt/data/category-keywords.csv")
outputDir.set(layout.buildDirectory.dir("generated/category-keywords"))
}
androidComponents {
onVariants { variant -> variant.sources.resources?.addGeneratedSourceDirectory(copyCategoryKeywords, CopyCategoryKeywords::outputDir) }
}
// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6), bundled as the Java resource
// se/hagstedt/kvitt/data/category-keywords.csv.
bundleCategoryKeywords("se/hagstedt/kvitt/data/category-keywords.csv")
@@ -1,99 +0,0 @@
package se.hagstedt.kvitt.feature.account
import android.content.res.Configuration
import android.os.LocaleList
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.captureScreenRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import org.robolectric.Shadows.shadowOf
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
* included): `src/test/screenshots/<state>_<sv_light|sv_dark|en_light>.png`, plus `<state>_sv_light_200.png` at 200 % font
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at mdpi (1x) to keep the goldens small (D-098). The
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
* snackbars render the same every run.
*/
object ScreenSnapshots {
const val QUALIFIERS = "w411dp-h891dp-mdpi"
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private const val SETTLE_MILLIS = 1_000L
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
@OptIn(ExperimentalRoborazziApi::class)
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 1f)) +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.mainClock.autoAdvance = false
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark, content = content)
}
}
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
// The screen (and a dialog window) draw in a later frame; one more frame and an idle main looper, so the
// capture shows this variant and not the previous one.
shadowOf(Looper.getMainLooper()).idle()
compose.mainClock.advanceTimeByFrame()
shadowOf(Looper.getMainLooper()).idle()
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}
@@ -1,99 +0,0 @@
package se.hagstedt.kvitt.feature.home
import android.content.res.Configuration
import android.os.LocaleList
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.captureScreenRoboImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.TimeZone
import org.robolectric.Shadows.shadowOf
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
* included): `src/test/screenshots/<state>_<sv_light|sv_dark|en_light>.png`, plus `<state>_sv_light_200.png` at 200 % font
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at mdpi (1x) to keep the goldens small (D-098). The
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
* snackbars render the same every run.
*/
object ScreenSnapshots {
const val QUALIFIERS = "w411dp-h891dp-mdpi"
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private const val SETTLE_MILLIS = 1_000L
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
@OptIn(ExperimentalRoborazziApi::class)
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 1f)) +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.mainClock.autoAdvance = false
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark, content = content)
}
}
variants.forEach { variant ->
current = variant
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
// The screen (and a dialog window: S-16's lost version, S-17's install dialog) draw in a later frame; one
// more frame and an idle main looper, so the capture shows this variant and not the previous one.
shadowOf(Looper.getMainLooper()).idle()
compose.mainClock.advanceTimeByFrame()
shadowOf(Looper.getMainLooper()).idle()
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> withMain(block: () -> T): T {
Dispatchers.setMain(UnconfinedTestDispatcher())
try {
return block()
} finally {
Dispatchers.resetMain()
}
}
@@ -1,76 +0,0 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Captures a whole screen in three variants (Swedish light and dark, English light; D-098): `src/test/screenshots/<name>_<sv_light|sv_dark|en_light>.png`, plus
* `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish, light), as `:core:design`'s Snapshots
* do for components. The phone is the Galaxy S26 (411 dp wide, SPEC §13.1); the clock is fixed at 17 October 2026
* 14:02 in Stockholm so every render is reproducible.
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 1f)) +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark) {
Box(Modifier.fillMaxSize()) { content() }
}
}
}
variants.forEach { variant ->
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}
@@ -1,17 +0,0 @@
package se.hagstedt.kvitt.feature.settings.logic
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/** `viewModelScope` runs on an unconfined test dispatcher, so every ViewModel call completes before it returns. */
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(UnconfinedTestDispatcher())
override fun finished(description: Description) = Dispatchers.resetMain()
}