Android overhaul: stale comments and the module README

* Milestone and ownership notes that no longer hold ("owned by M5/M6/M8", "M8 replaces it", "(M8b)", "M7x add",
  "proposed decision in the M5 report", which is D-027 and D-028) in build scripts, :core:design, :core:fakes, :data,
  :app, :core:model and the settings string files.
* SyncEngine: the clock_skew KDoc sat orphaned above restampAfterClockSkew's; it now documents renewMutationId.
* libs.versions.toml header: no milestone, the drift date.
* README: the module table as built (no owners, no DebugFakesModule, which M8 removed), the test fixtures in the
  architecture check and the shared screenshot harness.

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 23:09:43 +02:00
parent 0714242954
commit a92c236689
24 changed files with 46 additions and 41 deletions
+14 -9
View File
@@ -11,18 +11,18 @@ compile, unit tests (including every vector of `contracts/test-vectors/`), lint
|---|---|---|
| `: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 |
| `: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, and, in `:core:model` and `:domain`, on an Android plugin, on a library outside
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`.
@@ -45,7 +45,7 @@ debug configuration, and, in `:core:model` and `:domain`, on an Android plugin,
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 (release since `e6ad858`, debug too once M8 deletes the temporary debug fakes module); tests and previews use `:core:fakes`.
`: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
@@ -55,7 +55,12 @@ debug configuration, and, in `:core:model` and `:domain`, on an Android plugin,
## Screenshot tests
Roborazzi on Robolectric. `roborazzi.test.verify=true` in `gradle.properties` makes every unit-test run compare with the
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.
@@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
/** The M2b shell: the themed surface with the app name. M8 replaces it with the navigation graph. */
/** The themed surface with the app name, shown until the session state has loaded (KvittApp). */
@Composable
fun KvittEmptyScreen(modifier: Modifier = Modifier) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
@@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (savedInstanceState == null) app.onLink(AppLinks.parse(intent))
// `:core:design` reads "now" and the zone from :data's clock (M6 contract request).
// `:core:design` reads "now" and the zone from :data's clock.
val kvittClock = KvittClock({ Instant.fromEpochMilliseconds(clock.nowMillis()) }, TimeZone.of(clock.zone().id))
setContent {
KvittTheme {
@@ -7,7 +7,7 @@ import org.gradle.api.artifacts.ProjectDependency
import org.gradle.api.plugins.BasePlugin
import org.gradle.language.base.plugins.LifecycleBasePlugin
/** Registers `architectureCheck` in every module and makes `check` depend on it (SPEC §6.2, owned by M2b). */
/** Registers `architectureCheck` in every module and makes `check` depend on it (SPEC §6.2). */
class ArchitecturePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
@@ -569,7 +569,7 @@ fun TopBarSheet() {
KvittTopAppBar(w("Ny utgift", "New expense"), navigation = TopBarNavigation.Close, actions = {
KvittButton(w("Spara", "Save"), {}, icon = KvittIcon.Check)
})
// Edge to edge (M8b): a 24 dp status-bar inset as on a phone; the fill runs behind it, the content sits below it.
// Edge to edge: a 24 dp status-bar inset as on a phone; the fill runs behind it, the content sits below it.
KvittTopAppBar(w("Statistik", "Analytics"), scrolled = true, windowInsets = WindowInsets(top = StatusBarSample))
}
@@ -360,7 +360,7 @@ private const val INVITE_MAX_LINES = 3
/**
* Groups the raw invite code for display (S-1): "7K3MQX9D2HPA" shows as "7K3M-QX9D-2HPA". The field's value stays the raw
* normalised code, so the platform keeps the cursor where the user typed and never lands it before a hyphen that
* appeared after the fourth or eighth character (M8b). A hyphen is shown only between groups, followed by a zero-width
* appeared after the fourth or eighth character. A hyphen is shown only between groups, followed by a zero-width
* space so a line may break after any group: the line breaker does not break between a hyphen and a digit
* ("QX9D-2HPA"), which cut the value at 200 % font scale. A cursor at a group boundary sits after the separator.
*/
@@ -228,7 +228,7 @@ object KvittDimens {
const val DISABLED_CONTAINER_ALPHA = 0.12f
const val DELETED_ALPHA = 0.62f
// Screen measures of design-spec §4 that the feature modules use (moved here from the features, M6b).
// Screen measures of design-spec §4 that the feature modules use (no feature writes a size of its own).
/** App mark (Sunny, `primary`, `receipt_long`): S-1 brand block 88 with icon 44; S-22 About 96 with icon 48. */
val appMark = 88.dp
val appMarkIcon = 44.dp
@@ -6,7 +6,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The invite code field's grouping (S-1, M8b): the value is the raw normalised code and the hyphens are display only, so
* The invite code field's grouping (S-1): the value is the raw normalised code and the hyphens are display only, so
* typing one character at a time keeps the cursor after the last character, and deleting across a hyphen removes the
* character before it. Each step mirrors the field: the edit lands at the original cursor, the field normalises the
* text, and the cursor is shown through the offset mapping.
@@ -28,7 +28,7 @@ import se.hagstedt.kvitt.design.KvittTheme
import java.util.Locale
/**
* A snackbar shown with [showKvittSnackbar] follows a language change while it is on screen (M6b item 5), and
* A snackbar shown with [showKvittSnackbar] follows a language change while it is on screen, and
* [styledStringResource] keeps the `<b>` parts of a resource.
*/
@RunWith(RobolectricTestRunner::class)
@@ -7,7 +7,7 @@ import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
/**
* Scenarios of the account screens (S-5, S-6; M7b) on the household of [HomeScenarios]. Open S-5 with the account ids
* Scenarios of the account screens (S-5, S-6) on the household of [HomeScenarios]. Open S-5 with the account ids
* named here; S-6 creates or edits against the same household.
*/
object AccountScenarios {
@@ -19,7 +19,7 @@ import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
/**
* Scenarios of S-11 (analytics) and S-12 (drill-down), M7e. All share FakeScenarios.empty's household and clock
* Scenarios of S-11 (analytics) and S-12 (drill-down). All share FakeScenarios.empty's household and clock
* (today 2026-10-17); the expense data is plain and deterministic so every screenshot is reproducible.
*
* - [loaded]: May to October 2026, eleven default categories, four accounts (one archived), settlements none.
@@ -23,7 +23,7 @@ import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
/**
* The household of the design spec's frames (S-4, S-5, S-16, S-17; M7b): Jacob (the viewer) and Anna, the accounts
* The household of the design spec's frames (S-4, S-5, S-16, S-17): Jacob (the viewer) and Anna, the accounts
* Vardag, Huset, Japan 2027 and Gotland 2026 (archived). The phone's clock is 17 October 2026 14:02 in Stockholm, the
* last sync three minutes earlier. Balances come from the expenses through `:domain`: Vardag +1 184,50 kr, Huset
* −3 084,50 kr, Japan 2027 −1 350,00 kr, Gotland 2026 0, globally "Du är skyldig Anna 3 250,00 kr".
@@ -45,7 +45,7 @@ class OnlineScript {
/**
* The fakes of [FakeRepositories] with the online calls of the settings screens routed through one [script]: the
* error, loading and in-progress states of S-14, S-15, S-19, S-20, S-21 and S-22 (M7f). Reads pass straight through.
* error, loading and in-progress states of S-14, S-15, S-19, S-20, S-21 and S-22. Reads pass straight through.
*/
class ScriptedSettingsRepositories(scenario: FakeScenario) {
val fakes = FakeRepositories(scenario)
@@ -29,7 +29,7 @@ import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/**
* Scenarios of `:feature:settings` (M7f; S-13 to S-15, S-19 to S-22), as in the design spec's frames: Jacob (admin) and
* Scenarios of `:feature:settings` (S-13 to S-15, S-19 to S-22), as in the design spec's frames: Jacob (admin) and
* Anna, the accounts Vardag, Huset and Japan 2027, the 14 defaults plus the archived custom "Pant", four recurring
* templates, both phones and one pending device invite. The phone's clock is 17 October 2026 14:02 Stockholm.
*/
@@ -2,7 +2,7 @@ package se.hagstedt.kvitt.model.analytics
import kotlinx.datetime.LocalDate
// Analytics input and output types (SPEC §14.5.4), package per the orchestrator's M2b decision.
// Analytics input and output types (SPEC §14.5.4), package se.hagstedt.kvitt.model.analytics (D-017).
data class AnalyticsExpense(val date: LocalDate, val accountId: String, val categoryId: String, val amountOre: Long, val shares: Map<String, Long>)
@@ -87,7 +87,7 @@ import javax.inject.Provider
import javax.inject.Qualifier
import javax.inject.Singleton
/** What `:app` (M8) provides: its version (§10.6) and build type (AC-2.8, AC-16.5). */
/** What `:app` provides: its version (§10.6) and build type (AC-2.8, AC-16.5). */
data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean)
@Qualifier
@@ -214,11 +214,6 @@ class SyncEngine(
}
}
/**
* `clock_skew` keeps the row pending (§14.4.5). The server stores rejected results by `mutation_id` and replays them
* (§14.4.4 steps 1 and 12), so the row gets a new mutation id; otherwise it could never sync after the clock is
* fixed (proposed decision in the M5 report). A row edited during the request already has a new one.
*/
/**
* D-027 (§14.4.5 step 3): on the first attempt after a `clock_skew` rejection, every pending row stamped later than
* the phone's clock now is re-stamped to now, so a corrected clock heals it. Rows stamped by a clock that is still
@@ -233,6 +228,11 @@ class SyncEngine(
}
}
/**
* `clock_skew` keeps the row pending (§14.4.5). The server stores rejected results by `mutation_id` and replays them
* (§14.4.4 steps 1 and 12), so the row gets a new mutation id; otherwise it could never sync after the clock is
* fixed (D-027). A row edited during the request already has a new one.
*/
private suspend fun renewMutationId(pending: Pending) {
val sent = pending.mutation.mutationId
when (pending.entityType) {
@@ -36,7 +36,7 @@ fun interface LocalWriteListener {
/**
* The sync triggers of AC-15.3 and §14.4.9: app start, every return to the foreground, 2 s after the last local write
* (debounced), every 15 minutes in the background, and every FCM data message (expedited). `:app` (M8) calls
* (debounced), every 15 minutes in the background, and every FCM data message (expedited). `:app` calls
* [onAppStart], [onForeground] and [syncForPush].
*/
class SyncTriggers(
@@ -85,7 +85,7 @@ class SyncTriggers(
* WorkManager wiring (§14.4.1, §14.4.9): one-off work under the unique name `kvitt-sync` with `APPEND_OR_REPLACE`,
* periodic work every 15 minutes with `KEEP`, both requiring a connected network, retries with exponential backoff
* from 30 seconds. Periodic and one-off work cannot share a unique name in WorkManager, so the periodic one is
* `kvitt-sync-periodic` (proposed decision in the M5 report).
* `kvitt-sync-periodic` (D-028).
*/
class WorkManagerSyncWork(private val context: Context) : SyncWork {
private val connected = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
@@ -127,7 +127,7 @@ class SyncWorker(
}
}
/** Creates [SyncWorker]s with the engine; `:app` (M8) installs it in its WorkManager `Configuration`. */
/** Creates [SyncWorker]s with the engine; `:app` installs it in its WorkManager `Configuration`. */
class KvittWorkerFactory(private val engine: () -> SyncEngine) : WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? =
if (workerClassName == SyncWorker::class.java.name) SyncWorker(appContext, workerParameters, engine()) else null
@@ -12,7 +12,7 @@ import org.junit.runners.Parameterized
import java.io.File
/**
* SPEC §14.3.5, M5 gate: every file under `contracts/examples/` decodes into its DTO and re-encodes to the same JSON
* SPEC §14.3.5: every file under `contracts/examples/` decodes into its DTO and re-encodes to the same JSON
* value (key order aside). One test case per file, so the case count equals the file count; [ExampleCoverageTest]
* checks that no file is skipped. Sync bodies also round-trip their nested `entity` / `current` objects.
*/
@@ -262,7 +262,7 @@ class ExpenseEditorViewModelTest {
fun editedAmounts_keepBothDecimals() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertEquals("1247.60", vm.state.value.form?.amount?.raw)
// The feature's own moneyInput is gone: :core:design's AmountInput.of restores money with both decimals (M6b).
// :core:design's AmountInput.of restores money with both decimals.
assertEquals("412.00", AmountInput.of(41_200).raw)
assertEquals("0.05", AmountInput.of(5).raw)
}
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
<!-- Strings of :feature:settings, keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
first. -->
<resources>
<string name="settings_profile_offline_banner">Kräver anslutning. Profilen kan bara ändras när telefonen är online.</string>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
<!-- Strings of :feature:settings, keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
first. -->
<resources>
<string name="settings_profile_offline_banner">Needs connection. Your profile can only be changed online.</string>
@@ -27,7 +27,7 @@ import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/**
* Navigation arguments of S-9 and S-10, read from the [SavedStateHandle] (`:app` puts them there, M8). S-9: none for
* Navigation arguments of S-9 and S-10, read from the [SavedStateHandle] (`:app` puts them there). S-9: none for
* the global settle-up, [ACCOUNT_ID] for the per-account variant, [SETTLEMENT_ID] for editing. S-10: [SETTLEMENT_ID].
*/
object SettleArgs {
+4 -4
View File
@@ -1,5 +1,5 @@
# Kvitt version catalog. Versions are the latest stable releases on Google Maven / Maven Central as of 2026-10-17
# (milestone M2b). Drift: python3 tools/dependency-report/latest.py
# Kvitt version catalog: the latest stable releases on Google Maven / Maven Central (no drift on 2026-10-18).
# Drift: python3 tools/dependency-report/latest.py
[versions]
minSdk = "36"
compileSdk = "37"
@@ -27,8 +27,8 @@ coil = "3.6.3"
junit = "4.13.2"
robolectric = "4.17"
roborazzi = "1.76.0"
# M8 (2026-10-18): Firebase BoM 35.0.2 (firebase-messaging 26.0.0) and the Google services plugin 4.5.0, the latest
# stable per firebase.google.com/support/release-notes/android
# Firebase BoM 35.0.2 (firebase-messaging 26.0.0) and the Google services plugin 4.5.0, the latest stable per
# firebase.google.com/support/release-notes/android on 2026-10-18
firebaseBom = "35.0.2"
googleServices = "4.5.0"