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:
@@ -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). */
|
||||
|
||||
+11
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user