From 8f0e156b38104acc28509a9ebaf1ec0c0d267554 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 17 Oct 2026 22:14:47 +0200 Subject: [PATCH] M5: Android data layer: DAOs, Ktor client and DTOs, token store, sync engine and workers, repositories, APK updater DAOs on M2b's Room 3 entities and KvittDatabase v1 (schema 1.json unchanged), DTOs for every wire object and endpoint body with a round-trip test over all 80 contract examples, the Ktor client with the section 14.3.1 headers and 14.3.2 error mapping, the Keystore-encrypted token store in auth.preferences_pb, the sync engine of section 14.4 (push batches, pull pages, epoch recovery, rejections, clock skew, 401 and 426 handling) tested with MockEngine including AC-15.6, WorkManager triggers, every :core:model repository, the APK updater behind an installer interface, Coil pictures, Hilt modules, Room migration test infrastructure. Fakes: the five settled TODO(spec) comments resolved per SPEC v0.4 (settlement row in an account's ledger carries that account's allocation). Co-Authored-By: Claude Opus 5.5 --- .../kvitt/fakes/FakeAnalyticsRepository.kt | 6 +- .../kvitt/fakes/FakeExpenseRepository.kt | 3 +- .../kvitt/fakes/FakeHouseholdRepositories.kt | 6 +- .../kvitt/fakes/FakeSettlementRepository.kt | 2 +- .../se/hagstedt/kvitt/fakes/FakeStore.kt | 6 +- android/data/build.gradle.kts | 34 +- android/data/src/main/AndroidManifest.xml | 16 + .../se/hagstedt/kvitt/data/api/ApiResult.kt | 62 +++ .../se/hagstedt/kvitt/data/api/Endpoints.kt | 177 +++++++ .../se/hagstedt/kvitt/data/api/KvittApi.kt | 239 ++++++++++ .../se/hagstedt/kvitt/data/api/KvittJson.kt | 44 ++ .../se/hagstedt/kvitt/data/api/PictureAuth.kt | 23 + .../se/hagstedt/kvitt/data/api/SyncDtos.kt | 147 ++++++ .../se/hagstedt/kvitt/data/api/WireObjects.kt | 193 ++++++++ .../kvitt/data/auth/CredentialStore.kt | 98 ++++ .../kvitt/data/auth/SessionManager.kt | 51 ++ .../hagstedt/kvitt/data/auth/TokenCipher.kt | 68 +++ .../kvitt/data/connectivity/Connectivity.kt | 113 +++++ .../kotlin/se/hagstedt/kvitt/data/db/Daos.kt | 208 +++++++++ .../hagstedt/kvitt/data/db/KvittDatabase.kt | 37 +- .../se/hagstedt/kvitt/data/di/DataModule.kt | 291 ++++++++++++ .../data/repo/AnalyticsRepositoryImpl.kt | 114 +++++ .../kvitt/data/repo/ExpenseRepositoryImpl.kt | 235 ++++++++++ .../kvitt/data/repo/HouseholdRepositories.kt | 152 ++++++ .../se/hagstedt/kvitt/data/repo/PhonePrefs.kt | 62 +++ .../se/hagstedt/kvitt/data/repo/Pictures.kt | 43 ++ .../kvitt/data/repo/SessionRepositoryImpl.kt | 120 +++++ .../kvitt/data/repo/SettingsRepositories.kt | 336 ++++++++++++++ .../data/repo/SettlementRepositoryImpl.kt | 175 +++++++ .../se/hagstedt/kvitt/data/repo/Snapshot.kt | 224 +++++++++ .../se/hagstedt/kvitt/data/repo/Timeline.kt | 71 +++ .../se/hagstedt/kvitt/data/sync/LocalRows.kt | 75 +++ .../se/hagstedt/kvitt/data/sync/Mappers.kt | 165 +++++++ .../se/hagstedt/kvitt/data/sync/SyncEngine.kt | 384 +++++++++++++++ .../hagstedt/kvitt/data/sync/SyncTriggers.kt | 134 ++++++ .../se/hagstedt/kvitt/data/update/Updater.kt | 194 ++++++++ .../se/hagstedt/kvitt/data/util/Clock.kt | 38 ++ .../kotlin/se/hagstedt/kvitt/data/util/Ids.kt | 25 + .../se/hagstedt/kvitt/data/FakeServer.kt | 215 +++++++++ .../kotlin/se/hagstedt/kvitt/data/Harness.kt | 198 ++++++++ .../se/hagstedt/kvitt/data/TestSupport.kt | 55 +++ .../kvitt/data/api/ExampleRoundTripTest.kt | 112 +++++ .../hagstedt/kvitt/data/api/KvittApiTest.kt | 96 ++++ .../kvitt/data/auth/CredentialStoreTest.kt | 52 +++ .../hagstedt/kvitt/data/db/MigrationTest.kt | 91 ++++ .../kvitt/data/repo/RepositoriesTest.kt | 292 ++++++++++++ .../kvitt/data/repo/SessionRepositoryTest.kt | 137 ++++++ .../data/sync/OfflineFourteenDaysTest.kt | 113 +++++ .../kvitt/data/sync/SyncEngineTest.kt | 439 ++++++++++++++++++ .../kvitt/data/sync/SyncTriggersTest.kt | 78 ++++ .../hagstedt/kvitt/data/update/UpdaterTest.kt | 145 ++++++ android/gradle/libs.versions.toml | 1 + 52 files changed, 6380 insertions(+), 15 deletions(-) create mode 100644 android/data/src/main/AndroidManifest.xml create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/Endpoints.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittJson.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/SyncDtos.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/WireObjects.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/CredentialStore.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/SessionManager.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/TokenCipher.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/connectivity/Connectivity.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/AnalyticsRepositoryImpl.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/HouseholdRepositories.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Pictures.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryImpl.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Timeline.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/Mappers.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggers.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Clock.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Ids.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/auth/CredentialStoreTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/OfflineFourteenDaysTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggersTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt index 63333d8..adc1066 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeAnalyticsRepository.kt @@ -64,8 +64,8 @@ class FakeAnalyticsRepository(private val store: FakeStore) : AnalyticsRepositor val measured = matching.sumOf { e -> if (filter.measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == s.me.id }?.shareOre ?: 0L } - // TODO(spec): DrillDownState.title is a String but S-12's title ("Mat & hushåll, oktober 2026") needs the - // UI's localised names and month format; the fakes join the raw parts (group label or key, bucket key). + // §14.2.4 (v0.4): the title is the unlocalised parts joined by ", " (category custom name or lowercase + // default key, account name, bucket key); S-12 formats its visible title itself. val title = listOfNotNull( categoryId?.let { id -> s.category(id)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } }, accountId?.let { id -> s.account(id)?.name }, @@ -90,7 +90,7 @@ private fun FakeScenario.measure(measure: Measure): AnalyticsMeasure = /** * AC-14.2 via `resolveRange`. The UI only sends CUSTOM with both dates; the real repository maps * IncompleteCustomRangeException to `validation_failed`. The fakes fill a missing custom date with today instead, since - * AnalyticsState has no error field (TODO(spec): how an analytics flow reports `validation_failed`, §14.2.4). + * AnalyticsState has no error field, so a missing custom date is resolved as today (§14.2.4, v0.4). */ private fun FakeScenario.range(filter: AnalyticsFilter): DateRange { val today = LocalDate.parse(today) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt index 7c10b2e..911e39f 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt @@ -70,8 +70,7 @@ class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository { if (amountOre == null) errors += FieldError("amountOre", "required") if (accountId == null) errors += FieldError("accountId", "required") if (categoryId == null) errors += FieldError("categoryId", "required") - // TODO(spec): §14.2.4 names only domain tokens and "required" for FieldError.error; the §9.6 length and date - // limits have no domain token, so the fakes use the REST code `validation_failed` for them. + // §14.2.4 (v0.4): a §9.6 rule without a domain token (lengths, dates) is `validation_failed`. when (draft.description.codePointLength()) { 0 -> errors += FieldError("description", "required") !in 1..100 -> errors += FieldError("description", "validation_failed") diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt index ee5e36e..4cc69d1 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeHouseholdRepositories.kt @@ -147,10 +147,10 @@ internal fun FakeScenario.accountDeletable(id: AccountId): Boolean = private fun FakeScenario.ledgerMonths(id: AccountId): List { val expenseRows = expenses.filter { !it.deleted && it.accountId == id } .mapNotNull { e -> expenseRow(e)?.let { Triple, LedgerRow, Long>(e.date to e.createdAt, it, e.amountOre) } } - // TODO(spec): §14.2.4 does not say whether a settlement row in an account list carries the settlement's total - // or its allocation to that account; the fakes show the total, with the account count, as AC-11.7 describes. + // AC-11.7 (v0.4): in an account's list a settlement row carries the allocation to that account; accountCount + // stays the allocation count of the whole settlement. val settlementRows = settlements.filter { !it.deleted && id in it.allocations } - .mapNotNull { s -> settlementRow(s)?.let { Triple, LedgerRow, Long>(s.date to s.createdAt, it, 0L) } } + .mapNotNull { s -> settlementRow(s, account = id)?.let { Triple, LedgerRow, Long>(s.date to s.createdAt, it, 0L) } } return (expenseRows + settlementRows) .sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) } .groupBy { it.first.first.take(7) } diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt index e7b10bf..bfbf9c0 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettlementRepository.kt @@ -52,7 +52,7 @@ class FakeSettlementRepository(private val store: FakeStore) : SettlementReposit errors += FieldError(if (it == SettlementError.AMOUNT_OUT_OF_RANGE) "amountOre" else "allocations", it.name.lowercase()) } } - // TODO(spec): no domain token for the §9.6 note and date limits; the fakes use `validation_failed` (as expenses). + // §14.2.4 (v0.4): the §9.6 note and date limits have no domain token and are `validation_failed`. if (request.note.codePointLength() > 200) errors += FieldError("note", "validation_failed") if (!s.validDate(request.date)) errors += FieldError("date", "validation_failed") if (errors.isNotEmpty() || amount == null) return SaveResult.Invalid(errors) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt index a178e95..548094d 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeStore.kt @@ -92,10 +92,12 @@ internal fun FakeScenario.expenseRow(e: FakeExpense): LedgerRow.ExpenseRow? { return LedgerRow.ExpenseRow(e.id, e.date, e.description, category, e.amountOre, e.effectFor(me.id), e.pending) } -internal fun FakeScenario.settlementRow(s: FakeSettlement): LedgerRow.SettlementRow? { +/** AC-11.7: the settlement total, or with [account] the allocation to that account (an account's list, S-5). */ +internal fun FakeScenario.settlementRow(s: FakeSettlement, account: AccountId? = null): LedgerRow.SettlementRow? { val from = user(s.from) ?: return null val to = user(s.to) ?: return null - return LedgerRow.SettlementRow(s.id, s.date, from, to, s.amountOre, s.allocations.size, s.pending) + val amount = if (account != null) s.allocations[account] ?: 0L else s.amountOre + return LedgerRow.SettlementRow(s.id, s.date, from, to, amount, s.allocations.size, s.pending) } /** AC-8.10: date descending, then `created_at` descending. */ diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts index 3768277..5fd9254 100644 --- a/android/data/build.gradle.kts +++ b/android/data/build.gradle.kts @@ -1,9 +1,12 @@ -// :data — Room database, Ktor client, sync engine and the real repositories (owned by M5). M2b ships the Room -// entities of SPEC §14.2 and the database class with its exported schema (version 1), no DAOs. +// :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. plugins { id("kvitt.android.library") + id("kvitt.android.hilt") alias(libs.plugins.ksp) alias(libs.plugins.room3) + alias(libs.plugins.kotlin.serialization) } room3 { @@ -15,4 +18,31 @@ dependencies { implementation(project(":domain")) implementation(libs.androidx.room3.runtime) ksp(libs.androidx.room3.compiler) + implementation(libs.androidx.sqlite.bundled) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.coil) + implementation(libs.coil.network.ktor3) + + testImplementation(libs.ktor.client.mock) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) +} + +// Unit tests read the shared examples (SPEC §14.3.5) and the exported Room schemas (§14.2.3) in place; both are inputs, +// so a changed contract file re-runs the tests. +val contractsExamples = rootProject.layout.projectDirectory.dir("../contracts/examples") +val roomSchemas = layout.projectDirectory.dir("schemas") +tasks.withType().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) } diff --git a/android/data/src/main/AndroidManifest.xml b/android/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000..51d4790 --- /dev/null +++ b/android/data/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt new file mode 100644 index 0000000..e81ea08 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/ApiResult.kt @@ -0,0 +1,62 @@ +package se.hagstedt.kvitt.data.api + +/** The outcome of one call to the server. */ +sealed interface ApiResult { + data class Ok(val value: T) : ApiResult + + /** An HTTP error with its §14.3.2 code; [code] falls back to the status' code when the body is not an Error. */ + data class Failure(val status: Int, val code: String, val field: String?) : ApiResult + + /** The server could not be reached (`network` in `sync_state.last_error_code`, §14.2.2). */ + data class NetworkError(val cause: Throwable) : ApiResult + + /** No credentials: the phone is not paired. */ + data object NotPaired : ApiResult +} + +/** The §14.3.2 codes the phone acts on. */ +object ErrorCodes { + const val INVALID_REQUEST = "invalid_request" + const val UNAUTHORIZED = "unauthorized" + const val DEVICE_REVOKED = "device_revoked" + const val NOT_FOUND = "not_found" + const val CURSOR_AHEAD = "cursor_ahead" + const val VALIDATION_FAILED = "validation_failed" + const val PROTOCOL_UNSUPPORTED = "protocol_unsupported" + const val RATE_LIMITED = "rate_limited" + const val INTERNAL = "internal" + const val UNAVAILABLE = "unavailable" + + /** Not a server code: the network failure marker of `sync_state.last_error_code` and of REST calls (§9.9). */ + const val NETWORK = "network" + + /** The code for a status whose body is not an Error object. */ + fun forStatus(status: Int): String = when (status) { + 400 -> INVALID_REQUEST + 401 -> UNAUTHORIZED + 404 -> NOT_FOUND + 422 -> VALIDATION_FAILED + 426 -> PROTOCOL_UNSUPPORTED + 429 -> RATE_LIMITED + 503 -> UNAVAILABLE + else -> INTERNAL + } +} + +/** The server's answers that change the session or stop syncing (§14.3.1, §14.4.5 step 2, §14.4.8). */ +interface ApiEvents { + /** 401 `device_revoked`: AC-3.3. */ + suspend fun onDeviceRevoked() + + /** 401 `unauthorized` on an authenticated call: `SessionState.Unrecognised`. */ + suspend fun onUnrecognised() + + /** 426 `protocol_unsupported`: stop syncing until the app is updated. */ + suspend fun onProtocolUnsupported() + + /** A call failed with a network error (§9.9, 30 seconds of "Needs connection"). */ + fun onNetworkError() + + /** A call reached the server. */ + fun onReachedServer(serverVersion: String?) +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/Endpoints.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/Endpoints.kt new file mode 100644 index 0000000..55961dc --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/Endpoints.kt @@ -0,0 +1,177 @@ +package se.hagstedt.kvitt.data.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Request and response bodies of the endpoints of SPEC §14.3.4 (sync bodies in SyncDtos.kt). + +/** E-1 */ +@Serializable +data class HealthDto( + val status: String, + @SerialName("server_version") val serverVersion: String, + @SerialName("schema_version") val schemaVersion: Int, + @SerialName("db_ok") val dbOk: Boolean, +) + +/** E-2 */ +@Serializable +data class SetupStatusDto(val initialized: Boolean) + +/** E-3 request. `display_name` is optional (ignored for device invites) and omitted when null. */ +@Serializable +data class AcceptInviteRequest( + val code: String, + @SerialName("device_name") val deviceName: String, + @SerialName("display_name") val displayName: String? = null, +) + +/** E-3 response. */ +@Serializable +data class AcceptInviteResponse( + val token: String, + val user: UserDto, + val device: DeviceDto, + val household: List, +) + +/** E-5: `latest.json` of §10.6. */ +@Serializable +data class ApkLatestDto( + @SerialName("version_code") val versionCode: Int, + @SerialName("version_name") val versionName: String, + @SerialName("file_name") val fileName: String, + @SerialName("size_bytes") val sizeBytes: Long, + val sha256: String, + @SerialName("released_at") val releasedAt: String, +) + +/** E-7 */ +@Serializable +data class MeResponse( + val user: UserDto, + val device: DeviceDto, + val household: List, + @SerialName("public_url") val publicUrl: String, +) + +/** E-8 */ +@Serializable +data class PatchMeRequest(@SerialName("display_name") val displayName: String) + +/** E-12. `target_user_id` is always sent (null for partner invites). */ +@Serializable +data class CreateInviteRequest( + val kind: String, + @SerialName("target_user_id") val targetUserId: String?, +) + +/** E-13 */ +@Serializable +data class InviteListDto(val invites: List) + +/** E-15 */ +@Serializable +data class DeviceListDto(val devices: List) + +/** E-17: the key is required, its value nullable (null clears). */ +@Serializable +data class PushTokenRequest(@SerialName("fcm_token") val fcmToken: String?) + +/** E-18 */ +@Serializable +data class CreateAccountRequest( + val name: String, + @SerialName("default_shares") val defaultShares: List, +) + +/** E-19 */ +@Serializable +data class PatchAccountRequest( + val name: String? = null, + @SerialName("default_shares") val defaultShares: List? = null, +) + +/** E-23 */ +@Serializable +data class CreateCategoryRequest( + val name: String, + val icon: String, + val color: String, +) + +/** E-24. `name: null` resets a default category's name, so `name` is tri-state. */ +@Serializable +data class PatchCategoryRequest( + val name: Patch = Patch.Absent, + val icon: String? = null, + val color: String? = null, + @SerialName("sort_order") val sortOrder: Int? = null, +) + +/** E-28 */ +@Serializable +data class CreateTemplateRequest( + @SerialName("account_id") val accountId: String, + @SerialName("category_id") val categoryId: String, + @SerialName("amount_ore") val amountOre: Long, + val description: String, + val notes: String, + @SerialName("payer_user_id") val payerUserId: String, + @SerialName("split_mode") val splitMode: String, + val splits: List, + @SerialName("day_of_month") val dayOfMonth: Int, + @SerialName("start_period") val startPeriod: String, + @SerialName("end_period") val endPeriod: String?, + val paused: Boolean, +) + +/** E-29: any subset of the E-28 fields; `split_mode` and `splits` together; `end_period: null` = no end. */ +@Serializable +data class PatchTemplateRequest( + @SerialName("account_id") val accountId: String? = null, + @SerialName("category_id") val categoryId: String? = null, + @SerialName("amount_ore") val amountOre: Long? = null, + val description: String? = null, + val notes: String? = null, + @SerialName("payer_user_id") val payerUserId: String? = null, + @SerialName("split_mode") val splitMode: String? = null, + val splits: List? = null, + @SerialName("day_of_month") val dayOfMonth: Int? = null, + @SerialName("start_period") val startPeriod: String? = null, + @SerialName("end_period") val endPeriod: Patch = Patch.Absent, + val paused: Boolean? = null, +) + +/** E-33 request. */ +@Serializable +data class ProposalRequest( + @SerialName("payer_user_id") val payerUserId: String, + @SerialName("amount_ore") val amountOre: Long, + @SerialName("exclude_settlement_id") val excludeSettlementId: String?, +) + +/** E-33 response. */ +@Serializable +data class ProposalResponse( + val allocations: List, + @SerialName("excess_ore") val excessOre: Long, + @SerialName("over_settled_account_ids") val overSettledAccountIds: List, +) + +/** + * The FCM HTTP v1 message the server sends (§14.6, push payload v1). The phone receives only `data`; `:adapter:fcm` + * maps it to `PushEvent`. Kept here so the push examples are part of the round-trip contract test. + */ +@Serializable +data class FcmMessageDto(val message: FcmMessageBody) + +@Serializable +data class FcmMessageBody( + val token: String, + val android: FcmAndroidConfig, + val data: Map, +) + +@Serializable +data class FcmAndroidConfig(val priority: String, val ttl: String) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt new file mode 100644 index 0000000..fd820f8 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt @@ -0,0 +1,239 @@ +package se.hagstedt.kvitt.data.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.prepareGet +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.serialization.JsonConvertException +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import java.io.IOException +import java.nio.channels.UnresolvedAddressException + +/** Where and as whom to call: the paired server and the device token (§8.2). */ +data class ApiTarget(val baseUrl: String, val token: String?) + +/** The headers' app side (§14.3.1). */ +interface ApiClientInfo { + val appVersionCode: Int + + /** The device locale as a BCP 47 tag, for `Accept-Language`. */ + fun acceptLanguage(): String +} + +/** + * The Ktor client of every endpoint the app calls (§14.3.4). Every request carries `X-Kvitt-Protocol: 1`, + * `X-Kvitt-App-Version` and `Accept-Language`, and authenticated calls `Authorization: Bearer` (§14.3.1). The token is + * only ever in that header, never in a URL or a log (§8.2). Errors map to the §14.3.2 codes; 401 and 426 are reported + * to [events] so the session and the sync engine react in one place. + */ +class KvittApi( + private val http: HttpClient, + private val target: suspend () -> ApiTarget?, + private val info: ApiClientInfo, + private val events: ApiEvents, +) { + // E-3: unauthenticated, to the server URL the user entered. + suspend fun acceptInvite(baseUrl: String, request: AcceptInviteRequest): ApiResult = + exchange(ApiTarget(baseUrl, null), HttpMethod.Post, "/invites/accept", authenticated = false) { jsonBody(request) } + + // E-5, E-6: unauthenticated. + suspend fun apkLatest(): ApiResult = + withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/apk/latest", authenticated = false) } + + /** E-6: streams the APK to [sink]; the caller verifies size and SHA-256 (AC-16.3). */ + suspend fun downloadApk(fileName: String, sink: suspend (ByteReadChannel, Long?) -> Unit): ApiResult = + withTarget { t -> + guarded { + http.prepareGet(url(t, "/apk/files/$fileName")) { commonHeaders() }.execute { response -> + events.onReachedServer(response.headers[SERVER_VERSION_HEADER]) + if (response.status.isSuccess()) { + sink(response.body(), response.headers[HttpHeaders.ContentLength]?.toLongOrNull()) + ApiResult.Ok(Unit) + } else { + failure(response, authenticated = false) + } + } + } + } + + // E-7 to E-10 + suspend fun me(): ApiResult = authed(HttpMethod.Get, "/me") + suspend fun patchMe(request: PatchMeRequest): ApiResult = authed(HttpMethod.Patch, "/me") { jsonBody(request) } + suspend fun putPicture(bytes: ByteArray, mimeType: String): ApiResult = authed(HttpMethod.Put, "/me/picture") { + contentType(ContentType.parse(mimeType)) + setBody(bytes) + } + suspend fun deletePicture(): ApiResult = authed(HttpMethod.Delete, "/me/picture") + + // E-12 to E-17 + suspend fun createInvite(request: CreateInviteRequest): ApiResult = authed(HttpMethod.Post, "/invites") { jsonBody(request) } + suspend fun listInvites(): ApiResult = authed(HttpMethod.Get, "/invites") + suspend fun cancelInvite(id: String): ApiResult = authedNoContent(HttpMethod.Delete, "/invites/$id") + suspend fun listDevices(): ApiResult = authed(HttpMethod.Get, "/devices") + suspend fun revokeDevice(id: String): ApiResult = authedNoContent(HttpMethod.Delete, "/devices/$id") + suspend fun putPushToken(request: PushTokenRequest): ApiResult = + authedNoContent(HttpMethod.Put, "/devices/current/push-token") { jsonBody(request) } + + // E-18 to E-22 + suspend fun createAccount(request: CreateAccountRequest): ApiResult = authed(HttpMethod.Post, "/accounts") { jsonBody(request) } + suspend fun patchAccount(id: String, request: PatchAccountRequest): ApiResult = + authed(HttpMethod.Patch, "/accounts/$id") { jsonBody(request) } + suspend fun archiveAccount(id: String): ApiResult = authed(HttpMethod.Post, "/accounts/$id/archive") + suspend fun unarchiveAccount(id: String): ApiResult = authed(HttpMethod.Post, "/accounts/$id/unarchive") + suspend fun deleteAccount(id: String): ApiResult = authed(HttpMethod.Delete, "/accounts/$id") + + // E-23 to E-27 + suspend fun createCategory(request: CreateCategoryRequest): ApiResult = authed(HttpMethod.Post, "/categories") { jsonBody(request) } + suspend fun patchCategory(id: String, request: PatchCategoryRequest): ApiResult = + authed(HttpMethod.Patch, "/categories/$id") { jsonBody(request) } + suspend fun archiveCategory(id: String): ApiResult = authed(HttpMethod.Post, "/categories/$id/archive") + suspend fun unarchiveCategory(id: String): ApiResult = authed(HttpMethod.Post, "/categories/$id/unarchive") + suspend fun deleteCategory(id: String): ApiResult = authed(HttpMethod.Delete, "/categories/$id") + + // E-28 to E-30 + suspend fun createTemplate(request: CreateTemplateRequest): ApiResult = + authed(HttpMethod.Post, "/recurring-templates") { jsonBody(request) } + suspend fun patchTemplate(id: String, request: PatchTemplateRequest): ApiResult = + authed(HttpMethod.Patch, "/recurring-templates/$id") { jsonBody(request) } + suspend fun deleteTemplate(id: String): ApiResult = authed(HttpMethod.Delete, "/recurring-templates/$id") + + // E-31, E-32 + suspend fun pull(since: Long, limit: Int): ApiResult = authed(HttpMethod.Get, "/sync") { + parameter("since", since) + parameter("limit", limit) + } + suspend fun push(request: PushRequest): ApiResult = authed(HttpMethod.Post, "/sync") { jsonBody(request) } + + // E-33 + suspend fun proposal(request: ProposalRequest): ApiResult = + authed(HttpMethod.Post, "/settlements/proposal") { jsonBody(request) } + + private suspend inline fun authed( + method: HttpMethod, + path: String, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult = authedWith(method, path, configure) { it.body() } + + /** For 204 responses (E-14, E-16, E-17). */ + private suspend fun authedNoContent(method: HttpMethod, path: String, configure: HttpRequestBuilder.() -> Unit = {}): ApiResult = + authedWith(method, path, configure) { } + + private suspend fun authedWith( + method: HttpMethod, + path: String, + configure: HttpRequestBuilder.() -> Unit, + decode: suspend (HttpResponse) -> T, + ): ApiResult { + val t = target() ?: return ApiResult.NotPaired + if (t.token == null) return ApiResult.NotPaired + return exchange(t, method, path, authenticated = true, configure, decode) + } + + private suspend fun withTarget(block: suspend (ApiTarget) -> ApiResult): ApiResult { + val t = target() ?: return ApiResult.NotPaired + return block(t) + } + + private suspend inline fun exchange( + t: ApiTarget, + method: HttpMethod, + path: String, + authenticated: Boolean, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult = exchange(t, method, path, authenticated, configure) { it.body() } + + private suspend fun exchange( + t: ApiTarget, + method: HttpMethod, + path: String, + authenticated: Boolean, + configure: HttpRequestBuilder.() -> Unit, + decode: suspend (HttpResponse) -> T, + ): ApiResult = guarded { + val response = http.request(url(t, path)) { + this.method = method + commonHeaders() + if (authenticated) t.token?.let { bearerAuth(it) } + configure() + } + events.onReachedServer(response.headers[SERVER_VERSION_HEADER]) + if (response.status.isSuccess()) ApiResult.Ok(decode(response)) else failure(response, authenticated) + } + + private fun HttpRequestBuilder.commonHeaders() { + header(PROTOCOL_HEADER, PROTOCOL_VERSION) + header(APP_VERSION_HEADER, info.appVersionCode.toString()) + header(HttpHeaders.AcceptLanguage, info.acceptLanguage()) + } + + private suspend fun failure(response: HttpResponse, authenticated: Boolean): ApiResult.Failure { + val status = response.status.value + val error = try { + response.body() + } catch (e: CancellationException) { + throw e + } catch (e: SerializationException) { + null + } catch (e: JsonConvertException) { + null + } catch (e: io.ktor.client.call.NoTransformationFoundException) { + null + } + val result = ApiResult.Failure(status, error?.code ?: ErrorCodes.forStatus(status), error?.field) + when { + result.code == ErrorCodes.PROTOCOL_UNSUPPORTED -> events.onProtocolUnsupported() + authenticated && status == 401 && result.code == ErrorCodes.DEVICE_REVOKED -> events.onDeviceRevoked() + authenticated && status == 401 -> events.onUnrecognised() + } + return result + } + + /** Network failures become [ApiResult.NetworkError]; cancellation is never swallowed. */ + private suspend fun guarded(block: suspend () -> ApiResult): ApiResult = try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + events.onNetworkError() + ApiResult.NetworkError(e) + } catch (e: UnresolvedAddressException) { + events.onNetworkError() + ApiResult.NetworkError(e) + } + + private fun url(t: ApiTarget, path: String): String = t.baseUrl.trimEnd('/') + path + + private inline fun HttpRequestBuilder.jsonBody(body: B) { + contentType(ContentType.Application.Json) + setBody(body, typeInfo()) + } + + companion object { + const val PROTOCOL_HEADER = "X-Kvitt-Protocol" + const val PROTOCOL_VERSION = "1" + const val APP_VERSION_HEADER = "X-Kvitt-App-Version" + const val SERVER_VERSION_HEADER = "X-Kvitt-Server-Version" + } +} + +/** Installs JSON content negotiation with [KvittJson] on an engine's client; production passes the OkHttp engine. */ +fun io.ktor.client.HttpClientConfig<*>.kvittDefaults() { + expectSuccess = false + install(ContentNegotiation) { json(KvittJson) } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittJson.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittJson.kt new file mode 100644 index 0000000..8532497 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittJson.kt @@ -0,0 +1,44 @@ +package se.hagstedt.kvitt.data.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json + +/** + * The JSON configuration of every request and response (§14.3.1). Optional response fields are always present with + * `null`, so DTO properties have no defaults except PATCH fields and optional request fields, which are omitted when + * unset (`encodeDefaults = false`). Unknown fields from a newer server are ignored. + */ +val KvittJson: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = true +} + +/** + * A PATCH field (§14.3.1: absent = unchanged). [Absent] is omitted from the body; [Set] carries a value, which may be + * `null` where the endpoint gives `null` a meaning (E-24 `name`: reset the default name; E-29 `end_period`: no end). + */ +@Serializable(with = PatchSerializer::class) +sealed interface Patch { + data object Absent : Patch + + data class Set(val value: T) : Patch +} + +class PatchSerializer(private val inner: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = inner.descriptor + + override fun serialize(encoder: Encoder, value: Patch) { + when (value) { + is Patch.Set -> inner.serialize(encoder, value.value) + // Absent is the property default and never reaches the encoder with encodeDefaults = false. + Patch.Absent -> throw IllegalStateException("Patch.Absent must be omitted; KvittJson has encodeDefaults = false") + } + } + + override fun deserialize(decoder: Decoder): Patch = Patch.Set(inner.deserialize(decoder)) +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt new file mode 100644 index 0000000..c70642c --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt @@ -0,0 +1,23 @@ +package se.hagstedt.kvitt.data.api + +import io.ktor.client.plugins.api.ClientPlugin +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.http.HttpHeaders + +/** The Coil client's plugin: the §14.3.1 headers and the bearer token, only on requests to the paired server. */ +class PictureAuthConfig { + var target: suspend () -> ApiTarget? = { null } + var appVersionCode: Int = 0 +} + +val PictureAuth: ClientPlugin = createClientPlugin("KvittPictureAuth", ::PictureAuthConfig) { + val target = pluginConfig.target + val versionCode = pluginConfig.appVersionCode + onRequest { request, _ -> + val t = target() ?: return@onRequest + if (!request.url.buildString().startsWith(t.baseUrl.trimEnd('/') + "/")) return@onRequest + request.headers.append(KvittApi.PROTOCOL_HEADER, KvittApi.PROTOCOL_VERSION) + request.headers.append(KvittApi.APP_VERSION_HEADER, versionCode.toString()) + t.token?.let { request.headers.append(HttpHeaders.Authorization, "Bearer $it") } + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/SyncDtos.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/SyncDtos.kt new file mode 100644 index 0000000..9b916a7 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/SyncDtos.kt @@ -0,0 +1,147 @@ +package se.hagstedt.kvitt.data.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +// Sync protocol v1 bodies (SPEC §14.4). + +/** E-31 response, one page (§14.4.2). Every array is present and sorted by `server_version`. */ +@Serializable +data class PullPage( + val epoch: String, + val since: Long, + @SerialName("next_since") val nextSince: Long, + @SerialName("has_more") val hasMore: Boolean, + @SerialName("server_time") val serverTime: String, + val users: List, + val categories: List, + val accounts: List, + @SerialName("recurring_templates") val recurringTemplates: List, + val expenses: List, + val settlements: List, + @SerialName("audit_entries") val auditEntries: List, +) + +/** E-32 request (§14.4.3): 1 to 100 mutations. */ +@Serializable +data class PushRequest(val mutations: List) + +/** One mutation; `entity` is an [ExpensePushEntity] or a [SettlementPushEntity] by `entity_type`. */ +@Serializable +data class MutationDto( + @SerialName("mutation_id") val mutationId: String, + @SerialName("entity_type") val entityType: String, + val entity: JsonObject, +) + +/** The client-writable fields of an expense, exactly (§14.4.3). A delete is the full entity with `deleted_at`. */ +@Serializable +data class ExpensePushEntity( + val id: String, + @SerialName("account_id") val accountId: String, + @SerialName("category_id") val categoryId: String, + @SerialName("amount_ore") val amountOre: Long, + val description: String, + val notes: String, + @SerialName("expense_date") val expenseDate: String, + @SerialName("payer_user_id") val payerUserId: String, + @SerialName("split_mode") val splitMode: String, + val splits: List, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("deleted_at") val deletedAt: String?, +) + +/** The client-writable fields of a settlement, exactly (§14.4.3). */ +@Serializable +data class SettlementPushEntity( + val id: String, + @SerialName("from_user_id") val fromUserId: String, + @SerialName("to_user_id") val toUserId: String, + @SerialName("amount_ore") val amountOre: Long, + @SerialName("settlement_date") val settlementDate: String, + val note: String, + val allocations: List, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("deleted_at") val deletedAt: String?, +) + +/** E-32 response: one result per mutation in request order. */ +@Serializable +data class PushResponse( + @SerialName("server_time") val serverTime: String, + val results: List, +) + +/** + * Accepted and rejected results have different key sets (§14.4.3), told apart by `status`. `entity` / `current` are + * full wire objects ([ExpenseDto] or [SettlementDto] by `entity_type`), decoded by the sync engine. + */ +@Serializable(with = PushResultSerializer::class) +sealed interface PushResultDto { + val mutationId: String + val entityType: String + val entityId: String + val status: String + + @Serializable + data class Accepted( + @SerialName("mutation_id") override val mutationId: String, + @SerialName("entity_type") override val entityType: String, + @SerialName("entity_id") override val entityId: String, + override val status: String, + @SerialName("server_version") val serverVersion: Long, + val entity: JsonObject, + ) : PushResultDto + + @Serializable + data class Rejected( + @SerialName("mutation_id") override val mutationId: String, + @SerialName("entity_type") override val entityType: String, + @SerialName("entity_id") override val entityId: String, + override val status: String, + val reason: String, + val message: String, + val current: JsonObject?, + ) : PushResultDto + + companion object { + const val ACCEPTED = "accepted" + const val REJECTED = "rejected" + } +} + +object PushResultSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("se.hagstedt.kvitt.data.api.PushResultDto") + + override fun serialize(encoder: Encoder, value: PushResultDto) { + val json = encoder as? JsonEncoder ?: throw SerializationException("PushResultDto is JSON only") + val element = when (value) { + is PushResultDto.Accepted -> json.json.encodeToJsonElement(PushResultDto.Accepted.serializer(), value) + is PushResultDto.Rejected -> json.json.encodeToJsonElement(PushResultDto.Rejected.serializer(), value) + } + json.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): PushResultDto { + val json = decoder as? JsonDecoder ?: throw SerializationException("PushResultDto is JSON only") + val element = json.decodeJsonElement() + return when (val status = element.jsonObject["status"]?.jsonPrimitive?.content) { + PushResultDto.ACCEPTED -> json.json.decodeFromJsonElement(PushResultDto.Accepted.serializer(), element) + PushResultDto.REJECTED -> json.json.decodeFromJsonElement(PushResultDto.Rejected.serializer(), element) + else -> throw SerializationException("unknown push result status: $status") + } + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/WireObjects.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/WireObjects.kt new file mode 100644 index 0000000..2c68c84 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/WireObjects.kt @@ -0,0 +1,193 @@ +package se.hagstedt.kvitt.data.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +// Wire objects of SPEC §14.3.3. `?` there = nullable here (always present). Enum values travel as their lowercase +// strings and are mapped to :core:model enums at the edge, so an unknown value from a newer server is not a crash. + +@Serializable +data class UserDto( + val id: String, + val role: String, + @SerialName("display_name") val displayName: String, + @SerialName("picture_version") val pictureVersion: Int?, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("server_version") val serverVersion: Long, +) + +@Serializable +data class DeviceDto( + val id: String, + @SerialName("user_id") val userId: String, + val name: String, + @SerialName("app_version_code") val appVersionCode: Int?, + @SerialName("created_at") val createdAt: String, + @SerialName("last_seen_at") val lastSeenAt: String, + @SerialName("revoked_at") val revokedAt: String?, + @SerialName("is_current") val isCurrent: Boolean, +) + +@Serializable +data class InviteDto( + val id: String, + val kind: String, + @SerialName("target_user_id") val targetUserId: String?, + val code: String?, + val link: String?, + @SerialName("created_at") val createdAt: String, + @SerialName("expires_at") val expiresAt: String, +) + +/** Share of an account default. */ +@Serializable +data class ShareBpDto( + @SerialName("user_id") val userId: String, + @SerialName("share_bp") val shareBp: Int, +) + +@Serializable +data class AccountDto( + val id: String, + val name: String, + @SerialName("default_shares") val defaultShares: List, + @SerialName("created_by_user_id") val createdByUserId: String, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("archived_at") val archivedAt: String?, + @SerialName("deleted_at") val deletedAt: String?, + @SerialName("server_version") val serverVersion: Long, +) + +@Serializable +data class CategoryDto( + val id: String, + @SerialName("default_key") val defaultKey: String?, + val name: String?, + val icon: String, + val color: String, + @SerialName("sort_order") val sortOrder: Int, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("archived_at") val archivedAt: String?, + @SerialName("deleted_at") val deletedAt: String?, + @SerialName("server_version") val serverVersion: Long, +) + +/** Split of an expense: `share_bp` null for mode exact. */ +@Serializable +data class SplitDto( + @SerialName("user_id") val userId: String, + @SerialName("share_ore") val shareOre: Long, + @SerialName("share_bp") val shareBp: Int?, +) + +/** Template split: exactly one of `share_bp` / `share_ore` non-null; empty array for mode default. */ +@Serializable +data class TemplateSplitDto( + @SerialName("user_id") val userId: String, + @SerialName("share_bp") val shareBp: Int?, + @SerialName("share_ore") val shareOre: Long?, +) + +@Serializable +data class ExpenseDto( + val id: String, + @SerialName("account_id") val accountId: String, + @SerialName("category_id") val categoryId: String, + @SerialName("amount_ore") val amountOre: Long, + val description: String, + val notes: String, + @SerialName("expense_date") val expenseDate: String, + @SerialName("payer_user_id") val payerUserId: String, + @SerialName("split_mode") val splitMode: String, + val splits: List, + @SerialName("recurring_template_id") val recurringTemplateId: String?, + @SerialName("recurring_period") val recurringPeriod: String?, + @SerialName("created_by_user_id") val createdByUserId: String, + @SerialName("updated_by_user_id") val updatedByUserId: String, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("deleted_at") val deletedAt: String?, + @SerialName("server_version") val serverVersion: Long, +) + +@Serializable +data class AllocationDto( + @SerialName("account_id") val accountId: String, + @SerialName("amount_ore") val amountOre: Long, +) + +@Serializable +data class SettlementDto( + val id: String, + @SerialName("from_user_id") val fromUserId: String, + @SerialName("to_user_id") val toUserId: String, + @SerialName("amount_ore") val amountOre: Long, + @SerialName("settlement_date") val settlementDate: String, + val note: String, + val allocations: List, + @SerialName("created_by_user_id") val createdByUserId: String, + @SerialName("updated_by_user_id") val updatedByUserId: String, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("deleted_at") val deletedAt: String?, + @SerialName("server_version") val serverVersion: Long, +) + +@Serializable +data class RecurringTemplateDto( + val id: String, + @SerialName("account_id") val accountId: String, + @SerialName("category_id") val categoryId: String, + @SerialName("amount_ore") val amountOre: Long, + val description: String, + val notes: String, + @SerialName("payer_user_id") val payerUserId: String, + @SerialName("split_mode") val splitMode: String, + val splits: List, + @SerialName("day_of_month") val dayOfMonth: Int, + @SerialName("start_period") val startPeriod: String, + @SerialName("end_period") val endPeriod: String?, + val paused: Boolean, + @SerialName("last_generated_period") val lastGeneratedPeriod: String?, + @SerialName("next_occurrence_date") val nextOccurrenceDate: String?, + @SerialName("created_by_user_id") val createdByUserId: String, + @SerialName("updated_by_user_id") val updatedByUserId: String, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + @SerialName("deleted_at") val deletedAt: String?, + @SerialName("server_version") val serverVersion: Long, +) + +/** One field change of an audit entry (§14.4.7); `old` and `new` are any JSON value or null. */ +@Serializable +data class AuditChangeDto( + val field: String, + val old: JsonElement?, + val new: JsonElement?, +) + +@Serializable +data class AuditEntryDto( + val id: String, + @SerialName("entity_type") val entityType: String, + @SerialName("entity_id") val entityId: String, + val action: String, + val source: String, + @SerialName("actor_user_id") val actorUserId: String, + @SerialName("client_at") val clientAt: String, + @SerialName("server_at") val serverAt: String, + val changes: List, + @SerialName("server_version") val serverVersion: Long, +) + +/** The error shape of §14.3.2. */ +@Serializable +data class ErrorDto( + val code: String, + val message: String, + val field: String?, +) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/CredentialStore.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/CredentialStore.kt new file mode 100644 index 0000000..05cff24 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/CredentialStore.kt @@ -0,0 +1,98 @@ +package se.hagstedt.kvitt.data.auth + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.Base64 + +/** The pairing: server base URL, user id, device id (plain text) and the device token (§8.2). */ +data class Credentials(val baseUrl: String, val userId: String, val deviceId: String, val token: String) { + override fun toString(): String = "Credentials(baseUrl=$baseUrl, userId=$userId, deviceId=$deviceId, token=)" +} + +/** Where the phone stands with its server; [CredentialStore] persists it beside the credentials. */ +enum class PairingStatus { UNPAIRED, PAIRED, REVOKED, UNRECOGNISED } + +/** What the session reads: the status plus the ids kept for it (an unrecognised phone keeps its user id, §14.4.8). */ +data class PairingRecord(val status: PairingStatus, val baseUrl: String?, val userId: String?, val deviceId: String?) + +/** + * DataStore file `auth.preferences_pb` (§8.2): token ciphertext and IV (Base64), base URL, user id and device id in plain + * text, and the pairing status. The token itself is decrypted only on use and never logged. + */ +class CredentialStore(private val store: DataStore, private val cipher: TokenCipher) { + val record: Flow = store.data.map { it.record() }.distinctUntilChanged() + + suspend fun current(): PairingRecord = store.data.first().record() + + /** The credentials while paired; null otherwise. */ + suspend fun credentials(): Credentials? { + val prefs = store.data.first() + if (prefs.record().status != PairingStatus.PAIRED) return null + val baseUrl = prefs[BASE_URL] ?: return null + val userId = prefs[USER_ID] ?: return null + val deviceId = prefs[DEVICE_ID] ?: return null + val ciphertext = prefs[TOKEN_CIPHERTEXT] ?: return null + val iv = prefs[TOKEN_IV] ?: return null + val token = cipher.open(SealedToken(Base64.getDecoder().decode(ciphertext), Base64.getDecoder().decode(iv))) + return Credentials(baseUrl, userId, deviceId, token) + } + + suspend fun save(credentials: Credentials) { + val sealed = cipher.seal(credentials.token) + store.edit { + it[BASE_URL] = credentials.baseUrl + it[USER_ID] = credentials.userId + it[DEVICE_ID] = credentials.deviceId + it[TOKEN_CIPHERTEXT] = Base64.getEncoder().encodeToString(sealed.ciphertext) + it[TOKEN_IV] = Base64.getEncoder().encodeToString(sealed.iv) + it[STATUS] = PairingStatus.PAIRED.name + } + } + + /** 401 `device_revoked` (AC-3.3): the token and its key are deleted at once; only the status remains. */ + suspend fun markRevoked() { + store.edit { + it.clear() + it[STATUS] = PairingStatus.REVOKED.name + } + cipher.destroyKey() + } + + /** 401 `unauthorized` (§14.4.8): the token no longer works; base URL and user id are kept for the re-pairing. */ + suspend fun markUnrecognised() { + store.edit { + if (it[STATUS] == PairingStatus.PAIRED.name) { + it.remove(TOKEN_CIPHERTEXT) + it.remove(TOKEN_IV) + it[STATUS] = PairingStatus.UNRECOGNISED.name + } + } + } + + suspend fun clear() { + store.edit { it.clear() } + cipher.destroyKey() + } + + private fun Preferences.record(): PairingRecord { + val status = this[STATUS]?.let { name -> PairingStatus.entries.firstOrNull { it.name == name } } ?: PairingStatus.UNPAIRED + return PairingRecord(status, this[BASE_URL], this[USER_ID], this[DEVICE_ID]) + } + + companion object { + /** DataStore name; the file is `auth.preferences_pb`. */ + const val FILE = "auth" + private val BASE_URL = stringPreferencesKey("base_url") + private val USER_ID = stringPreferencesKey("user_id") + private val DEVICE_ID = stringPreferencesKey("device_id") + private val TOKEN_CIPHERTEXT = stringPreferencesKey("token_ciphertext") + private val TOKEN_IV = stringPreferencesKey("token_iv") + private val STATUS = stringPreferencesKey("status") + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/SessionManager.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/SessionManager.kt new file mode 100644 index 0000000..6259262 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/SessionManager.kt @@ -0,0 +1,51 @@ +package se.hagstedt.kvitt.data.auth + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import se.hagstedt.kvitt.data.api.ApiEvents +import se.hagstedt.kvitt.data.connectivity.NetworkErrorTracker +import se.hagstedt.kvitt.data.repo.PhonePrefs +import se.hagstedt.kvitt.data.sync.SyncGate + +/** Deletes everything local (AC-3.3): the database, the per-phone preferences and the cached pictures. */ +fun interface LocalWipe { + suspend fun wipe() +} + +/** + * Reacts to the server's session answers in one place (§14.3.1, §14.4.5 step 2, §14.4.8) and decides whether a sync may + * run. 401 `device_revoked` deletes the token and all local data at once (AC-3.3); 401 `unauthorized` keeps the data and + * waits for a re-pairing; 426 stops syncing until the installed version changes. + */ +class SessionManager( + private val credentials: CredentialStore, + private val prefs: PhonePrefs, + private val wipe: LocalWipe, + private val errors: NetworkErrorTracker, + private val appVersionCode: Int, +) : ApiEvents, SyncGate { + private val serverVersionState = MutableStateFlow(null) + + /** `X-Kvitt-Server-Version` of the last response (S-22). */ + val serverVersion: StateFlow = serverVersionState.asStateFlow() + + override suspend fun onDeviceRevoked() { + credentials.markRevoked() + wipe.wipe() + } + + override suspend fun onUnrecognised() = credentials.markUnrecognised() + + override suspend fun onProtocolUnsupported() = prefs.blockProtocol(appVersionCode) + + override fun onNetworkError() = errors.record() + + override fun onReachedServer(serverVersion: String?) { + errors.clear() + if (serverVersion != null) serverVersionState.value = serverVersion + } + + override suspend fun canSync(): Boolean = + credentials.current().status == PairingStatus.PAIRED && !prefs.protocolBlocked(appVersionCode) +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/TokenCipher.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/TokenCipher.kt new file mode 100644 index 0000000..665f4f4 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/auth/TokenCipher.kt @@ -0,0 +1,68 @@ +package se.hagstedt.kvitt.data.auth + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Ciphertext and IV of the device token, as stored in DataStore (§8.2). */ +class SealedToken(val ciphertext: ByteArray, val iv: ByteArray) + +/** Encrypts the device token at rest. Production: [KeystoreTokenCipher]; tests use a typed fake (no Keystore on the JVM). */ +interface TokenCipher { + fun seal(token: String): SealedToken + + fun open(sealed: SealedToken): String + + /** Forgets the key (AC-3.3: the token is deleted with its key). */ + fun destroyKey() +} + +/** + * An Android Keystore AES-256-GCM key, alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, never + * exportable (Keystore keys are not). The IV is generated by the Keystore per encryption (§8.2, §6.2 Secrets). + */ +class KeystoreTokenCipher : TokenCipher { + private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + override fun seal(token: String): SealedToken { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key()) + return SealedToken(cipher.doFinal(token.toByteArray(Charsets.UTF_8)), cipher.iv) + } + + override fun open(sealed: SealedToken): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed.iv)) + return String(cipher.doFinal(sealed.ciphertext), Charsets.UTF_8) + } + + override fun destroyKey() { + if (keyStore.containsAlias(KEY_ALIAS)) keyStore.deleteEntry(KEY_ALIAS) + } + + private fun key(): SecretKey { + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_BITS) + .setUserAuthenticationRequired(false) + .build(), + ) + return generator.generateKey() + } + + companion object { + const val KEY_ALIAS = "kvitt_token_key" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val KEY_BITS = 256 + private const val TAG_BITS = 128 + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/connectivity/Connectivity.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/connectivity/Connectivity.kt new file mode 100644 index 0000000..08124d3 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/connectivity/Connectivity.kt @@ -0,0 +1,113 @@ +package se.hagstedt.kvitt.data.connectivity + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import se.hagstedt.kvitt.data.api.ApiResult +import se.hagstedt.kvitt.data.api.ErrorCodes +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.OnlineResult + +/** Whether the phone has a validated network (Android `NET_CAPABILITY_VALIDATED`, §9.9). */ +interface NetworkMonitor { + val validated: Flow + + fun isValidatedNow(): Boolean +} + +class AndroidNetworkMonitor(context: Context) : NetworkMonitor { + private val manager = context.getSystemService(ConnectivityManager::class.java) + + override val validated: Flow = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + } + + override fun onLost(network: Network) { + trySend(false) + } + } + trySend(isValidatedNow()) + manager.registerDefaultNetworkCallback(callback) + awaitClose { manager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() + + override fun isValidatedNow(): Boolean = + manager.getNetworkCapabilities(manager.activeNetwork)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true +} + +/** The time of the last request that failed with a network error; cleared when a request reaches the server. */ +class NetworkErrorTracker(private val clock: Clock) { + private val last = MutableStateFlow(null) + val lastErrorAt: StateFlow = last.asStateFlow() + + fun record() { + last.value = clock.nowMillis() + } + + fun clear() { + last.value = null + } + + /** Milliseconds until the 30-second window of §9.9 ends, or 0 when there is none. */ + fun remainingMillis(): Long = last.value?.let { (it + WINDOW_MILLIS - clock.nowMillis()).coerceAtLeast(0) } ?: 0 + + companion object { + /** §9.9: "the last request failed with a network error less than 30 seconds ago". */ + const val WINDOW_MILLIS = 30_000L + } +} + +/** §9.9: online = validated network and no network error within the last 30 seconds. */ +class ConnectivityRepositoryImpl(private val monitor: NetworkMonitor, private val errors: NetworkErrorTracker) : ConnectivityRepository { + @OptIn(ExperimentalCoroutinesApi::class) + override val online: Flow = combine(monitor.validated, errors.lastErrorAt) { validated, _ -> validated } + .flatMapLatest { validated -> + flow { + if (!validated) { + emit(false) + } else { + val remaining = errors.remainingMillis() + if (remaining > 0) { + emit(false) + delay(remaining) + } + emit(true) + } + } + } + .distinctUntilChanged() + + fun isOnlineNow(): Boolean = monitor.isValidatedNow() && errors.remainingMillis() == 0L +} + +/** + * Runs an online-only action (§9.9): [OnlineResult.Offline] without touching the network when offline; a network + * failure during the call changes nothing locally and is `Failed("network")` ("Could not reach the server"). + */ +class OnlineGate(private val connectivity: ConnectivityRepositoryImpl) { + suspend fun run(call: suspend () -> ApiResult, onOk: suspend (T) -> R): OnlineResult { + if (!connectivity.isOnlineNow()) return OnlineResult.Offline + return when (val result = call()) { + is ApiResult.Ok -> OnlineResult.Ok(onOk(result.value)) + is ApiResult.Failure -> OnlineResult.Failed(result.code, result.field) + is ApiResult.NetworkError -> OnlineResult.Failed(ErrorCodes.NETWORK, null) + ApiResult.NotPaired -> OnlineResult.Failed(ErrorCodes.UNAUTHORIZED, null) + } + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt new file mode 100644 index 0000000..4fed584 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/Daos.kt @@ -0,0 +1,208 @@ +package se.hagstedt.kvitt.data.db + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow + +// DAOs of KvittDatabase. Room 3 DAOs are suspend or Flow only. No foreign keys (§14.2): readers join in memory and skip +// rows whose references do not resolve yet. Multi-table writes run inside `withWriteTransaction` in the callers. + +@Dao +interface UserDao { + @Query("SELECT * FROM users") + fun observeAll(): Flow> + + @Query("SELECT * FROM users") + suspend fun all(): List + + @Query("SELECT * FROM users WHERE id = :id") + suspend fun get(id: String): UserEntity? + + @Upsert + suspend fun upsert(user: UserEntity) +} + +@Dao +interface AccountDao { + @Query("SELECT * FROM accounts") + fun observeAll(): Flow> + + @Query("SELECT * FROM account_default_shares") + fun observeShares(): Flow> + + @Query("SELECT * FROM accounts WHERE id = :id") + suspend fun get(id: String): AccountEntity? + + @Upsert + suspend fun upsert(account: AccountEntity) + + @Query("DELETE FROM account_default_shares WHERE account_id = :accountId") + suspend fun deleteShares(accountId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertShares(shares: List) +} + +@Dao +interface CategoryDao { + @Query("SELECT * FROM categories") + fun observeAll(): Flow> + + @Query("SELECT * FROM categories WHERE id = :id") + suspend fun get(id: String): CategoryEntity? + + @Upsert + suspend fun upsert(category: CategoryEntity) +} + +@Dao +interface TemplateDao { + @Query("SELECT * FROM recurring_templates") + fun observeAll(): Flow> + + @Query("SELECT * FROM recurring_template_splits") + fun observeSplits(): Flow> + + @Query("SELECT * FROM recurring_templates WHERE id = :id") + suspend fun get(id: String): RecurringTemplateEntity? + + @Upsert + suspend fun upsert(template: RecurringTemplateEntity) + + @Query("DELETE FROM recurring_template_splits WHERE template_id = :templateId") + suspend fun deleteSplits(templateId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSplits(splits: List) +} + +@Dao +interface ExpenseDao { + @Query("SELECT * FROM expenses") + fun observeAll(): Flow> + + @Query("SELECT * FROM expense_splits") + fun observeSplits(): Flow> + + @Query("SELECT * FROM expenses") + suspend fun all(): List + + @Query("SELECT * FROM expenses WHERE id = :id") + suspend fun get(id: String): ExpenseEntity? + + @Query("SELECT * FROM expense_splits WHERE expense_id = :expenseId ORDER BY user_id") + suspend fun splits(expenseId: String): List + + /** The push queue (§14.4.5 step 1): oldest local change first. */ + @Query("SELECT * FROM expenses WHERE pending = 1 ORDER BY local_updated_at, id LIMIT :limit") + suspend fun pending(limit: Int): List + + @Query("SELECT COUNT(*) FROM expenses WHERE pending = 1") + fun observePendingCount(): Flow + + @Upsert + suspend fun upsert(expense: ExpenseEntity) + + @Query("DELETE FROM expense_splits WHERE expense_id = :expenseId") + suspend fun deleteSplits(expenseId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSplits(splits: List) + + @Query("DELETE FROM expenses WHERE id = :id") + suspend fun delete(id: String) + + /** §14.4.5 step 3, accepted while edited again during the request: only the server-owned fields. */ + @Query("UPDATE expenses SET server_version = :serverVersion, created_by_user_id = :createdBy, updated_by_user_id = :updatedBy WHERE id = :id") + suspend fun setServerFields(id: String, serverVersion: Long, createdBy: String, updatedBy: String) + + /** Epoch recovery (§14.4.8): pending with a new mutation id, `updated_at` kept. */ + @Query("UPDATE expenses SET pending = 1, pending_mutation_id = :mutationId, local_updated_at = :localUpdatedAt WHERE id = :id") + suspend fun markPending(id: String, mutationId: String, localUpdatedAt: Long) +} + +@Dao +interface SettlementDao { + @Query("SELECT * FROM settlements") + fun observeAll(): Flow> + + @Query("SELECT * FROM settlement_allocations") + fun observeAllocations(): Flow> + + @Query("SELECT * FROM settlements") + suspend fun all(): List + + @Query("SELECT * FROM settlements WHERE id = :id") + suspend fun get(id: String): SettlementEntity? + + @Query("SELECT * FROM settlement_allocations WHERE settlement_id = :settlementId ORDER BY account_id") + suspend fun allocations(settlementId: String): List + + @Query("SELECT * FROM settlements WHERE pending = 1 ORDER BY local_updated_at, id LIMIT :limit") + suspend fun pending(limit: Int): List + + @Query("SELECT COUNT(*) FROM settlements WHERE pending = 1") + fun observePendingCount(): Flow + + @Upsert + suspend fun upsert(settlement: SettlementEntity) + + @Query("DELETE FROM settlement_allocations WHERE settlement_id = :settlementId") + suspend fun deleteAllocations(settlementId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllocations(allocations: List) + + @Query("DELETE FROM settlements WHERE id = :id") + suspend fun delete(id: String) + + @Query("UPDATE settlements SET server_version = :serverVersion, created_by_user_id = :createdBy, updated_by_user_id = :updatedBy WHERE id = :id") + suspend fun setServerFields(id: String, serverVersion: Long, createdBy: String, updatedBy: String) + + @Query("UPDATE settlements SET pending = 1, pending_mutation_id = :mutationId, local_updated_at = :localUpdatedAt WHERE id = :id") + suspend fun markPending(id: String, mutationId: String, localUpdatedAt: Long) +} + +@Dao +interface AuditDao { + /** Audit entries are insert-or-ignore (§14.4.2 step 3): they never change once written. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnore(entries: List) + + @Query("SELECT * FROM audit_log WHERE entity_type = :entityType AND entity_id = :entityId ORDER BY server_version") + fun observeFor(entityType: String, entityId: String): Flow> + + @Query("SELECT * FROM audit_log WHERE id = :id") + suspend fun get(id: String): AuditEntryEntity? +} + +@Dao +interface SyncStateDao { + @Query("SELECT * FROM sync_state WHERE id = 1") + fun observe(): Flow + + @Query("SELECT * FROM sync_state WHERE id = 1") + suspend fun get(): SyncStateEntity? + + @Upsert + suspend fun upsert(state: SyncStateEntity) +} + +@Dao +interface RejectionDao { + /** A replayed mutation id (§14.4.4 step 1) does not add a second record. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnore(rejection: SyncRejectionEntity) + + @Query("SELECT * FROM sync_rejections WHERE dismissed = 0 ORDER BY rejected_at DESC, mutation_id") + fun observeActive(): Flow> + + @Query("SELECT * FROM sync_rejections") + suspend fun all(): List + + @Query("UPDATE sync_rejections SET dismissed = 1 WHERE mutation_id = :mutationId") + suspend fun dismiss(mutationId: String) +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt index 90d8702..613a003 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/KvittDatabase.kt @@ -1,11 +1,17 @@ package se.hagstedt.kvitt.data.db +import android.content.Context import androidx.room3.Database +import androidx.room3.Room import androidx.room3.RoomDatabase +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.Dispatchers /** * The phone's database (SPEC §14.2): file `kvitt.db`, version 1, schema exported to `android/data/schemas/`. - * DAOs, the builder and the SQLite driver are added by M5. Every later version adds a migration and its test. + * Every later version adds a migration to [KvittMigrations.ALL] and its test in `MigrationTest` (§14.2.3). */ @Database( entities = [ @@ -23,11 +29,38 @@ import androidx.room3.RoomDatabase SyncStateEntity::class, SyncRejectionEntity::class, ], - version = 1, + version = KvittDatabase.VERSION, exportSchema = true, ) abstract class KvittDatabase : RoomDatabase() { + abstract fun users(): UserDao + abstract fun accounts(): AccountDao + abstract fun categories(): CategoryDao + abstract fun templates(): TemplateDao + abstract fun expenses(): ExpenseDao + abstract fun settlements(): SettlementDao + abstract fun audit(): AuditDao + abstract fun syncState(): SyncStateDao + abstract fun rejections(): RejectionDao + companion object { const val FILE_NAME = "kvitt.db" + const val VERSION = 1 + + /** + * The app's database. Production uses the bundled SQLite driver (Room 3 with sqlite-bundled); + * tests pass the framework driver under Robolectric. No destructive fallback: user data must survive. + */ + fun build(context: Context, driver: SQLiteDriver = BundledSQLiteDriver(), name: String = FILE_NAME): KvittDatabase = + Room.databaseBuilder(context, KvittDatabase::class.java, name) + .setDriver(driver) + .addMigrations(*KvittMigrations.ALL.toTypedArray()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() } } + +/** Every Room migration, in version order. Version 1 has none (§14.2.3). */ +object KvittMigrations { + val ALL: List = emptyList() +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt new file mode 100644 index 0000000..6965b96 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt @@ -0,0 +1,291 @@ +package se.hagstedt.kvitt.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import coil3.ImageLoader +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.migration.DisableInstallInCheck +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import se.hagstedt.kvitt.data.api.ApiClientInfo +import se.hagstedt.kvitt.data.api.ApiTarget +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.api.PictureAuth +import se.hagstedt.kvitt.data.api.kvittDefaults +import se.hagstedt.kvitt.data.auth.CredentialStore +import se.hagstedt.kvitt.data.auth.KeystoreTokenCipher +import se.hagstedt.kvitt.data.auth.LocalWipe +import se.hagstedt.kvitt.data.auth.PairingRecord +import se.hagstedt.kvitt.data.auth.SessionManager +import se.hagstedt.kvitt.data.connectivity.AndroidNetworkMonitor +import se.hagstedt.kvitt.data.connectivity.ConnectivityRepositoryImpl +import se.hagstedt.kvitt.data.connectivity.NetworkErrorTracker +import se.hagstedt.kvitt.data.connectivity.OnlineGate +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.repo.AboutRepositoryImpl +import se.hagstedt.kvitt.data.repo.AccountRepositoryImpl +import se.hagstedt.kvitt.data.repo.AnalyticsRepositoryImpl +import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl +import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl +import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl +import se.hagstedt.kvitt.data.repo.HomeRepositoryImpl +import se.hagstedt.kvitt.data.repo.InviteRepositoryImpl +import se.hagstedt.kvitt.data.repo.LocalData +import se.hagstedt.kvitt.data.repo.PhonePrefs +import se.hagstedt.kvitt.data.repo.Pictures +import se.hagstedt.kvitt.data.repo.ProfileRepositoryImpl +import se.hagstedt.kvitt.data.repo.RecurringRepositoryImpl +import se.hagstedt.kvitt.data.repo.SessionRepositoryImpl +import se.hagstedt.kvitt.data.repo.SettlementRepositoryImpl +import se.hagstedt.kvitt.data.repo.SyncRepositoryImpl +import se.hagstedt.kvitt.data.repo.SyncSummaries +import se.hagstedt.kvitt.data.sync.KvittWorkerFactory +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.sync.SyncTriggers +import se.hagstedt.kvitt.data.sync.SyncWork +import se.hagstedt.kvitt.data.sync.WorkManagerSyncWork +import se.hagstedt.kvitt.data.update.AndroidApkInstaller +import se.hagstedt.kvitt.data.update.UpdateRepositoryImpl +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.IdGenerator +import se.hagstedt.kvitt.data.util.SystemClock +import se.hagstedt.kvitt.data.util.UuidV7Generator +import se.hagstedt.kvitt.model.AboutRepository +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.AnalyticsRepository +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.DeviceRepository +import se.hagstedt.kvitt.model.ExpenseRepository +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.InviteRepository +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.RecurringRepository +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SettlementRepository +import se.hagstedt.kvitt.model.SyncRepository +import se.hagstedt.kvitt.model.UpdateRepository +import java.util.Locale +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). */ +data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean) + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class PrefsStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DataScope + +/** + * The `:data` object graph. It provides the implementations only; it binds no `:core:model` interface, so `:app` can + * install either [DataBindingsModule] (release) or the fakes (debug, tests) without duplicate bindings. `:app` must + * provide [AppBuildInfo], and install [KvittWorkerFactory] in its WorkManager configuration. + */ +@Module +@InstallIn(SingletonComponent::class) +object DataModule { + /** §8.5: release builds use https only (the app's network security config); the default server of AC-2.7. */ + const val DEFAULT_SERVER_URL = "https://icehack.asuscomm.com:8443" + + @Provides @Singleton @DataScope + fun scope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Provides @Singleton + fun clock(): Clock = SystemClock + + @Provides @Singleton + fun ids(clock: Clock): IdGenerator = UuidV7Generator(clock) + + @Provides @Singleton + fun database(@ApplicationContext context: Context): KvittDatabase = KvittDatabase.build(context) + + @Provides @Singleton @AuthStore + fun authStore(@ApplicationContext context: Context): DataStore = + PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(CredentialStore.FILE) } + + @Provides @Singleton @PrefsStore + fun prefsStore(@ApplicationContext context: Context): DataStore = + PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(PhonePrefs.FILE) } + + @Provides @Singleton + fun credentials(@AuthStore store: DataStore): CredentialStore = CredentialStore(store, KeystoreTokenCipher()) + + @Provides @Singleton + fun prefs(@PrefsStore store: DataStore): PhonePrefs = PhonePrefs(store) + + @Provides @Singleton + fun networkErrors(clock: Clock): NetworkErrorTracker = NetworkErrorTracker(clock) + + @Provides @Singleton + fun connectivity(@ApplicationContext context: Context, errors: NetworkErrorTracker): ConnectivityRepositoryImpl = + ConnectivityRepositoryImpl(AndroidNetworkMonitor(context), errors) + + @Provides @Singleton + fun onlineGate(connectivity: ConnectivityRepositoryImpl): OnlineGate = OnlineGate(connectivity) + + @Provides @Singleton + fun imageLoader(@ApplicationContext context: Context, credentials: CredentialStore, build: AppBuildInfo, @DataScope scope: CoroutineScope): ImageLoader { + val base = credentials.record.stateInBaseUrl(scope) + val client = HttpClient(OkHttp) { + install(PictureAuth) { + target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } } + appVersionCode = build.versionCode + } + } + return Pictures.imageLoader(context, client) { base.value } + } + + @Provides @Singleton + fun wipe(@ApplicationContext context: Context, db: KvittDatabase, prefs: PhonePrefs, images: ImageLoader): LocalWipe = LocalWipe { + db.clearAllTables() + prefs.clear() + images.memoryCache?.clear() + images.diskCache?.clear() + Pictures.directory(context).deleteRecursively() + } + + @Provides @Singleton + fun sessionManager(credentials: CredentialStore, prefs: PhonePrefs, wipe: LocalWipe, errors: NetworkErrorTracker, build: AppBuildInfo): SessionManager = + SessionManager(credentials, prefs, wipe, errors, build.versionCode) + + @Provides @Singleton + fun api(credentials: CredentialStore, session: SessionManager, build: AppBuildInfo): KvittApi = KvittApi( + http = HttpClient(OkHttp) { kvittDefaults() }, + target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }, + info = object : ApiClientInfo { + override val appVersionCode: Int = build.versionCode + override fun acceptLanguage(): String = Locale.getDefault().toLanguageTag() + }, + events = session, + ) + + @Provides @Singleton + fun engine(db: KvittDatabase, api: KvittApi, clock: Clock, ids: IdGenerator, session: SessionManager): SyncEngine = + SyncEngine(db, api, clock, ids, session) + + @Provides @Singleton + fun syncWork(@ApplicationContext context: Context): SyncWork = WorkManagerSyncWork(context) + + @Provides @Singleton + fun triggers(work: SyncWork, engine: SyncEngine, @DataScope scope: CoroutineScope): SyncTriggers = SyncTriggers(work, engine, scope) + + @Provides @Singleton + fun workerFactory(engine: Provider): KvittWorkerFactory = KvittWorkerFactory { engine.get() } + + @Provides @Singleton + fun localData(db: KvittDatabase, credentials: CredentialStore, @DataScope scope: CoroutineScope): LocalData = LocalData(db, credentials, scope) + + @Provides @Singleton + fun summaries(db: KvittDatabase, engine: SyncEngine): SyncSummaries = SyncSummaries(db, engine) + + @Provides @Singleton + fun update(@ApplicationContext context: Context, api: KvittApi, prefs: PhonePrefs, clock: Clock, build: AppBuildInfo): UpdateRepositoryImpl = + UpdateRepositoryImpl(api, context.cacheDir, AndroidApkInstaller(context), prefs, clock, build.versionCode, build.debug) + + @Provides @Singleton + fun session( + credentials: CredentialStore, + db: KvittDatabase, + api: KvittApi, + gate: OnlineGate, + engine: SyncEngine, + triggers: SyncTriggers, + wipe: LocalWipe, + clock: Clock, + build: AppBuildInfo, + ): SessionRepositoryImpl = SessionRepositoryImpl(credentials, db, api, gate, engine, triggers, wipe, clock, build.debug) + + @Provides @Singleton + fun home(local: LocalData, summaries: SyncSummaries, update: UpdateRepositoryImpl, clock: Clock): HomeRepositoryImpl = + HomeRepositoryImpl(local, summaries, update.state, clock) + + @Provides @Singleton + fun accounts(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): AccountRepositoryImpl = + AccountRepositoryImpl(local, api, gate, db, clock) + + @Provides @Singleton + fun expenses(local: LocalData, db: KvittDatabase, prefs: PhonePrefs, clock: Clock, ids: IdGenerator, triggers: SyncTriggers): ExpenseRepositoryImpl = + ExpenseRepositoryImpl(local, db, prefs, clock, ids, triggers) + + @Provides @Singleton + fun settlements(local: LocalData, db: KvittDatabase, clock: Clock, ids: IdGenerator, triggers: SyncTriggers): SettlementRepositoryImpl = + SettlementRepositoryImpl(local, db, clock, ids, triggers) + + @Provides @Singleton + fun analytics(local: LocalData, clock: Clock): AnalyticsRepositoryImpl = AnalyticsRepositoryImpl(local, clock) + + @Provides @Singleton + fun profile(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): ProfileRepositoryImpl = + ProfileRepositoryImpl(local, api, gate, db, clock) + + @Provides @Singleton + fun categories(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): CategoryRepositoryImpl = + CategoryRepositoryImpl(local, api, gate, db, clock) + + @Provides @Singleton + fun recurring(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): RecurringRepositoryImpl = + RecurringRepositoryImpl(local, api, gate, db, clock) + + @Provides @Singleton + fun devices(local: LocalData, api: KvittApi, gate: OnlineGate): DeviceRepositoryImpl = DeviceRepositoryImpl(local, api, gate) + + @Provides @Singleton + fun invites(api: KvittApi, gate: OnlineGate): InviteRepositoryImpl = InviteRepositoryImpl(api, gate) + + @Provides @Singleton + fun sync(local: LocalData, db: KvittDatabase, summaries: SyncSummaries, engine: SyncEngine, work: SyncWork): SyncRepositoryImpl = + SyncRepositoryImpl(local, db, summaries, engine, work) + + @Provides @Singleton + fun about(credentials: CredentialStore, session: SessionManager, build: AppBuildInfo): AboutRepositoryImpl = + AboutRepositoryImpl(credentials, session.serverVersion, build.versionName, build.versionCode, DEFAULT_SERVER_URL) +} + +/** The `:core:model` interfaces bound to `:data`. Not installed automatically: `:app` includes it in its release graph. */ +@Module +@DisableInstallInCheck +abstract class DataBindingsModule { + @Binds abstract fun session(impl: SessionRepositoryImpl): SessionRepository + @Binds abstract fun home(impl: HomeRepositoryImpl): HomeRepository + @Binds abstract fun accounts(impl: AccountRepositoryImpl): AccountRepository + @Binds abstract fun expenses(impl: ExpenseRepositoryImpl): ExpenseRepository + @Binds abstract fun settlements(impl: SettlementRepositoryImpl): SettlementRepository + @Binds abstract fun analytics(impl: AnalyticsRepositoryImpl): AnalyticsRepository + @Binds abstract fun profile(impl: ProfileRepositoryImpl): ProfileRepository + @Binds abstract fun categories(impl: CategoryRepositoryImpl): CategoryRepository + @Binds abstract fun recurring(impl: RecurringRepositoryImpl): RecurringRepository + @Binds abstract fun devices(impl: DeviceRepositoryImpl): DeviceRepository + @Binds abstract fun invites(impl: InviteRepositoryImpl): InviteRepository + @Binds abstract fun sync(impl: SyncRepositoryImpl): SyncRepository + @Binds abstract fun update(impl: UpdateRepositoryImpl): UpdateRepository + @Binds abstract fun about(impl: AboutRepositoryImpl): AboutRepository + @Binds abstract fun connectivity(impl: ConnectivityRepositoryImpl): ConnectivityRepository +} + +private fun Flow.stateInBaseUrl(scope: CoroutineScope): StateFlow = + map { it.baseUrl }.stateIn(scope, SharingStarted.Eagerly, null) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/AnalyticsRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/AnalyticsRepositoryImpl.kt new file mode 100644 index 0000000..298b1cd --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/AnalyticsRepositoryImpl.kt @@ -0,0 +1,114 @@ +package se.hagstedt.kvitt.data.repo + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.datetime.LocalDate +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.today +import se.hagstedt.kvitt.domain.resolveRange +import se.hagstedt.kvitt.domain.spendSeries +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AnalyticsFilter +import se.hagstedt.kvitt.model.AnalyticsRepository +import se.hagstedt.kvitt.model.AnalyticsState +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.DrillDownState +import se.hagstedt.kvitt.model.Granularity +import se.hagstedt.kvitt.model.GroupBy +import se.hagstedt.kvitt.model.GroupKey +import se.hagstedt.kvitt.model.Measure +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.SpendBucket +import se.hagstedt.kvitt.model.analytics.AnalyticsExpense +import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity +import se.hagstedt.kvitt.model.analytics.AnalyticsGroupBy +import se.hagstedt.kvitt.model.analytics.AnalyticsMeasure +import se.hagstedt.kvitt.model.analytics.DateRange + +/** + * FR-14 from Room through `resolveRange` and `spendSeries` (§14.5.4): offline, pending changes included, deleted + * expenses excluded, archived accounts and categories included (AC-14.1, AC-14.9). + */ +class AnalyticsRepositoryImpl(private val local: LocalData, private val clock: Clock) : AnalyticsRepository { + override fun analytics(filter: AnalyticsFilter): Flow = local.snapshot.map { s -> + val range = s.range(filter, clock) + val expenses = s.analyticsExpenses() + val measure = s.measure(filter.measure) + val granularity = filter.granularity.domain() + val chosen = if (filter.groupBy == GroupBy.CATEGORY) AnalyticsGroupBy.CATEGORY else AnalyticsGroupBy.ACCOUNT + val series = spendSeries(expenses, range, granularity, measure, chosen) + val byCategory = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.CATEGORY).groupTotals + val byAccount = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.ACCOUNT).groupTotals + fun ranked(totals: Map, groupBy: GroupBy) = + totals.entries.sortedWith(compareByDescending> { it.value }.thenBy { it.key }).map { s.groupKey(it.key, groupBy) to it.value } + AnalyticsState( + filter = filter, + start = range.start.toString(), + end = range.endInclusive.toString(), + groups = ranked(series.groupTotals, filter.groupBy).map { it.first }, + buckets = series.buckets.map { SpendBucket(it.key, it.start.toString(), it.endInclusive.toString(), it.totals, it.total) }, + byCategory = ranked(byCategory, GroupBy.CATEGORY), + byAccount = ranked(byAccount, GroupBy.ACCOUNT), + totalOre = series.grandTotal, + ) + }.distinctUntilChanged() + + override fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow = + local.snapshot.map { s -> + val range = s.range(filter, clock) + val bucket = bucketKey?.let { key -> + spendSeries(emptyList(), range, filter.granularity.domain(), AnalyticsMeasure.Household, AnalyticsGroupBy.CATEGORY).buckets.firstOrNull { it.key == key } + } + val start = bucket?.start ?: range.start + val end = bucket?.endInclusive ?: range.endInclusive + val matching = s.liveExpenses.filter { e -> + val date = LocalDate.parse(e.row.expenseDate) + date >= start && date <= end && + (categoryId == null || e.row.categoryId == categoryId.value) && (accountId == null || e.row.accountId == accountId.value) + } + val me = s.meId + val measured = matching.sumOf { e -> + if (filter.measure == Measure.HOUSEHOLD) e.row.amountOre else e.splits.firstOrNull { it.userId == me?.value }?.shareOre ?: 0L + } + // §14.2.4 (v0.4): unlocalised parts joined by ", " (category custom name or default key, account name, bucket key). + val title = listOfNotNull( + categoryId?.let { id -> s.category(id.value)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } }, + accountId?.let { id -> s.account(id.value)?.name }, + bucketKey, + ).joinToString(", ") + DrillDownState( + title = title, + totalOre = measured, + rows = matching.sortedWith { a, b -> ledgerOrder.compare(a.row.expenseDate to a.row.createdAt, b.row.expenseDate to b.row.createdAt) } + .mapNotNull { s.expenseRow(it) }, + ) + }.distinctUntilChanged() +} + +private fun Granularity.domain() = if (this == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK + +private fun Snapshot.analyticsExpenses(): List = liveExpenses.map { e -> + AnalyticsExpense(LocalDate.parse(e.row.expenseDate), e.row.accountId, e.row.categoryId, e.row.amountOre, e.splits.associate { it.userId to it.shareOre }) +} + +private fun Snapshot.measure(measure: Measure): AnalyticsMeasure = + if (measure == Measure.HOUSEHOLD || meId == null) AnalyticsMeasure.Household else AnalyticsMeasure.Share(meId.value) + +/** + * AC-14.2 via `resolveRange`. §14.2.4 (v0.4): a `CUSTOM` filter with a missing date resolves that date as today before + * calling, so `IncompleteCustomRangeException` is never raised here. + */ +private fun Snapshot.range(filter: AnalyticsFilter, clock: Clock): DateRange { + val today = LocalDate.parse(clock.today()) + val earliest = liveExpenses.minOfOrNull { it.row.expenseDate }?.let { LocalDate.parse(it) } + val custom = filter.preset == RangePreset.CUSTOM + val start = filter.customStart?.let { LocalDate.parse(it) } ?: today.takeIf { custom } + val end = filter.customEnd?.let { LocalDate.parse(it) } ?: today.takeIf { custom } + return resolveRange(filter.preset, today, earliest, start, end) +} + +private fun Snapshot.groupKey(id: String, groupBy: GroupBy): GroupKey = when (groupBy) { + GroupBy.CATEGORY -> category(id).let { GroupKey(id, it?.customName, it?.color) } + GroupBy.ACCOUNT -> GroupKey(id, account(id)?.name, null) +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt new file mode 100644 index 0000000..1b4df43 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt @@ -0,0 +1,235 @@ +package se.hagstedt.kvitt.data.repo + +import androidx.room3.withWriteTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import se.hagstedt.kvitt.data.db.ExpenseEntity +import se.hagstedt.kvitt.data.db.ExpenseSplitEntity +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.sync.LocalWriteListener +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.IdGenerator +import se.hagstedt.kvitt.data.util.codePointLength +import se.hagstedt.kvitt.data.util.nowTimestamp +import se.hagstedt.kvitt.data.util.today +import se.hagstedt.kvitt.domain.BP_TOTAL +import se.hagstedt.kvitt.domain.computeSplit +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.ExpenseDetailState +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.ExpenseEditorOptions +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.ExpenseRepository +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.model.money.SplitError +import se.hagstedt.kvitt.model.money.SplitResult +import se.hagstedt.kvitt.model.money.SplitSpec + +/** S-7, S-8. Saves and deletes are local and offline-capable (AC-8.2); they become pending and trigger a sync. */ +class ExpenseRepositoryImpl( + private val local: LocalData, + private val db: KvittDatabase, + private val prefs: PhonePrefs, + private val clock: Clock, + private val ids: IdGenerator, + private val writes: LocalWriteListener, +) : ExpenseRepository { + private val timelines = Timelines(db, local) + + override fun editorOptions(): Flow = combine(local.snapshot, prefs.editorDefaults) { s, defaults -> + val me = s.me ?: return@combine null + val partner = s.partner ?: return@combine null + val active = s.accounts.filter { !it.archived } + val categories = s.categories.filter { !it.archived } + val other = categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER } + ExpenseEditorOptions( + me = me, + partner = partner, + accounts = active, + categories = categories, + // AC-8.6: the account last used on this phone if still active, else the oldest active account. + defaultAccountId = defaults.lastAccountId?.let { id -> active.firstOrNull { it.id.value == id }?.id } ?: active.firstOrNull()?.id, + // AC-8.6: the category last used in that account on this phone if still active, else `other`. + defaultCategoryFor = active.mapNotNull { account -> + val last = defaults.lastCategoryFor[account.id.value]?.let { id -> categories.firstOrNull { it.id.value == id }?.id } + (last ?: other?.id)?.let { account.id to it } + }.toMap(), + today = clock.today(), + ) + }.filterNotNull().distinctUntilChanged() + + override suspend fun load(id: ExpenseId): ExpenseDraft? { + val e = local.snapshot.first().expense(id.value)?.takeIf { !it.deleted } ?: return null + return ExpenseDraft( + id = e.id, + accountId = se.hagstedt.kvitt.model.AccountId(e.row.accountId), + categoryId = se.hagstedt.kvitt.model.CategoryId(e.row.categoryId), + amountOre = e.row.amountOre, + description = e.row.description, + notes = e.row.notes, + date = e.row.expenseDate, + payer = UserId(e.row.payerUserId), + mode = e.mode, + ratioBp = if (e.mode == SplitMode.RATIO) e.shares.associate { it.userId to (it.shareBp ?: 0) } else null, + exactOre = if (e.mode == SplitMode.EXACT) e.shares.associate { it.userId to it.shareOre } else null, + fullOn = if (e.mode == SplitMode.FULL) e.shares.firstOrNull { it.shareBp == BP_TOTAL }?.userId else null, + ) + } + + override fun preview(draft: ExpenseDraft): List? = (local.current.value?.split(draft) as? SplitOutcome.Ok)?.shares + + override suspend fun save(draft: ExpenseDraft): SaveResult { + val s = local.snapshot.first() + val amountOre = draft.amountOre + val account = draft.accountId?.let { id -> s.accounts.firstOrNull { it.id == id } } + val category = draft.categoryId?.let { id -> s.categories.firstOrNull { it.id == id } } + val errors = mutableListOf() + if (amountOre == null) errors += FieldError("amountOre", REQUIRED) + if (account == null) errors += FieldError("accountId", REQUIRED) + if (category == null) errors += FieldError("categoryId", REQUIRED) + when (draft.description.codePointLength()) { + 0 -> errors += FieldError("description", REQUIRED) + !in 1..MAX_DESCRIPTION -> errors += FieldError("description", VALIDATION_FAILED) + } + if (draft.notes.codePointLength() > MAX_NOTES) errors += FieldError("notes", VALIDATION_FAILED) + if (!validDate(draft.date, clock)) errors += FieldError("date", VALIDATION_FAILED) + val outcome = s.split(draft) + if (outcome is SplitOutcome.Err) { + errors += FieldError(if (outcome.error == SplitError.AMOUNT_OUT_OF_RANGE) "amountOre" else "split", outcome.error.name.lowercase()) + } + if (errors.isNotEmpty() || outcome !is SplitOutcome.Ok || amountOre == null || account == null || category == null) { + return SaveResult.Invalid(errors) + } + + val existing = draft.id?.let { s.expense(it.value) }?.takeIf { !it.deleted } + val id = existing?.row?.id ?: draft.id?.value ?: ids.newId() + val now = clock.nowTimestamp() + val row = ExpenseEntity( + id = id, + accountId = account.id.value, + categoryId = category.id.value, + amountOre = amountOre, + description = draft.description.trim(), + notes = draft.notes, + expenseDate = draft.date, + payerUserId = draft.payer.value, + splitMode = draft.mode.wire(), + recurringTemplateId = existing?.row?.recurringTemplateId, + recurringPeriod = existing?.row?.recurringPeriod, + createdByUserId = existing?.row?.createdByUserId, + updatedByUserId = existing?.row?.updatedByUserId, + createdAt = existing?.row?.createdAt ?: now, + updatedAt = now, + deletedAt = null, + serverVersion = existing?.row?.serverVersion, + pending = true, + pendingMutationId = ids.newId(), + localUpdatedAt = clock.nowMillis(), + ) + db.withWriteTransaction { + db.expenses().upsert(row) + db.expenses().deleteSplits(id) + db.expenses().insertSplits(outcome.shares.map { ExpenseSplitEntity(id, it.userId.value, it.shareOre, it.shareBp) }) + } + prefs.rememberEditorChoice(account.id.value, category.id.value) + writes.onLocalWrite() + return SaveResult.Saved(ExpenseId(id)) + } + + /** A local delete keeps the row as a tombstone: `deleted_at` = `updated_at` = now, pending (§14.2.1). */ + override suspend fun delete(id: ExpenseId) { + val now = clock.nowTimestamp() + val changed = db.withWriteTransaction { + val row = db.expenses().get(id.value)?.takeIf { it.deletedAt == null } ?: return@withWriteTransaction false + db.expenses().upsert(row.copy(deletedAt = now, updatedAt = now, pending = true, pendingMutationId = ids.newId(), localUpdatedAt = clock.nowMillis())) + true + } + if (changed) writes.onLocalWrite() + } + + override fun detail(id: ExpenseId): Flow = timelines.observe(SyncEngine.EXPENSE, id.value) { s, entries -> + val e = s.expense(id.value) ?: return@observe null + val account = s.account(e.row.accountId) ?: return@observe null + val category = s.category(e.row.categoryId) ?: return@observe null + val payer = s.user(e.row.payerUserId) ?: return@observe null + ExpenseDetailState( + id = e.id, + account = account, + category = category, + amountOre = e.row.amountOre, + description = e.row.description, + notes = e.row.notes, + date = e.row.expenseDate, + payer = payer, + mode = e.mode, + shares = e.shares, + recurringTemplateName = e.row.recurringTemplateId?.let { t -> s.templateRecords.firstOrNull { it.row.id == t }?.row?.description }, + deleted = e.deleted, + pending = e.row.pending, + timeline = s.timeline(entries, e.row.pending, e.deleted, accepted = e.row.serverVersion != null, pendingAt = e.row.updatedAt), + ) + }.distinctUntilChanged() + + companion object { + const val REQUIRED = "required" + + /** §14.2.4 (v0.4): a §9.6 rule without a domain token. */ + const val VALIDATION_FAILED = "validation_failed" + const val MAX_DESCRIPTION = 100 + const val MAX_NOTES = 1000 + } +} + +internal sealed interface SplitOutcome { + data class Ok(val shares: List) : SplitOutcome + data class Err(val error: SplitError) : SplitOutcome + data object Incomplete : SplitOutcome +} + +/** §9.1 through `computeSplit`; mode `default` copies the account's default ratio (AC-8.5). Shares sorted by user id. */ +internal fun Snapshot.split(draft: ExpenseDraft): SplitOutcome { + val amount = draft.amountOre ?: return SplitOutcome.Incomplete + val userIds = household.map { it.id }.sortedBy { it.value } + val bp: Map? = when (draft.mode) { + SplitMode.DEFAULT -> draft.accountId?.let { account(it.value) }?.defaultShares ?: return SplitOutcome.Incomplete + SplitMode.RATIO -> draft.ratioBp ?: return SplitOutcome.Incomplete + SplitMode.FULL -> { + val bearer = draft.fullOn ?: return SplitOutcome.Incomplete + userIds.associateWith { if (it == bearer) BP_TOTAL else 0 } + } + SplitMode.EXACT -> null + } + val spec = when (draft.mode) { + SplitMode.EXACT -> SplitSpec.Exact((draft.exactOre ?: return SplitOutcome.Incomplete).mapKeys { it.key.value }) + SplitMode.FULL -> SplitSpec.Full((draft.fullOn ?: return SplitOutcome.Incomplete).value) + else -> SplitSpec.Ratio(bp.orEmpty().mapKeys { it.key.value }) + } + return when (val result = computeSplit(amount, draft.payer.value, userIds.map { it.value }, spec)) { + is SplitResult.Err -> SplitOutcome.Err(result.error) + is SplitResult.Ok -> SplitOutcome.Ok(userIds.map { u -> Share(u, result.shares[u.value] ?: 0L, bp?.get(u)) }) + } +} + +/** §9.6: a valid date from 2000-01-01 to today + 365 days (the phone's today; the server checks with its own). */ +internal fun validDate(date: String, clock: Clock): Boolean { + val parsed = try { + LocalDate.parse(date) + } catch (e: IllegalArgumentException) { + return false + } + val today = LocalDate.parse(clock.today()) + return parsed >= LocalDate(2000, 1, 1) && parsed <= today.plus(MAX_DAYS_AHEAD, DateTimeUnit.DAY) +} + +private const val MAX_DAYS_AHEAD = 365 diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/HouseholdRepositories.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/HouseholdRepositories.kt new file mode 100644 index 0000000..7b358a2 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/HouseholdRepositories.kt @@ -0,0 +1,152 @@ +package se.hagstedt.kvitt.data.repo + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import se.hagstedt.kvitt.data.api.CreateAccountRequest +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.api.PatchAccountRequest +import se.hagstedt.kvitt.data.api.ShareBpDto +import se.hagstedt.kvitt.data.connectivity.OnlineGate +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.sync.LocalRows +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.today +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountDetailState +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.AccountSummary +import se.hagstedt.kvitt.model.BalanceView +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.HomeState +import se.hagstedt.kvitt.model.LedgerMonth +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.SyncSummary +import se.hagstedt.kvitt.model.UpdateInfo +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.UserId + +/** `SyncSummary` (AC-15.4) from `sync_state`, the pending and rejection counts and the engine. */ +class SyncSummaries(db: KvittDatabase, engine: SyncEngine) { + val summary: Flow = combine( + db.syncState().observe(), + db.expenses().observePendingCount(), + db.settlements().observePendingCount(), + db.rejections().observeActive(), + engine.running, + ) { state, pendingExpenses, pendingSettlements, rejections, running -> + SyncSummary( + lastSuccessAt = state?.lastSuccessAt, + pendingCount = pendingExpenses + pendingSettlements, + rejectedCount = rejections.size, + clockSkew = state?.clockSkew ?: false, + running = running, + ) + }.distinctUntilChanged() +} + +internal fun UpdateState.info(): UpdateInfo? = when (this) { + UpdateState.None -> null + is UpdateState.Available -> info + is UpdateState.Downloading -> info + is UpdateState.Failed -> info + is UpdateState.NeedsInstallPermission -> info +} + +/** S-4 (AC-10.1, AC-10.2, AC-10.5, AC-15.4, AC-16.2). Balances include pending changes and exclude tombstones (AC-10.4). */ +class HomeRepositoryImpl( + private val local: LocalData, + private val summaries: SyncSummaries, + private val updates: Flow, + private val clock: Clock, +) : HomeRepository { + override fun home(): Flow = combine(local.snapshot, summaries.summary, updates) { s, sync, update -> + val me = s.me ?: return@combine null + val balances = s.balances() + val month = clock.today().take(7) + val summaries = s.accounts.map { account -> + AccountSummary( + account = account, + netOre = balances.net(account.id.value, me.id), + expensesThisMonth = s.liveExpenses.count { it.row.accountId == account.id.value && it.row.expenseDate.startsWith(month) }, + ) + } + HomeState( + me = me, + partner = s.partner, + global = s.partner?.let { BalanceView(balances.globalNet(me.id), it) }, + active = summaries.filter { !it.account.archived }, + archived = summaries.filter { it.account.archived }, + sync = sync, + update = update.info(), + ) + }.filterNotNull().distinctUntilChanged() +} + +/** S-5, S-6. Writes are online-only (FR-6, §9.9); the response row is stored at once (§7). */ +class AccountRepositoryImpl( + private val local: LocalData, + private val api: KvittApi, + private val gate: OnlineGate, + db: KvittDatabase, + clock: Clock, +) : AccountRepository { + private val rows = LocalRows(db, clock) + + override fun detail(id: AccountId): Flow = local.snapshot.map { s -> + val account = s.account(id.value)?.takeIf { a -> s.accounts.any { it.id == a.id } } ?: return@map null + val me = s.me ?: return@map null + val partner = s.partner ?: return@map null + AccountDetailState( + account = account, + balance = BalanceView(s.balances().net(id.value, me.id), partner), + months = s.ledgerMonths(id.value), + deletable = s.accountDeletable(id.value), + users = s.household, + ) + }.distinctUntilChanged() + + override fun all(): Flow> = local.snapshot.map { it.accounts }.distinctUntilChanged() + + override suspend fun create(name: String, defaultShares: Map): OnlineResult = + gate.run({ api.createAccount(CreateAccountRequest(name.trim(), defaultShares.toWire())) }) { + rows.writeAccountIfNewer(it) + AccountId(it.id) + } + + override suspend fun update(id: AccountId, name: String, defaultShares: Map): OnlineResult = + gate.run({ api.patchAccount(id.value, PatchAccountRequest(name.trim(), defaultShares.toWire())) }) { rows.writeAccountIfNewer(it) } + + override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult = + gate.run({ if (archived) api.archiveAccount(id.value) else api.unarchiveAccount(id.value) }) { rows.writeAccountIfNewer(it) } + + override suspend fun delete(id: AccountId): OnlineResult = gate.run({ api.deleteAccount(id.value) }) { rows.writeAccountIfNewer(it) } +} + +private fun Map.toWire() = entries.sortedBy { it.key.value }.map { ShareBpDto(it.key.value, it.value) } + +/** AC-6.5: no non-deleted expense, allocation of a non-deleted settlement or non-deleted template references it. */ +internal fun Snapshot.accountDeletable(id: String): Boolean = + liveExpenses.none { it.row.accountId == id } && + liveSettlements.none { s -> s.allocations.any { it.accountId == id } } && + liveTemplates.none { it.row.accountId == id } + +/** + * S-5 / AC-10.3: the account's expenses and settlements interleaved by date, grouped by month, newest first; the month + * total is the household spend of the account's expenses; a settlement row carries its allocation to this account. + */ +internal fun Snapshot.ledgerMonths(id: String): List { + val expenseRows = liveExpenses.filter { it.row.accountId == id } + .mapNotNull { e -> expenseRow(e)?.let { Triple, LedgerRow, Long>(e.row.expenseDate to e.row.createdAt, it, e.row.amountOre) } } + val settlementRows = liveSettlements.filter { s -> s.allocations.any { it.accountId == id } } + .mapNotNull { s -> settlementRow(s, account = id)?.let { Triple, LedgerRow, Long>(s.row.settlementDate to s.row.createdAt, it, 0L) } } + return (expenseRows + settlementRows) + .sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) } + .groupBy { it.first.first.take(7) } + .map { (period, rows) -> LedgerMonth(period, rows.sumOf { it.third }, rows.map { it.second }) } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt new file mode 100644 index 0000000..b63fee7 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/PhonePrefs.kt @@ -0,0 +1,62 @@ +package se.hagstedt.kvitt.data.repo + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** Editor defaults remembered on this phone (AC-8.6). */ +data class EditorDefaults(val lastAccountId: String?, val lastCategoryFor: Map) + +/** + * Per-phone preferences in DataStore `prefs.preferences_pb` (§14.2.2): last used account, last used category per + * account, whether the "install unknown apps" explanation was shown, and the app version that got 426. + */ +class PhonePrefs(private val store: DataStore) { + val editorDefaults: Flow = store.data.map { prefs -> + EditorDefaults( + lastAccountId = prefs[LAST_ACCOUNT], + lastCategoryFor = prefs.asMap().mapNotNull { (key, value) -> + key.name.removePrefix(CATEGORY_PREFIX).takeIf { key.name.startsWith(CATEGORY_PREFIX) }?.let { it to value.toString() } + }.toMap(), + ) + } + + suspend fun rememberEditorChoice(accountId: String, categoryId: String) { + store.edit { + it[LAST_ACCOUNT] = accountId + it[stringPreferencesKey(CATEGORY_PREFIX + accountId)] = categoryId + } + } + + suspend fun installExplanationShown(): Boolean = store.data.first()[INSTALL_EXPLAINED] ?: false + + suspend fun setInstallExplanationShown() { + store.edit { it[INSTALL_EXPLAINED] = true } + } + + /** 426 stops syncing "until updated" (§14.3.1): blocked while the installed version is the one that got 426. */ + suspend fun protocolBlocked(versionCode: Int): Boolean = store.data.first()[PROTOCOL_BLOCKED_AT] == versionCode + + suspend fun blockProtocol(versionCode: Int) { + store.edit { it[PROTOCOL_BLOCKED_AT] = versionCode } + } + + suspend fun clear() { + store.edit { it.clear() } + } + + companion object { + /** DataStore name; the file is `prefs.preferences_pb`. */ + const val FILE = "prefs" + private val LAST_ACCOUNT = stringPreferencesKey("last_account_id") + private const val CATEGORY_PREFIX = "last_category_" + private val INSTALL_EXPLAINED = booleanPreferencesKey("install_unknown_apps_explained") + private val PROTOCOL_BLOCKED_AT = intPreferencesKey("protocol_blocked_version_code") + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Pictures.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Pictures.kt new file mode 100644 index 0000000..4b3c53c --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Pictures.kt @@ -0,0 +1,43 @@ +package se.hagstedt.kvitt.data.repo + +import android.content.Context +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.disk.DiskCache +import coil3.map.Mapper +import coil3.network.ktor3.KtorNetworkFetcherFactory +import coil3.request.Options +import io.ktor.client.HttpClient +import okio.Path.Companion.toOkioPath +import se.hagstedt.kvitt.model.User +import java.io.File + +/** + * Profile pictures through Coil (AC-4.6, §6.2 Images): a `User` model maps to `GET /users/{id}/picture?size=512&v=` + * (E-11). The URL carries `picture_version`, so Coil's memory and disk caches are keyed by user id and version and show + * the picture offline. [client] adds the bearer header (the same plugin as the API client). Users without a picture + * map to nothing; the UI shows initials (AC-4.5). + */ +object Pictures { + const val SIZE = 512 + private const val DIR = "pictures" + + @OptIn(ExperimentalCoilApi::class) // the fetcher factory that takes our own HttpClient (with the bearer plugin) + fun imageLoader(context: Context, client: HttpClient, baseUrl: () -> String?): ImageLoader = + ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(client)) + add(UserPictureMapper(baseUrl)) + } + .diskCache { DiskCache.Builder().directory(directory(context).toOkioPath()).build() } + .build() + + fun directory(context: Context): File = File(context.cacheDir, DIR) + + fun url(baseUrl: String, user: User): String? = + user.pictureVersion?.let { "${baseUrl.trimEnd('/')}/users/${user.id.value}/picture?size=$SIZE&v=$it" } +} + +class UserPictureMapper(private val baseUrl: () -> String?) : Mapper { + override fun map(data: User, options: Options): String? = baseUrl()?.let { Pictures.url(it, data) } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryImpl.kt new file mode 100644 index 0000000..1c4c9cf --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryImpl.kt @@ -0,0 +1,120 @@ +package se.hagstedt.kvitt.data.repo + +import androidx.room3.withWriteTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import se.hagstedt.kvitt.data.api.AcceptInviteRequest +import se.hagstedt.kvitt.data.api.ErrorCodes +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.auth.CredentialStore +import se.hagstedt.kvitt.data.auth.Credentials +import se.hagstedt.kvitt.data.auth.LocalWipe +import se.hagstedt.kvitt.data.auth.PairingStatus +import se.hagstedt.kvitt.data.connectivity.OnlineGate +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.sync.LocalRows +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.sync.SyncTriggers +import se.hagstedt.kvitt.data.sync.toEntity +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.model.DeviceId +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.User +import java.net.URI + +/** Pairing (S-1, AC-2.4 to AC-2.9), revocation (AC-3.3) and re-pairing (§14.4.8). */ +class SessionRepositoryImpl( + private val credentials: CredentialStore, + private val db: KvittDatabase, + private val api: KvittApi, + private val gate: OnlineGate, + private val engine: SyncEngine, + private val triggers: SyncTriggers, + private val wipe: LocalWipe, + private val clock: Clock, + private val debugBuild: Boolean, +) : SessionRepository { + private val rows = LocalRows(db, clock) + + override val session: Flow = combine(credentials.record, db.users().observeAll()) { record, users -> + when (record.status) { + PairingStatus.UNPAIRED -> SessionState.Unpaired + PairingStatus.REVOKED -> SessionState.Revoked + PairingStatus.UNRECOGNISED -> SessionState.Unrecognised + PairingStatus.PAIRED -> { + val me = users.firstOrNull { it.id == record.userId }?.toModel() + val deviceId = record.deviceId + if (me == null || deviceId == null) { + null + } else { + SessionState.Paired(me, users.firstOrNull { it.id != record.userId }?.toModel(), DeviceId(deviceId)) + } + } + } + }.filterNotNull().distinctUntilChanged() + + override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult { + val baseUrl = normaliseServerUrl(serverUrl, debugBuild) ?: return OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "server_url") + val normalised = normaliseInviteCode(code) ?: return OnlineResult.Failed(INVITE_INVALID, "code") + val request = AcceptInviteRequest(normalised, deviceName.trim(), displayName?.trim()?.takeIf { it.isNotEmpty() }) + return gate.run({ api.acceptInvite(baseUrl, request) }) { response -> + val previous = credentials.current() + val sameUser = previous.status == PairingStatus.UNRECOGNISED && previous.userId == response.user.id + if (!sameUser) wipe.wipe() + credentials.save(Credentials(baseUrl, response.user.id, response.device.id, response.token)) + db.withWriteTransaction { response.household.forEach { rows.writeUserIfNewer(it) } } + // §14.4.8: the same user keeps its data and pushes everything again; AC-2.9: then a full sync. + if (sameUser) engine.prepareRepush() + triggers.onAppStart() + response.user.toEntity(clock.nowMillis()).toModel() + } + } + + override suspend fun acknowledgeRevoked() { + wipe.wipe() + credentials.clear() + } + + companion object { + private const val INVITE_INVALID = "invite_invalid" + private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + private const val CODE_LENGTH = 12 + + /** §8.3: uppercase; drop `-` and spaces; O -> 0, I and L -> 1; then exactly 12 alphabet characters. */ + fun normaliseInviteCode(code: String): String? { + val normalised = code.uppercase().filter { it != '-' && !it.isWhitespace() } + .map { c -> when (c) { 'O' -> '0'; 'I', 'L' -> '1'; else -> c } }.joinToString("") + return normalised.takeIf { it.length == CODE_LENGTH && it.all { c -> c in ALPHABET } } + } + + /** + * AC-2.8: release builds accept only `https://`; debug builds also `http://` to `localhost`, `10.0.2.2` and + * private LAN addresses (`10.*`, `172.16.*` to `172.31.*`, `192.168.*`). Returns the URL without a trailing `/`. + */ + fun normaliseServerUrl(url: String, debugBuild: Boolean): String? { + val trimmed = url.trim().trimEnd('/') + val uri = try { + URI(trimmed) + } catch (e: java.net.URISyntaxException) { + return null + } + val host = uri.host ?: return null + return when (uri.scheme?.lowercase()) { + "https" -> trimmed + "http" -> trimmed.takeIf { debugBuild && isLocalHost(host) } + else -> null + } + } + + private fun isLocalHost(host: String): Boolean { + if (host == "localhost" || host == "10.0.2.2") return true + val parts = host.split('.').map { it.toIntOrNull() ?: return false } + if (parts.size != 4 || parts.any { it !in 0..255 }) return false + return parts[0] == 10 || (parts[0] == 172 && parts[1] in 16..31) || (parts[0] == 192 && parts[1] == 168) + } + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt new file mode 100644 index 0000000..a12811f --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt @@ -0,0 +1,336 @@ +package se.hagstedt.kvitt.data.repo + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import se.hagstedt.kvitt.data.api.CreateCategoryRequest +import se.hagstedt.kvitt.data.api.CreateInviteRequest +import se.hagstedt.kvitt.data.api.CreateTemplateRequest +import se.hagstedt.kvitt.data.api.DeviceDto +import se.hagstedt.kvitt.data.api.ErrorCodes +import se.hagstedt.kvitt.data.api.InviteDto +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.api.Patch +import se.hagstedt.kvitt.data.api.PatchCategoryRequest +import se.hagstedt.kvitt.data.api.PatchMeRequest +import se.hagstedt.kvitt.data.api.PatchTemplateRequest +import se.hagstedt.kvitt.data.api.TemplateSplitDto +import se.hagstedt.kvitt.data.auth.CredentialStore +import se.hagstedt.kvitt.data.connectivity.OnlineGate +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.db.SyncRejectionEntity +import se.hagstedt.kvitt.data.sync.LocalRows +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.sync.SyncOutcome +import se.hagstedt.kvitt.data.sync.SyncWork +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.domain.BP_TOTAL +import se.hagstedt.kvitt.model.AboutRepository +import se.hagstedt.kvitt.model.AboutState +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.Device +import se.hagstedt.kvitt.model.DeviceId +import se.hagstedt.kvitt.model.DeviceRepository +import se.hagstedt.kvitt.model.EntityType +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.Invite +import se.hagstedt.kvitt.model.InviteId +import se.hagstedt.kvitt.model.InviteKind +import se.hagstedt.kvitt.model.InviteRepository +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.RecurringRepository +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.Rejection +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.SyncRejectionReason +import se.hagstedt.kvitt.model.SyncRepository +import se.hagstedt.kvitt.model.SyncStatusState +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +/** S-13, S-14 (FR-4): name and picture writes are online-only; the returned user row is stored at once. */ +class ProfileRepositoryImpl( + private val local: LocalData, + private val api: KvittApi, + private val gate: OnlineGate, + db: KvittDatabase, + clock: Clock, +) : ProfileRepository { + private val rows = LocalRows(db, clock) + + override fun household(): Flow> = local.snapshot.map { it.household }.distinctUntilChanged() + + override suspend fun setDisplayName(name: String): OnlineResult = + gate.run({ api.patchMe(PatchMeRequest(name.trim())) }) { rows.writeUserIfNewer(it) } + + override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult = + gate.run({ api.putPicture(bytes, mimeType) }) { rows.writeUserIfNewer(it) } + + override suspend fun removePicture(): OnlineResult = gate.run({ api.deletePicture() }) { rows.writeUserIfNewer(it) } +} + +/** S-19 (FR-7). Reordering is one PATCH per category whose `sort_order` changes (E-24). */ +class CategoryRepositoryImpl( + private val local: LocalData, + private val api: KvittApi, + private val gate: OnlineGate, + db: KvittDatabase, + clock: Clock, +) : CategoryRepository { + private val rows = LocalRows(db, clock) + + override fun all(): Flow> = local.snapshot.map { it.categories }.distinctUntilChanged() + + override suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult = + gate.run({ api.createCategory(CreateCategoryRequest(name.trim(), icon, color.name.lowercase())) }) { + rows.writeCategoryIfNewer(it) + CategoryId(it.id) + } + + override suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult = + gate.run({ api.patchCategory(id.value, PatchCategoryRequest(Patch.Set(name?.trim()), icon, color.name.lowercase())) }) { rows.writeCategoryIfNewer(it) } + + override suspend fun reorder(idsInOrder: List): OnlineResult { + val current = local.snapshot.first().categories.associateBy { it.id } + val changes = idsInOrder.mapIndexedNotNull { index, id -> + val order = (index + 1) * SORT_STEP + id.takeIf { current[it]?.sortOrder != order }?.let { it to order } + } + for ((id, order) in changes) { + val result = gate.run({ api.patchCategory(id.value, PatchCategoryRequest(sortOrder = order)) }) { rows.writeCategoryIfNewer(it) } + if (result !is OnlineResult.Ok) return result + } + return OnlineResult.Ok(Unit) + } + + override suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult = + gate.run({ if (archived) api.archiveCategory(id.value) else api.unarchiveCategory(id.value) }) { rows.writeCategoryIfNewer(it) } + + override suspend fun delete(id: CategoryId): OnlineResult = gate.run({ api.deleteCategory(id.value) }) { rows.writeCategoryIfNewer(it) } + + private companion object { + /** The step between orders, as AC-7.2 and AC-7.3 use. */ + const val SORT_STEP = 10 + } +} + +/** S-20 (FR-9). */ +class RecurringRepositoryImpl( + private val local: LocalData, + private val api: KvittApi, + private val gate: OnlineGate, + db: KvittDatabase, + clock: Clock, +) : RecurringRepository { + private val rows = LocalRows(db, clock) + + override fun all(): Flow> = local.snapshot.map { s -> + s.liveTemplates.sortedWith(compareBy({ it.row.createdAt }, { it.row.id })).map { it.toModel() } + }.distinctUntilChanged() + + override suspend fun create(t: RecurringTemplate): OnlineResult { + val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.missing() + return gate.run({ api.createTemplate(body) }) { + rows.writeTemplateIfNewer(it) + TemplateId(it.id) + } + } + + override suspend fun update(t: RecurringTemplate): OnlineResult { + val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.missing() + val patch = PatchTemplateRequest( + accountId = body.accountId, + categoryId = body.categoryId, + amountOre = body.amountOre, + description = body.description, + notes = body.notes, + payerUserId = body.payerUserId, + splitMode = body.splitMode, + splits = body.splits, + dayOfMonth = body.dayOfMonth, + startPeriod = body.startPeriod, + endPeriod = Patch.Set(body.endPeriod), + paused = body.paused, + ) + return gate.run({ api.patchTemplate(t.id.value, patch) }) { rows.writeTemplateIfNewer(it) } + } + + override suspend fun delete(id: TemplateId): OnlineResult = gate.run({ api.deleteTemplate(id.value) }) { rows.writeTemplateIfNewer(it) } + + private fun RecurringTemplate.missing(): OnlineResult.Failed = when { + draft.accountId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "account_id") + draft.categoryId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "category_id") + draft.amountOre == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "amount_ore") + else -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "splits") + } +} + +/** E-28 body from the model; splits per §9.1: none for default, two bp for ratio and full, two öre for exact. */ +private fun RecurringTemplate.toWire(household: List): CreateTemplateRequest? { + val accountId = draft.accountId ?: return null + val categoryId = draft.categoryId ?: return null + val amount = draft.amountOre ?: return null + val splits = when (draft.mode) { + SplitMode.DEFAULT -> emptyList() + SplitMode.RATIO -> draft.ratioBp?.map { TemplateSplitDto(it.key.value, it.value, null) } ?: return null + SplitMode.FULL -> { + val bearer = draft.fullOn ?: return null + if (household.size != 2) return null + household.map { TemplateSplitDto(it.value, if (it == bearer) BP_TOTAL else 0, null) } + } + SplitMode.EXACT -> draft.exactOre?.map { TemplateSplitDto(it.key.value, null, it.value) } ?: return null + }.sortedBy { it.userId } + return CreateTemplateRequest( + accountId = accountId.value, + categoryId = categoryId.value, + amountOre = amount, + description = draft.description.trim(), + notes = draft.notes, + payerUserId = draft.payer.value, + splitMode = draft.mode.wire(), + splits = splits, + dayOfMonth = dayOfMonth, + startPeriod = startPeriod, + endPeriod = endPeriod, + paused = paused, + ) +} + +/** + * The model of a stored template. `ExpenseDraft.date` has no meaning for a template; it carries the next occurrence + * (or the first day of `start_period` when there is none) so the editor fields always hold a valid date. + */ +private fun TemplateRecord.toModel(): RecurringTemplate { + val mode = splitModeOf(row.splitMode) + val bp = splits.filter { it.shareBp != null }.associate { UserId(it.userId) to (it.shareBp ?: 0) } + return RecurringTemplate( + id = TemplateId(row.id), + draft = ExpenseDraft( + id = null, + accountId = AccountId(row.accountId), + categoryId = CategoryId(row.categoryId), + amountOre = row.amountOre, + description = row.description, + notes = row.notes, + date = row.nextOccurrenceDate ?: "${row.startPeriod}-01", + payer = UserId(row.payerUserId), + mode = mode, + ratioBp = bp.takeIf { mode == SplitMode.RATIO }, + exactOre = splits.filter { it.shareOre != null }.associate { UserId(it.userId) to (it.shareOre ?: 0L) }.takeIf { mode == SplitMode.EXACT }, + fullOn = bp.entries.firstOrNull { it.value == BP_TOTAL }?.key.takeIf { mode == SplitMode.FULL }, + ), + dayOfMonth = row.dayOfMonth, + startPeriod = row.startPeriod, + endPeriod = row.endPeriod, + paused = row.paused, + nextOccurrence = row.nextOccurrenceDate, + ) +} + +/** S-21 (FR-3): read from the server, online only. */ +class DeviceRepositoryImpl(private val local: LocalData, private val api: KvittApi, private val gate: OnlineGate) : DeviceRepository { + override suspend fun list(): OnlineResult> = gate.run({ api.listDevices() }) { response -> + val s = local.snapshot.first() + response.devices.mapNotNull { it.toModel(s) } + } + + override suspend fun revoke(id: DeviceId): OnlineResult = gate.run({ api.revokeDevice(id.value) }) { } + + private fun DeviceDto.toModel(s: Snapshot): Device? { + val user = s.user(userId) ?: return null + return Device(DeviceId(id), user, name, createdAt, lastSeenAt, appVersionCode, isCurrent, revokedAt != null) + } +} + +/** S-15 (FR-2): admin only, online only. */ +class InviteRepositoryImpl(private val api: KvittApi, private val gate: OnlineGate) : InviteRepository { + override suspend fun list(): OnlineResult> = gate.run({ api.listInvites() }) { it.invites.mapNotNull { dto -> dto.toModel() } } + + override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult { + // Only partner and device invites are created in the app; admin_setup comes from the CLI (AC-1.1). + if (kind == InviteKind.ADMIN_SETUP) return OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "kind") + val request = CreateInviteRequest(kind.name.lowercase(), targetUser?.value.takeIf { kind == InviteKind.DEVICE }) + return gate.run({ api.createInvite(request) }) { dto -> + dto.toModel() ?: Invite(InviteId(dto.id), kind, targetUser, dto.code, dto.link, dto.createdAt, dto.expiresAt) + } + } + + override suspend fun cancel(id: InviteId): OnlineResult = gate.run({ api.cancelInvite(id.value) }) { } + + private fun InviteDto.toModel(): Invite? { + val kind = InviteKind.entries.firstOrNull { it.name.lowercase() == kind } ?: return null + return Invite(InviteId(id), kind, targetUserId?.let(::UserId), code, link, createdAt, expiresAt) + } +} + +/** S-16 (AC-15.4, AC-15.5): pending rows, rejections not dismissed, and "sync now". */ +class SyncRepositoryImpl( + private val local: LocalData, + private val db: KvittDatabase, + private val summaries: SyncSummaries, + private val engine: SyncEngine, + private val work: SyncWork, +) : SyncRepository { + override fun status(): Flow = combine(local.snapshot, summaries.summary, db.rejections().observeActive()) { s, summary, rejections -> + val rows: List, LedgerRow?>> = + s.expenseRecords.filter { it.row.pending }.map { (it.row.expenseDate to it.row.createdAt) to s.expenseRow(it) } + + s.settlementRecords.filter { it.row.pending }.map { (it.row.settlementDate to it.row.createdAt) to s.settlementRow(it) } + SyncStatusState( + summary = summary, + pending = rows.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }.mapNotNull { it.second }, + rejections = rejections.mapNotNull { it.toModel() }, + ) + }.distinctUntilChanged() + + /** Runs a cycle now; a retryable failure is handed to WorkManager for backoff. */ + override suspend fun syncNow() { + if (engine.sync() is SyncOutcome.Retry) work.enqueueOneOff(expedited = false) + } + + override suspend fun dismiss(mutationId: String) = db.rejections().dismiss(mutationId) +} + +/** + * A rejection for S-16. `summary` is the rejected entity's own text (expense description; settlement note, or its + * amount in öre when the note is empty); the screen adds the explanation for the reason (§14.4.6). + */ +private fun SyncRejectionEntity.toModel(): Rejection? { + val type = EntityType.entries.firstOrNull { it.name.lowercase() == entityType } ?: return null + val reason = SyncRejectionReason.entries.firstOrNull { it.name.lowercase() == reason } ?: return null + val payload = try { + Json.parseToJsonElement(rejectedPayload) as? JsonObject + } catch (e: kotlinx.serialization.SerializationException) { + null + } + val text = when (type) { + EntityType.EXPENSE -> payload?.get("description")?.jsonPrimitive?.contentOrNull + EntityType.SETTLEMENT -> payload?.get("note")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: payload?.get("amount_ore")?.jsonPrimitive?.contentOrNull + } + return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt) +} + +/** S-22 (FR-16 versions, the paired server and the server's version header). */ +class AboutRepositoryImpl( + private val credentials: CredentialStore, + private val serverVersion: Flow, + private val versionName: String, + private val versionCode: Int, + private val defaultServerUrl: String, +) : AboutRepository { + override fun about(): Flow = combine(credentials.record, serverVersion) { record, server -> + AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, server) + }.distinctUntilChanged() +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt new file mode 100644 index 0000000..e983d21 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt @@ -0,0 +1,175 @@ +package se.hagstedt.kvitt.data.repo + +import androidx.room3.withWriteTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.db.SettlementAllocationEntity +import se.hagstedt.kvitt.data.db.SettlementEntity +import se.hagstedt.kvitt.data.sync.LocalWriteListener +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.IdGenerator +import se.hagstedt.kvitt.data.util.codePointLength +import se.hagstedt.kvitt.data.util.nowTimestamp +import se.hagstedt.kvitt.data.util.today +import se.hagstedt.kvitt.domain.overSettledAccounts +import se.hagstedt.kvitt.domain.proposeAllocation +import se.hagstedt.kvitt.domain.validateSettlement +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AllocationLine +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.SettleRequest +import se.hagstedt.kvitt.model.SettleState +import se.hagstedt.kvitt.model.SettlementDetailState +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.SettlementRepository +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.model.money.AccountPosition +import se.hagstedt.kvitt.model.money.Allocation +import se.hagstedt.kvitt.model.money.AllocationResult +import se.hagstedt.kvitt.model.money.SettlementError +import kotlin.math.absoluteValue + +/** S-9, S-10 (FR-11). Proposals and warnings are computed locally with `:domain` (AC-11.8), offline included. */ +class SettlementRepositoryImpl( + private val local: LocalData, + private val db: KvittDatabase, + private val clock: Clock, + private val ids: IdGenerator, + private val writes: LocalWriteListener, +) : SettlementRepository { + private val timelines = Timelines(db, local) + + override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState { + val s = local.snapshot.first() + val me = checkNotNull(s.me) { "Settle up needs a paired session" } + val partner = checkNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" } + val existing = editing?.let { s.settlement(it.value) }?.takeIf { !it.deleted } + if (existing != null) { + return s.settleState( + SettleRequest(existing.id, UserId(existing.row.fromUserId), existing.row.amountOre, fixedAccount, existing.allocationMap, existing.row.settlementDate, existing.row.note), + ) + } + // AC-11.3 / AC-11.4: the user who owes pays; the amount is the absolute balance, empty when it is zero. + val balances = s.balances() + val myNet = if (fixedAccount != null) balances.net(fixedAccount.value, me.id) else balances.globalNet(me.id) + val payer = if (myNet > 0) partner.id else me.id + return s.settleState(SettleRequest(null, payer, myNet.absoluteValue.takeIf { it > 0 }, fixedAccount, null, clock.today(), "")) + } + + override suspend fun recompute(request: SettleRequest): SettleState = local.snapshot.first().settleState(request) + + override suspend fun save(request: SettleRequest): SaveResult { + val s = local.snapshot.first() + val state = s.settleState(request) + val errors = mutableListOf() + val amount = request.amountOre + if (amount == null) { + errors += FieldError("amountOre", ExpenseRepositoryImpl.REQUIRED) + } else { + validateSettlement(amount, state.payer.id.value, state.recipient.id.value, s.household.map { it.id.value }, state.allocations())?.let { + errors += FieldError(if (it == SettlementError.AMOUNT_OUT_OF_RANGE) "amountOre" else "allocations", it.name.lowercase()) + } + } + if (request.note.codePointLength() > MAX_NOTE) errors += FieldError("note", ExpenseRepositoryImpl.VALIDATION_FAILED) + if (!validDate(request.date, clock)) errors += FieldError("date", ExpenseRepositoryImpl.VALIDATION_FAILED) + if (errors.isNotEmpty() || amount == null) return SaveResult.Invalid(errors) + + val existing = request.editing?.let { s.settlement(it.value) }?.takeIf { !it.deleted } + val id = existing?.row?.id ?: request.editing?.value ?: ids.newId() + val now = clock.nowTimestamp() + val row = SettlementEntity( + id = id, + fromUserId = state.payer.id.value, + toUserId = state.recipient.id.value, + amountOre = amount, + settlementDate = request.date, + note = request.note.trim(), + createdByUserId = existing?.row?.createdByUserId, + updatedByUserId = existing?.row?.updatedByUserId, + createdAt = existing?.row?.createdAt ?: now, + updatedAt = now, + deletedAt = null, + serverVersion = existing?.row?.serverVersion, + pending = true, + pendingMutationId = ids.newId(), + localUpdatedAt = clock.nowMillis(), + ) + db.withWriteTransaction { + db.settlements().upsert(row) + db.settlements().deleteAllocations(id) + db.settlements().insertAllocations(state.lines.map { SettlementAllocationEntity(id, it.account.id.value, it.amountOre) }) + } + writes.onLocalWrite() + return SaveResult.Saved(SettlementId(id)) + } + + override suspend fun delete(id: SettlementId) { + val now = clock.nowTimestamp() + val changed = db.withWriteTransaction { + val row = db.settlements().get(id.value)?.takeIf { it.deletedAt == null } ?: return@withWriteTransaction false + db.settlements().upsert(row.copy(deletedAt = now, updatedAt = now, pending = true, pendingMutationId = ids.newId(), localUpdatedAt = clock.nowMillis())) + true + } + if (changed) writes.onLocalWrite() + } + + override fun detail(id: SettlementId): Flow = timelines.observe(SyncEngine.SETTLEMENT, id.value) { s, entries -> + val st = s.settlement(id.value) ?: return@observe null + val from = s.user(st.row.fromUserId) ?: return@observe null + val to = s.user(st.row.toUserId) ?: return@observe null + SettlementDetailState( + id = st.id, + from = from, + to = to, + amountOre = st.row.amountOre, + date = st.row.settlementDate, + note = st.row.note, + allocations = st.allocations.sortedBy { it.accountId }.mapNotNull { a -> s.account(a.accountId)?.let { it to a.amountOre } }, + deleted = st.deleted, + pending = st.row.pending, + timeline = s.timeline(entries, st.row.pending, st.deleted, accepted = st.row.serverVersion != null, pendingAt = st.row.updatedAt), + ) + }.distinctUntilChanged() + + private companion object { + const val MAX_NOTE = 200 + } +} + +private fun SettleState.allocations(): List = lines.map { Allocation(it.account.id.value, it.amountOre) } + +/** + * §9.4 for a request: with `allocations == null` the proposal of `proposeAllocation`, otherwise the given rows; a + * per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6). + */ +internal fun Snapshot.settleState(request: SettleRequest): SettleState { + val payer = requireNotNull(user(request.payer.value)) { "unknown payer" } + val recipient = requireNotNull(household.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" } + val balances = balances(excludingSettlement = request.editing?.value) + val positions = accounts.map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id.value, request.payer)) } + val amount = request.amountOre + val fixed = request.fixedAccount + val given = request.allocations + val rows: List> = when { + fixed != null -> listOf(fixed to (given?.get(fixed) ?: amount ?: 0L)) + given != null -> given.toList() + amount == null -> emptyList() + else -> when (val proposal = proposeAllocation(amount, positions)) { + is AllocationResult.Ok -> proposal.allocations.map { AccountId(it.accountId) to it.amountOre } + is AllocationResult.Err -> emptyList() + } + }.filter { (id, _) -> accounts.any { it.id == id } } + val over = overSettledAccounts(rows.map { Allocation(it.first.value, it.second) }, positions).toSet() + val lines = rows.mapNotNull { (id, value) -> + account(id.value)?.let { AllocationLine(it, balances.net(id.value, request.payer), value, id.value in over) } + } + val allocated = lines.sumOf { it.amountOre } + val valid = amount != null && validateSettlement( + amount, payer.id.value, recipient.id.value, household.map { it.id.value }, lines.map { Allocation(it.account.id.value, it.amountOre) }, + ) == null + return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note) +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt new file mode 100644 index 0000000..0b94599 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt @@ -0,0 +1,224 @@ +package se.hagstedt.kvitt.data.repo + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import se.hagstedt.kvitt.data.auth.CredentialStore +import se.hagstedt.kvitt.data.db.AccountDefaultShareEntity +import se.hagstedt.kvitt.data.db.AccountEntity +import se.hagstedt.kvitt.data.db.CategoryEntity +import se.hagstedt.kvitt.data.db.ExpenseEntity +import se.hagstedt.kvitt.data.db.ExpenseSplitEntity +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.db.RecurringTemplateEntity +import se.hagstedt.kvitt.data.db.RecurringTemplateSplitEntity +import se.hagstedt.kvitt.data.db.SettlementAllocationEntity +import se.hagstedt.kvitt.data.db.SettlementEntity +import se.hagstedt.kvitt.data.db.UserEntity +import se.hagstedt.kvitt.domain.computeBalances +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.ExpenseEffect +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.model.money.BalanceExpense +import se.hagstedt.kvitt.model.money.BalanceSettlement +import se.hagstedt.kvitt.model.money.Balances + +/** An expense row with its splits, in model types where the UI needs them. */ +data class ExpenseRecord(val row: ExpenseEntity, val splits: List) { + val id: ExpenseId get() = ExpenseId(row.id) + val deleted: Boolean get() = row.deletedAt != null + val mode: SplitMode get() = splitModeOf(row.splitMode) + val shares: List get() = splits.sortedBy { it.userId }.map { Share(UserId(it.userId), it.shareOre, it.shareBp) } +} + +data class SettlementRecord(val row: SettlementEntity, val allocations: List) { + val id: SettlementId get() = SettlementId(row.id) + val deleted: Boolean get() = row.deletedAt != null + + /** Allocations in their wire order (`account_id`). */ + val allocationMap: Map get() = allocations.sortedBy { it.accountId }.associate { AccountId(it.accountId) to it.amountOre } +} + +data class TemplateRecord(val row: RecurringTemplateEntity, val splits: List) + +/** + * Everything the repositories read, from one consistent set of Room emissions. Like the fakes' scenario (§6.2), every + * `*State` is derived from it with the rules in `:domain`; pending local rows are included and tombstones excluded. + */ +data class Snapshot( + val meId: UserId?, + val userRows: List, + val accountRows: List, + val shareRows: List, + val categoryRows: List, + val expenseRecords: List, + val settlementRecords: List, + val templateRecords: List, +) { + val users: List by lazy { userRows.sortedBy { it.createdAt }.map { it.toModel() } } + val me: User? by lazy { meId?.let { id -> users.firstOrNull { it.id == id } } } + val partner: User? by lazy { meId?.let { id -> users.firstOrNull { it.id != id } } } + + /** The viewer first, then the partner. */ + val household: List by lazy { listOfNotNull(me, partner) } + + private val sharesByAccount by lazy { shareRows.groupBy { it.accountId } } + private val accountsById: Map by lazy { accountRows.associate { it.id to it.toModel(sharesByAccount[it.id].orEmpty()) } } + private val categoriesById: Map by lazy { categoryRows.associate { it.id to it.toModel() } } + + /** Non-deleted accounts by `created_at`, then id (AC-6.6). */ + val accounts: List by lazy { + accountRows.filter { it.deletedAt == null }.sortedWith(compareBy({ it.createdAt }, { it.id })).mapNotNull { accountsById[it.id] } + } + + /** Non-deleted categories by `sort_order`, archived included (AC-7.5). */ + val categories: List by lazy { + categoryRows.filter { it.deletedAt == null }.sortedWith(compareBy({ it.sortOrder }, { it.id })).mapNotNull { categoriesById[it.id] } + } + + val liveExpenses: List by lazy { expenseRecords.filter { !it.deleted } } + val liveSettlements: List by lazy { settlementRecords.filter { !it.deleted } } + val liveTemplates: List by lazy { templateRecords.filter { it.row.deletedAt == null } } + + fun user(id: String): User? = users.firstOrNull { it.id.value == id } + + /** Any account by id, deleted ones included, so history still resolves. */ + fun account(id: String): Account? = accountsById[id] + + fun category(id: String): Category? = categoriesById[id] + + fun expense(id: String): ExpenseRecord? = expenseRecords.firstOrNull { it.row.id == id } + + fun settlement(id: String): SettlementRecord? = settlementRecords.firstOrNull { it.row.id == id } + + /** §9.3 over non-deleted rows, optionally excluding the settlement being edited (AC-11.6). */ + fun balances(excludingSettlement: String? = null): Balances = computeBalances( + userIds = users.map { it.id.value }, + accountIds = accounts.map { it.id.value }, + expenses = liveExpenses.map { e -> BalanceExpense(e.row.accountId, e.row.amountOre, e.row.payerUserId, e.splits.associate { it.userId to it.shareOre }) }, + settlements = liveSettlements.filter { it.row.id != excludingSettlement }.map { s -> + BalanceSettlement(s.row.fromUserId, s.row.toUserId, s.allocations.associate { it.accountId to it.amountOre }) + }, + ) + + /** §9.2 from the viewer's side. */ + fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect { + val payer = e.row.payerUserId + val nonPayerShare = e.splits.firstOrNull { it.userId != payer }?.shareOre ?: 0L + return when { + nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved + viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare) + else -> ExpenseEffect.Borrowed(e.splits.firstOrNull { it.userId == viewer.value }?.shareOre ?: 0L) + } + } + + /** A ledger row, or null while its category has not arrived (no foreign keys, §14.2). */ + fun expenseRow(e: ExpenseRecord): LedgerRow.ExpenseRow? { + val viewer = meId ?: return null + val category = category(e.row.categoryId) ?: return null + return LedgerRow.ExpenseRow(e.id, e.row.expenseDate, e.row.description, category, e.row.amountOre, effectFor(e, viewer), e.row.pending) + } + + /** AC-11.7: the total, or with [account] the allocation to that account (S-5); `accountCount` of the whole settlement. */ + fun settlementRow(s: SettlementRecord, account: String? = null): LedgerRow.SettlementRow? { + val from = user(s.row.fromUserId) ?: return null + val to = user(s.row.toUserId) ?: return null + val amount = if (account != null) s.allocations.firstOrNull { it.accountId == account }?.amountOre ?: 0L else s.row.amountOre + return LedgerRow.SettlementRow(s.id, s.row.settlementDate, from, to, amount, s.allocations.size, s.row.pending) + } +} + +/** AC-8.10: date descending, then `created_at` descending. */ +val ledgerOrder: Comparator> = compareByDescending> { it.first }.thenByDescending { it.second } + +fun Balances.net(account: String, user: UserId): Long = perAccount[account]?.get(user.value) ?: 0L + +fun Balances.globalNet(user: UserId): Long = global[user.value] ?: 0L + +/** The Room flows behind [Snapshot]; [current] is kept hot for the synchronous `ExpenseRepository.preview`. */ +class LocalData(db: KvittDatabase, credentials: CredentialStore, scope: CoroutineScope) { + private val household = combine( + credentials.record.map { it.userId?.let(::UserId) }, + db.users().observeAll(), + db.accounts().observeAll(), + db.accounts().observeShares(), + db.categories().observeAll(), + ) { me, users, accounts, shares, categories -> Household(me, users, accounts, shares, categories) } + + private val ledger = combine( + db.expenses().observeAll(), + db.expenses().observeSplits(), + db.settlements().observeAll(), + db.settlements().observeAllocations(), + ) { expenses, splits, settlements, allocations -> + val splitsBy = splits.groupBy { it.expenseId } + val allocationsBy = allocations.groupBy { it.settlementId } + expenses.map { ExpenseRecord(it, splitsBy[it.id].orEmpty()) } to settlements.map { SettlementRecord(it, allocationsBy[it.id].orEmpty()) } + } + + private val templates = combine(db.templates().observeAll(), db.templates().observeSplits()) { rows, splits -> + val splitsBy = splits.groupBy { it.templateId } + rows.map { TemplateRecord(it, splitsBy[it.id].orEmpty()) } + } + + val snapshot: Flow = combine(household, ledger, templates) { h, (expenses, settlements), t -> + Snapshot(h.me, h.users, h.accounts, h.shares, h.categories, expenses, settlements, t) + }.conflate().flowOn(Dispatchers.Default) + + val current: StateFlow = snapshot.stateIn(scope, SharingStarted.Eagerly, null) + + private data class Household( + val me: UserId?, + val users: List, + val accounts: List, + val shares: List, + val categories: List, + ) +} + +// Entity -> model. Wire enums are lowercase names (§14.2.4); an unknown value from a newer server degrades gracefully. + +fun UserEntity.toModel() = User(UserId(id), if (role == "admin") Role.ADMIN else Role.MEMBER, displayName, pictureVersion) + +fun AccountEntity.toModel(shares: List) = Account( + id = AccountId(id), + name = name, + defaultShares = shares.sortedBy { it.userId }.associate { UserId(it.userId) to it.shareBp }, + createdAt = createdAt, + archived = archivedAt != null, +) + +fun CategoryEntity.toModel() = Category( + id = CategoryId(id), + defaultKey = defaultKey?.let { key -> DefaultCategoryKey.entries.firstOrNull { it.name.lowercase() == key } }, + customName = name, + icon = icon, + color = colorOf(color), + sortOrder = sortOrder, + archived = archivedAt != null, +) + +fun colorOf(token: String): ColorToken = ColorToken.entries.firstOrNull { it.name.lowercase() == token } ?: ColorToken.GREY + +fun splitModeOf(token: String): SplitMode = SplitMode.entries.firstOrNull { it.name.lowercase() == token } ?: SplitMode.DEFAULT + +fun SplitMode.wire(): String = name.lowercase() diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Timeline.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Timeline.kt new file mode 100644 index 0000000..7830f0a --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Timeline.kt @@ -0,0 +1,71 @@ +package se.hagstedt.kvitt.data.repo + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import se.hagstedt.kvitt.data.api.AuditChangeDto +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.db.AuditEntryEntity +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.AuditSource +import se.hagstedt.kvitt.model.FieldChange +import se.hagstedt.kvitt.model.TimelineLine + +/** + * FR-12 timelines (AC-12.4, AC-12.5): one line per audit entry, oldest first, ids resolved to names; amounts and dates + * raw. A pending local change ends the timeline with a line marked `pendingLocal`. + */ +internal class Timelines(private val db: KvittDatabase, private val local: LocalData) { + fun observe(entityType: String, entityId: String, build: (Snapshot, List) -> T): Flow = + combine(local.snapshot, db.audit().observeFor(entityType, entityId), build) +} + +internal fun Snapshot.timeline(entries: List, pending: Boolean, deleted: Boolean, accepted: Boolean, pendingAt: String): List { + val lines = entries.mapNotNull { entry -> + val action = AuditAction.entries.firstOrNull { it.name.lowercase() == entry.action } ?: return@mapNotNull null + val source = AuditSource.entries.firstOrNull { it.name.lowercase() == entry.source } ?: AuditSource.APP + val changes = KvittJson.decodeFromString(ListSerializer(AuditChangeDto.serializer()), entry.changes) + TimelineLine(user(entry.actorUserId), action, source, entry.clientAt, changes.map { FieldChange(it.field, display(it.field, it.old), display(it.field, it.new)) }, pendingLocal = false) + } + if (!pending) return lines + val action = when { + deleted -> AuditAction.DELETE + !accepted -> AuditAction.CREATE + else -> AuditAction.UPDATE + } + return lines + TimelineLine(me, action, AuditSource.APP, pendingAt, emptyList(), pendingLocal = true) +} + +/** + * A diff value for display (§14.4.7, FieldChange): account, category and user ids become names; `splits` and + * `allocations` become "name share" pairs joined by "; " (no format is specified for them; the UI may re-render). + */ +private fun Snapshot.display(field: String, value: JsonElement?): String? { + if (value == null || value is JsonNull) return null + return when (field) { + "account_id" -> value.text()?.let { id -> account(id)?.name ?: id } + "category_id" -> value.text()?.let { id -> category(id)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } ?: id } + "payer_user_id", "from_user_id", "to_user_id" -> value.text()?.let { id -> user(id)?.displayName ?: id } + "splits" -> (value as? JsonArray)?.joinToString("; ") { item -> + val o = item as? JsonObject + val id = o?.get("user_id")?.text().orEmpty() + "${user(id)?.displayName ?: id} ${o?.get("share_ore")?.text().orEmpty()}" + } + "allocations" -> (value as? JsonArray)?.joinToString("; ") { item -> + val o = item as? JsonObject + val id = o?.get("account_id")?.text().orEmpty() + "${account(id)?.name ?: id} ${o?.get("amount_ore")?.text().orEmpty()}" + } + else -> value.text() ?: value.toString() + } +} + +private fun JsonElement.text(): String? = (this as? JsonPrimitive)?.jsonPrimitive?.contentOrNull diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt new file mode 100644 index 0000000..2aa9752 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/LocalRows.kt @@ -0,0 +1,75 @@ +package se.hagstedt.kvitt.data.sync + +import se.hagstedt.kvitt.data.api.AccountDto +import se.hagstedt.kvitt.data.api.CategoryDto +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.RecurringTemplateDto +import se.hagstedt.kvitt.data.api.SettlementDto +import se.hagstedt.kvitt.data.api.UserDto +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.util.Clock + +/** + * Writes rows that came from the network (pull pages, push results, REST responses) with their children, setting + * `local_updated_at` (§14.2). Callers that need atomicity call these inside `withWriteTransaction`. + */ +class LocalRows(private val db: KvittDatabase, private val clock: Clock) { + suspend fun writeUser(dto: UserDto) = db.users().upsert(dto.toEntity(clock.nowMillis())) + + suspend fun writeAccount(dto: AccountDto) { + db.accounts().upsert(dto.toEntity(clock.nowMillis())) + db.accounts().deleteShares(dto.id) + db.accounts().insertShares(dto.shareEntities()) + } + + suspend fun writeCategory(dto: CategoryDto) = db.categories().upsert(dto.toEntity(clock.nowMillis())) + + suspend fun writeTemplate(dto: RecurringTemplateDto) { + db.templates().upsert(dto.toEntity(clock.nowMillis())) + db.templates().deleteSplits(dto.id) + db.templates().insertSplits(dto.splitEntities()) + } + + suspend fun writeExpense(dto: ExpenseDto) { + db.expenses().upsert(dto.toEntity(clock.nowMillis())) + db.expenses().deleteSplits(dto.id) + db.expenses().insertSplits(dto.splitEntities()) + } + + suspend fun writeSettlement(dto: SettlementDto) { + db.settlements().upsert(dto.toEntity(clock.nowMillis())) + db.settlements().deleteAllocations(dto.id) + db.settlements().insertAllocations(dto.allocationEntities()) + } + + suspend fun deleteExpense(id: String) { + db.expenses().deleteSplits(id) + db.expenses().delete(id) + } + + suspend fun deleteSettlement(id: String) { + db.settlements().deleteAllocations(id) + db.settlements().delete(id) + } + + /** A REST write that changes a synced row returns it; it is stored at once unless the local copy is newer (§7). */ + suspend fun writeUserIfNewer(dto: UserDto) { + val local = db.users().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) writeUser(dto) + } + + suspend fun writeAccountIfNewer(dto: AccountDto) { + val local = db.accounts().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) writeAccount(dto) + } + + suspend fun writeCategoryIfNewer(dto: CategoryDto) { + val local = db.categories().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) writeCategory(dto) + } + + suspend fun writeTemplateIfNewer(dto: RecurringTemplateDto) { + val local = db.templates().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) writeTemplate(dto) + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/Mappers.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/Mappers.kt new file mode 100644 index 0000000..8c07898 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/Mappers.kt @@ -0,0 +1,165 @@ +package se.hagstedt.kvitt.data.sync + +import se.hagstedt.kvitt.data.api.AccountDto +import se.hagstedt.kvitt.data.api.AllocationDto +import se.hagstedt.kvitt.data.api.AuditEntryDto +import se.hagstedt.kvitt.data.api.CategoryDto +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.ExpensePushEntity +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.api.RecurringTemplateDto +import se.hagstedt.kvitt.data.api.SettlementDto +import se.hagstedt.kvitt.data.api.SettlementPushEntity +import se.hagstedt.kvitt.data.api.SplitDto +import se.hagstedt.kvitt.data.api.AuditChangeDto +import se.hagstedt.kvitt.data.api.UserDto +import se.hagstedt.kvitt.data.db.AccountDefaultShareEntity +import se.hagstedt.kvitt.data.db.AccountEntity +import se.hagstedt.kvitt.data.db.AuditEntryEntity +import se.hagstedt.kvitt.data.db.CategoryEntity +import se.hagstedt.kvitt.data.db.ExpenseEntity +import se.hagstedt.kvitt.data.db.ExpenseSplitEntity +import se.hagstedt.kvitt.data.db.RecurringTemplateEntity +import se.hagstedt.kvitt.data.db.RecurringTemplateSplitEntity +import se.hagstedt.kvitt.data.db.SettlementAllocationEntity +import se.hagstedt.kvitt.data.db.SettlementEntity +import se.hagstedt.kvitt.data.db.UserEntity +import kotlinx.serialization.builtins.ListSerializer + +// Wire object <-> Room row. Server-only columns are not mirrored (§14.2); `local_updated_at` is the phone clock. + +fun UserDto.toEntity(localUpdatedAt: Long) = UserEntity(id, role, displayName, pictureVersion, createdAt, updatedAt, serverVersion, localUpdatedAt) + +fun AccountDto.toEntity(localUpdatedAt: Long) = + AccountEntity(id, name, createdByUserId, createdAt, updatedAt, archivedAt, deletedAt, serverVersion, localUpdatedAt) + +fun AccountDto.shareEntities() = defaultShares.map { AccountDefaultShareEntity(id, it.userId, it.shareBp) } + +fun CategoryDto.toEntity(localUpdatedAt: Long) = + CategoryEntity(id, defaultKey, name, icon, color, sortOrder, createdAt, updatedAt, archivedAt, deletedAt, serverVersion, localUpdatedAt) + +fun RecurringTemplateDto.toEntity(localUpdatedAt: Long) = RecurringTemplateEntity( + id = id, + accountId = accountId, + categoryId = categoryId, + amountOre = amountOre, + description = description, + notes = notes, + payerUserId = payerUserId, + splitMode = splitMode, + dayOfMonth = dayOfMonth, + startPeriod = startPeriod, + endPeriod = endPeriod, + paused = paused, + lastGeneratedPeriod = lastGeneratedPeriod, + createdByUserId = createdByUserId, + updatedByUserId = updatedByUserId, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, + serverVersion = serverVersion, + nextOccurrenceDate = nextOccurrenceDate, + localUpdatedAt = localUpdatedAt, +) + +fun RecurringTemplateDto.splitEntities() = splits.map { RecurringTemplateSplitEntity(id, it.userId, it.shareBp, it.shareOre) } + +/** A row accepted by the server: not pending (§14.4.5 step 3). */ +fun ExpenseDto.toEntity(localUpdatedAt: Long) = ExpenseEntity( + id = id, + accountId = accountId, + categoryId = categoryId, + amountOre = amountOre, + description = description, + notes = notes, + expenseDate = expenseDate, + payerUserId = payerUserId, + splitMode = splitMode, + recurringTemplateId = recurringTemplateId, + recurringPeriod = recurringPeriod, + createdByUserId = createdByUserId, + updatedByUserId = updatedByUserId, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, + serverVersion = serverVersion, + pending = false, + pendingMutationId = null, + localUpdatedAt = localUpdatedAt, +) + +fun ExpenseDto.splitEntities() = splits.map { ExpenseSplitEntity(id, it.userId, it.shareOre, it.shareBp) } + +fun SettlementDto.toEntity(localUpdatedAt: Long) = SettlementEntity( + id = id, + fromUserId = fromUserId, + toUserId = toUserId, + amountOre = amountOre, + settlementDate = settlementDate, + note = note, + createdByUserId = createdByUserId, + updatedByUserId = updatedByUserId, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, + serverVersion = serverVersion, + pending = false, + pendingMutationId = null, + localUpdatedAt = localUpdatedAt, +) + +fun SettlementDto.allocationEntities() = allocations.map { SettlementAllocationEntity(id, it.accountId, it.amountOre) } + +/** `changes` is stored as the JSON text received (§14.2.1). */ +fun AuditEntryDto.toEntity() = AuditEntryEntity( + id = id, + entityType = entityType, + entityId = entityId, + action = action, + source = source, + actorUserId = actorUserId, + clientAt = clientAt, + serverAt = serverAt, + changes = KvittJson.encodeToString(ListSerializer(AuditChangeDto.serializer()), changes), + serverVersion = serverVersion, +) + +/** The pushed entity: exactly the client-writable fields; arrays sorted as on the wire (§14.3.3, §14.4.3). */ +fun ExpenseEntity.toPushEntity(splits: List) = ExpensePushEntity( + id = id, + accountId = accountId, + categoryId = categoryId, + amountOre = amountOre, + description = description, + notes = notes, + expenseDate = expenseDate, + payerUserId = payerUserId, + splitMode = splitMode, + splits = splits.sortedBy { it.userId }.map { SplitDto(it.userId, it.shareOre, it.shareBp) }, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, +) + +fun SettlementEntity.toPushEntity(allocations: List) = SettlementPushEntity( + id = id, + fromUserId = fromUserId, + toUserId = toUserId, + amountOre = amountOre, + settlementDate = settlementDate, + note = note, + allocations = allocations.sortedBy { it.accountId }.map { AllocationDto(it.accountId, it.amountOre) }, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, +) + +/** The client-writable fields of a server row, to compare with what was pushed (§14.4.5 step 3, equal content). */ +fun ExpenseDto.clientFields() = ExpensePushEntity( + id, accountId, categoryId, amountOre, description, notes, expenseDate, payerUserId, splitMode, + splits.sortedBy { it.userId }, createdAt, updatedAt, deletedAt, +) + +fun SettlementDto.clientFields() = SettlementPushEntity( + id, fromUserId, toUserId, amountOre, settlementDate, note, allocations.sortedBy { it.accountId }, createdAt, updatedAt, deletedAt, +) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt new file mode 100644 index 0000000..6ff912e --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncEngine.kt @@ -0,0 +1,384 @@ +package se.hagstedt.kvitt.data.sync + +import androidx.room3.withWriteTransaction +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import se.hagstedt.kvitt.data.api.ApiResult +import se.hagstedt.kvitt.data.api.ErrorCodes +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.ExpensePushEntity +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.api.MutationDto +import se.hagstedt.kvitt.data.api.PullPage +import se.hagstedt.kvitt.data.api.PushRequest +import se.hagstedt.kvitt.data.api.PushResultDto +import se.hagstedt.kvitt.data.api.SettlementDto +import se.hagstedt.kvitt.data.api.SettlementPushEntity +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.db.SyncRejectionEntity +import se.hagstedt.kvitt.data.db.SyncStateEntity +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.IdGenerator +import se.hagstedt.kvitt.data.util.nowTimestamp + +/** Whether a sync may run now: paired, not revoked or unrecognised, protocol supported. */ +fun interface SyncGate { + suspend fun canSync(): Boolean +} + +/** The result of one cycle; [SyncOutcome.Retry] asks WorkManager for exponential backoff from 30 s (§14.4.9). */ +sealed interface SyncOutcome { + data object Success : SyncOutcome + data object Skipped : SyncOutcome + data class Retry(val code: String) : SyncOutcome + data class Stopped(val code: String) : SyncOutcome +} + +/** + * The phone side of sync protocol v1 (SPEC §14.4). One cycle at a time per process ([mutex]); a cycle pushes every + * pending expense and settlement in batches of [PUSH_BATCH], then pulls pages of [PULL_LIMIT] until `has_more` is false. + * Epoch changes (§14.4.8) restart the cycle after marking everything pending. + */ +class SyncEngine( + private val db: KvittDatabase, + private val api: KvittApi, + private val clock: Clock, + private val ids: IdGenerator, + private val gate: SyncGate, +) { + private val mutex = Mutex() + private val rows = LocalRows(db, clock) + private val runningState = MutableStateFlow(false) + private val completedState = MutableStateFlow(CompletedCycle(0, null)) + + /** True while a cycle runs (S-16, `SyncSummary.running`). */ + val running: StateFlow = runningState.asStateFlow() + + /** Count and outcome of finished cycles; the FCM path waits on it (AC-13.5). */ + val completed: StateFlow = completedState.asStateFlow() + + data class CompletedCycle(val count: Long, val outcome: SyncOutcome?) + + suspend fun sync(): SyncOutcome = mutex.withLock { + if (!gate.canSync()) return@withLock SyncOutcome.Skipped + runningState.value = true + try { + val outcome = cycle() + completedState.value = CompletedCycle(completedState.value.count + 1, outcome) + outcome + } finally { + runningState.value = false + } + } + + /** + * Re-pairing with the same user (§14.4.8): the local data is kept and everything is pushed again, then fully + * pulled. The epoch is cleared so the next page's epoch is taken as is. + */ + suspend fun prepareRepush() = mutex.withLock { db.withWriteTransaction { recoverEpoch(null) } } + + private suspend fun cycle(): SyncOutcome { + updateState { it.copy(lastAttemptAt = clock.nowTimestamp()) } + var restarts = 0 + while (true) { + val push = pushAll() + if (push is Step.Stop) return finish(push.outcome) + when (val pull = pullAll()) { + is Step.Stop -> return finish(pull.outcome) + Step.Restart -> if (++restarts > MAX_RESTARTS) return finish(SyncOutcome.Retry(ErrorCodes.INTERNAL)) + Step.Continue -> { + val pushError = (push as? Step.ContinueWithError)?.code + updateState { it.copy(lastSuccessAt = clock.nowTimestamp(), lastErrorCode = pushError) } + return SyncOutcome.Success + } + is Step.ContinueWithError -> return finish(SyncOutcome.Stopped(pull.code)) + } + } + } + + private suspend fun finish(outcome: SyncOutcome): SyncOutcome { + val code = when (outcome) { + is SyncOutcome.Retry -> outcome.code + is SyncOutcome.Stopped -> outcome.code.takeIf { it != CLOCK_SKEW } + SyncOutcome.Success, SyncOutcome.Skipped -> null + } + if (code != null) updateState { it.copy(lastErrorCode = code) } + return outcome + } + + private sealed interface Step { + data object Continue : Step + data class ContinueWithError(val code: String) : Step + data object Restart : Step + data class Stop(val outcome: SyncOutcome) : Step + } + + // §14.4.5 Phone push procedure + + private suspend fun pushAll(): Step { + var lastError: String? = null + while (true) { + val batch = loadBatch() + if (batch.isEmpty()) break + val request = PushRequest(batch.map { it.mutation }) + when (val result = api.push(request)) { + is ApiResult.NetworkError -> return Step.Stop(SyncOutcome.Retry(ErrorCodes.NETWORK)) + ApiResult.NotPaired -> return Step.Stop(SyncOutcome.Stopped(ErrorCodes.UNAUTHORIZED)) + is ApiResult.Failure -> { + val stop = stopFor(result) ?: run { + // A whole-request refusal (invalid_request, batch_too_large, payload_too_large, + // household_incomplete): the batch made no progress, so the cycle goes on to pull. + lastError = result.code + null + } + if (stop != null) return stop + break + } + is ApiResult.Ok -> { + val applied = applyResults(batch, result.value.results) + if (applied.clockSkew) return Step.Stop(SyncOutcome.Stopped(CLOCK_SKEW)) + if (!applied.progress) break + } + } + } + return lastError?.let { Step.ContinueWithError(it) } ?: Step.Continue + } + + private fun stopFor(failure: ApiResult.Failure): Step.Stop? = when { + failure.status >= 500 -> Step.Stop(SyncOutcome.Retry(failure.code)) + failure.code == ErrorCodes.RATE_LIMITED -> Step.Stop(SyncOutcome.Retry(failure.code)) + failure.status == 401 || failure.code == ErrorCodes.PROTOCOL_UNSUPPORTED -> Step.Stop(SyncOutcome.Stopped(failure.code)) + else -> null + } + + private class Pending(val entityType: String, val id: String, val localUpdatedAt: Long, val mutation: MutationDto) + + /** Step 1: pending expenses and settlements by `local_updated_at`, at most [PUSH_BATCH]. */ + private suspend fun loadBatch(): List { + val expenses = db.expenses().pending(PUSH_BATCH).map { e -> + val mutationId = e.pendingMutationId ?: assignMutationId { db.expenses().markPending(e.id, it, e.localUpdatedAt) } + val entity = e.toPushEntity(db.expenses().splits(e.id)) + Pending(EXPENSE, e.id, e.localUpdatedAt, MutationDto(mutationId, EXPENSE, json.encodeToJsonElement(ExpensePushEntity.serializer(), entity).jsonObject)) + } + val settlements = db.settlements().pending(PUSH_BATCH).map { s -> + val mutationId = s.pendingMutationId ?: assignMutationId { db.settlements().markPending(s.id, it, s.localUpdatedAt) } + val entity = s.toPushEntity(db.settlements().allocations(s.id)) + Pending(SETTLEMENT, s.id, s.localUpdatedAt, MutationDto(mutationId, SETTLEMENT, json.encodeToJsonElement(SettlementPushEntity.serializer(), entity).jsonObject)) + } + return (expenses + settlements).sortedWith(compareBy({ it.localUpdatedAt }, { it.id })).take(PUSH_BATCH) + } + + /** A pending row always has a mutation id (§14.2.1); one missing is repaired before it is pushed. */ + private suspend inline fun assignMutationId(store: (String) -> Unit): String = ids.newId().also { store(it) } + + private class Applied(val progress: Boolean, val clockSkew: Boolean) + + /** Step 3, in one Room transaction. */ + private suspend fun applyResults(batch: List, results: List): Applied { + val sent = batch.associateBy { it.mutation.mutationId } + return db.withWriteTransaction { + var progress = false + var clockSkew = false + for (result in results) { + val pending = sent[result.mutationId] ?: continue + when (result) { + is PushResultDto.Accepted -> { + accept(pending, result.entity) + progress = true + } + is PushResultDto.Rejected -> when { + result.reason == CLOCK_SKEW -> { + clockSkew = true + renewMutationId(pending) + } + result.current != null && sameClientFields(pending, result.current) -> { + accept(pending, result.current) + progress = true + } + else -> { + reject(pending, result) + progress = true + } + } + } + } + updateState { it.copy(clockSkew = clockSkew) } + Applied(progress, clockSkew) + } + } + + /** + * `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. + */ + private suspend fun renewMutationId(pending: Pending) { + val sent = pending.mutation.mutationId + when (pending.entityType) { + EXPENSE -> db.expenses().get(pending.id)?.takeIf { it.pendingMutationId == sent }?.let { db.expenses().markPending(it.id, ids.newId(), it.localUpdatedAt) } + SETTLEMENT -> db.settlements().get(pending.id)?.takeIf { it.pendingMutationId == sent }?.let { db.settlements().markPending(it.id, ids.newId(), it.localUpdatedAt) } + } + } + + private suspend fun accept(pending: Pending, entity: JsonObject) { + val mutationId = pending.mutation.mutationId + when (pending.entityType) { + EXPENSE -> { + val dto = json.decodeFromJsonElement(ExpenseDto.serializer(), entity) + val local = db.expenses().get(pending.id) + if (local == null || local.pendingMutationId == mutationId) { + rows.writeExpense(dto) + } else { + db.expenses().setServerFields(dto.id, dto.serverVersion, dto.createdByUserId, dto.updatedByUserId) + } + } + SETTLEMENT -> { + val dto = json.decodeFromJsonElement(SettlementDto.serializer(), entity) + val local = db.settlements().get(pending.id) + if (local == null || local.pendingMutationId == mutationId) { + rows.writeSettlement(dto) + } else { + db.settlements().setServerFields(dto.id, dto.serverVersion, dto.createdByUserId, dto.updatedByUserId) + } + } + } + } + + private suspend fun reject(pending: Pending, result: PushResultDto.Rejected) { + val mutationId = pending.mutation.mutationId + val current = result.current + when (pending.entityType) { + EXPENSE -> if (db.expenses().get(pending.id)?.pendingMutationId == mutationId) { + if (current != null) rows.writeExpense(json.decodeFromJsonElement(ExpenseDto.serializer(), current)) else rows.deleteExpense(pending.id) + } + SETTLEMENT -> if (db.settlements().get(pending.id)?.pendingMutationId == mutationId) { + if (current != null) rows.writeSettlement(json.decodeFromJsonElement(SettlementDto.serializer(), current)) else rows.deleteSettlement(pending.id) + } + } + db.rejections().insertOrIgnore( + SyncRejectionEntity( + mutationId = mutationId, + entityType = pending.entityType, + entityId = pending.id, + reason = result.reason, + message = result.message, + rejectedPayload = pending.mutation.entity.toString(), + serverCurrent = current?.toString(), + rejectedAt = clock.nowTimestamp(), + dismissed = false, + ), + ) + } + + /** "`current` equal to the pushed entity in every client-writable field" (§14.4.5 step 3). */ + private fun sameClientFields(pending: Pending, current: JsonObject): Boolean { + val currentFields = when (pending.entityType) { + EXPENSE -> json.encodeToJsonElement(ExpensePushEntity.serializer(), json.decodeFromJsonElement(ExpenseDto.serializer(), current).clientFields()) + SETTLEMENT -> json.encodeToJsonElement(SettlementPushEntity.serializer(), json.decodeFromJsonElement(SettlementDto.serializer(), current).clientFields()) + else -> return false + } + return currentFields == pending.mutation.entity + } + + // §14.4.2 Pull, phone steps + + private suspend fun pullAll(): Step { + while (true) { + val state = state() + when (val result = api.pull(state.cursor, PULL_LIMIT)) { + is ApiResult.NetworkError -> return Step.Stop(SyncOutcome.Retry(ErrorCodes.NETWORK)) + ApiResult.NotPaired -> return Step.Stop(SyncOutcome.Stopped(ErrorCodes.UNAUTHORIZED)) + is ApiResult.Failure -> { + stopFor(result)?.let { return it } + if (result.code == ErrorCodes.CURSOR_AHEAD) { + // Proposed decision (report): the server's sequence is behind the cursor, so it was restored + // from a backup; recover as for a new epoch, accepting whatever epoch the next page carries. + db.withWriteTransaction { recoverEpoch(null) } + return Step.Restart + } + return Step.ContinueWithError(result.code) + } + is ApiResult.Ok -> { + val page = result.value + val restarted = db.withWriteTransaction { + val current = state() + if (current.epoch != null && current.epoch != page.epoch) { + recoverEpoch(page.epoch) + true + } else { + apply(page) + db.syncState().upsert(current.copy(cursor = page.nextSince, epoch = page.epoch)) + false + } + } + if (restarted) return Step.Restart + if (!page.hasMore) return Step.Continue + } + } + } + } + + /** Steps 2 and 3: arrays in the fixed order; skip rows not newer or pending; children replaced. */ + private suspend fun apply(page: PullPage) { + for (dto in page.users) { + val local = db.users().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) rows.writeUser(dto) + } + for (dto in page.categories) { + val local = db.categories().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) rows.writeCategory(dto) + } + for (dto in page.accounts) { + val local = db.accounts().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) rows.writeAccount(dto) + } + for (dto in page.recurringTemplates) { + val local = db.templates().get(dto.id) + if (local == null || local.serverVersion < dto.serverVersion) rows.writeTemplate(dto) + } + for (dto in page.expenses) { + val local = db.expenses().get(dto.id) + val skip = local != null && (local.pending || (local.serverVersion ?: Long.MIN_VALUE) >= dto.serverVersion) + if (!skip) rows.writeExpense(dto) + } + for (dto in page.settlements) { + val local = db.settlements().get(dto.id) + val skip = local != null && (local.pending || (local.serverVersion ?: Long.MIN_VALUE) >= dto.serverVersion) + if (!skip) rows.writeSettlement(dto) + } + db.audit().insertOrIgnore(page.auditEntries.map { it.toEntity() }) + } + + /** + * §14.4.8, inside the caller's transaction: cursor 0, the new epoch, and every expense and settlement (tombstones + * included) pending with a new mutation id, `updated_at` kept. + */ + private suspend fun recoverEpoch(epoch: String?) { + val now = clock.nowMillis() + db.syncState().upsert(state().copy(cursor = 0, epoch = epoch)) + for (e in db.expenses().all()) db.expenses().markPending(e.id, ids.newId(), now) + for (s in db.settlements().all()) db.settlements().markPending(s.id, ids.newId(), now) + } + + private suspend fun state(): SyncStateEntity = db.syncState().get() ?: INITIAL_STATE + + private suspend fun updateState(change: (SyncStateEntity) -> SyncStateEntity) = db.syncState().upsert(change(state())) + + companion object { + /** §14.4.9 */ + const val PUSH_BATCH = 100 + const val PULL_LIMIT = 500 + private const val MAX_RESTARTS = 2 + const val EXPENSE = "expense" + const val SETTLEMENT = "settlement" + const val CLOCK_SKEW = "clock_skew" + val INITIAL_STATE = SyncStateEntity(id = 1, cursor = 0, epoch = null, lastSuccessAt = null, lastAttemptAt = null, lastErrorCode = null, clockSkew = false) + private val json = KvittJson + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggers.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggers.kt new file mode 100644 index 0000000..24941b2 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggers.kt @@ -0,0 +1,134 @@ +package se.hagstedt.kvitt.data.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +/** Enqueues sync work; production is [WorkManagerSyncWork], tests a fake. */ +interface SyncWork { + fun enqueueOneOff(expedited: Boolean) + + fun ensurePeriodic() +} + +/** Called by every local write (expense and settlement saves and deletes). */ +fun interface LocalWriteListener { + fun onLocalWrite() +} + +/** + * 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 + * [onAppStart], [onForeground] and [syncForPush]. + */ +class SyncTriggers( + private val work: SyncWork, + private val engine: SyncEngine, + private val scope: CoroutineScope, +) : LocalWriteListener { + private var debounce: Job? = null + + fun onAppStart() { + work.ensurePeriodic() + work.enqueueOneOff(expedited = false) + } + + fun onForeground() = work.enqueueOneOff(expedited = false) + + override fun onLocalWrite() { + synchronized(this) { + debounce?.cancel() + debounce = scope.launch { + delay(DEBOUNCE_MILLIS) + work.enqueueOneOff(expedited = false) + } + } + } + + /** + * §14.6 phone step 1: an expedited sync, waited for up to 20 seconds (AC-13.5). True when a cycle finished + * successfully in that time; the notification then uses local data, otherwise the generic text. + */ + suspend fun syncForPush(timeoutMillis: Long = PUSH_WAIT_MILLIS): Boolean { + val before = engine.completed.value.count + work.enqueueOneOff(expedited = true) + val done = withTimeoutOrNull(timeoutMillis) { engine.completed.first { it.count > before } } + return done?.outcome == SyncOutcome.Success + } + + companion object { + /** §14.4.9 */ + const val DEBOUNCE_MILLIS = 2_000L + const val PUSH_WAIT_MILLIS = 20_000L + } +} + +/** + * 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). + */ +class WorkManagerSyncWork(private val context: Context) : SyncWork { + private val connected = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + + override fun enqueueOneOff(expedited: Boolean) { + val request = OneTimeWorkRequestBuilder() + .setConstraints(connected) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .apply { if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) } + .build() + WorkManager.getInstance(context).enqueueUniqueWork(ONE_OFF, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + + override fun ensurePeriodic() { + val request = PeriodicWorkRequestBuilder(PERIOD_MINUTES, TimeUnit.MINUTES) + .setConstraints(connected) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.KEEP, request) + } + + companion object { + const val ONE_OFF = "kvitt-sync" + const val PERIODIC = "kvitt-sync-periodic" + const val PERIOD_MINUTES = 15L + const val BACKOFF_SECONDS = 30L + } +} + +/** Runs one sync cycle; a network error or 5xx asks for a retry with backoff (§14.4.5 step 2). */ +class SyncWorker( + context: Context, + params: WorkerParameters, + private val engine: SyncEngine, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result = when (engine.sync()) { + is SyncOutcome.Retry -> Result.retry() + SyncOutcome.Success, SyncOutcome.Skipped, is SyncOutcome.Stopped -> Result.success() + } +} + +/** Creates [SyncWorker]s with the engine; `:app` (M8) 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 +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt new file mode 100644 index 0000000..0e199d5 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt @@ -0,0 +1,194 @@ +package se.hagstedt.kvitt.data.update + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.util.Log +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import se.hagstedt.kvitt.data.api.ApiResult +import se.hagstedt.kvitt.data.api.ApkLatestDto +import se.hagstedt.kvitt.data.api.ErrorCodes +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.repo.PhonePrefs +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.model.UpdateInfo +import se.hagstedt.kvitt.model.UpdateRepository +import se.hagstedt.kvitt.model.UpdateState +import java.io.File +import java.io.IOException +import java.security.MessageDigest + +/** The `PackageInstaller` call, isolated so the updater's logic is unit-tested without a device. */ +interface ApkInstaller { + /** Whether "Install unknown apps" is allowed for Kvitt. */ + fun canRequestPackageInstalls(): Boolean + + suspend fun install(apk: File) +} + +/** Size and SHA-256 of a downloaded APK against `latest.json` (AC-16.3). */ +object ApkVerifier { + fun matches(file: File, expectedSize: Long, expectedSha256: String): Boolean { + if (!file.isFile || file.length() != expectedSize) return false + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(BUFFER) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + val hex = digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xFF) } + return hex.equals(expectedSha256, ignoreCase = true) + } + + private const val BUFFER = 64 * 1024 +} + +/** + * FR-16. Release builds check `GET /apk/latest` on cold start and at most every 6 hours while resumed (`:app` calls + * [check] on both); a newer `version_code` is [UpdateState.Available]. [install] explains "Install unknown apps" once + * before the first install, downloads to the cache, verifies size and SHA-256, deletes the file on any failure + * ("Download failed", AC-16.4) and installs through [ApkInstaller]. Debug builds never check (AC-16.5). + */ +class UpdateRepositoryImpl( + private val api: KvittApi, + private val cacheDir: File, + private val installer: ApkInstaller, + private val prefs: PhonePrefs, + private val clock: Clock, + private val installedVersionCode: Int, + private val debugBuild: Boolean, +) : UpdateRepository { + private val mutable = MutableStateFlow(UpdateState.None) + override val state: StateFlow = mutable.asStateFlow() + private var latest: ApkLatestDto? = null + private var lastCheckAt: Long? = null + + override suspend fun check() { + if (debugBuild) return + val now = clock.nowMillis() + lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return } + lastCheckAt = now + when (val result = api.apkLatest()) { + is ApiResult.Ok -> { + val dto = result.value + if (dto.versionCode > installedVersionCode && FILE_NAME.matches(dto.fileName)) { + latest = dto + if (mutable.value !is UpdateState.Downloading) mutable.value = UpdateState.Available(dto.info()) + } else { + latest = null + mutable.value = UpdateState.None + } + } + is ApiResult.Failure -> if (result.code == ErrorCodes.NOT_FOUND) { + latest = null + mutable.value = UpdateState.None + } + is ApiResult.NetworkError, ApiResult.NotPaired -> lastCheckAt = null + } + } + + override suspend fun install() { + val dto = latest ?: return + val info = dto.info() + if (!installer.canRequestPackageInstalls() && !prefs.installExplanationShown()) { + prefs.setInstallExplanationShown() + mutable.value = UpdateState.NeedsInstallPermission(info) + return + } + val file = File(File(cacheDir, APK_DIR).apply { mkdirs() }, dto.fileName) + mutable.value = UpdateState.Downloading(info, 0f) + val ok = try { + val downloaded = api.downloadApk(dto.fileName) { channel, _ -> copy(channel, file, dto.sizeBytes, info) } + downloaded is ApiResult.Ok && ApkVerifier.matches(file, dto.sizeBytes, dto.sha256) + } catch (e: CancellationException) { + file.delete() + throw e + } catch (e: IOException) { + Log.w(TAG, "APK download failed: ${e.javaClass.simpleName}") + false + } + if (!ok) { + file.delete() + mutable.value = UpdateState.Failed(info) + return + } + installer.install(file) + mutable.value = UpdateState.Available(info) + } + + private suspend fun copy(channel: ByteReadChannel, file: File, expected: Long, info: UpdateInfo) = withContext(Dispatchers.IO) { + file.outputStream().use { out -> + val buffer = ByteArray(BUFFER) + var total = 0L + while (true) { + val read = channel.readAvailable(buffer, 0, buffer.size) + if (read < 0) break + if (read == 0) continue + out.write(buffer, 0, read) + total += read + if (total > expected) throw IOException("more bytes than size_bytes") + mutable.value = UpdateState.Downloading(info, (total.toFloat() / expected).coerceIn(0f, 1f)) + } + } + } + + private fun ApkLatestDto.info() = UpdateInfo(versionCode, versionName, sizeBytes) + + companion object { + /** AC-16.2 */ + const val CHECK_INTERVAL_MILLIS = 6 * 60 * 60 * 1000L + const val APK_DIR = "apk" + + /** §10.6: the only file names the server serves. */ + val FILE_NAME = Regex("^kvitt-[0-9]+\\.apk$") + private const val BUFFER = 64 * 1024 + private const val TAG = "KvittUpdate" + } +} + +/** Installs through a `PackageInstaller` session; the confirmation comes back to [InstallResultReceiver]. */ +class AndroidApkInstaller(private val context: Context) : ApkInstaller { + override fun canRequestPackageInstalls(): Boolean = context.packageManager.canRequestPackageInstalls() + + override suspend fun install(apk: File) = withContext(Dispatchers.IO) { + val installer = context.packageManager.packageInstaller + val sessionId = installer.createSession(PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)) + installer.openSession(sessionId).use { session -> + apk.inputStream().use { input -> + session.openWrite("kvitt.apk", 0, apk.length()).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + val intent = Intent(context, InstallResultReceiver::class.java) + // Mutable: the system adds the session status and the confirmation intent to it. + val pending = PendingIntent.getBroadcast(context, sessionId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) + session.commit(pending.intentSender) + } + } +} + +/** Starts the system's install confirmation when the session needs the user (first install, unknown sources). */ +class InstallResultReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)?.let { + context.startActivity(it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + PackageInstaller.STATUS_SUCCESS -> Unit + else -> Log.w("KvittUpdate", "install finished with status $status") + } + } +} diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Clock.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Clock.kt new file mode 100644 index 0000000..e01f6b0 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Clock.kt @@ -0,0 +1,38 @@ +package se.hagstedt.kvitt.data.util + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** The phone's clock and time zone. Injected so tests can script time (AC-15.6). */ +interface Clock { + /** Epoch milliseconds. */ + fun nowMillis(): Long + + /** The phone's time zone; "today" defaults use it (§9.10). */ + fun zone(): ZoneId +} + +object SystemClock : Clock { + override fun nowMillis(): Long = System.currentTimeMillis() + + override fun zone(): ZoneId = ZoneId.systemDefault() +} + +/** The fixed RFC 3339 form of every timestamp (§14.3.3): UTC, millisecond precision, `Z`, as `2026-10-16T17:02:11.123Z`. */ +object Timestamps { + private val fixed: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC) + + fun format(epochMillis: Long): String = fixed.format(Instant.ofEpochMilli(epochMillis)) + + fun parse(timestamp: String): Long = Instant.parse(timestamp).toEpochMilli() +} + +fun Clock.nowTimestamp(): String = Timestamps.format(nowMillis()) + +/** Today's date in the phone's zone, `YYYY-MM-DD`. */ +fun Clock.today(): String = Instant.ofEpochMilli(nowMillis()).atZone(zone()).toLocalDate().toString() + +fun Clock.todayDate(): LocalDate = LocalDate.parse(today()) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Ids.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Ids.kt new file mode 100644 index 0000000..fa63418 --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/util/Ids.kt @@ -0,0 +1,25 @@ +package se.hagstedt.kvitt.data.util + +import java.security.SecureRandom + +/** Client-generated ids: UUIDv7 for expense and settlement ids and push mutation ids (AC-8.2, §14.2.1). */ +fun interface IdGenerator { + fun newId(): String +} + +/** RFC 9562 UUIDv7: 48-bit Unix milliseconds, version 7, 74 random bits, variant 10. */ +class UuidV7Generator(private val clock: Clock, private val random: SecureRandom = SecureRandom()) : IdGenerator { + override fun newId(): String { + val millis = clock.nowMillis() + val bytes = ByteArray(16) + random.nextBytes(bytes) + for (i in 0 until 6) bytes[i] = (millis ushr (8 * (5 - i))).toByte() + bytes[6] = ((bytes[6].toInt() and 0x0F) or 0x70).toByte() + bytes[8] = ((bytes[8].toInt() and 0x3F) or 0x80).toByte() + val hex = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) } + return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}" + } +} + +/** Length in Unicode code points after trimming (§9.6). */ +internal fun String.codePointLength(): Int = trim().let { it.codePointCount(0, it.length) } diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt new file mode 100644 index 0000000..7f10066 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/FakeServer.kt @@ -0,0 +1,215 @@ +package se.hagstedt.kvitt.data + +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import se.hagstedt.kvitt.data.api.AccountDto +import se.hagstedt.kvitt.data.api.AllocationDto +import se.hagstedt.kvitt.data.api.AuditEntryDto +import se.hagstedt.kvitt.data.api.CategoryDto +import se.hagstedt.kvitt.data.api.ErrorDto +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.ExpensePushEntity +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.api.PullPage +import se.hagstedt.kvitt.data.api.PushRequest +import se.hagstedt.kvitt.data.api.PushResponse +import se.hagstedt.kvitt.data.api.PushResultDto +import se.hagstedt.kvitt.data.api.SettlementDto +import se.hagstedt.kvitt.data.api.SettlementPushEntity +import se.hagstedt.kvitt.data.api.ShareBpDto +import se.hagstedt.kvitt.data.api.UserDto +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.Timestamps +import java.io.IOException + +/** + * A small in-memory Kvitt server for MockEngine: the server side of §14.4 as far as the phone can observe it (one global + * version sequence, LWW on `updated_at`, deletes win, replay by `mutation_id`, clock skew, pages with `has_more`). + * Tests script deviations with [pushOverride], [beforePushResponse], [failNetwork] and [failStatus]. + */ +class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") { + var seq = 0L + var currentEpoch = epoch + val users = linkedMapOf() + val categories = linkedMapOf() + val accounts = linkedMapOf() + val expenses = linkedMapOf() + val settlements = linkedMapOf() + val audit = mutableListOf() + private val replay = mutableMapOf() + + val pushes = mutableListOf() + val pulls = mutableListOf() + var failNetwork = false + var failStatus: Pair? = null + var pushOverride: ((PushRequest) -> PushResponse?)? = null + var maxPage: Int? = null + var inFlight = 0 + var maxInFlight = 0 + var beforePushResponse: (suspend (PushRequest) -> Unit)? = null + var apkBody: ByteArray? = null + var restHandler: ((HttpRequestData, String) -> Pair?)? = null + + fun next() = ++seq + + fun addUser(id: String, role: String, name: String) { + users[id] = UserDto(id, role, name, null, T0, T0, next()) + } + + fun addCategory(id: String, key: String?, name: String?) { + categories[id] = CategoryDto(id, key, name, "category", "grey", (categories.size + 1) * 10, T0, T0, null, null, next()) + } + + fun addAccount(id: String, name: String, shares: Map, createdAt: String = T0) { + accounts[id] = AccountDto(id, name, shares.entries.sortedBy { it.key }.map { ShareBpDto(it.key, it.value) }, users.keys.first(), createdAt, createdAt, null, null, next()) + } + + fun storeExpense(dto: ExpenseDto) { + expenses[dto.id] = dto.copy(serverVersion = next()) + } + + suspend fun handle(scope: MockRequestHandleScope, request: HttpRequestData): HttpResponseData { + inFlight++ + maxInFlight = maxOf(maxInFlight, inFlight) + try { + return route(scope, request) + } finally { + inFlight-- + } + } + + private suspend fun route(scope: MockRequestHandleScope, request: HttpRequestData): HttpResponseData { + if (failNetwork) throw IOException("scripted network failure") + failStatus?.let { (status, code) -> return scope.json(status, KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto(code, code, null))) } + val path = request.url.encodedPath + restHandler?.invoke(request, path)?.let { (status, body) -> return scope.json(status, body) } + val apk = apkBody + if (apk != null && path.startsWith("/apk/files/")) { + return scope.respond(apk, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType to listOf("application/vnd.android.package-archive"))) + } + return when { + path == "/sync" && request.method == HttpMethod.Get -> { + val since = request.url.parameters["since"]?.toLong() ?: 0 + val limit = minOf(request.url.parameters["limit"]?.toInt() ?: 500, maxPage ?: Int.MAX_VALUE) + pulls += since + if (since > seq) { + return scope.json(HttpStatusCode.Conflict, KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("cursor_ahead", "cursor_ahead", "since"))) + } + scope.json(HttpStatusCode.OK, KvittJson.encodeToString(PullPage.serializer(), page(since, limit))) + } + path == "/sync" && request.method == HttpMethod.Post -> { + val text = String(request.body.toByteArray()) + check(text.isNotEmpty()) { "empty push body: ${request.body::class.java.name} ${request.body.contentType} ${request.body.contentLength}" } + val body = KvittJson.decodeFromString(PushRequest.serializer(), text) + pushes += body + val response = pushOverride?.invoke(body) ?: push(body) + beforePushResponse?.invoke(body) + scope.json(HttpStatusCode.OK, KvittJson.encodeToString(PushResponse.serializer(), response)) + } + else -> scope.json(HttpStatusCode.NotFound, KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("not_found", "no route", null))) + } + } + + private fun MockRequestHandleScope.json(status: HttpStatusCode, body: String) = + respond(body, status, headersOf(HttpHeaders.ContentType to listOf("application/json; charset=utf-8"), "X-Kvitt-Server-Version" to listOf("0.1.0"))) + + fun page(since: Long, limit: Int): PullPage { + data class Row(val version: Long, val add: (MutableLists) -> Unit) + val rows = buildList { + users.values.forEach { u -> add(Row(u.serverVersion) { it.users += u }) } + categories.values.forEach { c -> add(Row(c.serverVersion) { it.categories += c }) } + accounts.values.forEach { a -> add(Row(a.serverVersion) { it.accounts += a }) } + expenses.values.forEach { e -> add(Row(e.serverVersion) { it.expenses += e }) } + settlements.values.forEach { s -> add(Row(s.serverVersion) { it.settlements += s }) } + audit.forEach { a -> add(Row(a.serverVersion) { it.audit += a }) } + }.filter { it.version > since }.sortedBy { it.version } + val pageRows = rows.take(limit) + val lists = MutableLists() + pageRows.forEach { it.add(lists) } + val nextSince = pageRows.lastOrNull()?.version ?: since + return PullPage( + currentEpoch, since, nextSince, rows.size > pageRows.size, Timestamps.format(clock.nowMillis()), + lists.users, lists.categories, lists.accounts, emptyList(), lists.expenses, lists.settlements, lists.audit, + ) + } + + class MutableLists { + val users = mutableListOf() + val categories = mutableListOf() + val accounts = mutableListOf() + val expenses = mutableListOf() + val settlements = mutableListOf() + val audit = mutableListOf() + } + + /** §14.4.4, simplified: replay, clock, tombstones, deletes win, insert, stale, update. Actor is the first user. */ + fun push(request: PushRequest): PushResponse = PushResponse(Timestamps.format(clock.nowMillis()), request.mutations.map { m -> + replay[m.mutationId] ?: when (m.entityType) { + "expense" -> pushExpense(m.mutationId, KvittJson.decodeFromJsonElement(ExpensePushEntity.serializer(), m.entity)) + else -> pushSettlement(m.mutationId, KvittJson.decodeFromJsonElement(SettlementPushEntity.serializer(), m.entity)) + }.also { replay[m.mutationId] = it } + }) + + private val actor get() = users.keys.first() + private fun skewed(updatedAt: String) = Timestamps.parse(updatedAt) > clock.nowMillis() + 10 * 60 * 1000 + + private fun pushExpense(mutationId: String, e: ExpensePushEntity): PushResultDto { + if (skewed(e.updatedAt)) return rejected(mutationId, "expense", e.id, "clock_skew", expenses[e.id]?.json()) + val stored = expenses[e.id] + if (stored?.deletedAt != null) { + return if (e.deletedAt != null) accepted(mutationId, "expense", e.id, stored.serverVersion, stored.json()) + else rejected(mutationId, "expense", e.id, "deleted", stored.json()) + } + if (stored != null && e.deletedAt == null && Timestamps.parse(e.updatedAt) <= Timestamps.parse(stored.updatedAt)) { + return rejected(mutationId, "expense", e.id, "stale", stored.json()) + } + val dto = ExpenseDto( + e.id, e.accountId, e.categoryId, e.amountOre, e.description, e.notes, e.expenseDate, e.payerUserId, e.splitMode, e.splits, + stored?.recurringTemplateId, stored?.recurringPeriod, stored?.createdByUserId ?: actor, actor, e.createdAt, + if (stored != null && Timestamps.parse(stored.updatedAt) > Timestamps.parse(e.updatedAt)) stored.updatedAt else e.updatedAt, + e.deletedAt, next(), + ) + expenses[e.id] = dto + return accepted(mutationId, "expense", e.id, dto.serverVersion, dto.json()) + } + + private fun pushSettlement(mutationId: String, s: SettlementPushEntity): PushResultDto { + if (skewed(s.updatedAt)) return rejected(mutationId, "settlement", s.id, "clock_skew", settlements[s.id]?.json()) + val stored = settlements[s.id] + if (stored?.deletedAt != null) { + return if (s.deletedAt != null) accepted(mutationId, "settlement", s.id, stored.serverVersion, stored.json()) + else rejected(mutationId, "settlement", s.id, "deleted", stored.json()) + } + if (stored != null && s.deletedAt == null && Timestamps.parse(s.updatedAt) <= Timestamps.parse(stored.updatedAt)) { + return rejected(mutationId, "settlement", s.id, "stale", stored.json()) + } + val dto = SettlementDto( + s.id, s.fromUserId, s.toUserId, s.amountOre, s.settlementDate, s.note, s.allocations.map { AllocationDto(it.accountId, it.amountOre) }, + stored?.createdByUserId ?: actor, actor, s.createdAt, s.updatedAt, s.deletedAt, next(), + ) + settlements[s.id] = dto + return accepted(mutationId, "settlement", s.id, dto.serverVersion, dto.json()) + } + + companion object { + const val T0 = "2026-10-01T08:00:00.000Z" + + fun accepted(mutationId: String, type: String, id: String, version: Long, entity: JsonObject) = + PushResultDto.Accepted(mutationId, type, id, "accepted", version, entity) + + fun rejected(mutationId: String, type: String, id: String, reason: String, current: JsonObject?) = + PushResultDto.Rejected(mutationId, type, id, "rejected", reason, reason, current) + } +} + +fun ExpenseDto.json(): JsonObject = KvittJson.encodeToJsonElement(ExpenseDto.serializer(), this).jsonObject +fun SettlementDto.json(): JsonObject = KvittJson.encodeToJsonElement(SettlementDto.serializer(), this).jsonObject diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt new file mode 100644 index 0000000..7272b93 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt @@ -0,0 +1,198 @@ +package se.hagstedt.kvitt.data + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import androidx.sqlite.driver.AndroidSQLiteDriver +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.request.HttpRequestData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.robolectric.RuntimeEnvironment +import se.hagstedt.kvitt.data.api.ApiClientInfo +import se.hagstedt.kvitt.data.api.ApiTarget +import se.hagstedt.kvitt.data.api.KvittApi +import se.hagstedt.kvitt.data.api.kvittDefaults +import se.hagstedt.kvitt.data.auth.CredentialStore +import se.hagstedt.kvitt.data.auth.Credentials +import se.hagstedt.kvitt.data.auth.LocalWipe +import se.hagstedt.kvitt.data.auth.SealedToken +import se.hagstedt.kvitt.data.auth.SessionManager +import se.hagstedt.kvitt.data.auth.TokenCipher +import se.hagstedt.kvitt.data.connectivity.ConnectivityRepositoryImpl +import se.hagstedt.kvitt.data.connectivity.NetworkErrorTracker +import se.hagstedt.kvitt.data.connectivity.NetworkMonitor +import se.hagstedt.kvitt.data.connectivity.OnlineGate +import se.hagstedt.kvitt.data.db.KvittDatabase +import se.hagstedt.kvitt.data.repo.LocalData +import se.hagstedt.kvitt.data.repo.PhonePrefs +import se.hagstedt.kvitt.data.sync.LocalWriteListener +import se.hagstedt.kvitt.data.sync.SyncEngine +import se.hagstedt.kvitt.data.sync.SyncWork +import se.hagstedt.kvitt.data.util.Clock +import se.hagstedt.kvitt.data.util.IdGenerator +import se.hagstedt.kvitt.data.util.Timestamps +import java.io.File +import java.nio.file.Files +import java.time.ZoneId +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** A scripted clock (AC-15.6). */ +class TestClock(var now: Long = Timestamps.parse("2026-10-17T10:00:00.000Z")) : Clock { + override fun nowMillis(): Long = now + + override fun zone(): ZoneId = ZoneId.of("Europe/Stockholm") + + fun advance(millis: Long) { + now += millis + } +} + +/** Deterministic UUID-shaped ids. */ +class SequentialIds(private val prefix: String = "0192ffff-0000-7000-8000-") : IdGenerator { + private var n = 0L + + override fun newId(): String = prefix + (++n).toString(16).padStart(12, '0') +} + +/** A JVM AES-GCM key in memory: the Keystore's stand-in on the JVM (no Android Keystore under Robolectric). */ +class FakeTokenCipher : TokenCipher { + private var key: SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + var destroyed = 0 + + override fun seal(token: String): SealedToken { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + return SealedToken(cipher.doFinal(token.toByteArray()), cipher.iv) + } + + override fun open(sealed: SealedToken): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed.iv)) + return String(cipher.doFinal(sealed.ciphertext)) + } + + override fun destroyKey() { + destroyed++ + key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + } +} + +class FakeNetworkMonitor(initial: Boolean = true) : NetworkMonitor { + val state = MutableStateFlow(initial) + override val validated = state + + override fun isValidatedNow(): Boolean = state.value +} + +class FakeSyncWork : SyncWork { + val oneOff = mutableListOf() + var periodic = 0 + + override fun enqueueOneOff(expedited: Boolean) { + oneOff += expedited + } + + override fun ensurePeriodic() { + periodic++ + } +} + +/** + * Everything `:data` needs, on an in-memory Room database (framework driver under Robolectric), DataStore files in a + * temporary directory and a [FakeServer] behind Ktor's MockEngine. + */ +class Harness { + val clock = TestClock() + val ids = SequentialIds() + val server = FakeServer(clock) + val requests = mutableListOf() + val dir: File = Files.createTempDirectory("kvitt-data-test").toFile() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val db: KvittDatabase = Room.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), KvittDatabase::class.java) + .setDriver(AndroidSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + val cipher = FakeTokenCipher() + val credentials = CredentialStore(PreferenceDataStoreFactory.create(scope = scope) { File(dir, "auth.preferences_pb") }, cipher) + val prefs = PhonePrefs(PreferenceDataStoreFactory.create(scope = scope) { File(dir, "prefs.preferences_pb") }) + val errors = NetworkErrorTracker(clock) + val monitor = FakeNetworkMonitor() + val connectivity = ConnectivityRepositoryImpl(monitor, errors) + val gate = OnlineGate(connectivity) + var wipes = 0 + val wipe = LocalWipe { + wipes++ + db.clearAllTables() + prefs.clear() + } + val session = SessionManager(credentials, prefs, wipe, errors, APP_VERSION) + val mock = MockEngine { request -> + requests += request + server.handle(this, request) + } + val api = KvittApi( + HttpClient(mock) { kvittDefaults() }, + { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }, + object : ApiClientInfo { + override val appVersionCode = APP_VERSION + override fun acceptLanguage() = "sv-SE" + }, + session, + ) + val engine = SyncEngine(db, api, clock, ids, session) + val local = LocalData(db, credentials, scope) + var localWrites = 0 + val writes = LocalWriteListener { localWrites++ } + val work = FakeSyncWork() + + /** The household of the tests: me (admin) and partner, two categories, one 50/50 account; paired as me. */ + fun seedServer() { + server.addUser(ME, "admin", "Jacob") + server.addUser(PARTNER, "member", "Anna") + server.addCategory(GROCERIES, "groceries", null) + server.addCategory(OTHER, "other", null) + server.addAccount(ACCOUNT, "Vardag", mapOf(ME to 5000, PARTNER to 5000)) + } + + fun pair() = runBlocking { credentials.save(Credentials(BASE_URL, ME, DEVICE, TOKEN)) } + + /** Paired, seeded and synced once. */ + fun ready(): Harness { + seedServer() + pair() + runBlocking { + engine.sync() + local.snapshot.first { it.me != null && it.accounts.isNotEmpty() } + } + requests.clear() + server.pulls.clear() + return this + } + + fun close() { + db.close() + scope.cancel() + dir.deleteRecursively() + } + + companion object { + const val APP_VERSION = 41 + const val BASE_URL = "https://kvitt.test:8443" + const val TOKEN = "kvt_testtoken0000000000000000000000000000000000" + const val ME = "0192a000-0000-7000-8000-000000000001" + const val PARTNER = "0192a000-0000-7000-8000-000000000002" + const val DEVICE = "0192a001-0000-7000-8000-000000000001" + const val GROCERIES = "01920000-0000-7000-8000-000000000001" + const val OTHER = "01920000-0000-7000-8000-00000000000a" + const val ACCOUNT = "0192a003-0000-7000-8000-000000000001" + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt new file mode 100644 index 0000000..86d5a28 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt @@ -0,0 +1,55 @@ +package se.hagstedt.kvitt.data + +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.ExpensePushEntity +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.api.MutationDto +import se.hagstedt.kvitt.data.api.SplitDto +import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl +import se.hagstedt.kvitt.data.repo.SettlementRepositoryImpl +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UserId + +fun SaveResult.savedId(): T = when (this) { + is SaveResult.Saved -> id + is SaveResult.Invalid -> throw AssertionError("save was invalid: $errors") +} + +fun Harness.expenseRepo() = ExpenseRepositoryImpl(local, db, prefs, clock, ids, writes) + +fun Harness.settlementRepo() = SettlementRepositoryImpl(local, db, clock, ids, writes) + +fun draft( + description: String, + amountOre: Long = 41200, + id: ExpenseId? = null, + mode: SplitMode = SplitMode.DEFAULT, + date: String = "2026-10-16", + payer: String = Harness.ME, + account: String = Harness.ACCOUNT, +) = ExpenseDraft(id, AccountId(account), CategoryId(Harness.GROCERIES), amountOre, description, "", date, UserId(payer), mode, null, null, null) + +suspend fun Harness.addExpense(description: String, amountOre: Long = 41200): String = expenseRepo().save(draft(description, amountOre)).savedId().value + +suspend fun Harness.editExpense(id: String, description: String, amountOre: Long = 41200) { + expenseRepo().save(draft(description, amountOre, ExpenseId(id))).savedId() +} + +/** A server row with exactly the pushed client fields: what a re-push of an already stored state gets back. */ +fun MutationDto.asStoredExpense(version: Long, actor: String = Harness.ME): ExpenseDto { + val e = KvittJson.decodeFromJsonElement(ExpensePushEntity.serializer(), entity) + return ExpenseDto( + e.id, e.accountId, e.categoryId, e.amountOre, e.description, e.notes, e.expenseDate, e.payerUserId, e.splitMode, + e.splits.map { SplitDto(it.userId, it.shareOre, it.shareBp) }, null, null, actor, actor, e.createdAt, e.updatedAt, e.deletedAt, version, + ) +} + +/** runBlocking with a bound, so a hang fails with a stack trace instead of stalling the build. */ +fun runSync(block: suspend kotlinx.coroutines.CoroutineScope.() -> Unit) { + kotlinx.coroutines.runBlocking { kotlinx.coroutines.withTimeout(60_000, block) } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt new file mode 100644 index 0000000..12bf109 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt @@ -0,0 +1,112 @@ +package se.hagstedt.kvitt.data.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +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 + * 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. + */ +@RunWith(Parameterized::class) +class ExampleRoundTripTest(private val name: String) { + @Test + fun roundTrip() { + val file = File(examplesDir(), name) + val original = Json.parseToJsonElement(file.readText()) + val serializer = requireNotNull(serializerFor(name)) { "no DTO for $name" } + assertEquals(name, original, reencode(serializer, original)) + nestedEntities(name, original).forEach { (entitySerializer, element) -> + assertEquals("$name nested", element, reencode(entitySerializer, element)) + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun files(): List = exampleFiles() + } +} + +class ExampleCoverageTest { + @Test + fun everyExampleFileHasADto() { + val files = exampleFiles() + assertTrue("examples found", files.size >= 80) + assertEquals(emptyList(), files.filter { serializerFor(it) == null }) + } +} + +internal fun examplesDir(): File = File(requireNotNull(System.getProperty("kvitt.contracts.examples")) { "kvitt.contracts.examples not set" }) + +internal fun exampleFiles(): List = examplesDir().walkTopDown().filter { it.isFile && it.extension == "json" } + .map { it.relativeTo(examplesDir()).invariantSeparatorsPath }.sorted().toList() + +private fun reencode(serializer: KSerializer, element: JsonElement): JsonElement = + KvittJson.encodeToJsonElement(serializer, KvittJson.decodeFromJsonElement(serializer, element)) + +/** The DTO of each example, by endpoint directory and body (§14.3.5 names). */ +internal fun serializerFor(path: String): KSerializer<*>? { + val dir = path.substringBefore('/') + val file = path.substringAfter('/') + val request = file == "request.json" + return when { + dir == "errors" -> ErrorDto.serializer() + dir == "push" -> FcmMessageDto.serializer() + dir == "sync" && file.startsWith("pull-") -> PullPage.serializer() + dir == "sync" && file.startsWith("push-request") -> PushRequest.serializer() + dir == "sync" && file.startsWith("push-response") -> PushResponse.serializer() + else -> when (dir.substringAfter("E-").substringBefore('-').toIntOrNull()) { + 1 -> HealthDto.serializer() + 2 -> SetupStatusDto.serializer() + 3 -> if (request) AcceptInviteRequest.serializer() else AcceptInviteResponse.serializer() + 5 -> ApkLatestDto.serializer() + 7 -> MeResponse.serializer() + 8 -> if (request) PatchMeRequest.serializer() else UserDto.serializer() + 9, 10 -> UserDto.serializer() + 12 -> if (request) CreateInviteRequest.serializer() else InviteDto.serializer() + 13 -> InviteListDto.serializer() + 15 -> DeviceListDto.serializer() + 17 -> PushTokenRequest.serializer() + 18 -> if (request) CreateAccountRequest.serializer() else AccountDto.serializer() + 19 -> if (request) PatchAccountRequest.serializer() else AccountDto.serializer() + 20, 21, 22 -> AccountDto.serializer() + 23 -> if (request) CreateCategoryRequest.serializer() else CategoryDto.serializer() + 24 -> if (request) PatchCategoryRequest.serializer() else CategoryDto.serializer() + 25, 26, 27 -> CategoryDto.serializer() + 28 -> if (request) CreateTemplateRequest.serializer() else RecurringTemplateDto.serializer() + 29 -> if (request) PatchTemplateRequest.serializer() else RecurringTemplateDto.serializer() + 30 -> RecurringTemplateDto.serializer() + 31 -> PullPage.serializer() + 32 -> if (request) PushRequest.serializer() else PushResponse.serializer() + 33 -> if (request) ProposalRequest.serializer() else ProposalResponse.serializer() + else -> null + } + } +} + +/** `entity` of each mutation and `entity` / `current` of each result, typed by `entity_type`. */ +private fun nestedEntities(path: String, element: JsonElement): List, JsonElement>> { + val o = element as? JsonObject ?: return emptyList() + return when (serializerFor(path)) { + PushRequest.serializer() -> KvittJson.decodeFromJsonElement(PushRequest.serializer(), o).mutations.map { m -> + (if (m.entityType == "expense") ExpensePushEntity.serializer() else SettlementPushEntity.serializer()) to m.entity + } + PushResponse.serializer() -> KvittJson.decodeFromJsonElement(PushResponse.serializer(), o).results.mapNotNull { r -> + val wire: KSerializer<*> = if (r.entityType == "expense") ExpenseDto.serializer() else SettlementDto.serializer() + when (r) { + is PushResultDto.Accepted -> wire to r.entity + is PushResultDto.Rejected -> r.current?.let { wire to it } + } + } + else -> emptyList() + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt new file mode 100644 index 0000000..305e06d --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt @@ -0,0 +1,96 @@ +package se.hagstedt.kvitt.data.api + +import io.ktor.client.engine.mock.toByteArray +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.first +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.addExpense +import se.hagstedt.kvitt.data.runSync + +/** §14.3.1 headers, §14.3.2 error mapping, §8.2 token handling. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class KvittApiTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness().ready() + } + + @After + fun tearDown() = h.close() + + @Test + fun everyAuthenticatedCallCarriesTheCommonHeadersAndTheBearerToken() = runSync { + h.addExpense("ICA") + h.engine.sync() + assertTrue(h.requests.size >= 2) + for (r in h.requests) { + assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER]) + assertEquals(Harness.APP_VERSION.toString(), r.headers[KvittApi.APP_VERSION_HEADER]) + assertEquals("sv-SE", r.headers[HttpHeaders.AcceptLanguage]) + assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization]) + assertFalse("the token is never in a URL", r.url.toString().contains(Harness.TOKEN)) + } + } + + @Test + fun acceptInviteIsUnauthenticated() = runSync { + h.server.restHandler = { _, path -> if (path == "/invites/accept") HttpStatusCode.BadRequest to """{"code":"invite_invalid","message":"x","field":null}""" else null } + val result = h.api.acceptInvite("https://other.test", AcceptInviteRequest("7K3MQX9D2HPA", "Galaxy", null)) + assertEquals(ApiResult.Failure(400, "invite_invalid", null), result) + val r = h.requests.single() + assertNull(r.headers[HttpHeaders.Authorization]) + assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER]) + assertEquals("https://other.test/invites/accept", r.url.toString()) + assertFalse("display_name omitted when null", String(r.body.toByteArray()).contains("display_name")) + } + + @Test + fun errorsMapToTheirCodeAndField() = runSync { + h.server.restHandler = { _, path -> if (path == "/accounts") HttpStatusCode.Conflict to """{"code":"name_taken","message":"x","field":"name"}""" else null } + assertEquals(ApiResult.Failure(409, "name_taken", "name"), h.api.createAccount(CreateAccountRequest("Vardag", emptyList()))) + } + + @Test + fun aBodyThatIsNotAnErrorFallsBackToTheStatusCode() = runSync { + h.server.restHandler = { _, _ -> HttpStatusCode.ServiceUnavailable to "" } + assertEquals(ApiResult.Failure(503, "unavailable", null), h.api.me()) + } + + @Test + fun networkFailureIsNetworkError_andOpensTheThirtySecondWindow() = runSync { + h.server.failNetwork = true + assertTrue(h.api.me() is ApiResult.NetworkError) + assertFalse(h.connectivity.isOnlineNow()) + assertFalse(h.connectivity.online.first()) + h.clock.advance(30_000) + assertTrue(h.connectivity.isOnlineNow()) + } + + @Test + fun serverVersionHeaderIsKept() = runSync { + h.api.pull(0, 1) + assertEquals("0.1.0", h.session.serverVersion.value) + } + + @Test + fun noCredentialsIsNotPaired() = runSync { + h.credentials.clear() + h.requests.clear() + assertEquals(ApiResult.NotPaired, h.api.me()) + assertTrue(h.requests.isEmpty()) + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/auth/CredentialStoreTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/auth/CredentialStoreTest.kt new file mode 100644 index 0000000..a73296b --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/auth/CredentialStoreTest.kt @@ -0,0 +1,52 @@ +package se.hagstedt.kvitt.data.auth + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.runSync +import java.io.File + +/** §8.2: the token is stored encrypted beside the plain base URL, user id and device id, and never printed. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class CredentialStoreTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness() + } + + @After + fun tearDown() = h.close() + + @Test + fun tokenRoundTripsEncrypted_andIsNeverInPlainText() = runSync { + h.pair() + assertEquals(Harness.TOKEN, h.credentials.credentials()?.token) + val stored = File(h.dir, "auth.preferences_pb").readBytes().toString(Charsets.ISO_8859_1) + assertTrue("the base URL is stored in plain text", stored.contains(Harness.BASE_URL)) + assertFalse("the token never lands in plain text", stored.contains(Harness.TOKEN)) + assertFalse(h.credentials.credentials().toString().contains(Harness.TOKEN)) + } + + @Test + fun revokedDeletesTokenAndKey_unrecognisedKeepsTheUser() = runSync { + h.pair() + h.credentials.markUnrecognised() + assertNull(h.credentials.credentials()) + assertEquals(PairingStatus.UNRECOGNISED, h.credentials.current().status) + assertEquals(Harness.ME, h.credentials.current().userId) + h.credentials.markRevoked() + assertEquals(PairingRecord(PairingStatus.REVOKED, null, null, null), h.credentials.current()) + assertEquals(1, h.cipher.destroyed) + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt new file mode 100644 index 0000000..fc17463 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/db/MigrationTest.kt @@ -0,0 +1,91 @@ +package se.hagstedt.kvitt.data.db + +import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.driver.AndroidSQLiteDriver +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.runSync +import java.io.File + +/** + * Room migration test infrastructure (§14.2.3, CLAUDE.md: every schema change gets a migration and a test). A database + * is created at any exported version straight from `android/data/schemas/.json` (its `createSql` and setup + * queries), filled with user data, then opened by the current [KvittDatabase] with [KvittMigrations.ALL]: Room runs the + * migrations and verifies the result against the compiled schema (identity hash and table info). At version 1 there + * are no migrations; the same path proves the committed `1.json` matches the entities. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class MigrationTest { + private val schemas = File(requireNotNull(System.getProperty("kvitt.room.schemas")), KvittDatabase::class.java.name) + + @Test + fun everyVersionHasASchemaFile_andEveryStepAMigration() { + val versions = schemas.listFiles().orEmpty().mapNotNull { it.nameWithoutExtension.toIntOrNull() }.sorted() + assertEquals((1..KvittDatabase.VERSION).toList(), versions) + val steps = KvittMigrations.ALL.map { it.startVersion to it.endVersion } + assertEquals((1 until KvittDatabase.VERSION).map { it to it + 1 }, steps) + } + + @Test + fun everyExportedVersionOpensWithTheCurrentDatabase_andKeepsUserData() = runSync { + for (version in 1..KvittDatabase.VERSION) { + val name = "migration-$version.db" + val file = RuntimeEnvironment.getApplication().getDatabasePath(name) + file.parentFile?.mkdirs() + file.delete() + createAt(version, file) + val db = KvittDatabase.build(RuntimeEnvironment.getApplication(), AndroidSQLiteDriver(), name) + try { + val expense = db.expenses().get("e1") + assertEquals("user data survives version $version", "ICA", expense?.description) + assertEquals(true, expense?.pending) + assertEquals(listOf(20600L, 20600L), db.expenses().splits("e1").map { it.shareOre }) + assertEquals(1, db.users().observeAll().first().size) + } finally { + db.close() + file.delete() + } + } + } + + /** A database file exactly as Room would have created it at [version], with one row of user data. */ + private fun createAt(version: Int, file: File) { + val schema = Json.parseToJsonElement(File(schemas, "$version.json").readText()).jsonObject.getValue("database").jsonObject + assertEquals(version, schema.getValue("version").jsonPrimitive.int) + val sqlite = SQLiteDatabase.openOrCreateDatabase(file, null) + try { + for (entity in schema.getValue("entities").jsonArray) { + val o = entity.jsonObject + val table = o.getValue("tableName").jsonPrimitive.content + sqlite.execSQL(o.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table)) + o["indices"]?.jsonArray?.forEach { index -> + sqlite.execSQL(index.jsonObject.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table)) + } + } + schema.getValue("setupQueries").jsonArray.forEach { sqlite.execSQL(it.jsonPrimitive.content) } + sqlite.execSQL("INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version, local_updated_at) VALUES ('u1', 'admin', 'Jacob', NULL, 't', 't', 1, 0)") + sqlite.execSQL( + "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, recurring_template_id, recurring_period, " + + "created_by_user_id, updated_by_user_id, created_at, updated_at, deleted_at, server_version, pending, pending_mutation_id, local_updated_at) " + + "VALUES ('e1', 'a1', 'c1', 41200, 'ICA', '', '2026-10-16', 'u1', 'default', NULL, NULL, NULL, NULL, 't', 't', NULL, NULL, 1, 'm1', 0)", + ) + sqlite.execSQL("INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES ('e1', 'u1', 20600, 5000), ('e1', 'u2', 20600, 5000)") + sqlite.version = version + } finally { + sqlite.close() + } + assertTrue(file.exists()) + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt new file mode 100644 index 0000000..49ca6e4 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/RepositoriesTest.kt @@ -0,0 +1,292 @@ +package se.hagstedt.kvitt.data.repo + +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.JsonPrimitive +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.FakeServer +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT +import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES +import se.hagstedt.kvitt.data.Harness.Companion.ME +import se.hagstedt.kvitt.data.Harness.Companion.OTHER +import se.hagstedt.kvitt.data.Harness.Companion.PARTNER +import se.hagstedt.kvitt.data.addExpense +import se.hagstedt.kvitt.data.api.AccountDto +import se.hagstedt.kvitt.data.api.AllocationDto +import se.hagstedt.kvitt.data.api.AuditChangeDto +import se.hagstedt.kvitt.data.api.AuditEntryDto +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.api.SettlementDto +import se.hagstedt.kvitt.data.api.ShareBpDto +import se.hagstedt.kvitt.data.api.SplitDto +import se.hagstedt.kvitt.data.draft +import se.hagstedt.kvitt.data.expenseRepo +import se.hagstedt.kvitt.data.runSync +import se.hagstedt.kvitt.data.savedId +import se.hagstedt.kvitt.data.settlementRepo +import se.hagstedt.kvitt.data.util.Timestamps +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AnalyticsFilter +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.Granularity +import se.hagstedt.kvitt.model.GroupBy +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.Measure +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.RangePreset +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.SyncRejectionReason +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.UserId +import kotlinx.coroutines.flow.flowOf + +/** The real repositories over Room (balances with pending rows, tombstones, editor defaults, online-only results). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class RepositoriesTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness().ready() + } + + @After + fun tearDown() = h.close() + + private fun home() = HomeRepositoryImpl(h.local, SyncSummaries(h.db, h.engine), flowOf(UpdateState.None), h.clock) + private fun accounts() = AccountRepositoryImpl(h.local, h.api, h.gate, h.db, h.clock) + + private fun serverExpense(id: String, amount: Long, deletedAt: String? = null) = ExpenseDto( + id, ACCOUNT, GROCERIES, amount, "server $id", "", "2026-10-10", ME, "default", + listOf(SplitDto(ME, amount - amount / 2, 5000), SplitDto(PARTNER, amount / 2, 5000)), null, null, ME, ME, FakeServer.T0, FakeServer.T0, deletedAt, 0, + ) + + @Test + fun home_balancesIncludePendingRows_andExcludeTombstones() = runSync { + h.server.storeExpense(serverExpense("tomb", 9_999, deletedAt = "2026-10-11T00:00:00.000Z")) + h.engine.sync() + h.addExpense("pending", amountOre = 1001) + val gone = h.addExpense("deleted locally", amountOre = 5000) + h.expenseRepo().delete(ExpenseId(gone)) + val state = home().home().first { it.sync.pendingCount == 2 } + // net(me) = 1001 paid - 501 own share (§9.1: the payer absorbs the remainder). + assertEquals(500L, state.global?.netOre) + assertEquals(500L, state.active.single().netOre) + assertEquals("AC-10.5", state.global?.netOre, state.active.sumOf { it.netOre } + state.archived.sumOf { it.netOre }) + assertEquals(PARTNER, state.partner?.id?.value) + } + + @Test + fun editorDefaults_lastUsedAccountAndCategory_elseOldestActiveAndOther() = runSync { + h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-09-01T00:00:00.000Z") + h.engine.sync() + val repo = h.expenseRepo() + val fresh = repo.editorOptions().first { it.accounts.size == 2 } + assertEquals(OLDER, fresh.defaultAccountId?.value) + assertEquals(OTHER, fresh.defaultCategoryFor[AccountId(ACCOUNT)]?.value) + assertEquals("2026-10-17", fresh.today) + + h.addExpense("ICA") + val used = repo.editorOptions().first { it.defaultAccountId?.value == ACCOUNT } + assertEquals(GROCERIES, used.defaultCategoryFor[AccountId(ACCOUNT)]?.value) + assertEquals(OTHER, used.defaultCategoryFor[AccountId(OLDER)]?.value) + + h.server.accounts[ACCOUNT] = h.server.accounts.getValue(ACCOUNT).copy(archivedAt = "2026-10-17T00:00:00.000Z", serverVersion = h.server.next()) + h.engine.sync() + val archived = repo.editorOptions().first { it.accounts.size == 1 } + assertEquals("an archived last account falls back to the oldest active", OLDER, archived.defaultAccountId?.value) + } + + @Test + fun onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing() = runSync { + h.monitor.state.value = false + h.requests.clear() + assertEquals(OnlineResult.Offline, accounts().create("Resor", mapOf(UserId(ME) to 5000, UserId(PARTNER) to 5000))) + assertTrue(h.requests.isEmpty()) + } + + @Test + fun onlineOnly_withinThirtySecondsOfANetworkError_isOffline() = runSync { + h.server.failNetwork = true + assertEquals(OnlineResult.Failed("network", null), accounts().setArchived(AccountId(ACCOUNT), true)) + h.server.failNetwork = false + h.clock.advance(29_000) + assertEquals(OnlineResult.Offline, accounts().setArchived(AccountId(ACCOUNT), true)) + h.clock.advance(1_000) + h.server.restHandler = { _, path -> if (path.endsWith("/archive")) HttpStatusCode.OK to archived() else null } + assertTrue(accounts().setArchived(AccountId(ACCOUNT), true) is OnlineResult.Ok) + } + + @Test + fun rest_networkFailure_changesNothingLocally() = runSync { + val before = accounts().all().first() + h.server.failNetwork = true + assertEquals(OnlineResult.Failed("network", null), accounts().update(AccountId(ACCOUNT), "Nytt", mapOf(UserId(ME) to 5000, UserId(PARTNER) to 5000))) + assertEquals(before, accounts().all().first()) + } + + @Test + fun rest_success_storesTheReturnedRowAtOnce() = runSync { + h.server.restHandler = { r, path -> if (path == "/accounts/$ACCOUNT/archive" && r.method == HttpMethod.Post) HttpStatusCode.OK to archived() else null } + assertEquals(OnlineResult.Ok(Unit), accounts().setArchived(AccountId(ACCOUNT), true)) + assertTrue(accounts().all().first().single().archived) + } + + @Test + fun rest_errorCodeAndFieldAreReturned() = runSync { + h.server.restHandler = { _, path -> if (path == "/accounts") HttpStatusCode.Conflict to """{"code":"name_taken","message":"m","field":"name"}""" else null } + assertEquals(OnlineResult.Failed("name_taken", "name"), accounts().create("Vardag", mapOf(UserId(ME) to 5000, UserId(PARTNER) to 5000))) + } + + private fun archived(): String = KvittJson.encodeToString( + AccountDto.serializer(), + AccountDto(ACCOUNT, "Vardag", listOf(ShareBpDto(ME, 5000), ShareBpDto(PARTNER, 5000)), ME, FakeServer.T0, FakeServer.T0, "2026-10-17T10:00:00.000Z", null, 9_000), + ) + + @Test + fun expenseSave_writesAPendingRowWithAMutationIdAndTimestamps() = runSync { + val id = h.addExpense("ICA", amountOre = 999) + val row = h.db.expenses().get(id) + assertNotNull(row) + assertEquals(true, row?.pending) + assertNotNull(row?.pendingMutationId) + assertEquals(Timestamps.format(h.clock.now), row?.updatedAt) + assertEquals(row?.updatedAt, row?.createdAt) + assertEquals(h.clock.now, row?.localUpdatedAt) + assertNull(row?.serverVersion) + assertEquals(listOf(500L, 499L), h.db.expenses().splits(id).map { it.shareOre }) + assertEquals(1, h.localWrites) + + h.clock.advance(1000) + h.expenseRepo().delete(ExpenseId(id)) + val tomb = h.db.expenses().get(id) + assertEquals(Timestamps.format(h.clock.now), tomb?.deletedAt) + assertEquals(tomb?.deletedAt, tomb?.updatedAt) + assertEquals(2, h.localWrites) + assertNull("deleted expenses are not loadable", h.expenseRepo().load(ExpenseId(id))) + } + + @Test + fun expenseSave_reportsRequiredAndValidationErrors() = runSync { + val result = h.expenseRepo().save(draft("x".repeat(101)).copy(amountOre = null, categoryId = null)) + assertEquals( + SaveResult.Invalid(listOf(FieldError("amountOre", "required"), FieldError("categoryId", "required"), FieldError("description", "validation_failed"))), + result, + ) + val split = h.expenseRepo().save(draft("ICA").copy(mode = se.hagstedt.kvitt.model.SplitMode.RATIO, ratioBp = mapOf(UserId(ME) to 5000, UserId(PARTNER) to 4000))) + assertEquals(SaveResult.Invalid(listOf(FieldError("split", "bp_sum_not_10000"))), split) + } + + @Test + fun preview_usesTheAccountDefault() = runSync { + h.local.current.first { it?.accounts?.isNotEmpty() == true } + assertEquals(listOf(501L, 500L), h.expenseRepo().preview(draft("ICA", amountOre = 1001))?.map { it.shareOre }) + } + + @Test + fun settleStart_theUserWhoOwesPays_amountIsTheBalance() = runSync { + h.addExpense("ICA", amountOre = 1001) + h.local.snapshot.first { it.liveExpenses.isNotEmpty() } + val state = h.settlementRepo().start(null, null) + assertEquals(PARTNER, state.payer.id.value) + assertEquals(500L, state.amountOre) + assertEquals(listOf(500L), state.lines.map { it.amountOre }) + assertTrue(state.canConfirm) + val id = h.settlementRepo().save(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, state.amountOre, null, null, state.date, "Swish")).savedId() + assertEquals(true, h.db.settlements().get(id.value)?.pending) + assertEquals(0L, home().home().first { it.sync.pendingCount == 2 }.global?.netOre) + } + + @Test + fun accountLedger_settlementRowCarriesTheAllocationToThatAccount() = runSync { + h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000)) + h.server.settlements["s1"] = SettlementDto( + "s1", PARTNER, ME, 800, "2026-10-12", "", listOf(AllocationDto(ACCOUNT, 300), AllocationDto(OLDER, 500)), PARTNER, PARTNER, FakeServer.T0, FakeServer.T0, null, h.server.next(), + ) + h.engine.sync() + val detail = accounts().detail(AccountId(ACCOUNT)).first { it?.months?.isNotEmpty() == true } + val row = detail?.months?.single()?.rows?.single() as LedgerRow.SettlementRow + assertEquals(300L, row.amountOre) + assertEquals(2, row.accountCount) + assertFalse(detail.deletable) + } + + @Test + fun analytics_includePending_excludeDeleted() = runSync { + h.server.storeExpense(serverExpense("tomb", 7_000, deletedAt = "2026-10-11T00:00:00.000Z")) + h.engine.sync() + h.addExpense("ICA", amountOre = 1200) + val repo = AnalyticsRepositoryImpl(h.local, h.clock) + val filter = AnalyticsFilter(RangePreset.THIS_MONTH, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY) + val state = repo.analytics(filter).first { it.totalOre > 0 } + assertEquals(1200L, state.totalOre) + assertEquals(listOf(GROCERIES to 1200L), state.byCategory.map { it.first.id to it.second }) + val mine = repo.analytics(filter.copy(measure = Measure.MY_SHARE)).first() + assertEquals(600L, mine.totalOre) + val custom = repo.analytics(filter.copy(preset = RangePreset.CUSTOM, customStart = "2026-10-01")).first() + assertEquals("a missing custom end resolves as today (§14.2.4)", "2026-10-17", custom.end) + val drill = repo.drillDown(filter, "2026-10", CategoryId(GROCERIES), null).first() + assertEquals("groceries, 2026-10", drill.title) + assertEquals(1, drill.rows.size) + } + + @Test + fun syncStatus_listsPendingRowsAndRejections_andDismisses() = runSync { + val id = h.addExpense("ICA") + h.server.pushOverride = { request -> + val m = request.mutations.single() + se.hagstedt.kvitt.data.api.PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "reference_not_found", null))) + } + val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work) + assertEquals(1, repo.status().first { it.pending.isNotEmpty() }.summary.pendingCount) + repo.syncNow() + val after = repo.status().first { it.rejections.isNotEmpty() } + assertTrue(after.pending.isEmpty()) + val rejection = after.rejections.single() + assertEquals(SyncRejectionReason.REFERENCE_NOT_FOUND, rejection.reason) + assertEquals("ICA", rejection.summary) + assertEquals(1, after.summary.rejectedCount) + repo.dismiss(rejection.mutationId) + assertTrue(repo.status().first { it.rejections.isEmpty() }.rejections.isEmpty()) + } + + @Test + fun expenseDetail_timelineResolvesNames_andEndsWithThePendingLine() = runSync { + h.server.storeExpense(serverExpense("e1", 1000)) + h.server.audit += AuditEntryDto( + "a1", "expense", "e1", "create", "app", PARTNER, FakeServer.T0, FakeServer.T0, + listOf(AuditChangeDto("account_id", null, JsonPrimitive(ACCOUNT)), AuditChangeDto("amount_ore", null, JsonPrimitive(1000))), h.server.next(), + ) + h.engine.sync() + h.expenseRepo().save(draft("edited", amountOre = 1000, id = ExpenseId("e1"))).savedId() + val detail = h.expenseRepo().detail(ExpenseId("e1")).first { it?.pending == true } + val timeline = detail?.timeline.orEmpty() + assertEquals(2, timeline.size) + assertEquals("Anna", timeline[0].actor?.displayName) + assertEquals("Vardag", timeline[0].changes[0].newDisplay) + assertEquals("1000", timeline[0].changes[1].newDisplay) + assertTrue(timeline[1].pendingLocal) + assertEquals(AuditAction.UPDATE, timeline[1].action) + } + + private companion object { + const val OLDER = "0192a003-0000-7000-8000-000000000002" + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryTest.kt new file mode 100644 index 0000000..9f7d07e --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/SessionRepositoryTest.kt @@ -0,0 +1,137 @@ +package se.hagstedt.kvitt.data.repo + +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.first +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.Harness.Companion.ME +import se.hagstedt.kvitt.data.Harness.Companion.PARTNER +import se.hagstedt.kvitt.data.addExpense +import se.hagstedt.kvitt.data.api.AcceptInviteResponse +import se.hagstedt.kvitt.data.api.DeviceDto +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.auth.PairingStatus +import se.hagstedt.kvitt.data.runSync +import se.hagstedt.kvitt.data.sync.SyncTriggers +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.SessionState + +/** Pairing (AC-2.4, AC-2.7 to AC-2.9), revocation (AC-3.3) and re-pairing (§14.4.8). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class SessionRepositoryTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness() + h.seedServer() + } + + @After + fun tearDown() = h.close() + + private fun repo(debug: Boolean = false) = + SessionRepositoryImpl(h.credentials, h.db, h.api, h.gate, h.engine, SyncTriggers(h.work, h.engine, h.scope), h.wipe, h.clock, debug) + + private fun acceptAs(userId: String) { + val user = h.server.users.getValue(userId) + h.server.restHandler = { _, path -> + if (path == "/invites/accept") { + HttpStatusCode.OK to KvittJson.encodeToString( + AcceptInviteResponse.serializer(), + AcceptInviteResponse("kvt_new", user, DeviceDto("d2", userId, "Galaxy", 41, "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", null, true), h.server.users.values.toList()), + ) + } else { + null + } + } + } + + @Test + fun pair_storesCredentials_writesTheHousehold_andStartsAFullSync() = runSync { + acceptAs(ME) + val result = repo().pair("https://kvitt.test:8443/ ", "7k3m-qx9d-2hpa", "Jacob", "Galaxy S26") + assertEquals(ME, (result as OnlineResult.Ok).value.id.value) + val request = h.requests.single() + assertEquals("https://kvitt.test:8443/invites/accept", request.url.toString()) + val credentials = h.credentials.credentials() + assertEquals("kvt_new", credentials?.token) + assertEquals("https://kvitt.test:8443", credentials?.baseUrl) + assertEquals("d2", credentials?.deviceId) + val session = repo().session.first() + assertTrue(session is SessionState.Paired) + assertEquals(PARTNER, (session as SessionState.Paired).partner?.id?.value) + assertEquals(1, h.work.periodic) + assertEquals(listOf(false), h.work.oneOff) + } + + @Test + fun pair_normalisesTheCode_andRefusesBadInput_withoutARequest() = runSync { + assertEquals("7K3MQX9D2HPA", SessionRepositoryImpl.normaliseInviteCode(" 7k3m-qx9d 2hpa ")) + assertEquals("01100000000A", SessionRepositoryImpl.normaliseInviteCode("OIL0-0000-000A")) + assertNull(SessionRepositoryImpl.normaliseInviteCode("7K3M-QX9D-2HP")) + assertNull(SessionRepositoryImpl.normaliseInviteCode("7K3M-QX9D-2HPU")) + assertEquals(OnlineResult.Failed("invite_invalid", "code"), repo().pair("https://kvitt.test", "short", null, "Galaxy")) + assertEquals(OnlineResult.Failed("invalid_request", "server_url"), repo().pair("http://192.168.1.10:8080", "7K3MQX9D2HPA", null, "Galaxy")) + assertTrue(h.requests.isEmpty()) + } + + @Test + fun serverUrls_releaseHttpsOnly_debugAlsoLocalHttp() { + assertEquals("https://icehack.asuscomm.com:8443", SessionRepositoryImpl.normaliseServerUrl("https://icehack.asuscomm.com:8443/", debugBuild = false)) + assertNull(SessionRepositoryImpl.normaliseServerUrl("http://192.168.1.10:8080", debugBuild = false)) + for (ok in listOf("http://localhost:8080", "http://10.0.2.2:8080", "http://10.1.2.3", "http://172.16.0.1", "http://172.31.255.1", "http://192.168.1.10:8080")) { + assertEquals(ok, SessionRepositoryImpl.normaliseServerUrl(ok, debugBuild = true)) + } + for (bad in listOf("http://172.32.0.1", "http://8.8.8.8", "http://example.com", "ftp://x", "not a url")) { + assertNull(bad, SessionRepositoryImpl.normaliseServerUrl(bad, debugBuild = true)) + } + } + + @Test + fun rePairing_sameUser_keepsLocalData_andPushesEverythingAgain() = runSync { + h.pair() + h.engine.sync() + val id = h.addExpense("kept") + h.engine.sync() + h.credentials.markUnrecognised() + assertEquals(SessionState.Unrecognised, repo().session.first()) + acceptAs(ME) + assertTrue(repo().pair("https://kvitt.test", "7K3MQX9D2HPA", null, "Galaxy") is OnlineResult.Ok) + assertEquals(0, h.wipes) + assertEquals(true, h.db.expenses().get(id)?.pending) + assertEquals(0L, h.db.syncState().get()?.cursor) + } + + @Test + fun rePairing_otherUser_wipesFirst() = runSync { + h.pair() + h.engine.sync() + h.addExpense("someone else's") + h.credentials.markUnrecognised() + acceptAs(PARTNER) + assertTrue(repo().pair("https://kvitt.test", "7K3MQX9D2HPA", null, "Galaxy") is OnlineResult.Ok) + assertEquals(1, h.wipes) + assertTrue(h.db.expenses().all().isEmpty()) + assertEquals(PARTNER, h.credentials.credentials()?.userId) + } + + @Test + fun revoked_thenAcknowledged_isUnpaired() = runSync { + h.pair() + h.credentials.markRevoked() + assertEquals(SessionState.Revoked, repo().session.first()) + repo().acknowledgeRevoked() + assertEquals(SessionState.Unpaired, repo().session.first()) + assertEquals(PairingStatus.UNPAIRED, h.credentials.current().status) + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/OfflineFourteenDaysTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/OfflineFourteenDaysTest.kt new file mode 100644 index 0000000..0071d20 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/OfflineFourteenDaysTest.kt @@ -0,0 +1,113 @@ +package se.hagstedt.kvitt.data.sync + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.addExpense +import se.hagstedt.kvitt.data.editExpense +import se.hagstedt.kvitt.data.expenseRepo +import se.hagstedt.kvitt.data.runSync +import se.hagstedt.kvitt.data.settlementRepo +import se.hagstedt.kvitt.data.savedId +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.SettleRequest +import se.hagstedt.kvitt.model.UserId + +/** + * AC-15.6: a phone offline for 14 days with 200 local changes syncs all of them on reconnect. Scripted clock: the + * changes are spread over 14 days, every background attempt in between fails with a network error, then the phone + * reconnects. Nothing is lost: tokens do not expire, the cursor stays valid, every change reaches the server. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class OfflineFourteenDaysTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness().ready() + } + + @After + fun tearDown() = h.close() + + @Test + fun ac15_6_fourteenDaysOffline_twoHundredChanges_allSyncOnReconnect() = runSync { + val cursorBefore = h.db.syncState().get()?.cursor + h.server.failNetwork = true + h.monitor.state.value = false + val step = 14 * DAY / CHANGES + val created = mutableListOf() + val deleted = mutableSetOf() + val finalDescription = mutableMapOf() + var settlements = 0 + repeat(CHANGES) { i -> + h.clock.advance(step) + when { + i % 10 == 9 && created.size > deleted.size + 1 -> { + val victim = created.first { it !in deleted } + h.expenseRepo().delete(ExpenseId(victim)) + deleted += victim + } + i % 5 == 4 && created.isNotEmpty() -> { + val target = created.last { it !in deleted } + val text = "edited $i" + h.editExpense(target, text, amountOre = 1000L + i) + finalDescription[target] = text + } + i % 25 == 24 -> { + h.settlementRepo().save(SettleRequest(null, UserId(Harness.PARTNER), 500L + i, AccountId(Harness.ACCOUNT), null, "2026-10-16", "")).savedId() + settlements++ + } + else -> { + val id = h.addExpense("offline $i", amountOre = 1000L + i) + created += id + finalDescription[id] = "offline $i" + } + } + // A background attempt every few hours fails and keeps everything (§14.4.5 step 2). + if (i % 12 == 0) assertEquals(SyncOutcome.Retry("network"), h.engine.sync()) + } + val pending = h.db.expenses().pending(1000).size + h.db.settlements().pending(1000).size + assertEquals(created.size + settlements, pending) + assertTrue("no push reached the server while offline", h.server.pushes.isEmpty()) + + h.server.failNetwork = false + h.monitor.state.value = true + h.clock.advance(DAY) + assertEquals(SyncOutcome.Success, h.engine.sync()) + + assertEquals(0, h.db.expenses().pending(1000).size + h.db.settlements().pending(1000).size) + assertTrue("batches of at most 100", h.server.pushes.all { it.mutations.size <= SyncEngine.PUSH_BATCH }) + assertEquals(pending, h.server.pushes.sumOf { it.mutations.size }) + for (id in created) { + val stored = h.server.expenses[id] + assertNotNull("expense $id reached the server", stored) + if (id in deleted) { + assertNotNull("delete of $id reached the server", stored?.deletedAt) + } else { + assertNull(stored?.deletedAt) + assertEquals(finalDescription[id], stored?.description) + } + } + assertEquals(settlements, h.server.settlements.size) + assertTrue(h.db.rejections().all().isEmpty()) + assertEquals(h.server.seq, h.db.syncState().get()?.cursor) + assertTrue((h.db.syncState().get()?.cursor ?: 0) > (cursorBefore ?: 0)) + assertEquals(Harness.TOKEN, h.credentials.credentials()?.token) + } + + private companion object { + const val CHANGES = 200 + const val DAY = 24 * 60 * 60 * 1000L + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt new file mode 100644 index 0000000..f5419b8 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncEngineTest.kt @@ -0,0 +1,439 @@ +package se.hagstedt.kvitt.data.sync + +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import se.hagstedt.kvitt.data.runSync +import kotlinx.serialization.json.jsonObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.FakeServer +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT +import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES +import se.hagstedt.kvitt.data.Harness.Companion.ME +import se.hagstedt.kvitt.data.Harness.Companion.PARTNER +import se.hagstedt.kvitt.data.addExpense +import se.hagstedt.kvitt.data.api.AuditChangeDto +import se.hagstedt.kvitt.data.api.AuditEntryDto +import se.hagstedt.kvitt.data.api.ExpenseDto +import se.hagstedt.kvitt.data.api.PushResponse +import se.hagstedt.kvitt.data.api.ShareBpDto +import se.hagstedt.kvitt.data.api.SplitDto +import se.hagstedt.kvitt.data.asStoredExpense +import se.hagstedt.kvitt.data.auth.PairingStatus +import se.hagstedt.kvitt.data.editExpense +import se.hagstedt.kvitt.data.expenseRepo +import se.hagstedt.kvitt.data.json +import se.hagstedt.kvitt.data.util.Timestamps +import se.hagstedt.kvitt.model.ExpenseId +import kotlinx.serialization.json.JsonPrimitive + +/** Sync protocol v1, phone side (SPEC §14.4.2, §14.4.5, §14.4.8), against [FakeServer] behind Ktor's MockEngine. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class SyncEngineTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness() + } + + @After + fun tearDown() = h.close() + + private fun serverExpense(id: String, description: String, version: Long, deletedAt: String? = null, shares: Pair = 20600L to 20600L) = ExpenseDto( + id, ACCOUNT, GROCERIES, shares.first + shares.second, description, "", "2026-10-10", ME, "default", + listOf(SplitDto(ME, shares.first, 5000), SplitDto(PARTNER, shares.second, 5000)), null, null, ME, ME, + FakeServer.T0, FakeServer.T0, deletedAt, version, + ) + + // §14.4.2 pull + + @Test + fun pull_pagesUntilHasMoreFalse_thenStoresCursorEpochAndLastSuccess() = runSync { + h.seedServer() + h.server.maxPage = 2 + h.pair() + assertEquals(SyncOutcome.Success, h.engine.sync()) + assertEquals(listOf(0L, 2L, 4L), h.server.pulls) + assertEquals("500", h.requests.first { it.method.value == "GET" }.url.parameters["limit"]) + val state = h.db.syncState().get() + assertNotNull(state) + assertEquals(5L, state?.cursor) + assertEquals("epoch-1", state?.epoch) + assertEquals(Timestamps.format(h.clock.now), state?.lastSuccessAt) + assertEquals(2, h.db.users().all().size) + assertNotNull(h.db.accounts().get(ACCOUNT)) + } + + @Test + fun pull_storesExpensesWithTheirSplits() = runSync { + h.seedServer() + h.server.storeExpense(serverExpense("e1", "ICA", 0)) + h.pair() + h.engine.sync() + assertEquals("ICA", h.db.expenses().get("e1")?.description) + assertEquals(listOf(ME, PARTNER), h.db.expenses().splits("e1").map { it.userId }) + } + + @Test + fun pull_skipsRowsWhoseLocalServerVersionIsNotLower() = runSync { + h.ready() + val local = h.db.users().get(PARTNER) + assertNotNull(local) + h.db.users().upsert(requireNotNull(local).copy(displayName = "Local Anna", serverVersion = 10_000)) + h.server.users[PARTNER] = h.server.users.getValue(PARTNER).copy(displayName = "Server Anna", serverVersion = h.server.next()) + h.engine.sync() + assertEquals("Local Anna", h.db.users().get(PARTNER)?.displayName) + } + + @Test + fun pull_skipsPendingExpenses_thePushResolvesThem() = runSync { + h.ready() + h.server.storeExpense(serverExpense("e1", "server v1", 0)) + h.engine.sync() + h.editExpense("e1", "local edit") + h.server.storeExpense(serverExpense("e1", "server v2", 0)) + h.server.pushOverride = { PushResponse(Timestamps.format(h.clock.now), emptyList()) } + h.engine.sync() + val row = h.db.expenses().get("e1") + assertEquals("local edit", row?.description) + assertEquals(true, row?.pending) + assertEquals("a batch without progress ends the push loop", 1, h.server.pushes.size) + } + + @Test + fun pull_replacesChildRows() = runSync { + h.ready() + h.server.storeExpense(serverExpense("e1", "ICA", 0, shares = 500L to 500L)) + h.engine.sync() + h.server.storeExpense(serverExpense("e1", "ICA", 0, shares = 700L to 300L)) + h.server.accounts[ACCOUNT] = h.server.accounts.getValue(ACCOUNT).copy(defaultShares = listOf(ShareBpDto(ME, 6000), ShareBpDto(PARTNER, 4000)), serverVersion = h.server.next()) + h.engine.sync() + assertEquals(listOf(700L, 300L), h.db.expenses().splits("e1").map { it.shareOre }) + assertEquals(mapOf(ME to 6000, PARTNER to 4000), h.local.current.value?.account(ACCOUNT)?.defaultShares?.mapKeys { it.key.value }) + } + + @Test + fun pull_auditEntriesAreInsertOrIgnore() = runSync { + h.ready() + val entry = AuditEntryDto("a1", "expense", "e1", "create", "app", ME, FakeServer.T0, FakeServer.T0, listOf(AuditChangeDto("amount_ore", null, JsonPrimitive(100))), h.server.next()) + h.server.audit += entry + h.engine.sync() + h.server.audit[0] = entry.copy(action = "update", serverVersion = h.server.next()) + h.engine.sync() + assertEquals("create", h.db.audit().get("a1")?.action) + } + + @Test + fun pull_storesTombstones() = runSync { + h.ready() + h.server.storeExpense(serverExpense("e1", "gone", 0, deletedAt = "2026-10-02T08:00:00.000Z")) + h.engine.sync() + assertEquals("2026-10-02T08:00:00.000Z", h.db.expenses().get("e1")?.deletedAt) + } + + // §14.4.8 epoch recovery + + @Test + fun epochChange_discardsThePage_marksEverythingPending_pushesAll_thenPullsFromZero() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.engine.sync() + val before = h.db.expenses().get(id) + assertEquals(false, before?.pending) + h.server.pushes.clear() + h.server.pulls.clear() + h.server.currentEpoch = "epoch-2" + h.server.users[ME] = h.server.users.getValue(ME).copy(displayName = "from the new epoch page", serverVersion = h.server.next()) + + assertEquals(SyncOutcome.Success, h.engine.sync()) + assertEquals("the re-push carries every row", listOf(id), h.server.pushes.single().mutations.map { it.entity["id"].toString().trim('"') }) + assertEquals("updated_at is kept", before?.updatedAt, h.server.pushes.single().mutations.single().entity["updated_at"].toString().trim('"')) + assertEquals(listOf(h.server.pulls.first(), 0L), h.server.pulls.take(2)) + assertEquals("epoch-2", h.db.syncState().get()?.epoch) + assertEquals(false, h.db.expenses().get(id)?.pending) + assertTrue("an identical re-push is no rejection", h.db.rejections().all().isEmpty()) + assertEquals("from the new epoch page", h.db.users().get(ME)?.displayName) + } + + @Test + fun cursorAhead_recoversLikeANewEpoch() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.engine.sync() + h.db.syncState().upsert(requireNotNull(h.db.syncState().get()).copy(cursor = 999)) + h.server.pushes.clear() + h.server.pulls.clear() + assertEquals(SyncOutcome.Success, h.engine.sync()) + assertEquals(listOf(999L, 0L), h.server.pulls) + assertEquals(listOf(id), h.server.pushes.single().mutations.map { it.entity["id"].toString().trim('"') }) + assertTrue(h.db.syncState().get()?.cursor ?: 0 in 1..h.server.seq) + } + + // §14.4.5 push + + @Test + fun push_batchesOf100_oldestLocalChangeFirst_withExactlyTheClientWritableFields() = runSync { + h.ready() + val ids = (1..150).map { + h.clock.advance(1000) + h.addExpense("e$it") + } + assertEquals(SyncOutcome.Success, h.engine.sync()) + assertEquals(listOf(100, 50), h.server.pushes.map { it.mutations.size }) + assertEquals(ids, h.server.pushes.flatMap { p -> p.mutations.map { it.entity["id"].toString().trim('"') } }) + val keys = h.server.pushes.first().mutations.first().entity.keys + assertEquals( + setOf("id", "account_id", "category_id", "amount_ore", "description", "notes", "expense_date", "payer_user_id", "split_mode", "splits", "created_at", "updated_at", "deleted_at"), + keys, + ) + assertEquals("expense", h.server.pushes.first().mutations.first().entityType) + } + + @Test + fun push_mutationIdIsThePendingMutationId() = runSync { + h.ready() + val id = h.addExpense("ICA") + val mutationId = h.db.expenses().get(id)?.pendingMutationId + h.engine.sync() + assertEquals(mutationId, h.server.pushes.single().mutations.single().mutationId) + } + + @Test + fun push_accepted_overwritesTheRowAndClearsPending() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.engine.sync() + val row = h.db.expenses().get(id) + assertEquals(false, row?.pending) + assertNull(row?.pendingMutationId) + assertEquals(h.server.expenses.getValue(id).serverVersion, row?.serverVersion) + assertEquals(ME, row?.createdByUserId) + } + + @Test + fun push_accepted_whileEditedAgain_setsOnlyServerFieldsAndStaysPending() = runSync { + h.ready() + val id = h.addExpense("first") + var edited = false + h.server.beforePushResponse = { + if (!edited) { + edited = true + h.editExpense(id, "second") + } + } + // The second push makes no progress, so the state after the first one stays observable. + h.server.pushOverride = { _ -> if (h.server.pushes.size > 1) PushResponse(Timestamps.format(h.clock.now), emptyList()) else null } + h.engine.sync() + val row = h.db.expenses().get(id) + assertEquals(true, row?.pending) + assertEquals("second", row?.description) + assertEquals(h.server.expenses.getValue(id).serverVersion, row?.serverVersion) + assertEquals(ME, row?.createdByUserId) + assertEquals("second", h.server.pushes[1].mutations.single().entity["description"].toString().trim('"')) + assertEquals(row?.pendingMutationId, h.server.pushes[1].mutations.single().mutationId) + } + + @Test + fun push_rejectedWithEqualContent_isTreatedAsAccepted() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.server.pushOverride = { request -> + val m = request.mutations.single() + PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "stale", m.asStoredExpense(777).json()))) + } + h.engine.sync() + val row = h.db.expenses().get(id) + assertEquals(false, row?.pending) + assertEquals(777L, row?.serverVersion) + assertTrue(h.db.rejections().all().isEmpty()) + } + + @Test + fun push_rejectedStale_overwritesWithCurrentAndRecordsTheRejection() = runSync { + h.ready() + h.server.storeExpense(serverExpense("e1", "theirs", 0)) + h.engine.sync() + h.editExpense("e1", "mine") + val sentMutation = h.db.expenses().get("e1")?.pendingMutationId + h.server.pushOverride = { request -> + val m = request.mutations.single() + PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", "e1", "stale", serverExpense("e1", "newer theirs", 900).json()))) + } + h.engine.sync() + val row = h.db.expenses().get("e1") + assertEquals("newer theirs", row?.description) + assertEquals(false, row?.pending) + val rejection = h.db.rejections().all().single() + assertEquals(sentMutation, rejection.mutationId) + assertEquals("stale", rejection.reason) + assertEquals("expense", rejection.entityType) + assertEquals("mine", kotlinx.serialization.json.Json.parseToJsonElement(rejection.rejectedPayload).jsonObject["description"].toString().trim('"')) + assertNotNull(rejection.serverCurrent) + assertFalse(rejection.dismissed) + } + + @Test + fun push_rejectedWithoutCurrent_deletesTheLocalRowAndItsChildren() = runSync { + h.ready() + val id = h.addExpense("in a deleted account") + h.server.pushOverride = { request -> + val m = request.mutations.single() + PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "reference_not_found", null))) + } + h.engine.sync() + assertNull(h.db.expenses().get(id)) + assertTrue(h.db.expenses().splits(id).isEmpty()) + assertEquals("reference_not_found", h.db.rejections().all().single().reason) + assertNull(h.db.rejections().all().single().serverCurrent) + } + + @Test + fun push_rejected_whileEditedAgain_recordsOnly_andKeepsTheNewerEditPending() = runSync { + h.ready() + val id = h.addExpense("first") + h.server.pushOverride = { request -> + val m = request.mutations.single() + if (h.server.pushes.size == 1) { + PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "validation_failed", null))) + } else { + PushResponse(Timestamps.format(h.clock.now), emptyList()) + } + } + h.server.beforePushResponse = { if (h.server.pushes.size == 1) h.editExpense(id, "second") } + h.engine.sync() + val row = h.db.expenses().get(id) + assertEquals("second", row?.description) + assertEquals(true, row?.pending) + assertEquals(1, h.db.rejections().all().size) + } + + @Test + fun push_clockSkew_keepsPending_setsTheFlag_andStopsBeforePulling() = runSync { + h.ready() + h.clock.advance(HOUR) + val id = h.addExpense("from the future") + h.clock.advance(-HOUR) + assertEquals(SyncOutcome.Stopped(SyncEngine.CLOCK_SKEW), h.engine.sync()) + assertEquals(true, h.db.expenses().get(id)?.pending) + assertEquals(true, h.db.syncState().get()?.clockSkew) + assertTrue(h.server.pulls.isEmpty()) + assertTrue(h.db.rejections().all().isEmpty()) + val first = h.server.pushes.single().mutations.single().mutationId + assertNotEquals("a replayed clock_skew result would block the row forever", first, h.db.expenses().get(id)?.pendingMutationId) + + h.clock.advance(2 * HOUR) + assertEquals(SyncOutcome.Success, h.engine.sync()) + assertEquals(false, h.db.expenses().get(id)?.pending) + assertEquals(false, h.db.syncState().get()?.clockSkew) + } + + @Test + fun push_networkError_keepsEverythingPending_andAsksForRetry() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.server.failNetwork = true + assertEquals(SyncOutcome.Retry("network"), h.engine.sync()) + assertEquals(true, h.db.expenses().get(id)?.pending) + assertEquals("network", h.db.syncState().get()?.lastErrorCode) + assertFalse("§9.9: 30 s of Needs connection", h.connectivity.isOnlineNow()) + } + + @Test + fun push_serverError_asksForRetry() = runSync { + h.ready() + h.addExpense("ICA") + h.server.failStatus = HttpStatusCode.ServiceUnavailable to "unavailable" + assertEquals(SyncOutcome.Retry("unavailable"), h.engine.sync()) + assertEquals(1, h.db.expenses().pending(10).size) + } + + @Test + fun deviceRevoked_wipesTokenAndLocalData() = runSync { + h.ready() + h.addExpense("ICA") + h.server.failStatus = HttpStatusCode.Unauthorized to "device_revoked" + assertEquals(SyncOutcome.Stopped("device_revoked"), h.engine.sync()) + assertEquals(PairingStatus.REVOKED, h.credentials.current().status) + assertNull(h.credentials.credentials()) + assertEquals(1, h.wipes) + assertEquals(1, h.cipher.destroyed) + assertTrue(h.db.expenses().all().isEmpty()) + assertEquals(SyncOutcome.Skipped, h.engine.sync()) + } + + @Test + fun unauthorized_keepsData_andStopsSyncingUntilPairedAgain() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.server.failStatus = HttpStatusCode.Unauthorized to "unauthorized" + assertEquals(SyncOutcome.Stopped("unauthorized"), h.engine.sync()) + assertEquals(PairingStatus.UNRECOGNISED, h.credentials.current().status) + assertEquals(ME, h.credentials.current().userId) + assertEquals(0, h.wipes) + assertEquals(true, h.db.expenses().get(id)?.pending) + h.requests.clear() + assertEquals(SyncOutcome.Skipped, h.engine.sync()) + assertTrue(h.requests.isEmpty()) + } + + @Test + fun protocolUnsupported_stopsSyncingUntilUpdated() = runSync { + h.ready() + h.server.failStatus = HttpStatusCode.UpgradeRequired to "protocol_unsupported" + assertEquals(SyncOutcome.Stopped("protocol_unsupported"), h.engine.sync()) + assertTrue(h.prefs.protocolBlocked(Harness.APP_VERSION)) + assertFalse(h.prefs.protocolBlocked(Harness.APP_VERSION + 1)) + h.requests.clear() + assertEquals(SyncOutcome.Skipped, h.engine.sync()) + assertTrue(h.requests.isEmpty()) + } + + @Test + fun delete_pushesTheFullEntityWithDeletedAt_andKeepsTheTombstone() = runSync { + h.ready() + val id = h.addExpense("ICA") + h.engine.sync() + h.clock.advance(1000) + h.expenseRepo().delete(ExpenseId(id)) + h.server.pushes.clear() + h.engine.sync() + val entity = h.server.pushes.single().mutations.single().entity + assertEquals(Timestamps.format(h.clock.now), entity["deleted_at"].toString().trim('"')) + assertEquals(entity["deleted_at"], entity["updated_at"]) + assertEquals("ICA", entity["description"].toString().trim('"')) + val row = h.db.expenses().get(id) + assertNotNull(row?.deletedAt) + assertEquals(false, row?.pending) + assertNotNull(h.server.expenses.getValue(id).deletedAt) + } + + @Test + fun oneCycleAtATime() = runSync { + h.ready() + repeat(3) { h.addExpense("e$it") } + h.server.beforePushResponse = { delay(50) } + val runs = (1..4).map { async { h.engine.sync() } } + runs.forEach { assertNotEquals(SyncOutcome.Skipped, it.await()) } + assertEquals(1, h.server.maxInFlight) + assertFalse(h.engine.running.value) + assertTrue(h.engine.completed.value.count >= 4) + } + + private companion object { + const val HOUR = 60 * 60 * 1000L + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggersTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggersTest.kt new file mode 100644 index 0000000..1371f8e --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/sync/SyncTriggersTest.kt @@ -0,0 +1,78 @@ +package se.hagstedt.kvitt.data.sync + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.FakeSyncWork +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.runSync + +/** AC-15.3 and §14.4.9 triggers: start, foreground, 2 s debounce after local writes, FCM (expedited, 20 s wait). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class SyncTriggersTest { + private lateinit var h: Harness + + @Before + fun setUp() { + h = Harness().ready() + } + + @After + fun tearDown() = h.close() + + @Test + fun appStartEnsuresThePeriodicWork_andSyncsOnce() { + val work = FakeSyncWork() + SyncTriggers(work, h.engine, h.scope).onAppStart() + assertEquals(1, work.periodic) + assertEquals(listOf(false), work.oneOff) + } + + @OptIn(ExperimentalCoroutinesApi::class) // virtual time of kotlinx-coroutines-test + @Test + fun localWritesAreDebouncedByTwoSeconds() = runTest { + val work = FakeSyncWork() + val triggers = SyncTriggers(work, h.engine, this) + triggers.onLocalWrite() + advanceTimeBy(1_500) + triggers.onLocalWrite() + advanceTimeBy(1_999) + runCurrent() + assertTrue("still inside the window of the second write", work.oneOff.isEmpty()) + advanceTimeBy(2) + runCurrent() + assertEquals(listOf(false), work.oneOff) + } + + @Test + fun pushMessagesEnqueueExpeditedWork_andWaitForTheCycle() = runSync { + val work = object : SyncWork { + override fun enqueueOneOff(expedited: Boolean) { + assertTrue(expedited) + h.scope.launch { h.engine.sync() } + } + + override fun ensurePeriodic() = Unit + } + assertTrue(SyncTriggers(work, h.engine, h.scope).syncForPush()) + } + + @Test + fun pushWaitGivesUpAfterItsTimeout() = runSync { + val work = FakeSyncWork() + assertFalse(SyncTriggers(work, h.engine, h.scope).syncForPush(timeoutMillis = 100)) + assertEquals(listOf(true), work.oneOff) + } +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt new file mode 100644 index 0000000..bf58bba --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/update/UpdaterTest.kt @@ -0,0 +1,145 @@ +package se.hagstedt.kvitt.data.update + +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.first +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.api.ApkLatestDto +import se.hagstedt.kvitt.data.api.KvittJson +import se.hagstedt.kvitt.data.runSync +import se.hagstedt.kvitt.model.UpdateInfo +import se.hagstedt.kvitt.model.UpdateState +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest + +/** FR-16 / AC-16.2 to AC-16.5 with the `PackageInstaller` call behind [ApkInstaller]. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class UpdaterTest { + private lateinit var h: Harness + private lateinit var cache: File + private val installer = FakeInstaller() + private val apk = ByteArray(200_000) { (it % 251).toByte() } + + class FakeInstaller : ApkInstaller { + var allowed = true + val installed = mutableListOf() + + override fun canRequestPackageInstalls() = allowed + + override suspend fun install(apk: File) { + assertTrue(apk.exists()) + installed += apk + } + } + + @Before + fun setUp() { + h = Harness().ready() + cache = Files.createTempDirectory("kvitt-cache").toFile() + } + + @After + fun tearDown() { + h.close() + cache.deleteRecursively() + } + + private fun latest(version: Int, sha: String = sha256(apk), size: Long = apk.size.toLong()) = + ApkLatestDto(version, "2026.11.02-1a2b3c4", "kvitt-$version.apk", size, sha, "2026-11-02T19:40:00.000Z") + + private fun serve(dto: ApkLatestDto, body: ByteArray = apk) { + h.server.restHandler = { _, path -> if (path == "/apk/latest") HttpStatusCode.OK to KvittJson.encodeToString(ApkLatestDto.serializer(), dto) else null } + h.server.apkBody = body + } + + private fun apkFile(version: Int) = File(File(cache, UpdateRepositoryImpl.APK_DIR), "kvitt-$version.apk") + + private fun repo(debug: Boolean = false) = UpdateRepositoryImpl(h.api, cache, installer, h.prefs, h.clock, Harness.APP_VERSION, debug) + + @Test + fun newerVersionIsAvailable_sameIsNone() = runSync { + serve(latest(Harness.APP_VERSION + 1)) + val repo = repo() + repo.check() + assertEquals(UpdateState.Available(UpdateInfo(Harness.APP_VERSION + 1, "2026.11.02-1a2b3c4", apk.size.toLong())), repo.state.first()) + val other = repo() + serve(latest(Harness.APP_VERSION)) + other.check() + assertEquals(UpdateState.None, other.state.first()) + } + + @Test + fun checksAtMostEverySixHours() = runSync { + serve(latest(Harness.APP_VERSION + 1)) + val repo = repo() + repo.check() + repo.check() + assertEquals(1, h.requests.count { it.url.encodedPath == "/apk/latest" }) + h.clock.advance(UpdateRepositoryImpl.CHECK_INTERVAL_MILLIS) + repo.check() + assertEquals(2, h.requests.count { it.url.encodedPath == "/apk/latest" }) + } + + @Test + fun debugBuildsNeverCheck() = runSync { + serve(latest(Harness.APP_VERSION + 1)) + repo(debug = true).check() + assertTrue(h.requests.isEmpty()) + } + + @Test + fun verifiedDownloadIsInstalled() = runSync { + serve(latest(Harness.APP_VERSION + 1)) + val repo = repo() + repo.check() + repo.install() + assertEquals(apkFile(Harness.APP_VERSION + 1), installer.installed.single()) + assertTrue(apk.contentEquals(installer.installed.single().readBytes())) + } + + @Test + fun shaMismatchDeletesTheFile_andFails() = runSync { + serve(latest(Harness.APP_VERSION + 1, sha = "0".repeat(64))) + val repo = repo() + repo.check() + repo.install() + assertTrue(repo.state.first() is UpdateState.Failed) + assertTrue(installer.installed.isEmpty()) + assertFalse(apkFile(Harness.APP_VERSION + 1).exists()) + } + + @Test + fun sizeMismatchDeletesTheFile_andFails() = runSync { + serve(latest(Harness.APP_VERSION + 1), body = apk.copyOf(apk.size - 1)) + val repo = repo() + repo.check() + repo.install() + assertTrue(repo.state.first() is UpdateState.Failed) + assertFalse(apkFile(Harness.APP_VERSION + 1).exists()) + } + + @Test + fun theInstallPermissionIsExplainedOnceBeforeTheFirstInstall() = runSync { + installer.allowed = false + serve(latest(Harness.APP_VERSION + 1)) + val repo = repo() + repo.check() + repo.install() + assertTrue(repo.state.first() is UpdateState.NeedsInstallPermission) + assertTrue(installer.installed.isEmpty()) + repo.install() + assertEquals(1, installer.installed.size) + } + + private fun sha256(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it.toInt() and 0xFF) } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 4116eff..d4496d1 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -62,6 +62,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" } junit = { module = "junit:junit", version.ref = "junit" }