- SyncEngineTest: 429 stops with Retry(rate_limited) before pulling; 400, 413, 409, 422 push refusals are no progress, the cycle pulls, succeeds and records last_error_code, one push only. - WorkManagerSyncWorkTest on work-testing's test WorkManager: kvitt-sync APPEND_OR_REPLACE (second request chained), kvitt-sync-periodic KEEP every 15 minutes, CONNECTED constraint. Adds androidx.work:work-testing (testImplementation, same version as work-runtime). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
Kvitt Android project
One Gradle project, modules as in SPEC §6.2 and §14.7. Build only through tools/gradle-serial.sh with
PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH (CLAUDE.md › Build and verify). The gate is tools/gradle-serial.sh check:
compile, unit tests (including every vector of contracts/test-vectors/), lint (MissingTranslation and
ExtraTranslation are errors), the architecture check and the screenshot tests.
Modules
| Module | Plugin (build-logic) | Notes |
|---|---|---|
:core:model |
kvitt.jvm.library |
UI contract (§14.2.4), domain types model.money (§14.5.1), analytics types model.analytics (§14.5.4) |
:domain |
kvitt.jvm.library |
§14.5 functions; VectorTest runs every vector file, copied by :domain:copyTestVectors |
:core:design |
kvitt.android.library + kvitt.android.compose |
KvittTheme, tokens, shared components, Canvas charts, formatters and shared strings (M6, 3080804; fixes M6b, 3a1bd14; SPEC §6.2); M6 owns the module |
:core:fakes |
kvitt.android.library |
FakeRepositories(scenario), FakeScenarios.empty; scenarios of feature X go in fakes/<x>/ (added by M7a to M7f) |
:data |
kvitt.android.library + KSP + Room 3 |
Room entities and KvittDatabase v1, schema in data/schemas/; M5 owns the module |
:adapter:fcm |
kvitt.android.library |
shell; M8 owns it |
:feature:* |
kvitt.android.feature |
screens built by M7a to M7f (landed 2026-10-18), goldens in feature/<x>/src/test/screenshots/ (full screen 411 × 891 dp at 1x, D-098); top-level screens draw their own KvittNavigationBar (SPEC §13.2, D-071); M7g does the cleanups after M6b |
:app |
kvitt.android.application + compose + hilt + Roborazzi |
Application, MainActivity, goldens in app/src/test/screenshots/; release binds :data (DataBindingsModule, e6ad858), debug binds the fakes in src/debug/.../DebugFakesModule.kt (72f21b2) until M8 deletes it; M8 owns the module |
Architecture check
architectureCheck runs in every module's check (build-logic/.../ArchitectureRules.kt holds the rules as data).
It fails on a project dependency outside the §6.2 table, on :core:fakes in any configuration that is not a test or
debug configuration, and, in :core:model and :domain, on an Android plugin, on a library outside
org.jetbrains.kotlin*, and on any import or fully qualified reference outside kotlin.*, kotlinx.* and
se.hagstedt.kvitt.* in src/main. A new module must be added to ArchitectureRules.allowed.
Feature convention
-
Each feature module exposes one composable entry point per screen. By default it obtains its ViewModel with
hiltViewModel()(fromandroidx.hilt:hilt-lifecycle-viewmodel-compose) and takes navigation as plain lambdas, for example:@Composable fun AccountDetailScreen( onOpenExpense: (ExpenseId) -> Unit, onAddExpense: (AccountId) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: AccountDetailViewModel = hiltViewModel(), )A stateless overload that takes the
*Stateand event lambdas is what previews and screenshot tests render. -
ViewModels are
@HiltViewModelclasses that receive repositories through the interfaces of:core:modelonly.:appbinds those interfaces to:datawith Hilt (release sincee6ad858, debug too once M8 deletes the temporary debug fakes module); tests and previews use:core:fakes. -
Features never reference another feature, the
NavHostor:data/:domain.:appowns theNavHostand wires the lambdas. -
Strings live in the feature's own
src/main/res/values/strings.xmlandvalues-sv/strings.xml, keys prefixed with the feature name; shared strings in:core:design;:appholds only the app name. -
Previews and screenshot tests use
FakeRepositories(scenario)from:core:fakes(debugImplementationandtestImplementation, added by the feature plugin). Fakes never reach a release build.
Screenshot tests
Roborazzi on Robolectric. roborazzi.test.verify=true in gradle.properties makes every unit-test run compare with the
committed goldens. Re-record an intended change with tools/gradle-serial.sh recordRoborazziDebug and open every
changed PNG before committing.
Golden layout (D-098, revising D-066): three variants per state, <name>_sv_light.png, <name>_sv_dark.png and
<name>_en_light.png (English covers string length, dark covers the theme; there is no en_dark), plus
<name>_sv_light_200.png at 200 % font scale for the long states. Every capture renders at 1x density: the
Robolectric qualifier is w411dp-h<height>dp-mdpi, so the Galaxy S26 viewport of 411 × 891 dp is 411 × 891 px and a
golden is a true 1x render, not a downscaled one. :app's empty_screen_<light|dark>.png are the only other names.