Files
kvitt/android
jacobh2 ad3523116f Android: portless fixture URLs after D-124
D-124 serves Kvitt at https://<host> on 443. The README, the AppBuildInfo and
AboutRepositoryImpl comments, the S-21/S-22 fake scenarios and the :data test
fixtures drop the :8443 port. S-21's invite-created goldens and every S-22
golden re-recorded (link and server row now portless). The release-URL
normaliser test checks a portless URL and keeps one explicit-port case (a
typed port survives), on example.org instead of the owner's old host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:33:15 +02:00
..

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.

Public host (D-121). The release server's host name is the build property kvitt.publicHost, never a literal in the code: :app exposes it as BuildConfig.PUBLIC_HOST, as the manifest placeholder publicHost (the host of the https://<host>/i/<CODE> invite links, AC-2.6) and as RELEASE_SERVER_URL = https://<host>, which S-1 prefills (AC-2.7). Gradle takes -Pkvitt.publicHost=… first (infra/publish-apk.sh passes it), else the key kvitt.publicHost in android/local.properties, where the owner sets it (git-ignored, never committed), else the placeholder kvitt.example.invalid, so a release built without it cannot talk to a real host. Debug builds pair with the address typed on S-1. Tests and goldens never depend on it: they use fixed hosts.

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 + kvitt.android.screenshots KvittTheme, tokens, shared components, Canvas charts, formatters, shared failure texts and limits, shared strings (SPEC §6.2); its test fixtures are the shared test support (below)
:core:fakes kvitt.android.library FakeRepositories(scenario), FakeScenarios.empty; scenarios of feature X go in fakes/<x>/
:data kvitt.android.library + kvitt.android.hilt + KSP + Room 3 Room entities and KvittDatabase, schema in data/schemas/, Ktor client, sync engine, repositories, updater
:adapter:fcm kvitt.android.library + kvitt.android.hilt Firebase Cloud Messaging: the service, token calls and the mapping to PushEvent
:feature:* kvitt.android.feature the screens; 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)
:app kvitt.android.application + compose + hilt + screenshots Application, MainActivity, NavHost, push and notifications; both variants bind :data (DataBindingsModule through di/AppDataModule.kt, D-081); goldens in app/src/test/screenshots/

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, on a module's test fixtures outside a test 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() (from androidx.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 *State and event lambdas is what previews and screenshot tests render.

  • ViewModels are @HiltViewModel classes that receive repositories through the interfaces of :core:model only. :app binds those interfaces to :data with Hilt in both variants; tests and previews use :core:fakes.

  • Features never reference another feature, the NavHost or :data/:domain. :app owns the NavHost and wires the lambdas.

  • Strings live in the feature's own src/main/res/values/strings.xml and values-sv/strings.xml, keys prefixed with the feature name; shared strings in :core:design; :app holds only the app name.

  • Previews and screenshot tests use FakeRepositories(scenario) from :core:fakes (debugImplementation and testImplementation, added by the feature plugin). Fakes never reach a release build.

Screenshot tests

Roborazzi on Robolectric, wired by kvitt.android.screenshots (the plugin, outputDir src/test/screenshots and the test libraries; the feature plugin applies it). The harness is shared: :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing, testImplementation(testFixtures(project(":core:design"))), added to every feature by its plugin) hold Variant, TestClock, TestEnvironment, ScreenSnapshots (capture modes Root, Screen and ScreenWhenIdle), MainDispatcherRule and withMain. A module adds only its own frame around the content (AnalyticsSnapshots, SettleSnapshots, SettingsSnapshots, :core:design's Snapshots). 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.