M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
@@ -26,10 +26,11 @@ data class AcceptInviteRequest(
@SerialName("display_name") val displayName: String? = null,
)
/** E-3 response. */
/** E-3 response. `invite_kind`: the accepted invite's kind (`admin_setup`, `partner`, `device`). */
@Serializable
data class AcceptInviteResponse(
val token: String,
@SerialName("invite_kind") val inviteKind: String,
val user: UserDto,
val device: DeviceDto,
val household: List<UserDto>,
@@ -32,13 +32,16 @@ data class ApiTarget(val baseUrl: String, val token: String?)
interface ApiClientInfo {
val appVersionCode: Int
/** The `versionName`, for `X-Kvitt-App-Version-Name` (schema 3, S-20). */
val appVersionName: String
/** 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
* `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` 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.
*/
@@ -179,6 +182,7 @@ class KvittApi(
private fun HttpRequestBuilder.commonHeaders() {
header(PROTOCOL_HEADER, PROTOCOL_VERSION)
header(APP_VERSION_HEADER, info.appVersionCode.toString())
versionNameHeader(info.appVersionName)?.let { header(APP_VERSION_NAME_HEADER, it) }
header(HttpHeaders.AcceptLanguage, info.acceptLanguage())
}
@@ -228,6 +232,12 @@ class KvittApi(
const val PROTOCOL_HEADER = "X-Kvitt-Protocol"
const val PROTOCOL_VERSION = "1"
const val APP_VERSION_HEADER = "X-Kvitt-App-Version"
const val APP_VERSION_NAME_HEADER = "X-Kvitt-App-Version-Name"
private const val VERSION_NAME_MAX = 32
/** The header value, or null when [name] is blank, longer than 32 or not printable ASCII (the server would ignore it). */
fun versionNameHeader(name: String): String? =
name.trim().takeIf { it.length in 1..VERSION_NAME_MAX && it.all { c -> c in ' '..'~' } }
const val SERVER_VERSION_HEADER = "X-Kvitt-Server-Version"
}
}
@@ -8,16 +8,19 @@ import io.ktor.http.HttpHeaders
class PictureAuthConfig {
var target: suspend () -> ApiTarget? = { null }
var appVersionCode: Int = 0
var appVersionName: String = ""
}
val PictureAuth: ClientPlugin<PictureAuthConfig> = createClientPlugin("KvittPictureAuth", ::PictureAuthConfig) {
val target = pluginConfig.target
val versionCode = pluginConfig.appVersionCode
val versionName = KvittApi.versionNameHeader(pluginConfig.appVersionName)
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())
versionName?.let { request.headers.append(KvittApi.APP_VERSION_NAME_HEADER, it) }
t.token?.let { request.headers.append(HttpHeaders.Authorization, "Bearer $it") }
}
}
@@ -24,6 +24,7 @@ data class DeviceDto(
@SerialName("user_id") val userId: String,
val name: String,
@SerialName("app_version_code") val appVersionCode: Int?,
@SerialName("app_version_name") val appVersionName: String?,
@SerialName("created_at") val createdAt: String,
@SerialName("last_seen_at") val lastSeenAt: String,
@SerialName("revoked_at") val revokedAt: String?,
@@ -114,6 +114,13 @@ interface ExpenseDao {
@Query("SELECT * FROM expenses WHERE pending = 1 ORDER BY local_updated_at, id LIMIT :limit")
suspend fun pending(limit: Int): List<ExpenseEntity>
/** D-027: pending rows stamped later than [now] (by a clock that ran ahead) take [now]; a tombstone's `deleted_at` too. */
@Query(
"UPDATE expenses SET updated_at = :now, deleted_at = CASE WHEN deleted_at > :now THEN :now ELSE deleted_at END " +
"WHERE pending = 1 AND updated_at > :now",
)
suspend fun restampFuturePending(now: String): Int
@Query("SELECT COUNT(*) FROM expenses WHERE pending = 1")
fun observePendingCount(): Flow<Int>
@@ -173,6 +180,13 @@ interface SettlementDao {
@Query("SELECT * FROM settlements WHERE pending = 1 ORDER BY local_updated_at, id LIMIT :limit")
suspend fun pending(limit: Int): List<SettlementEntity>
/** D-027, as for expenses. */
@Query(
"UPDATE settlements SET updated_at = :now, deleted_at = CASE WHEN deleted_at > :now THEN :now ELSE deleted_at END " +
"WHERE pending = 1 AND updated_at > :now",
)
suspend fun restampFuturePending(now: String): Int
@Query("SELECT COUNT(*) FROM settlements WHERE pending = 1")
fun observePendingCount(): Flow<Int>
@@ -156,6 +156,7 @@ object DataModule {
install(PictureAuth) {
target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }
appVersionCode = build.versionCode
appVersionName = build.versionName
}
}
return Pictures.imageLoader(context, client) { base.value }
@@ -180,6 +181,7 @@ object DataModule {
target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } },
info = object : ApiClientInfo {
override val appVersionCode: Int = build.versionCode
override val appVersionName: String = build.versionName
override fun acceptLanguage(): String = Locale.getDefault().toLanguageTag()
},
events = session,
@@ -11,6 +11,7 @@ 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.db.SyncStateEntity
import se.hagstedt.kvitt.data.sync.LocalRows
import se.hagstedt.kvitt.data.sync.SyncEngine
import se.hagstedt.kvitt.data.util.Clock
@@ -46,8 +47,24 @@ class SyncSummaries(db: KvittDatabase, engine: SyncEngine) {
rejectedCount = rejections.size,
clockSkew = state?.clockSkew ?: false,
running = running,
lastAttemptFailed = lastAttemptFailed(state, running),
)
}.distinctUntilChanged()
companion object {
/**
* The last finished cycle failed: it started (`last_attempt_at`) after the last success, or none succeeded yet,
* and no cycle runs now. Every cycle stamps `last_attempt_at` when it starts and `last_success_at` when it ends
* well, so a later attempt than success means it ended badly (network, 5xx, 401, 426, a failed pull or a
* `clock_skew` stop). Both are fixed-form RFC 3339 instants (§14.3.3), compared as text.
*/
fun lastAttemptFailed(state: SyncStateEntity?, running: Boolean): Boolean {
val attempt = state?.lastAttemptAt ?: return false
if (running) return false
val success = state.lastSuccessAt ?: return true
return attempt > success
}
}
}
internal fun UpdateState.info(): UpdateInfo? = when (this) {
@@ -20,10 +20,11 @@ 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.InviteKind
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.Pairing
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). */
@@ -57,7 +58,7 @@ class SessionRepositoryImpl(
}
}.filterNotNull().distinctUntilChanged()
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> {
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() })
@@ -70,7 +71,7 @@ class SessionRepositoryImpl(
// §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()
Pairing(response.user.toEntity(clock.nowMillis()).toModel(), inviteKindOf(response.inviteKind))
}
}
@@ -84,6 +85,9 @@ class SessionRepositoryImpl(
private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
private const val CODE_LENGTH = 12
/** E-3 `invite_kind`; a value this app does not know is read as `device`, which leads straight to Home. */
fun inviteKindOf(wire: String): InviteKind = InviteKind.entries.firstOrNull { it.name.lowercase() == wire } ?: InviteKind.DEVICE
/** §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() }
@@ -48,6 +48,7 @@ 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.NewRecurringTemplate
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
@@ -144,8 +145,9 @@ class RecurringRepositoryImpl(
s.liveTemplates.sortedWith(compareBy({ it.row.createdAt }, { it.row.id })).map { it.toModel() }
}.distinctUntilChanged()
override suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId> {
val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.missing()
/** E-28 has no id in its body: the server mints the template id, which comes back here. */
override suspend fun create(t: NewRecurringTemplate): OnlineResult<TemplateId> {
val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.draft.missing()
return gate.run({ api.createTemplate(body) }) {
rows.writeTemplateIfNewer(it)
TemplateId(it.id)
@@ -153,7 +155,8 @@ class RecurringRepositoryImpl(
}
override suspend fun update(t: RecurringTemplate): OnlineResult<Unit> {
val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.missing()
val fields = NewRecurringTemplate(t.draft, t.dayOfMonth, t.startPeriod, t.endPeriod, t.paused)
val body = fields.toWire(local.snapshot.first().household.map { it.id }) ?: return t.draft.missing()
val patch = PatchTemplateRequest(
accountId = body.accountId,
categoryId = body.categoryId,
@@ -173,16 +176,16 @@ class RecurringRepositoryImpl(
override suspend fun delete(id: TemplateId): OnlineResult<Unit> = 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")
private fun ExpenseDraft.missing(): OnlineResult.Failed = when {
accountId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "account_id")
categoryId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "category_id")
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<UserId>): CreateTemplateRequest? {
private fun NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
val accountId = draft.accountId ?: return null
val categoryId = draft.categoryId ?: return null
val amount = draft.amountOre ?: return null
@@ -254,7 +257,7 @@ class DeviceRepositoryImpl(private val local: LocalData, private val api: KvittA
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)
return Device(DeviceId(id), user, name, createdAt, lastSeenAt, appVersionCode, appVersionName, isCurrent, revokedAt != null)
}
}
@@ -294,7 +297,7 @@ class SyncRepositoryImpl(
SyncStatusState(
summary = summary,
pending = rows.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }.mapNotNull { it.second },
rejections = rejections.mapNotNull { it.toModel() },
rejections = rejections.mapNotNull { it.toModel(s) },
)
}.distinctUntilChanged()
@@ -308,21 +311,25 @@ class SyncRepositoryImpl(
/**
* 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).
* amount in öre when the note is empty); the screen adds the explanation for the reason (§14.4.6). `actor` is the
* `updated_by_user_id` of the server's `current` row stored with the rejection (§14.4.3), resolved to a household user.
*/
private fun SyncRejectionEntity.toModel(): Rejection? {
internal fun SyncRejectionEntity.toModel(s: Snapshot): 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 payload = jsonObjectOrNull(rejectedPayload)
val actor = serverCurrent?.let(::jsonObjectOrNull)?.get("updated_by_user_id")?.jsonPrimitive?.contentOrNull?.let(s::user)
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)
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor)
}
private fun jsonObjectOrNull(text: String): JsonObject? = try {
Json.parseToJsonElement(text) as? JsonObject
} catch (e: kotlinx.serialization.SerializationException) {
null
}
/** S-22 (FR-16 versions, the paired server and the server's version header). */
@@ -26,6 +26,7 @@ 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.UntouchedAccount
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.model.money.AccountPosition
import se.hagstedt.kvitt.model.money.Allocation
@@ -171,5 +172,14 @@ internal fun Snapshot.settleState(request: SettleRequest): SettleState {
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)
// §9.4 info line (D-074): accounts the payer is owed in that the rows leave out; none for a per-account settle-up.
val untouched = if (fixed != null) {
emptyList()
} else {
accounts.mapNotNull { account ->
val net = balances.net(account.id.value, request.payer)
UntouchedAccount(account, net).takeIf { net > 0 && lines.none { it.account.id == account.id } }
}
}
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note, untouched)
}
@@ -85,6 +85,7 @@ class SyncEngine(
private suspend fun cycle(): SyncOutcome {
updateState { it.copy(lastAttemptAt = clock.nowTimestamp()) }
if (state().clockSkew) restampAfterClockSkew()
var restarts = 0
while (true) {
val push = pushAll()
@@ -218,6 +219,20 @@ class SyncEngine(
* (§14.4.4 steps 1 and 12), so the row gets a new mutation id; otherwise it could never sync after the clock is
* fixed (proposed decision in the M5 report). A row edited during the request already has a new one.
*/
/**
* D-027 (§14.4.5 step 3): on the first attempt after a `clock_skew` rejection, every pending row stamped later than
* the phone's clock now is re-stamped to now, so a corrected clock heals it. Rows stamped by a clock that is still
* ahead are not later than now and stay as they are (they are refused again until the clock is fixed). The mutation
* id was already renewed when the rejection arrived.
*/
private suspend fun restampAfterClockSkew() {
val now = clock.nowTimestamp()
db.withWriteTransaction {
db.expenses().restampFuturePending(now)
db.settlements().restampFuturePending(now)
}
}
private suspend fun renewMutationId(pending: Pending) {
val sent = pending.mutation.mutationId
when (pending.entityType) {
@@ -20,6 +20,7 @@ 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.UpdateCheck
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
@@ -75,12 +76,17 @@ class UpdateRepositoryImpl(
private var latest: ApkLatestDto? = null
private var lastCheckAt: Long? = null
override suspend fun check() {
if (debugBuild) return
/**
* Within 6 hours of the last check that reached the server the stored answer is reported without a request. A 404
* (nothing published) is "up to date"; another error or no answer is [UpdateCheck.Failed] and leaves [state]
* unchanged (no answer also lets the next call try again at once).
*/
override suspend fun check(): UpdateCheck {
if (debugBuild) return UpdateCheck.UpToDate
val now = clock.nowMillis()
lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return }
lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return known() }
lastCheckAt = now
when (val result = api.apkLatest()) {
return when (val result = api.apkLatest()) {
is ApiResult.Ok -> {
val dto = result.value
if (dto.versionCode > installedVersionCode && FILE_NAME.matches(dto.fileName)) {
@@ -90,15 +96,24 @@ class UpdateRepositoryImpl(
latest = null
mutable.value = UpdateState.None
}
known()
}
is ApiResult.Failure -> if (result.code == ErrorCodes.NOT_FOUND) {
latest = null
mutable.value = UpdateState.None
UpdateCheck.UpToDate
} else {
UpdateCheck.Failed
}
is ApiResult.NetworkError, ApiResult.NotPaired -> {
lastCheckAt = null
UpdateCheck.Failed
}
is ApiResult.NetworkError, ApiResult.NotPaired -> lastCheckAt = null
}
}
private fun known(): UpdateCheck = latest?.let { UpdateCheck.Available(it.info()) } ?: UpdateCheck.UpToDate
override suspend fun install() {
val dto = latest ?: return
val info = dto.info()