From 8c607153c0e807798ddfabc7220cb150e74986cd Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 15:00:50 +0200 Subject: [PATCH] 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=, 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../fcm/{PushEvent.kt => PushMapping.kt} | 16 +-- .../kvitt/adapter/fcm/PushMappingTest.kt | 1 + .../kotlin/se/hagstedt/kvitt/link/AppLinks.kt | 13 +- .../kotlin/se/hagstedt/kvitt/nav/KvittApp.kt | 58 +++------ .../hagstedt/kvitt/push/NotificationTexts.kt | 2 +- .../se/hagstedt/kvitt/push/Notifications.kt | 2 +- .../se/hagstedt/kvitt/push/PushHandler.kt | 2 +- .../kotlin/se/hagstedt/kvitt/push/PushWork.kt | 2 +- .../se/hagstedt/kvitt/link/AppLinksTest.kt | 13 ++ .../kvitt/push/NotificationTextsTest.kt | 2 +- .../kvitt/design/catalogue/Catalogue.kt | 2 +- .../kvitt/design/catalogue/Samples.kt | 4 +- .../hagstedt/kvitt/design/component/Sync.kt | 18 +-- .../design/component/ComponentLogicTest.kt | 4 +- .../kvitt/fakes/FakeHouseholdRepositories.kt | 10 +- .../se/hagstedt/kvitt/fakes/FakeScenario.kt | 5 + .../se/hagstedt/kvitt/fakes/FakeScenarios.kt | 2 +- .../kvitt/fakes/FakeSettingsRepositories.kt | 23 ++-- .../kvitt/fakes/FakeSettlementRepository.kt | 11 +- .../kvitt/fakes/home/HomeScenarios.kt | 10 +- .../fakes/onboarding/ScriptedRepositories.kt | 3 +- .../settings/ScriptedSettingsRepositories.kt | 11 +- .../kvitt/fakes/settings/SettingsScenarios.kt | 6 +- .../kvitt/fakes/FakeEditorCategoriesTest.kt | 8 +- .../kotlin/se/hagstedt/kvitt/model/Home.kt | 3 +- .../kotlin/se/hagstedt/kvitt/model/Push.kt | 16 +++ .../kotlin/se/hagstedt/kvitt/model/Session.kt | 5 +- .../se/hagstedt/kvitt/model/Settings.kt | 8 +- .../se/hagstedt/kvitt/model/Settlements.kt | 9 +- .../kotlin/se/hagstedt/kvitt/model/Sync.kt | 19 ++- .../se/hagstedt/kvitt/data/api/Endpoints.kt | 3 +- .../se/hagstedt/kvitt/data/api/KvittApi.kt | 12 +- .../se/hagstedt/kvitt/data/api/PictureAuth.kt | 3 + .../se/hagstedt/kvitt/data/api/WireObjects.kt | 1 + .../kotlin/se/hagstedt/kvitt/data/db/Daos.kt | 14 +++ .../se/hagstedt/kvitt/data/di/DataModule.kt | 2 + .../kvitt/data/repo/HouseholdRepositories.kt | 17 +++ .../kvitt/data/repo/SessionRepositoryImpl.kt | 10 +- .../kvitt/data/repo/SettingsRepositories.kt | 43 ++++--- .../data/repo/SettlementRepositoryImpl.kt | 12 +- .../se/hagstedt/kvitt/data/sync/SyncEngine.kt | 15 +++ .../se/hagstedt/kvitt/data/update/Updater.kt | 25 +++- .../kotlin/se/hagstedt/kvitt/data/Harness.kt | 2 + .../hagstedt/kvitt/data/api/KvittApiTest.kt | 10 ++ .../kvitt/data/repo/RepositoriesTest.kt | 66 +++++++++++ .../kvitt/data/repo/SessionRepositoryTest.kt | 17 ++- .../kvitt/data/sync/SyncEngineTest.kt | 32 +++++ .../hagstedt/kvitt/data/update/UpdaterTest.kt | 25 +++- .../feature/account/AccountDetailScreen.kt | 7 +- .../feature/analytics/DrillDownScreen.kt | 11 +- .../feature/analytics/AnalyticsRulesTest.kt | 4 +- .../feature/expense/ExpenseDetailScreen.kt | 25 +++- .../feature/expense/ExpenseEditorScreen.kt | 27 +++-- .../kvitt/feature/home/SyncStatusScreen.kt | 22 ++-- .../kvitt/feature/home/SyncStatusViewModel.kt | 10 +- .../kvitt/feature/home/HomeScreenshotTest.kt | 13 +- .../kvitt/feature/home/HomeViewModelTest.kt | 3 +- .../kvitt/feature/onboarding/InviteLink.kt | 43 +++++-- .../kvitt/feature/onboarding/PairingScreen.kt | 19 ++- .../feature/onboarding/PairingViewModel.kt | 16 ++- .../feature/onboarding/InviteLinkTest.kt | 15 +++ .../onboarding/PairingViewModelTest.kt | 11 ++ .../onboarding/screenshot/OnboardingStates.kt | 8 ++ .../feature/settings/about/AboutViewModel.kt | 15 ++- .../feature/settings/devices/DevicesScreen.kt | 2 +- .../recurring/RecurringEditorViewModel.kt | 10 +- .../settings/recurring/RecurringForm.kt | 19 ++- .../DevicesInvitesAboutScreenshotTest.kt | 4 + .../kvitt/feature/settle/SettleViewModel.kt | 33 +++--- .../feature/settle/SettlementDetailScreen.kt | 8 +- contracts/examples/E-1-health/response.json | 2 +- .../examples/E-12-create-invite/response.json | 2 +- .../examples/E-15-list-devices/response.json | 3 + .../examples/E-3-invites-accept/response.json | 2 + contracts/examples/E-7-me/response.json | 1 + contracts/openapi.yaml | 58 ++++++++- .../migrations/0003_device_version_name.sql | 9 ++ server/crates/kvitt-import/tests/import.rs | 5 +- .../migrations/0003_device_version_name.sql | 9 ++ server/crates/kvitt-server/src/api/invites.rs | 38 ++++-- server/crates/kvitt-server/src/api/landing.rs | 82 +++++++++++-- server/crates/kvitt-server/src/api/rows.rs | 4 +- server/crates/kvitt-server/src/auth.rs | 22 +++- .../crates/kvitt-server/src/cli/bootstrap.rs | 2 +- server/crates/kvitt-server/src/db/mod.rs | 5 +- server/crates/kvitt-server/src/openapi.rs | 15 ++- server/crates/kvitt-server/src/router.rs | 4 +- server/crates/kvitt-server/src/wire/device.rs | 3 + server/crates/kvitt-server/src/wire/invite.rs | 4 +- .../kvitt-server/tests/core_api/devices.rs | 4 +- .../kvitt-server/tests/core_api/invites.rs | 11 +- .../kvitt-server/tests/core_api/landing.rs | 19 +++ .../crates/kvitt-server/tests/core_api/me.rs | 3 + .../kvitt-server/tests/skeleton/migration.rs | 112 ++++++++++++++++-- .../kvitt-server/tests/skeleton/routes.rs | 42 ++++++- .../kvitt-server/tests/skeleton/support.rs | 5 + 96 files changed, 1090 insertions(+), 298 deletions(-) rename android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/{PushEvent.kt => PushMapping.kt} (65%) create mode 100644 android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Push.kt create mode 100644 contracts/schema/migrations/0003_device_version_name.sql create mode 100644 server/crates/kvitt-server/migrations/0003_device_version_name.sql diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMapping.kt similarity index 65% rename from android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt rename to android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMapping.kt index 9324d90..b868100 100644 --- a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt +++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMapping.kt @@ -1,20 +1,6 @@ package se.hagstedt.kvitt.adapter.fcm -/** - * One push of payload v1 (SPEC §14.6), as the project's own record. [type] is kept as the open string the server sent - * so `:app` can treat an unknown type as "sync only" (§14.6 phone step 4). Empty [entityType] and [entityId] for - * `sync_batch`. No personal content ever reaches this record (AC-13.8). - * - * SPEC §6.2 places `PushEvent` in `:core:model`; it is not there at the M8 start, so it lives here until a contract - * change moves it (contract request in the M8 report). - */ -data class PushEvent( - val type: String, - val entityType: String, - val entityId: String, - val actorUserId: String, - val count: Int, -) +import se.hagstedt.kvitt.model.PushEvent /** What `:app` does with FCM: handle a push, and register this instance's id (AC-13.7). Bound by `:app` through Hilt. */ interface PushListener { diff --git a/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt b/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt index 040cc3d..cd0be62 100644 --- a/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt +++ b/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt @@ -3,6 +3,7 @@ package se.hagstedt.kvitt.adapter.fcm import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import se.hagstedt.kvitt.model.PushEvent /** SPEC §14.6: the data map of payload v1 to [PushEvent]; other versions are "sync only" (null). */ class PushMappingTest { diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt index 6a7e130..26bd92a 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt @@ -14,16 +14,19 @@ sealed interface OpenTarget { /** What an intent asks of the app. */ sealed interface AppLink { - /** An invite link (AC-2.6): S-1 with both fields filled. [serverUrl] null when the link names none. */ - data class Invite(val serverUrl: String?, val code: String) : AppLink + /** + * An invite link (AC-2.6): S-1 with both fields filled. [serverUrl] null when the link names none; [adminName] the + * inviting admin from the link's `from` parameter, null in links without one. + */ + data class Invite(val serverUrl: String?, val code: String, val adminName: String? = null) : AppLink /** A notification tap. */ data class Open(val target: OpenTarget) : AppLink } /** - * Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…` and - * `https://icehack.asuscomm.com:8443/i/` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader + * Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and + * `https://icehack.asuscomm.com:8443/i/[?from=…]` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader * S-1 uses for pasted links), and the notification taps this app builds ([openIntent]). */ object AppLinks { @@ -46,7 +49,7 @@ object AppLinks { /** An invite link, or null for anything else (a landing link with a bad code, another path, another scheme). */ fun parseUri(uri: String?): AppLink.Invite? { val text = uri?.trim()?.takeIf { it.startsWith("kvitt:", ignoreCase = true) || it.startsWith("https:", ignoreCase = true) } - return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code) } + return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code, it.adminName) } } fun parseOpen(kind: String?, id: String?): AppLink.Open? = when (kind) { diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt index 0c4c835..bcf8e70 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt @@ -9,12 +9,6 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable @@ -23,11 +17,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder @@ -40,13 +31,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import se.hagstedt.kvitt.AppViewModel import se.hagstedt.kvitt.KvittEmptyScreen -import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.component.AvatarPictures import se.hagstedt.kvitt.design.component.KvittClock import se.hagstedt.kvitt.design.component.KvittDestination import se.hagstedt.kvitt.design.component.KvittHousehold -import se.hagstedt.kvitt.design.component.KvittSnackbar -import se.hagstedt.kvitt.design.component.KvittSnackbarVisuals import se.hagstedt.kvitt.design.component.showKvittSnackbar import se.hagstedt.kvitt.design.component.LocalAvatarPictures import se.hagstedt.kvitt.design.component.LocalKvittClock @@ -159,22 +147,13 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck } } - Box(Modifier.fillMaxSize()) { - NavHost(nav, startDestination = start) { - onboarding(viewModel, shell) - topLevel(session, shell) - accountsAndExpenses(viewModel, shell) - settings(shell, updateCheckAvailable) - } - // Home shows result snackbars in its own scaffold; every other screen gets them here. - if (route != Routes.HOME) { - SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().imePadding()) { data -> - val visuals = data.visuals - val message = if (visuals is KvittSnackbarVisuals) stringResource(visuals.text, *visuals.args.toTypedArray()) else visuals.message - val action = if (visuals is KvittSnackbarVisuals) visuals.action?.let { stringResource(it) } else visuals.actionLabel - KvittSnackbar(message, Modifier.padding(KvittTheme.spacing.s4), action, { data.performAction() }) - } - } + // Result snackbars are shown in the scaffold of the screen returned to (Home, S-5, S-8, S-10, S-12, S-16), which + // each host [snackbar] (D-071). + NavHost(nav, startDestination = start) { + onboarding(viewModel, shell) + topLevel(session, shell) + accountsAndExpenses(viewModel, shell) + settings(shell, updateCheckAvailable) } } @@ -210,12 +189,6 @@ private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState, nav.popBackStack() } - /** A snackbar text a feature resolved itself (S-7, S-8 hand over a String). */ - fun show(message: String?) { - if (message == null) return - scope.launch { snackbar.showSnackbar(message, withDismissAction = false) } - } - /** `:app`'s own snackbars, resolved from resources while they show (language-safe, `showKvittSnackbar`). */ fun show(@StringRes text: Int, args: List = emptyList(), @StringRes action: Int? = null, onAction: () -> Unit = {}) { scope.launch { @@ -235,6 +208,7 @@ private fun NavGraphBuilder.onboarding(viewModel: AppViewModel, shell: Shell) { }, initialServerUrl = invite?.serverUrl, initialCode = invite?.code, + initialAdminName = invite?.adminName, ) } composable(Routes.PROFILE_STEP) { @@ -270,7 +244,7 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) { AnalyticsScreen(onOpenDrillDown = { shell.nav.navigate(Routes.drillDown(it)) }, onNavigate = shell::top) } composable(Routes.DRILL_DOWN, arguments = Routes.drillDownArgs) { - DrillDownScreen(onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onBack = shell::back) + DrillDownScreen(onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onBack = shell::back, snackbarHostState = shell.snackbar) } composable(Routes.SETTINGS) { SettingsHubScreen( @@ -289,6 +263,7 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) { onBack = shell::back, onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onOpenSettlement = { shell.nav.navigate(Routes.settlement(it)) }, + snackbarHostState = shell.snackbar, ) } } @@ -308,6 +283,7 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell: shell.show(AccountR.string.account_archived_snackbar, listOf(name), DesignR.string.common_undo) { viewModel.undoArchive(id) } }, onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) }, + snackbarHostState = shell.snackbar, ) } composable(Routes.ACCOUNT_EDITOR, arguments = Routes.accountEditorArgs) { entry -> @@ -325,12 +301,12 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell: onClose = shell::back, onSaved = { _, text -> shell.back() - shell.show(text) + text?.let { shell.show(it) } }, - onDeleted = { text -> + onDeleted = { // Deleting from the editor also closes the detail it was opened from. if (!shell.nav.popBackStack(Routes.EXPENSE, inclusive = true)) shell.back() - shell.show(text) + shell.show(DesignR.string.snackbar_expense_deleted) }, ) } @@ -338,10 +314,11 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell: ExpenseDetailScreen( onBack = shell::back, onEdit = { shell.nav.navigate(Routes.expenseEditor(id = it)) }, - onDeleted = { text -> + onDeleted = { shell.back() - shell.show(text) + shell.show(DesignR.string.snackbar_expense_deleted) }, + snackbarHostState = shell.snackbar, ) } composable(Routes.SETTLE, arguments = Routes.settleArgs) { @@ -361,6 +338,7 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell: shell.back() shell.show(DesignR.string.snackbar_payment_deleted) }, + snackbarHostState = shell.snackbar, ) } } diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt index 55d858d..d06537b 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt @@ -1,7 +1,7 @@ package se.hagstedt.kvitt.push import android.content.res.Resources -import se.hagstedt.kvitt.adapter.fcm.PushEvent +import se.hagstedt.kvitt.model.PushEvent import se.hagstedt.kvitt.design.format.KvittLanguage import se.hagstedt.kvitt.design.format.MoneyFormatter import se.hagstedt.kvitt.link.OpenTarget diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt index 7d8d986..9f6f601 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt @@ -11,7 +11,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first -import se.hagstedt.kvitt.adapter.fcm.PushEvent +import se.hagstedt.kvitt.model.PushEvent import se.hagstedt.kvitt.link.AppLinks import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.model.ExpenseRepository diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt index fa9f86b..0015c13 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import se.hagstedt.kvitt.adapter.fcm.FcmRegistration -import se.hagstedt.kvitt.adapter.fcm.PushEvent +import se.hagstedt.kvitt.model.PushEvent import se.hagstedt.kvitt.adapter.fcm.PushListener import se.hagstedt.kvitt.data.api.ApiResult import se.hagstedt.kvitt.data.api.KvittApi diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt index addd414..314c09d 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt @@ -9,7 +9,7 @@ import androidx.work.WorkManager import androidx.work.WorkerFactory import androidx.work.WorkerParameters import androidx.work.workDataOf -import se.hagstedt.kvitt.adapter.fcm.PushEvent +import se.hagstedt.kvitt.model.PushEvent import se.hagstedt.kvitt.data.sync.SyncTriggers /** diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt index a603b95..faa9363 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt @@ -33,6 +33,19 @@ class AppLinksTest { assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), link) } + @Test + fun linksWithTheAdminNameCarryIt_andOldLinksStillParse() { + assertEquals( + AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Åsa Berg"), + AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN?from=%C3%85sa%20Berg"), + ) + assertEquals( + AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Jacob"), + AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=ABCDEFGHJKMN&from=Jacob"), + ) + assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN")?.adminName) + } + @Test fun otherLinksAreIgnored() { assertNull(AppLinks.parseUri(null)) diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt index fb187f3..23b6edf 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt @@ -10,7 +10,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config -import se.hagstedt.kvitt.adapter.fcm.PushEvent +import se.hagstedt.kvitt.model.PushEvent import se.hagstedt.kvitt.link.OpenTarget import se.hagstedt.kvitt.model.Role import se.hagstedt.kvitt.model.User diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt index 97ead2f..0516e5a 100644 --- a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt @@ -277,7 +277,7 @@ fun SyncPillSheet() { SyncStatusPill(Samples.pendingSummary, online = false, onClick = {}) SyncStatusPill(Samples.rejectedSummary, online = true, onClick = {}) SyncStatusPill(Samples.clockSummary, online = true, onClick = {}) - SyncStatusPill(Samples.failedSummary, online = true, onClick = {}, failed = true) + SyncStatusPill(Samples.failedSummary, online = true, onClick = {}) } @Composable diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt index 5074c23..bfc1433 100644 --- a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt @@ -94,12 +94,12 @@ object Samples { val gotlandDetail = AccountDetailState(gotland, BalanceView(0, anna), emptyList(), deletable = false, users = listOf(jacob, anna)) val husetDetail = AccountDetailState(huset, BalanceView(-308_450, anna), emptyList(), deletable = false, users = listOf(jacob, anna)) - val syncedSummary = SyncSummary("2026-10-17T11:59:00.000Z", pendingCount = 0, rejectedCount = 0, clockSkew = false, running = false) + val syncedSummary = SyncSummary("2026-10-17T11:59:00.000Z", pendingCount = 0, rejectedCount = 0, clockSkew = false, running = false, lastAttemptFailed = false) val pendingSummary = syncedSummary.copy(pendingCount = 2) val runningSummary = syncedSummary.copy(running = true) val rejectedSummary = syncedSummary.copy(rejectedCount = 1) val clockSummary = syncedSummary.copy(clockSkew = true) - val failedSummary = syncedSummary.copy(lastSuccessAt = "2026-10-17T09:30:00.000Z") + val failedSummary = syncedSummary.copy(lastSuccessAt = "2026-10-17T09:30:00.000Z", lastAttemptFailed = true) val update = UpdateInfo(versionCode = 12, versionName = "1.2", sizeBytes = 8_400_000) diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt index 4500133..333b8d9 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt @@ -47,14 +47,14 @@ import kotlin.time.Instant enum class SyncPillVariant { Synced, Running, Offline, Rejected, ClockWrong, Failed } /** - * The pill's variant: the clock problem and refused changes win, then offline, a failed last attempt ([failed]: the - * repositories do not expose it yet, see the M6 report), running, else synced. + * The pill's variant: the clock problem and refused changes win, then offline, a failed last attempt + * (`SyncSummary.lastAttemptFailed`), running, else synced. */ -fun syncPillVariant(summary: SyncSummary, online: Boolean, failed: Boolean = false): SyncPillVariant = when { +fun syncPillVariant(summary: SyncSummary, online: Boolean): SyncPillVariant = when { summary.clockSkew -> SyncPillVariant.ClockWrong summary.rejectedCount > 0 -> SyncPillVariant.Rejected !online -> SyncPillVariant.Offline - failed -> SyncPillVariant.Failed + summary.lastAttemptFailed -> SyncPillVariant.Failed summary.running || summary.lastSuccessAt == null -> SyncPillVariant.Running else -> SyncPillVariant.Synced } @@ -75,10 +75,10 @@ fun relativeTime(rfc3339: String): String { /** The visible text of the sync line, also used by S-13's "Synkstatus" subtitle. */ @Composable @ReadOnlyComposable -fun syncLineText(summary: SyncSummary, online: Boolean, failed: Boolean = false): String { +fun syncLineText(summary: SyncSummary, online: Boolean): String { val dot = stringResource(R.string.common_separator_dot) val pending = if (summary.pendingCount > 0) pluralStringResource(R.plurals.sync_pending, summary.pendingCount, summary.pendingCount) else null - return when (syncPillVariant(summary, online, failed)) { + return when (syncPillVariant(summary, online)) { SyncPillVariant.ClockWrong -> stringResource(R.string.sync_clock_wrong_short) SyncPillVariant.Rejected -> pluralStringResource(R.plurals.sync_rejected, summary.rejectedCount, summary.rejectedCount) SyncPillVariant.Offline -> listOfNotNull(stringResource(R.string.common_offline), pending).joinToString(dot) @@ -93,11 +93,11 @@ fun syncLineText(summary: SyncSummary, online: Boolean, failed: Boolean = false) * The caller refreshes it every minute by recomposing with the clock. */ @Composable -fun SyncStatusPill(summary: SyncSummary, online: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, failed: Boolean = false) { +fun SyncStatusPill(summary: SyncSummary, online: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { val colors = KvittTheme.colors val scheme = MaterialTheme.colorScheme - val variant = syncPillVariant(summary, online, failed) - val text = syncLineText(summary, online, failed) + val variant = syncPillVariant(summary, online) + val text = syncLineText(summary, online) val (fill, icon, iconTint) = when (variant) { SyncPillVariant.Synced -> Triple(scheme.surfaceContainerHigh, KvittIcon.CloudDone, colors.owed) SyncPillVariant.Running -> Triple(scheme.surfaceContainerHigh, KvittIcon.Sync, scheme.onSurfaceVariant) diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt index b4ab5f8..a617dc8 100644 --- a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt @@ -97,13 +97,13 @@ class ComponentLogicTest { @Test fun syncPillVariants() { - val synced = SyncSummary("2026-10-17T11:59:00.000Z", 0, 0, clockSkew = false, running = false) + val synced = SyncSummary("2026-10-17T11:59:00.000Z", 0, 0, clockSkew = false, running = false, lastAttemptFailed = false) assertEquals(SyncPillVariant.Synced, syncPillVariant(synced, online = true)) assertEquals(SyncPillVariant.Running, syncPillVariant(synced.copy(running = true), online = true)) assertEquals(SyncPillVariant.Offline, syncPillVariant(synced.copy(pendingCount = 2), online = false)) assertEquals(SyncPillVariant.Rejected, syncPillVariant(synced.copy(rejectedCount = 1), online = false)) assertEquals(SyncPillVariant.ClockWrong, syncPillVariant(synced.copy(clockSkew = true, rejectedCount = 1), online = true)) - assertEquals(SyncPillVariant.Failed, syncPillVariant(synced, online = true, failed = true)) + assertEquals(SyncPillVariant.Failed, syncPillVariant(synced.copy(lastAttemptFailed = true), online = true)) } @Test 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 4cc69d1..7c57c14 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 @@ -9,25 +9,28 @@ 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.InviteKind import se.hagstedt.kvitt.model.LedgerMonth import se.hagstedt.kvitt.model.LedgerRow import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.Pairing +import se.hagstedt.kvitt.model.Role import se.hagstedt.kvitt.model.SessionRepository import se.hagstedt.kvitt.model.SessionState import se.hagstedt.kvitt.model.SyncSummary import se.hagstedt.kvitt.model.UpdateInfo import se.hagstedt.kvitt.model.UpdateState -import se.hagstedt.kvitt.model.User import se.hagstedt.kvitt.model.UserId import se.hagstedt.kvitt.domain.BP_TOTAL class FakeSessionRepository(private val store: FakeStore) : SessionRepository { override val session: Flow = store.derive { it.session } - override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult = + override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult = store.online { s -> store.edit { it.copy(session = SessionState.Paired(s.me, s.partner, s.deviceId)) } - OnlineResult.Ok(s.me) + val kind = s.pairingInviteKind ?: if (s.me.role == Role.MEMBER) InviteKind.PARTNER else InviteKind.ADMIN_SETUP + OnlineResult.Ok(Pairing(s.me, kind)) } override suspend fun acknowledgeRevoked() = store.edit { it.copy(session = SessionState.Unpaired) } @@ -39,6 +42,7 @@ internal fun FakeScenario.syncSummary(): SyncSummary = SyncSummary( rejectedCount = rejections.size, clockSkew = clockSkew, running = syncRunning, + lastAttemptFailed = lastAttemptFailed && !syncRunning, ) internal fun UpdateState.info(): UpdateInfo? = when (this) { diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt index 33770e2..85caaf3 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt @@ -9,6 +9,7 @@ import se.hagstedt.kvitt.model.Device import se.hagstedt.kvitt.model.DeviceId import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.model.Invite +import se.hagstedt.kvitt.model.InviteKind import se.hagstedt.kvitt.model.RecurringTemplate import se.hagstedt.kvitt.model.Rejection import se.hagstedt.kvitt.model.SessionState @@ -63,6 +64,8 @@ data class FakeSettlement( * @property today the phone's local date (`YYYY-MM-DD`); [now] the phone's clock in the fixed RFC 3339 form. * @property defaultAccountId the account last used on this phone (AC-8.6); [lastCategoryFor] the category last used * per account on this phone. + * @property lastAttemptFailed the last sync cycle failed (`SyncSummary.lastAttemptFailed`, false while one runs). + * @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin. */ data class FakeScenario( val me: User, @@ -87,6 +90,8 @@ data class FakeScenario( val now: String, val defaultAccountId: AccountId?, val lastCategoryFor: Map, + val lastAttemptFailed: Boolean = false, + val pairingInviteKind: InviteKind? = null, ) { val users: List get() = listOfNotNull(me, partner) } diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt index d627928..fe54155 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt @@ -63,7 +63,7 @@ object FakeScenarios { settlements = emptyList(), templates = emptyList(), devices = listOf( - Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, current = true, revoked = false), + Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, "1.0", current = true, revoked = false), ), invites = emptyList(), rejections = emptyList(), diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt index 2a45184..8c8ae5a 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeSettingsRepositories.kt @@ -26,12 +26,14 @@ 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.NewRecurringTemplate import se.hagstedt.kvitt.model.RecurringRepository import se.hagstedt.kvitt.model.RecurringTemplate import se.hagstedt.kvitt.model.Role import se.hagstedt.kvitt.model.SyncRepository import se.hagstedt.kvitt.model.SyncStatusState import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.UpdateCheck import se.hagstedt.kvitt.model.UpdateRepository import se.hagstedt.kvitt.model.UpdateState import se.hagstedt.kvitt.model.User @@ -124,10 +126,11 @@ class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository class FakeRecurringRepository(private val store: FakeStore) : RecurringRepository { override fun all(): Flow> = store.derive { it.templates } - override suspend fun create(t: RecurringTemplate): OnlineResult = store.online { s -> - s.validateTemplate(t) ?: run { - val id = TemplateId(store.newId()) - val created = t.copy(id = id, nextOccurrence = s.nextOccurrence(t)) + override suspend fun create(t: NewRecurringTemplate): OnlineResult = store.online { s -> + val id = TemplateId(store.newId()) + val template = RecurringTemplate(id, t.draft, t.dayOfMonth, t.startPeriod, t.endPeriod, t.paused, nextOccurrence = null) + s.validateTemplate(template) ?: run { + val created = template.copy(nextOccurrence = s.nextOccurrence(template)) store.edit { it.copy(templates = it.templates + created) } OnlineResult.Ok(id) } @@ -272,11 +275,17 @@ class FakeSyncRepository(private val store: FakeStore) : SyncRepository { override suspend fun dismiss(mutationId: String) = store.edit { s -> s.copy(rejections = s.rejections.filter { it.mutationId != mutationId }) } } -/** The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, AC-16.5). */ -class FakeUpdateRepository(store: FakeStore) : UpdateRepository { +/** + * The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, + * AC-16.5). `check` reports the scenario's state: `Available` as found, anything else as up to date. + */ +class FakeUpdateRepository(private val store: FakeStore) : UpdateRepository { override val state: Flow = store.derive { it.update } - override suspend fun check() = Unit + override suspend fun check(): UpdateCheck = when (val update = store.current.update) { + is UpdateState.Available -> UpdateCheck.Available(update.info) + else -> UpdateCheck.UpToDate + } override suspend fun install() = Unit } 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 bfbf9c0..6d79a1a 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 @@ -13,6 +13,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.User import se.hagstedt.kvitt.model.money.AccountPosition import se.hagstedt.kvitt.model.money.Allocation @@ -129,6 +130,14 @@ private fun FakeScenario.settleState(request: SettleRequest): SettleState { val valid = amount != null && validateSettlement( amount, payer.id.value, recipient.id.value, users.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) + val untouched = if (fixed != null) { + emptyList() + } else { + accountsInOrder().mapNotNull { account -> + val net = balances.net(account.id, 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) } diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt index 5d31036..0bd6579 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/home/HomeScenarios.kt @@ -120,7 +120,7 @@ object HomeScenarios { ) /** A `stale` rejection of Anna's newer Babette expense (S-16 loaded). */ - val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z") + val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z", FakeScenarios.partner) /** S-16 loaded: two changes waiting and one `stale` rejection. */ val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection)) @@ -180,10 +180,10 @@ object HomeScenarios { val allRejections: FakeScenario = pending.copy( rejections = listOf( staleRejection, - Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z"), - Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z"), - Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z"), - Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z"), + Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z", null), + Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z", null), + Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z", null), + Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z", null), ), ) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt index 7689e49..70e6561 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt @@ -11,6 +11,7 @@ import se.hagstedt.kvitt.model.InviteId import se.hagstedt.kvitt.model.InviteKind import se.hagstedt.kvitt.model.InviteRepository import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.Pairing import se.hagstedt.kvitt.model.ProfileRepository import se.hagstedt.kvitt.model.SessionRepository import se.hagstedt.kvitt.model.SessionState @@ -46,7 +47,7 @@ class ScriptedSessionRepository(private val store: FakeStore, var pairOutcome: C override val session: Flow = delegate.session - override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult { + override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult { pairCalls += PairCall(serverUrl, code, displayName, deviceName) return store.scripted(pairOutcome) { delegate.pair(serverUrl, code, displayName, deviceName) } } diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt index b856e0a..1ec9ecd 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt @@ -15,9 +15,11 @@ import se.hagstedt.kvitt.model.InviteKind import se.hagstedt.kvitt.model.InviteRepository import se.hagstedt.kvitt.model.OnlineResult import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.NewRecurringTemplate import se.hagstedt.kvitt.model.RecurringRepository import se.hagstedt.kvitt.model.RecurringTemplate import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.UpdateCheck import se.hagstedt.kvitt.model.UpdateRepository import se.hagstedt.kvitt.model.UpdateState import se.hagstedt.kvitt.model.UserId @@ -65,7 +67,7 @@ class ScriptedSettingsRepositories(scenario: FakeScenario) { } val recurring: RecurringRepository = object : RecurringRepository by fakes.recurring { - override suspend fun create(t: RecurringTemplate) = script.run { fakes.recurring.create(t) } + override suspend fun create(t: NewRecurringTemplate) = script.run { fakes.recurring.create(t) } override suspend fun update(t: RecurringTemplate) = script.run { fakes.recurring.update(t) } override suspend fun delete(id: TemplateId) = script.run { fakes.recurring.delete(id) } } @@ -89,12 +91,17 @@ class ScriptedSettingsRepositories(scenario: FakeScenario) { /** `check` of the fake leaves the state unchanged; [afterCheck] is the state a check finds (S-22 result states). */ var afterCheck: UpdateState? = null + /** A check that cannot reach the server: `Failed`, the state unchanged. */ + var checkFails: Boolean = false + val update: UpdateRepository = object : UpdateRepository { override val state = fakes.update.state - override suspend fun check() { + override suspend fun check(): UpdateCheck { if (script.hang) awaitCancellation() + if (checkFails) return UpdateCheck.Failed afterCheck?.let { found -> fakes.store.edit { it.copy(update = found) } } + return fakes.update.check() } override suspend fun install() = fakes.update.install() diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt index bb14b76..e4ee692 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt @@ -97,9 +97,9 @@ object SettingsScenarios { ) private val devices = listOf( - Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, current = true, revoked = false), - Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, current = false, revoked = false), - Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, current = false, revoked = true), + Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, "1.1", current = true, revoked = false), + Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, "1.1", current = false, revoked = false), + Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, "1.0", current = false, revoked = true), ) private val pendingDeviceInvite = Invite(InviteId("01920000-0000-7000-8000-0000000000aa"), InviteKind.DEVICE, anna.id, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z") diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt index 3a9bb45..10cfce3 100644 --- a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeEditorCategoriesTest.kt @@ -13,10 +13,9 @@ import se.hagstedt.kvitt.model.DefaultCategoryKey import se.hagstedt.kvitt.model.ExpenseDraft import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.model.OnlineResult -import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.NewRecurringTemplate import se.hagstedt.kvitt.model.SaveResult import se.hagstedt.kvitt.model.SplitMode -import se.hagstedt.kvitt.model.TemplateId /** UX-9 in the fakes: `frequentCategoryIds` and `deletable` follow the same rule as `:data`. */ class FakeEditorCategoriesTest { @@ -72,10 +71,9 @@ class FakeEditorCategoriesTest { assertFalse(fakes.categories.deletable(pant).first()) fakes.expenses.delete(e) assertTrue(fakes.categories.deletable(pant).first()) - val template = RecurringTemplate( - TemplateId("t"), + val template = NewRecurringTemplate( ExpenseDraft(null, vardag, pant, 10_000, "Pant", "", FakeScenarios.empty.today, FakeScenarios.owner.id, SplitMode.DEFAULT, null, null, null), - 1, "2026-10", null, paused = false, nextOccurrence = null, + 1, "2026-10", null, paused = false, ) val created = (fakes.recurring.create(template) as OnlineResult.Ok).value assertFalse(fakes.categories.deletable(pant).first()) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt index dc19b9f..7f2f9cd 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Home.kt @@ -9,7 +9,8 @@ data class BalanceView(val netOre: Long, val partner: User) data class AccountSummary(val account: Account, val netOre: Long, val expensesThisMonth: Int) -data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean) +/** [lastAttemptFailed]: the last finished sync cycle did not succeed (network, server error, refused); false while one runs. */ +data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean, val lastAttemptFailed: Boolean) data class HomeState(val me: User, val partner: User?, val global: BalanceView?, val active: List, val archived: List, val sync: SyncSummary, val update: UpdateInfo?) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Push.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Push.kt new file mode 100644 index 0000000..2b3d9a4 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Push.kt @@ -0,0 +1,16 @@ +package se.hagstedt.kvitt.model + +// Push payload v1 as the project's own record (SPEC §6.2, §14.6); `:adapter:fcm` maps FCM data messages into it. + +/** + * One push of payload v1. [type] is kept as the open string the server sent so `:app` can treat an unknown type as + * "sync only" (§14.6 phone step 4). Empty [entityType] and [entityId] for `sync_batch`. No personal content ever reaches + * this record (AC-13.8). + */ +data class PushEvent( + val type: String, + val entityType: String, + val entityId: String, + val actorUserId: String, + val count: Int, +) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt index 9b5809a..4449022 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Session.kt @@ -11,8 +11,11 @@ sealed interface SessionState { data object Unrecognised : SessionState // 401 unauthorized: pair again, data kept (§14.4.8) } +/** A successful pairing: the paired user and the kind of the accepted invite (E-3); S-2 follows only [InviteKind.PARTNER] (AC-2.10). */ +data class Pairing(val user: User, val inviteKind: InviteKind) + interface SessionRepository { val session: Flow - suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult + suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult suspend fun acknowledgeRevoked() // wipes local data (AC-3.3) and moves to Unpaired } diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt index 6fe1360..a67a811 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settings.kt @@ -29,14 +29,18 @@ interface CategoryRepository { data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?) +/** A template to create: the repository (the server, E-28) mints the id; the next occurrence comes back through sync. */ +data class NewRecurringTemplate(val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean) + interface RecurringRepository { fun all(): Flow> - suspend fun create(t: RecurringTemplate): OnlineResult + suspend fun create(t: NewRecurringTemplate): OnlineResult suspend fun update(t: RecurringTemplate): OnlineResult suspend fun delete(id: TemplateId): OnlineResult } -data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val current: Boolean, val revoked: Boolean) +/** [appVersionName]: the `versionName` the device last reported ("1.1"), null until it has (schema 3). */ +data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val appVersionName: String?, val current: Boolean, val revoked: Boolean) interface DeviceRepository { suspend fun list(): OnlineResult> diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt index e48e9e6..fd0aba2 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Settlements.kt @@ -6,7 +6,14 @@ import kotlinx.coroutines.flow.Flow data class AllocationLine(val account: Account, val payerNetOre: Long, val amountOre: Long, val overSettles: Boolean) -data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String) +/** An account the settlement leaves as it is although the payer is owed there ([payerNetOre] > 0, §9.4 info line). */ +data class UntouchedAccount(val account: Account, val payerNetOre: Long) + +/** + * [untouched]: accounts not in [lines] where the payer is owed, nets without the settlement being edited (AC-11.6), + * in `AccountRepository.all()` order; empty for a per-account settle-up (D-074). + */ +data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String, val untouched: List) data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map?, val date: String, val note: String) diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt index c9766ad..1cd5e87 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Sync.kt @@ -4,7 +4,11 @@ import kotlinx.coroutines.flow.Flow // Sync status, update, about and connectivity (S-16, S-17, S-22, §9.9), SPEC §14.2.4. -data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String) +/** + * [actor]: who made the server's current version (its `updated_by_user_id`, the "" of S-16's stale copy, §14.4.6); + * null when the server had no row or the user is unknown. + */ +data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String, val actor: User?) data class SyncStatusState(val summary: SyncSummary, val pending: List, val rejections: List) @@ -24,9 +28,20 @@ sealed interface UpdateState { data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState } +/** The outcome of [UpdateRepository.check] (S-22, AC-16.6). */ +sealed interface UpdateCheck { + /** No newer APK is published (or nothing is published); debug builds, which never check (AC-16.5), report this too. */ + data object UpToDate : UpdateCheck + + data class Available(val info: UpdateInfo) : UpdateCheck + + /** The server could not be reached or answered with an error; [UpdateRepository.state] is unchanged. */ + data object Failed : UpdateCheck +} + interface UpdateRepository { val state: Flow - suspend fun check() + suspend fun check(): UpdateCheck suspend fun install() } 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 index 55961dc..ee74427 100644 --- 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 @@ -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, 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 index fd820f8..8a06781 100644 --- 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 @@ -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" } } 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 index c70642c..57db78a 100644 --- 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 @@ -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 = 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") } } } 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 index 2c68c84..6ddc6ed 100644 --- 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 @@ -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?, 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 index 1fa4fb6..0b238d8 100644 --- 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 @@ -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 + /** 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 @@ -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 + /** 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 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 index ddbd13f..fbf5d5f 100644 --- 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 @@ -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, 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 index 7b358a2..8a18e7d 100644 --- 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 @@ -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) { 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 index 1c4c9cf..7c5cc35 100644 --- 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 @@ -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 { + 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() }) @@ -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() } 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 index d52a860..40cdd7f 100644 --- 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 @@ -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 { - 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 { + 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 { - 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 = 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): CreateTemplateRequest? { +private fun NewRecurringTemplate.toWire(household: List): 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). */ 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 index e983d21..9e9e89f 100644 --- 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 @@ -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) } 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 index 6ff912e..57fd7d8 100644 --- 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 @@ -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) { 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 index 0e199d5..24a0df0 100644 --- 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 @@ -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() 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 index 7272b93..baca736 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt @@ -144,6 +144,7 @@ class Harness { { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }, object : ApiClientInfo { override val appVersionCode = APP_VERSION + override val appVersionName = APP_VERSION_NAME override fun acceptLanguage() = "sv-SE" }, session, @@ -186,6 +187,7 @@ class Harness { companion object { const val APP_VERSION = 41 + const val APP_VERSION_NAME = "1.1" const val BASE_URL = "https://kvitt.test:8443" const val TOKEN = "kvt_testtoken0000000000000000000000000000000000" const val ME = "0192a000-0000-7000-8000-000000000001" 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 index 305e06d..34bd147 100644 --- 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 @@ -40,12 +40,22 @@ class KvittApiTest { for (r in h.requests) { assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER]) assertEquals(Harness.APP_VERSION.toString(), r.headers[KvittApi.APP_VERSION_HEADER]) + assertEquals(Harness.APP_VERSION_NAME, r.headers[KvittApi.APP_VERSION_NAME_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 versionNameHeaderIsSentOnlyWhenTheServerWouldStoreIt() { + assertEquals("1.1", KvittApi.versionNameHeader(" 1.1 ")) + assertEquals("dev", KvittApi.versionNameHeader("dev")) + assertNull(KvittApi.versionNameHeader(" ")) + assertNull(KvittApi.versionNameHeader("9".repeat(33))) + assertNull(KvittApi.versionNameHeader("1.1-å")) + } + @Test fun acceptInviteIsUnauthenticated() = runSync { h.server.restHandler = { _, path -> if (path == "/invites/accept") HttpStatusCode.BadRequest to """{"code":"invite_invalid","message":"x","field":null}""" else null } 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 index 49ca6e4..0dfadd6 100644 --- 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 @@ -5,6 +5,8 @@ import io.ktor.http.HttpStatusCode import kotlinx.coroutines.flow.first import kotlinx.serialization.json.JsonPrimitive import org.junit.After +import se.hagstedt.kvitt.data.json +import se.hagstedt.kvitt.data.sync.SyncEngine import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -214,6 +216,29 @@ class RepositoriesTest { assertEquals(0L, home().home().first { it.sync.pendingCount == 2 }.global?.netOre) } + @Test + fun settleState_listsTheAccountsTheSettlementLeavesUntouched() = runSync { + h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-09-01T00:00:00.000Z") + // Vardag: Anna owes me 1001. Hemma: I owe Anna 500. Globally Anna pays 501, all into Vardag. + h.server.storeExpense(serverExpense("v1", 2002)) + h.server.storeExpense( + ExpenseDto( + "h1", OLDER, GROCERIES, 1000, "Hemma", "", "2026-10-10", PARTNER, "default", + listOf(SplitDto(ME, 500, 5000), SplitDto(PARTNER, 500, 5000)), null, null, PARTNER, PARTNER, FakeServer.T0, FakeServer.T0, null, 0, + ), + ) + h.engine.sync() + h.local.snapshot.first { it.liveExpenses.size == 2 } + val state = h.settlementRepo().start(null, null) + assertEquals(PARTNER, state.payer.id.value) + assertEquals(listOf(ACCOUNT), state.lines.map { it.account.id.value }) + assertEquals(listOf(OLDER to 500L), state.untouched.map { it.account.id.value to it.payerNetOre }) + // Rows naming Hemma touch it; a per-account settle-up lists nothing. + val both = h.settlementRepo().recompute(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, 501, null, mapOf(AccountId(ACCOUNT) to 1L, AccountId(OLDER) to 500L), state.date, "")) + assertTrue(both.untouched.isEmpty()) + assertTrue(h.settlementRepo().start(null, AccountId(ACCOUNT)).untouched.isEmpty()) + } + @Test fun accountLedger_settlementRowCarriesTheAllocationToThatAccount() = runSync { h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000)) @@ -267,6 +292,47 @@ class RepositoriesTest { assertTrue(repo.status().first { it.rejections.isEmpty() }.rejections.isEmpty()) } + @Test + fun syncStatus_aRejectionNamesWhoMadeTheServersVersion() = runSync { + val id = h.addExpense("ICA") + h.server.pushOverride = { request -> + val m = request.mutations.single() + val current = serverExpense(id, 2000).copy(updatedByUserId = PARTNER) + se.hagstedt.kvitt.data.api.PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "stale", current.json()))) + } + val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work) + repo.syncNow() + val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single() + assertEquals(SyncRejectionReason.STALE, rejection.reason) + assertEquals(PARTNER, rejection.actor?.id?.value) + } + + @Test + fun syncSummary_lastAttemptFailed_afterAFailedCycle_untilOneSucceeds() = runSync { + val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work) + assertFalse(repo.status().first().summary.lastAttemptFailed) + h.clock.advance(60_000) + h.server.failNetwork = true + repo.syncNow() + assertTrue(repo.status().first { it.summary.lastAttemptFailed }.summary.lastAttemptFailed) + h.clock.advance(60_000) + h.server.failNetwork = false + repo.syncNow() + assertFalse(repo.status().first { !it.summary.lastAttemptFailed }.summary.lastAttemptFailed) + } + + @Test + fun lastAttemptFailed_rules() { + val base = SyncEngine.INITIAL_STATE + assertFalse("never tried", SyncSummaries.lastAttemptFailed(null, running = false)) + assertFalse("never tried", SyncSummaries.lastAttemptFailed(base, running = false)) + assertTrue("first sync failed", SyncSummaries.lastAttemptFailed(base.copy(lastAttemptAt = "2026-10-17T10:00:00.000Z"), running = false)) + assertFalse("running", SyncSummaries.lastAttemptFailed(base.copy(lastAttemptAt = "2026-10-17T10:00:00.000Z"), running = true)) + val ok = base.copy(lastAttemptAt = "2026-10-17T10:00:00.000Z", lastSuccessAt = "2026-10-17T10:00:01.000Z") + assertFalse("succeeded", SyncSummaries.lastAttemptFailed(ok, running = false)) + assertTrue("a later attempt failed", SyncSummaries.lastAttemptFailed(ok.copy(lastAttemptAt = "2026-10-17T10:05:00.000Z"), running = false)) + } + @Test fun expenseDetail_timelineResolvesNames_andEndsWithThePendingLine() = runSync { h.server.storeExpense(serverExpense("e1", 1000)) 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 index 9f7d07e..9554673 100644 --- 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 @@ -21,6 +21,7 @@ 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.InviteKind import se.hagstedt.kvitt.model.OnlineResult import se.hagstedt.kvitt.model.SessionState @@ -42,13 +43,13 @@ class SessionRepositoryTest { 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) { + private fun acceptAs(userId: String, kind: String = "admin_setup") { 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()), + AcceptInviteResponse("kvt_new", kind, user, DeviceDto("d2", userId, "Galaxy", 41, "1.1", "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", null, true), h.server.users.values.toList()), ) } else { null @@ -60,7 +61,8 @@ class SessionRepositoryTest { 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) + assertEquals(ME, (result as OnlineResult.Ok).value.user.id.value) + assertEquals(InviteKind.ADMIN_SETUP, result.value.inviteKind) val request = h.requests.single() assertEquals("https://kvitt.test:8443/invites/accept", request.url.toString()) val credentials = h.credentials.credentials() @@ -74,6 +76,15 @@ class SessionRepositoryTest { assertEquals(listOf(false), h.work.oneOff) } + @Test + fun pair_reportsTheInviteKind_soOnlyAPartnerPairingShowsTheProfileStep() = runSync { + acceptAs(PARTNER, "partner") + assertEquals(InviteKind.PARTNER, (repo().pair("https://kvitt.test", "7K3MQX9D2HPA", "Anna", "Galaxy") as OnlineResult.Ok).value.inviteKind) + acceptAs(PARTNER, "device") + assertEquals(InviteKind.DEVICE, (repo().pair("https://kvitt.test", "7K3MQX9D2HPA", null, "Galaxy") as OnlineResult.Ok).value.inviteKind) + assertEquals(InviteKind.DEVICE, SessionRepositoryImpl.inviteKindOf("something_new")) + } + @Test fun pair_normalisesTheCode_andRefusesBadInput_withoutARequest() = runSync { assertEquals("7K3MQX9D2HPA", SessionRepositoryImpl.normaliseInviteCode(" 7k3m-qx9d 2hpa ")) 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 index f5419b8..63add05 100644 --- 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 @@ -341,6 +341,38 @@ class SyncEngineTest { assertEquals(false, h.db.syncState().get()?.clockSkew) } + @Test + fun push_afterClockSkew_restampsRowsStampedAhead_soACorrectedClockHealsThem() = runSync { + h.ready() + h.clock.advance(HOUR) + val ahead = h.addExpense("stamped by a clock an hour ahead") + h.clock.advance(-HOUR) + val onTime = h.addExpense("stamped on time") + val onTimeStamp = h.db.expenses().get(onTime)?.updatedAt + assertEquals(SyncOutcome.Stopped(SyncEngine.CLOCK_SKEW), h.engine.sync()) + assertEquals(true, h.db.syncState().get()?.clockSkew) + + // D-027: the next attempt, with the clock still correct, re-stamps only the row from the future. + h.clock.advance(60_000) + assertEquals(SyncOutcome.Success, h.engine.sync()) + val healed = h.db.expenses().get(ahead) + assertEquals(false, healed?.pending) + assertEquals(Timestamps.format(h.clock.now), h.server.pushes.last().mutations.single { it.entity["id"]?.toString()?.trim('"') == ahead }.entity["updated_at"]?.toString()?.trim('"')) + assertEquals("a row stamped on time keeps its stamp", onTimeStamp, h.db.expenses().get(onTime)?.updatedAt) + assertEquals(false, h.db.syncState().get()?.clockSkew) + } + + @Test + fun push_withoutAClockSkewFlag_neverRestamps() = runSync { + h.ready() + h.clock.advance(5 * 60_000L) + val id = h.addExpense("five minutes ahead, inside the server's window") + h.clock.advance(-5 * 60_000L) + val stamp = h.db.expenses().get(id)?.updatedAt + assertEquals(SyncOutcome.Success, h.engine.sync()) + assertEquals(stamp, h.server.pushes.single().mutations.single().entity["updated_at"]?.toString()?.trim('"')) + } + @Test fun push_networkError_keepsEverythingPending_andAsksForRetry() = runSync { h.ready() 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 index bf58bba..b9bd3ed 100644 --- 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 @@ -15,6 +15,7 @@ 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.UpdateCheck import se.hagstedt.kvitt.model.UpdateInfo import se.hagstedt.kvitt.model.UpdateState import java.io.File @@ -70,14 +71,32 @@ class UpdaterTest { 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 info = UpdateInfo(Harness.APP_VERSION + 1, "2026.11.02-1a2b3c4", apk.size.toLong()) + assertEquals(UpdateCheck.Available(info), repo.check()) + assertEquals(UpdateState.Available(info), repo.state.first()) val other = repo() serve(latest(Harness.APP_VERSION)) - other.check() + assertEquals(UpdateCheck.UpToDate, other.check()) assertEquals(UpdateState.None, other.state.first()) } + @Test + fun checkResult_tellsUpToDateFromAFailure() = runSync { + h.server.restHandler = { _, path -> if (path == "/apk/latest") HttpStatusCode.NotFound to """{"code":"not_found","message":"x","field":null}""" else null } + assertEquals("nothing published", UpdateCheck.UpToDate, repo().check()) + h.server.restHandler = { _, path -> if (path == "/apk/latest") HttpStatusCode.InternalServerError to """{"code":"internal","message":"x","field":null}""" else null } + assertEquals(UpdateCheck.Failed, repo().check()) + h.server.failNetwork = true + val offline = repo() + assertEquals(UpdateCheck.Failed, offline.check()) + assertEquals(UpdateState.None, offline.state.first()) + h.server.failNetwork = false + serve(latest(Harness.APP_VERSION + 1)) + assertTrue("no answer: the next call asks again", offline.check() is UpdateCheck.Available) + assertTrue("within 6 hours: the stored answer", offline.check() is UpdateCheck.Available) + assertEquals(UpdateCheck.UpToDate, repo(debug = true).check()) + } + @Test fun checksAtMostEverySixHours() = runSync { serve(latest(Harness.APP_VERSION + 1)) diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt index bf88b99..07f4098 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountDetailScreen.kt @@ -74,6 +74,7 @@ data class AccountDetailActions( * Account detail (S-5). The account id is the navigation argument [AccountDetailViewModel.ARG_ACCOUNT_ID]. * [onArchived] is called after archiving with the account's name: `:app` returns to Home and shows * "Vardag arkiverades" with "Ångra" there (design-spec S-5). [onDeleted] after deleting, or when the account is gone. + * [snackbarHostState] is hosted in this screen's scaffold, so `:app` can show a result snackbar here (D-071). */ @Composable fun AccountDetailScreen( @@ -86,6 +87,7 @@ fun AccountDetailScreen( onArchived: (AccountId, String) -> Unit, onDeleted: () -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: AccountDetailViewModel = hiltViewModel(), ) { val ui by viewModel.ui.collectAsStateWithLifecycle() @@ -118,7 +120,7 @@ fun AccountDetailScreen( onRetry = viewModel::retry, onMessageShown = viewModel::messageShown, ) - AccountDetailContent(ui, actions, modifier) + AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState) } /** The stateless account screen, rendered by [AccountDetailScreen], previews and screenshot tests. */ @@ -128,9 +130,10 @@ fun AccountDetailContent( actions: AccountDetailActions, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { val ready = ui as? AccountDetailUi.Ready - val snackbar = remember { SnackbarHostState() } + val snackbar = snackbarHostState val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } } val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } } ready?.message?.let { message -> MessageEffect(message, snackbar, actions) } diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt index 3235e4d..3896e77 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/DrillDownScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -51,16 +52,20 @@ import se.hagstedt.kvitt.design.theme.KvittDimens import se.hagstedt.kvitt.model.ExpenseId import se.hagstedt.kvitt.core.design.R as DesignR -/** S-12 Analytics drill-down (design-spec S-12, AC-14.8). [onOpenExpense] opens S-8. */ +/** + * S-12 Analytics drill-down (design-spec S-12, AC-14.8). [onOpenExpense] opens S-8. [snackbarHostState] is hosted in + * this screen's scaffold ("Utgiften togs bort" after deleting an expense opened from here, D-071). + */ @Composable fun DrillDownScreen( onOpenExpense: (ExpenseId) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: DrillDownViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - DrillDownScreen(state, onOpenExpense, onBack, modifier) + DrillDownScreen(state, onOpenExpense, onBack, modifier, snackbarHostState = snackbarHostState) } /** The stateless S-12 that previews and screenshot tests render. */ @@ -71,11 +76,13 @@ fun DrillDownScreen( onBack: () -> Unit, modifier: Modifier = Modifier, scrollState: ScrollState = rememberScrollState(), + snackbarHostState: SnackbarHostState? = null, ) { val title = drillDownTitle(state) val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } } KvittScaffold( modifier.fillMaxSize(), + snackbarHostState = snackbarHostState, topBar = { KvittTopAppBar(title, navigation = TopBarNavigation.Back, onNavigation = onBack, scrolled = scrolled) }, ) { padding -> Column( diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt index 159cb0d..4308bc8 100644 --- a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsRulesTest.kt @@ -70,8 +70,8 @@ class AnalyticsRulesTest { @Test fun loadingOnlyBeforeTheFirstSync() { - assertTrue(AnalyticsRules.isFirstSync(SyncSummary(null, 0, 0, clockSkew = false, running = true))) - assertFalse(AnalyticsRules.isFirstSync(SyncSummary("2026-10-17T06:00:00.000Z", 3, 0, clockSkew = false, running = false))) + assertTrue(AnalyticsRules.isFirstSync(SyncSummary(null, 0, 0, clockSkew = false, running = true, lastAttemptFailed = false))) + assertFalse(AnalyticsRules.isFirstSync(SyncSummary("2026-10-17T06:00:00.000Z", 3, 0, clockSkew = false, running = false, lastAttemptFailed = false))) } @Test diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt index b7cda55..842b961 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt @@ -14,11 +14,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -29,6 +31,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.core.design.R as DesignR +import se.hagstedt.kvitt.design.component.KvittScaffold import se.hagstedt.kvitt.design.component.AmountStyle import se.hagstedt.kvitt.design.component.AmountTone import se.hagstedt.kvitt.design.component.BadgeKind @@ -80,22 +83,23 @@ interface ExpenseDetailActions { /** * S-8, the expense detail with its timeline (design-spec §4 S-8; SPEC AC-8.11, AC-12.4, AC-12.5). [onEdit] opens S-7 - * in edit mode; [onDeleted] goes back with the snackbar text "Utgiften togs bort". + * in edit mode; [onDeleted] goes back, where "Utgiften togs bort" (`snackbar_expense_deleted`) is shown, as S-10 does. + * [snackbarHostState] is hosted in this screen, so `:app` can show a result snackbar here (D-071). */ @Composable fun ExpenseDetailScreen( onBack: () -> Unit, onEdit: (ExpenseId) -> Unit, - onDeleted: (String) -> Unit, + onDeleted: () -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: ExpenseDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val deleted = stringResource(DesignR.string.snackbar_expense_deleted) LaunchedEffect(viewModel) { - viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted(deleted) } + viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted() } } - ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier) + ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier, snackbarHostState) } /** The stateless S-8 layout. */ @@ -106,10 +110,19 @@ fun ExpenseDetailContent( onBack: () -> Unit, onEdit: (ExpenseId) -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState? = null, ) { + // The scaffold only hosts the snackbar; the body keeps its own system-bar padding. + KvittScaffold(modifier.fillMaxSize(), snackbarHostState = snackbarHostState) { _ -> + DetailSurface(state, actions, onBack, onEdit) + } +} + +@Composable +private fun DetailSurface(state: ExpenseDetailUiState, actions: ExpenseDetailActions, onBack: () -> Unit, onEdit: (ExpenseId) -> Unit) { val detail = state.detail val me = state.me - Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { if (me == null || state.loading) return@Surface ProvideHousehold(me, state.partner) { Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt index 183b098..ce743df 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt @@ -1,6 +1,7 @@ package se.hagstedt.kvitt.feature.expense import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -123,28 +124,26 @@ interface ExpenseEditorActions { } /** - * S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar text for - * the opener ("Utgiften sparades" / offline wording for a new expense, null after an edit: back to S-8 without one); - * [onDeleted] gets "Utgiften togs bort" and goes back past S-8 to its opener. + * S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar string + * resource for the opener (`snackbar_expense_saved` / `snackbar_expense_saved_offline` for a new expense, null after an + * edit: back to S-8 without one); [onDeleted] goes back past S-8 to its opener, which shows `snackbar_expense_deleted`. + * `:app` resolves the resource while the snackbar shows, as for S-9 and S-10. */ @Composable fun ExpenseEditorScreen( onClose: () -> Unit, - onSaved: (ExpenseId, String?) -> Unit, - onDeleted: (String) -> Unit, + onSaved: (ExpenseId, Int?) -> Unit, + onDeleted: () -> Unit, modifier: Modifier = Modifier, viewModel: ExpenseEditorViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val descriptionFocus = remember { FocusRequester() } - val saved = stringResource(DesignR.string.snackbar_expense_saved) - val savedOffline = stringResource(DesignR.string.snackbar_expense_saved_offline) - val deleted = stringResource(DesignR.string.snackbar_expense_deleted) LaunchedEffect(viewModel) { viewModel.eventFlow.collect { event -> when (event) { - is EditorEvent.Saved -> onSaved(event.id, if (!event.isNew) null else if (event.offline) savedOffline else saved) - EditorEvent.Deleted -> onDeleted(deleted) + is EditorEvent.Saved -> onSaved(event.id, savedSnackbar(event.isNew, event.offline)) + EditorEvent.Deleted -> onDeleted() EditorEvent.Closed -> onClose() EditorEvent.FocusDescription -> descriptionFocus.requestFocus() } @@ -155,6 +154,14 @@ fun ExpenseEditorScreen( ExpenseEditorContent(state, viewModel, modifier, descriptionFocus) } +/** The snackbar after a save: none after an edit; for a new expense the online or offline wording. */ +@StringRes +internal fun savedSnackbar(isNew: Boolean, offline: Boolean): Int? = when { + !isNew -> null + offline -> DesignR.string.snackbar_expense_saved_offline + else -> DesignR.string.snackbar_expense_saved +} + /** The stateless S-7 layout: app bar, scrolling fields, the keypad, and the overlay of [ExpenseEditorState.overlay]. */ @Composable fun ExpenseEditorContent( diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt index 90b410d..4faef9c 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt @@ -18,10 +18,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -74,13 +76,17 @@ data class SyncStatusActions( val onOpenDateSettings: () -> Unit = {}, ) -/** Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). */ +/** + * Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). + * [snackbarHostState] is hosted in this screen's scaffold (a result snackbar after returning here, D-071). + */ @Composable fun SyncStatusScreen( onBack: () -> Unit, onOpenExpense: (ExpenseId) -> Unit, onOpenSettlement: (SettlementId) -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: SyncStatusViewModel = hiltViewModel(), ) { val ui by viewModel.ui.collectAsStateWithLifecycle() @@ -99,16 +105,17 @@ fun SyncStatusScreen( } }, ) - SyncStatusContent(ui, actions, modifier) + SyncStatusContent(ui, actions, modifier, snackbarHostState) } private const val LOG_TAG = "KvittSyncStatus" /** The stateless sync status screen, rendered by [SyncStatusScreen], previews and screenshot tests. */ @Composable -fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier) { +fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) { KvittScaffold( modifier = modifier, + snackbarHostState = snackbarHostState, topBar = { KvittTopAppBar(stringResource(R.string.home_sync_status_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) }, ) { padding -> val ready = ui as? SyncStatusUi.Ready @@ -246,10 +253,11 @@ private fun PendingGroup(rows: List, actions: SyncStatusActions) { private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: SyncStatusActions) { val scheme = MaterialTheme.colorScheme val clock = LocalKvittClock.current - val partnerName = ui.partner?.displayName.orEmpty() + // §14.4.6 "": who made the server's version; the partner when the server had none to name. + val actorName = (rejection.actor ?: ui.partner)?.displayName.orEmpty() val settlement = rejection.entityType == EntityType.SETTLEMENT val reason = when (rejection.reason) { - SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, partnerName) + SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, actorName) SyncRejectionReason.DELETED -> stringResource(R.string.home_sync_reject_deleted) SyncRejectionReason.VALIDATION_FAILED -> stringResource(R.string.home_sync_reject_validation) SyncRejectionReason.REFERENCE_NOT_FOUND -> stringResource(R.string.home_sync_reject_reference) @@ -257,8 +265,8 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: } val help = when { rejection.reason != SyncRejectionReason.STALE -> null - settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, partnerName) - else -> stringResource(R.string.home_sync_reject_stale_help, partnerName) + settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, actorName) + else -> stringResource(R.string.home_sync_reject_stale_help, actorName) } val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage()) val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt index 31189f4..3acb84c 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusViewModel.kt @@ -21,11 +21,13 @@ sealed interface SyncStatusUi { data object Starting : SyncStatusUi /** - * @property me the viewer and [partner] the other person: `stale` is explained with the partner's name (§14.4.6; - * `Rejection` carries no actor, see the M7b report). - * @property failed the last attempt failed: `SyncSummary` has no such field yet, so the ViewModel passes false. + * @property me the viewer and [partner] the other person: `stale` names the rejection's actor, or the partner when + * it has none (§14.4.6). */ - data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val failed: Boolean = false) : SyncStatusUi { + data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?) : SyncStatusUi { + /** The last attempt failed while online ("Synkningen misslyckades"); offline wins, as on the pill (§3.6). */ + val failed: Boolean get() = online && status.summary.lastAttemptFailed + /** Nothing pending, nothing refused, no clock problem: "Allt är synkat". */ val allSynced: Boolean get() = status.pending.isEmpty() && status.rejections.isEmpty() && !status.summary.clockSkew && !status.summary.running && online && !failed diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt index b9b9bce..bf56e7b 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeScreenshotTest.kt @@ -23,11 +23,10 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi vm.ui.value } -/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */ -fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain { +/** Sync status with [scenario]. */ +fun syncState(scenario: FakeScenario): SyncStatusUi = withMain { val fakes = FakeRepositories(scenario) - val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value - if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui + SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value } /** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */ @@ -54,7 +53,7 @@ val homeStates: List = listOf( sync("S-16-sync-rejections", HomeScenarios.allRejections), sync("S-16-sync-clock", HomeScenarios.clockSkew), sync("S-16-sync-running", HomeScenarios.syncing), - sync("S-16-sync-error", HomeScenarios.pending, failed = true), + sync("S-16-sync-error", HomeScenarios.pending.copy(lastAttemptFailed = true)), sync("S-16-sync-offline", HomeScenarios.syncOffline), sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true), ) @@ -64,8 +63,8 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) } } -private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, failed: Boolean = false): ScreenState { - val ui = syncState(scenario, failed) +private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState { + val ui = syncState(scenario) return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) } } diff --git a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt index 3d70781..8cbd87f 100644 --- a/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt +++ b/android/feature/home/src/test/kotlin/se/hagstedt/kvitt/feature/home/HomeViewModelTest.kt @@ -12,6 +12,7 @@ import se.hagstedt.kvitt.fakes.home.HomeScenarios import se.hagstedt.kvitt.model.DefaultCategoryKey import se.hagstedt.kvitt.model.SyncRejectionReason import se.hagstedt.kvitt.model.UpdateRepository +import se.hagstedt.kvitt.model.UpdateCheck import se.hagstedt.kvitt.model.UpdateState /** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */ @@ -19,7 +20,7 @@ private class RecordingUpdates(initial: UpdateState) : UpdateRepository { val flow = MutableStateFlow(initial) var installs = 0 override val state: Flow = flow - override suspend fun check() = Unit + override suspend fun check(): UpdateCheck = UpdateCheck.UpToDate override suspend fun install() { installs += 1 } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt index f556b54..3a1c9b4 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt @@ -6,13 +6,16 @@ import java.net.URI import java.net.URISyntaxException import java.net.URLDecoder -/** What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server. */ -data class PastedInvite(val code: String, val serverUrl: String?) +/** + * What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server and the + * inviting admin's display name ([adminName], the optional `from` parameter of E-12's link; older links have none). + */ +data class PastedInvite(val code: String, val serverUrl: String?, val adminName: String? = null) /** - * Reads pasted text on S-1 (design-spec S-1 "Klistra in"): an invite link `kvitt://invite?server=…&code=…`, a landing - * link `https:///i/` (AC-2.5, AC-2.6), or a bare code in any spelling §8.3 normalises. Anything else is - * null. Opening the app from a link is `:app`'s; this only reads the clipboard. + * Reads pasted text on S-1 (design-spec S-1 "Klistra in") and the links `:app` opens: an invite link + * `kvitt://invite?server=…&code=…[&from=…]`, a landing link `https:///i/[?from=…]` (AC-2.5, AC-2.6), or a + * bare code in any spelling §8.3 normalises. Anything else is null. */ object InviteLink { fun parse(text: String): PastedInvite? { @@ -31,13 +34,9 @@ object InviteLink { private fun fromAppLink(uri: URI): PastedInvite? { if (uri.schemeSpecificPart?.startsWith("//invite") != true) return null - val query = uri.rawQuery ?: return null - val params = query.split('&').mapNotNull { pair -> - val key = pair.substringBefore('=', "") - if (key.isEmpty()) null else key to URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8) - }.toMap() + val params = params(uri.rawQuery ?: return null) val code = params["code"]?.let(::code) ?: return null - return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }) + return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }, adminName(params[FROM])) } private fun fromLandingLink(uri: URI): PastedInvite? { @@ -46,9 +45,29 @@ object InviteLink { if (segments.size != 2 || segments[0] != "i") return null val code = code(segments[1]) ?: return null val port = if (uri.port == -1) "" else ":${uri.port}" - return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port") + return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port", adminName(uri.rawQuery?.let(::params)?.get(FROM))) } + /** The first value of each key; a value that is not valid percent-encoding is dropped. */ + private fun params(query: String): Map = query.split('&').mapNotNull { pair -> + val key = pair.substringBefore('=', "") + val value = try { + URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8) + } catch (_: IllegalArgumentException) { + null + } + if (key.isEmpty() || value == null) null else key to value + }.distinctBy { it.first }.toMap() + + /** A display name of §9.6 (1 to 40 code points after trimming, no control characters), else null. */ + private fun adminName(value: String?): String? { + val name = value?.trim() ?: return null + return name.takeIf { it.codePointCount(0, it.length) in 1..NAME_MAX && it.none(Char::isISOControl) } + } + + private const val FROM = "from" + private const val NAME_MAX = 40 + /** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */ private fun code(text: String): String? { val stripped = text.uppercase().filter { it != '-' && !it.isWhitespace() } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt index dff9cd5..3e23694 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt @@ -62,8 +62,8 @@ data class PairingActions( ) /** - * S-1 Pairing (design-spec S-1). [initialServerUrl] and [initialCode] come from an invite link that `:app` parsed - * (AC-2.6); [onPaired] receives where to go next (S-2 for a partner, AC-2.10; else Home). + * S-1 Pairing (design-spec S-1). [initialServerUrl], [initialCode] and [initialAdminName] come from an invite link that + * `:app` parsed (AC-2.6); [onPaired] receives where to go next (S-2 after a partner invite, AC-2.10; else Home). */ @Composable fun PairingScreen( @@ -71,13 +71,14 @@ fun PairingScreen( modifier: Modifier = Modifier, initialServerUrl: String? = null, initialCode: String? = null, + initialAdminName: String? = null, viewModel: PairingViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val clipboard = LocalClipboard.current val context = LocalContext.current val scope = rememberCoroutineScope() - LaunchedEffect(initialServerUrl, initialCode) { viewModel.prefill(initialServerUrl, initialCode) } + LaunchedEffect(initialServerUrl, initialCode, initialAdminName) { viewModel.prefill(initialServerUrl, initialCode, initialAdminName) } LaunchedEffect(state.done) { state.done?.let { onPaired(it) @@ -181,7 +182,7 @@ fun PairingContent( readOnly = state.pairing, ) } - HelpLine() + HelpLine(state.adminName) val serverError = when (state.error) { PairingError.Network -> stringResource(R.string.onboarding_error_network) PairingError.HttpsRequired -> stringResource(R.string.onboarding_error_https) @@ -245,19 +246,17 @@ private fun Brand(mode: PairingMode) { } } -/** - * Where the code comes from. The admin's name is not known before pairing (an invite link carries only server and - * code, AC-2.6), so the generic line is used (`onboarding_code_help_generic`; spec gap in the M7a report). - */ +/** Where the code comes from: from the admin named in the invite link, else the generic line (AC-2.6). */ @Composable -private fun HelpLine() { +private fun HelpLine(adminName: String?) { val muted = MaterialTheme.colorScheme.onSurfaceVariant Row( Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), ) { KvittIconImage(KvittIcon.Info, null, size = KvittDimens.helpIcon, tint = muted) - Text(stringResource(R.string.onboarding_code_help_generic), style = MaterialTheme.typography.bodyMedium, color = muted) + val text = adminName?.let { stringResource(R.string.onboarding_code_help, it) } ?: stringResource(R.string.onboarding_code_help_generic) + Text(text, style = MaterialTheme.typography.bodyMedium, color = muted) } } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt index 8997086..e034448 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.launch import se.hagstedt.kvitt.design.component.INVITE_LENGTH import se.hagstedt.kvitt.design.component.normalizeInviteCode import se.hagstedt.kvitt.model.OnlineResult -import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.InviteKind import se.hagstedt.kvitt.model.SessionRepository import se.hagstedt.kvitt.model.SessionState import javax.inject.Inject @@ -38,17 +38,21 @@ enum class PairingError { /** Where S-1 goes after a successful pairing. */ enum class PairingDone { - /** A `member` on a first pairing confirms the profile first (AC-2.10). */ + /** A `partner` pairing confirms the profile first (AC-2.10); a device invite does not, even for the member. */ ProfileStep, /** Everyone else goes to Home (its loading state until the first sync ends). */ Home, } -/** The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. */ +/** + * The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. [adminName] is the inviting + * admin's name from the link, for the help line (`onboarding_code_help`, AC-2.6); null without one. + */ data class PairingUiState( val mode: PairingMode = PairingMode.First, val code: String = "", + val adminName: String? = null, val name: String = "", val serverUrl: String = RELEASE_SERVER_URL, val serverExpanded: Boolean = false, @@ -99,13 +103,14 @@ class PairingViewModel internal constructor( * Fills the fields from an invite link once (AC-2.6); `:app` parses the intent. The server row opens expanded when * the link names a server other than the release default (design-spec S-1). */ - fun prefill(serverUrl: String?, code: String?) { + fun prefill(serverUrl: String?, code: String?, adminName: String? = null) { if (prefilled || (serverUrl == null && code == null)) return prefilled = true mutable.update { s -> val server = serverUrl?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() } s.copy( code = code?.let(::normalizeInviteCode) ?: s.code, + adminName = adminName ?: s.adminName, serverUrl = server ?: s.serverUrl, serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL), ) @@ -134,6 +139,7 @@ class PairingViewModel internal constructor( val server = invite.serverUrl s.copy( code = invite.code, + adminName = invite.adminName ?: s.adminName, serverUrl = server ?: s.serverUrl, serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL), error = null, @@ -159,7 +165,7 @@ class PairingViewModel internal constructor( when (result) { is OnlineResult.Ok -> current.copy( pairing = false, - done = if (s.mode == PairingMode.First && result.value.role == Role.MEMBER) PairingDone.ProfileStep else PairingDone.Home, + done = if (result.value.inviteKind == InviteKind.PARTNER) PairingDone.ProfileStep else PairingDone.Home, ) OnlineResult.Offline -> current.copy(pairing = false, error = PairingError.Network, serverExpanded = true) is OnlineResult.Failed -> { diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt index 39b73b1..594b9d4 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt @@ -29,6 +29,21 @@ class InviteLinkTest { assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA")) } + @Test + fun linksMayNameTheAdmin_andABadNameIsDropped() { + assertEquals( + PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Jacob"), + InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA&from=Jacob"), + ) + assertEquals( + PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Åsa Berg"), + InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"), + ) + for (bad in listOf("", "%20", "a%0Ab", "A".repeat(41))) { + assertEquals(bad, PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=$bad")) + } + } + @Test fun theLandingLinkGivesCodeAndServer() { assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA")) diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt index 78af585..b84eb82 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt @@ -18,6 +18,7 @@ import se.hagstedt.kvitt.fakes.onboarding.CallOutcome import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios import se.hagstedt.kvitt.fakes.onboarding.PairCall import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository +import se.hagstedt.kvitt.model.InviteKind @OptIn(ExperimentalCoroutinesApi::class) class PairingViewModelTest { @@ -68,6 +69,16 @@ class PairingViewModelTest { assertEquals(PairingDone.ProfileStep, vm.state.value.done) } + @Test + fun aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin() { + val (_, vm) = vm(OnboardingScenarios.unpairedPartner.copy(pairingInviteKind = InviteKind.DEVICE)) + vm.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Jacob") + assertEquals("Jacob", vm.state.value.adminName) + vm.onNameChange("Anna") + vm.pair("Galaxy S26+") + assertEquals(PairingDone.Home, vm.state.value.done) + } + @Test fun theAdminGoesHomeAndTheAgainVariantSendsNoName() { val (_, admin) = vm(OnboardingScenarios.unpaired) diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt index 143d234..fc59115 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt @@ -76,6 +76,14 @@ object OnboardingStates { ScreenState("s1_pairing_empty", render = pairing(OnboardingScenarios.unpaired) {}), ScreenState("s1_pairing", largeFont = true, render = pairing(OnboardingScenarios.unpaired) { fillFromLink() }), ScreenState("s1_pairing_link_lan", render = pairing(OnboardingScenarios.unpaired, allowLanHttp = true) { fillFromLink(LAN_SERVER) }), + // AC-2.6: a link that names the inviting admin fills the help line with the name. + ScreenState( + "s1_pairing_link_admin", + render = pairing(OnboardingScenarios.unpaired) { + prefill(RELEASE_SERVER_URL, CODE, "Jacob") + onNameChange("Anna") + }, + ), ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }), ScreenState( "s1_pairing_error", diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt index ebd422b..a8ecb33 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -16,6 +15,7 @@ import se.hagstedt.kvitt.feature.settings.common.UiMessage import se.hagstedt.kvitt.model.AboutRepository import se.hagstedt.kvitt.model.AboutState import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.UpdateCheck import se.hagstedt.kvitt.model.UpdateRepository import se.hagstedt.kvitt.model.UpdateState import se.hagstedt.kvitt.core.design.R as DesignR @@ -37,8 +37,8 @@ class AboutViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Eagerly, null) /** - * "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner, or "Du har den senaste - * versionen". `check()` reports no failure (§14.2.4), so a check that finds nothing reads as up to date. + * "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner (Available), "Du har den + * senaste versionen" (UpToDate, AC-16.6) or "Kunde inte nå servern" (Failed). */ fun check() { val s = state.value ?: return @@ -49,9 +49,12 @@ class AboutViewModel @Inject constructor( } local.update { it.copy(checking = true, bannerHidden = false) } viewModelScope.launch { - update.check() - val found = update.state.first() - local.update { it.copy(checking = false, message = if (found is UpdateState.None) UiMessage(R.string.settings_about_up_to_date) else it.message) } + val message = when (update.check()) { + UpdateCheck.UpToDate -> UiMessage(R.string.settings_about_up_to_date) + UpdateCheck.Failed -> UiMessage(DesignR.string.common_could_not_reach_server) + is UpdateCheck.Available -> null + } + local.update { it.copy(checking = false, message = message ?: it.message) } } } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt index 400d8a5..62c3d22 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt @@ -126,7 +126,7 @@ private fun DeviceRow(item: SegmentedItemScope, device: Device, revocable: Boole val meta = listOfNotNull( stringResource(R.string.settings_devices_paired, listDate(device.pairedAt.take(DATE_LENGTH))), stringResource(R.string.settings_devices_last_seen, relativeTime(device.lastSeenAt)), - device.appVersionCode?.let { stringResource(R.string.settings_devices_app_version, it.toString()) }, + (device.appVersionName ?: device.appVersionCode?.toString())?.let { stringResource(R.string.settings_devices_app_version, it) }, ).joinToString(dot) Row( Modifier diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt index ad65fdc..642e8bc 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt @@ -225,11 +225,17 @@ class RecurringEditorViewModel @Inject constructor( local.update { it.copy(triedSave = true) } val s = state.value ?: return if (!s.canSave) return - val template = s.form.template(s.original, s.options.me.id, s.options.partner.id, s.options.today) + val me = s.options.me.id + val partner = s.options.partner.id focus(EditorFocus.None) local.update { it.copy(busy = true) } viewModelScope.launch { - val result: OnlineResult<*> = if (s.original == null) recurring.create(template) else recurring.update(template) + val original = s.original + val result: OnlineResult<*> = if (original == null) { + recurring.create(s.form.newTemplate(me, partner, s.options.today)) + } else { + recurring.update(s.form.template(original, me, partner, s.options.today)) + } local.update { it.copy(busy = false) } when { result is OnlineResult.Ok -> local.update { it.copy(done = true) } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt index 33df4d3..7d34bf3 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt @@ -7,9 +7,9 @@ import se.hagstedt.kvitt.model.AccountId import se.hagstedt.kvitt.model.CategoryId import se.hagstedt.kvitt.model.ExpenseDraft import se.hagstedt.kvitt.model.ExpenseEditorOptions +import se.hagstedt.kvitt.model.NewRecurringTemplate import se.hagstedt.kvitt.model.RecurringTemplate import se.hagstedt.kvitt.model.SplitMode -import se.hagstedt.kvitt.model.TemplateId import se.hagstedt.kvitt.model.UserId /** Which keypad field has focus (design-spec §3.7: the keypad drives the amount, the exact share and the percents). */ @@ -80,16 +80,27 @@ data class RecurringForm( ) } - fun template(original: RecurringTemplate?, me: UserId, partner: UserId, today: String): RecurringTemplate = RecurringTemplate( - id = original?.id ?: TemplateId(""), + /** A new template for `RecurringRepository.create`, which mints the id (E-28). */ + fun newTemplate(me: UserId, partner: UserId, today: String): NewRecurringTemplate = NewRecurringTemplate( draft = draft(me, partner, today), dayOfMonth = dayOfMonth ?: 0, startPeriod = startPeriod(today), endPeriod = end, paused = paused, - nextOccurrence = original?.nextOccurrence, ) + /** [original] with the form's values, for `RecurringRepository.update`. */ + fun template(original: RecurringTemplate, me: UserId, partner: UserId, today: String): RecurringTemplate { + val fields = newTemplate(me, partner, today) + return original.copy( + draft = fields.draft, + dayOfMonth = fields.dayOfMonth, + startPeriod = fields.startPeriod, + endPeriod = fields.endPeriod, + paused = fields.paused, + ) + } + /** One keypad key on the focused field; a rejected key leaves the form unchanged. */ fun press(key: AmountKey): RecurringForm = when (focus) { EditorFocus.Amount -> amount.press(key)?.let { copy(amount = it) } ?: this diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt index 4cec936..a6dd06c 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt @@ -104,6 +104,10 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) { about("online", SettingsScenarios.loaded), about("offline", SettingsScenarios.offline), about("check_up_to_date", SettingsScenarios.loaded) { _, vm -> vm.check() }, + about("check_failed", SettingsScenarios.loaded) { r, vm -> + r.checkFails = true + vm.check() + }, about("check_update_found", SettingsScenarios.loaded) { r, vm -> r.afterCheck = UpdateState.Available(SettingsScenarios.update) vm.check() diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt index 0663f0f..9d520d9 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleViewModel.kt @@ -17,6 +17,7 @@ import se.hagstedt.kvitt.model.AccountRepository import se.hagstedt.kvitt.model.AllocationLine import se.hagstedt.kvitt.model.FieldError import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.UntouchedAccount import se.hagstedt.kvitt.model.SaveResult import se.hagstedt.kvitt.model.SettleRequest import se.hagstedt.kvitt.model.SettleState @@ -48,8 +49,8 @@ data class SettleRow(val line: AllocationLine, val input: AmountInput) /** * Everything S-9 shows. [settle] fields come from `SettlementRepository.start`/`recompute` (§14.2.4); the feature never - * computes the proposal, the remaining amount, the warnings or [canConfirm] itself. [viewerNets] are the viewer's - * account balances from Home, used only for the §9.4 info line about accounts left untouched. + * computes the proposal, the remaining amount, the warnings or [canConfirm] itself. [untouchedAccounts] are the + * repository's accounts left untouched (`SettleState.untouched`, D-074), for the §9.4 info line. */ data class SettleUiState( val loading: Boolean = true, @@ -67,7 +68,7 @@ data class SettleUiState( val rowsEdited: Boolean = false, val fixedAccount: Account? = null, val accounts: List = emptyList(), - val viewerNets: Map = emptyMap(), + val untouchedAccounts: List = emptyList(), val errors: List = emptyList(), val dialog: SettleDialog? = null, val dirty: Boolean = false, @@ -87,18 +88,15 @@ data class SettleUiState( val showRemaining: Boolean get() = mode != SettleMode.Account && rows.isNotEmpty() /** - * §9.4 Consequences: accounts where the payer is owed and that are not in the rows (global variant). The viewer's - * net sign of each says who owes there; all of them have the same sign because the payer is owed in each. + * §9.4 Consequences: the repository's accounts where the payer is owed and that are not in the rows (global + * variant), each with the viewer's net there; all of them have the same sign because the payer is owed in each. */ val untouched: List> get() { val payer = payer ?: return emptyList() if (mode == SettleMode.Account || rows.isEmpty()) return emptyList() - return accounts.mapNotNull { account -> - val viewerNet = viewerNets[account.id] ?: 0L - val payerNet = if (payer.id == me?.id) viewerNet else -viewerNet - account.takeIf { payerNet > 0 && rows.none { it.line.account.id == account.id } }?.let { it to viewerNet } - } + return untouchedAccounts.filter { u -> rows.none { it.line.account.id == u.account.id } } + .map { u -> u.account to if (payer.id == me?.id) u.payerNetOre else -u.payerNetOre } } val overSettled: List get() = rows.filter { it.line.overSettles } @@ -152,10 +150,7 @@ class SettleViewModel @Inject constructor( } } viewModelScope.launch { - home.home().collect { h -> - val nets = (h.active + h.archived).associate { it.account.id to it.netOre } - mutable.update { it.copy(me = h.me, partner = h.partner, viewerNets = nets) } - } + home.home().collect { h -> mutable.update { it.copy(me = h.me, partner = h.partner) } } } viewModelScope.launch { val start = settlements.start(editing, if (editing == null) fixedAccountId else null) @@ -271,5 +266,13 @@ internal fun SettleUiState.withResult(result: SettleState, propose: Boolean): Se rows.map { row -> SettleRow(result.lines.firstOrNull { it.account.id == row.line.account.id } ?: row.line, row.input) } } val amount = if (loading) AmountText.of(result.amountOre) else amount - return copy(payer = result.payer, recipient = result.recipient, amount = amount, rows = rows, remainingOre = result.remainingOre, canConfirm = result.canConfirm) + return copy( + payer = result.payer, + recipient = result.recipient, + amount = amount, + rows = rows, + remainingOre = result.remainingOre, + canConfirm = result.canConfirm, + untouchedAccounts = result.untouched, + ) } diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt index 09123d2..d8a8f1c 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt @@ -13,10 +13,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -68,6 +70,7 @@ import kotlin.time.Instant /** * S-10 Settlement detail (design-spec S-10) for [SettleArgs.SETTLEMENT_ID]. [onEdit] opens S-9 editing; after a * confirmed delete [onDeleted] goes back, where "Betalningen togs bort" (`snackbar_payment_deleted`) is shown. + * [snackbarHostState] is hosted in this screen's scaffold ("Betalningen sparades" after an edit, D-071). */ @Composable fun SettlementDetailScreen( @@ -75,11 +78,12 @@ fun SettlementDetailScreen( onEdit: (SettlementId) -> Unit, onDeleted: () -> Unit, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewModel: SettlementDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(state.deletedByMe) { if (state.deletedByMe) onDeleted() } - SettlementDetailContent(state, viewModel, onBack, onEdit, modifier) + SettlementDetailContent(state, viewModel, onBack, onEdit, modifier, snackbarHostState = snackbarHostState) } /** The stateless S-10, rendered by [SettlementDetailScreen], previews and screenshot tests. */ @@ -91,12 +95,14 @@ fun SettlementDetailContent( onEdit: (SettlementId) -> Unit, modifier: Modifier = Modifier, overlayHost: OverlayHost = WindowOverlayHost, + snackbarHostState: SnackbarHostState? = null, ) { val me = state.me val detail = state.detail val body: @Composable () -> Unit = { KvittScaffold( modifier.fillMaxSize(), + snackbarHostState = snackbarHostState, topBar = { KvittTopAppBar(stringResource(R.string.settle_detail_title), navigation = TopBarNavigation.Back, onNavigation = onBack) { if (state.actions && detail != null) { diff --git a/contracts/examples/E-1-health/response.json b/contracts/examples/E-1-health/response.json index c0f5499..379edc3 100644 --- a/contracts/examples/E-1-health/response.json +++ b/contracts/examples/E-1-health/response.json @@ -1,6 +1,6 @@ { "status": "ok", "server_version": "0.1.0", - "schema_version": 2, + "schema_version": 3, "db_ok": true } diff --git a/contracts/examples/E-12-create-invite/response.json b/contracts/examples/E-12-create-invite/response.json index 830cd2d..f1e1498 100644 --- a/contracts/examples/E-12-create-invite/response.json +++ b/contracts/examples/E-12-create-invite/response.json @@ -3,7 +3,7 @@ "kind": "partner", "target_user_id": null, "code": "4N8RVT2KW6ZC", - "link": "https://icehack.asuscomm.com:8443/i/4N8RVT2KW6ZC", + "link": "https://icehack.asuscomm.com:8443/i/4N8RVT2KW6ZC?from=Jacob", "created_at": "2026-10-17T06:30:02.004Z", "expires_at": "2026-10-20T06:30:02.004Z" } diff --git a/contracts/examples/E-15-list-devices/response.json b/contracts/examples/E-15-list-devices/response.json index 0583150..b5a549c 100644 --- a/contracts/examples/E-15-list-devices/response.json +++ b/contracts/examples/E-15-list-devices/response.json @@ -5,6 +5,7 @@ "user_id": "0192a000-0000-7000-8000-000000000001", "name": "Galaxy S26", "app_version_code": 41, + "app_version_name": "1.1", "created_at": "2026-10-17T06:12:40.512Z", "last_seen_at": "2026-10-17T09:00:00.000Z", "revoked_at": null, @@ -15,6 +16,7 @@ "user_id": "0192a000-0000-7000-8000-000000000001", "name": "Old phone", "app_version_code": null, + "app_version_name": null, "created_at": "2026-10-17T06:20:00.000Z", "last_seen_at": "2026-10-17T06:25:00.000Z", "revoked_at": "2026-10-17T06:40:00.000Z", @@ -25,6 +27,7 @@ "user_id": "0192a000-0000-7000-8000-000000000002", "name": "Galaxy S26+", "app_version_code": 41, + "app_version_name": "1.1", "created_at": "2026-10-17T07:01:15.000Z", "last_seen_at": "2026-10-17T08:40:12.000Z", "revoked_at": null, diff --git a/contracts/examples/E-3-invites-accept/response.json b/contracts/examples/E-3-invites-accept/response.json index 09a9da3..adc2de2 100644 --- a/contracts/examples/E-3-invites-accept/response.json +++ b/contracts/examples/E-3-invites-accept/response.json @@ -1,5 +1,6 @@ { "token": "kvt_q7Zm0yHc1u2R9vXgB4kLw8sTn3eP6aJdF5oQ1iYbV0c", + "invite_kind": "admin_setup", "user": { "id": "0192a000-0000-7000-8000-000000000001", "role": "admin", @@ -14,6 +15,7 @@ "user_id": "0192a000-0000-7000-8000-000000000001", "name": "Galaxy S26", "app_version_code": 41, + "app_version_name": "1.1", "created_at": "2026-10-17T06:12:40.512Z", "last_seen_at": "2026-10-17T09:00:00.000Z", "revoked_at": null, diff --git a/contracts/examples/E-7-me/response.json b/contracts/examples/E-7-me/response.json index 0b1bad4..1875581 100644 --- a/contracts/examples/E-7-me/response.json +++ b/contracts/examples/E-7-me/response.json @@ -13,6 +13,7 @@ "user_id": "0192a000-0000-7000-8000-000000000001", "name": "Galaxy S26", "app_version_code": 41, + "app_version_name": "1.1", "created_at": "2026-10-17T06:12:40.512Z", "last_seen_at": "2026-10-17T09:00:00.000Z", "revoked_at": null, diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 25a222a..b9a0edf 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -36,6 +36,7 @@ paths: parameters: - $ref: '#/components/parameters/ProtocolOptional' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -74,6 +75,7 @@ paths: parameters: - $ref: '#/components/parameters/ProtocolOptional' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -100,6 +102,7 @@ paths: parameters: - $ref: '#/components/parameters/ProtocolOptional' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -148,8 +151,15 @@ paths: required: true schema: type: string + - name: from + in: query + required: false + description: The inviting admin's display name (E-12 link), passed on into the `kvitt://invite` link; an invalid value is ignored. + schema: + type: string - $ref: '#/components/parameters/ProtocolOptional' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -185,6 +195,7 @@ paths: parameters: - $ref: '#/components/parameters/ProtocolOptional' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -220,6 +231,7 @@ paths: type: string - $ref: '#/components/parameters/ProtocolOptional' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -253,6 +265,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -280,6 +293,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -322,6 +336,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -374,6 +389,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -418,6 +434,7 @@ paths: type: integer - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -465,6 +482,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -474,7 +492,7 @@ paths: $ref: '#/components/schemas/CreateInviteRequest' responses: '201': - description: The invite with `code` (12 characters, no hyphens) and `link` (`/i/`). + description: The invite with `code` (12 characters, no hyphens) and `link` (`/i/?from=`). headers: X-Kvitt-Server-Version: $ref: '#/components/headers/ServerVersion' @@ -510,6 +528,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -544,6 +563,7 @@ paths: - $ref: '#/components/parameters/InviteId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '204': @@ -575,6 +595,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -606,6 +627,7 @@ paths: - $ref: '#/components/parameters/DeviceId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '204': @@ -640,6 +662,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -674,6 +697,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -719,6 +743,7 @@ paths: - $ref: '#/components/parameters/AccountId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -766,6 +791,7 @@ paths: - $ref: '#/components/parameters/AccountId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -803,6 +829,7 @@ paths: - $ref: '#/components/parameters/AccountId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -837,6 +864,7 @@ paths: - $ref: '#/components/parameters/AccountId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -870,6 +898,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -915,6 +944,7 @@ paths: - $ref: '#/components/parameters/CategoryId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -961,6 +991,7 @@ paths: - $ref: '#/components/parameters/CategoryId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -998,6 +1029,7 @@ paths: - $ref: '#/components/parameters/CategoryId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -1032,6 +1064,7 @@ paths: - $ref: '#/components/parameters/CategoryId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -1065,6 +1098,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -1111,6 +1145,7 @@ paths: - $ref: '#/components/parameters/TemplateId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -1154,6 +1189,7 @@ paths: - $ref: '#/components/parameters/TemplateId' - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -1202,6 +1238,7 @@ paths: default: 500 - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' responses: '200': @@ -1236,6 +1273,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -1284,6 +1322,7 @@ paths: parameters: - $ref: '#/components/parameters/Protocol' - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AppVersionName' - $ref: '#/components/parameters/AcceptLanguage' requestBody: required: true @@ -1355,6 +1394,13 @@ components: description: The app's `versionCode`, sent by the app on every call. schema: type: integer + AppVersionName: + name: X-Kvitt-App-Version-Name + in: header + required: false + description: The app's `versionName`, sent by the app on every call; stored per device like `X-Kvitt-App-Version` (1 to 32 characters, anything else ignored). + schema: + type: string AcceptLanguage: name: Accept-Language in: header @@ -1650,7 +1696,7 @@ components: Device: type: object description: Only in /me, /devices and /invites/accept. - required: [id, user_id, name, app_version_code, created_at, last_seen_at, revoked_at, is_current] + required: [id, user_id, name, app_version_code, app_version_name, created_at, last_seen_at, revoked_at, is_current] properties: id: $ref: '#/components/schemas/Uuid' @@ -1660,6 +1706,9 @@ components: type: string app_version_code: type: [integer, 'null'] + app_version_name: + type: [string, 'null'] + description: The app's `versionName` from `X-Kvitt-App-Version-Name`; null until a request carried it. created_at: $ref: '#/components/schemas/Timestamp' last_seen_at: @@ -2026,11 +2075,14 @@ components: description: Required for `admin_setup` and `partner` invites (`invalid_request` if missing), ignored for `device`. AcceptInviteResponse: type: object - required: [token, user, device, household] + required: [token, invite_kind, user, device, household] properties: token: type: string description: '`kvt_` + base64url without padding of 32 random bytes (47 characters, SPEC §8.2).' + invite_kind: + $ref: '#/components/schemas/InviteKind' + description: The kind of the accepted invite; the app shows the profile step only after `partner` (AC-2.10). user: $ref: '#/components/schemas/User' device: diff --git a/contracts/schema/migrations/0003_device_version_name.sql b/contracts/schema/migrations/0003_device_version_name.sql new file mode 100644 index 0000000..3c26475 --- /dev/null +++ b/contracts/schema/migrations/0003_device_version_name.sql @@ -0,0 +1,9 @@ +-- Kvitt server schema v3: the app's versionName per device (devices.app_version_name), sent in the +-- X-Kvitt-App-Version-Name header and written like app_version_code (SPEC §14.3.1, §8.2). Do not edit; add a new +-- migration and bump the schema version (SPEC §14.8). Applied on top of 0002_categories_v2.sql. +-- +-- Existing devices keep every column and get NULL until their next request that carries the header. +ALTER TABLE devices ADD COLUMN app_version_name TEXT + CHECK (app_version_name IS NULL OR length(app_version_name) BETWEEN 1 AND 32); + +UPDATE meta SET value = '3' WHERE key = 'schema_version'; diff --git a/server/crates/kvitt-import/tests/import.rs b/server/crates/kvitt-import/tests/import.rs index 2ab3775..f543815 100644 --- a/server/crates/kvitt-import/tests/import.rs +++ b/server/crates/kvitt-import/tests/import.rs @@ -706,7 +706,10 @@ async fn a_schema_1_database_is_refused_until_migrated() { let refused = kvitt_import::open(&path).await; match refused { Err(ImportError::Io(message)) => { - assert!(message.contains("expected 2"), "{message}"); + assert!( + message.contains(&format!("expected {}", db::SCHEMA_VERSION)), + "{message}" + ); assert!(message.contains("kvitt-server migrate"), "{message}"); } Err(other) => panic!("expected a database error, got {other:?}"), diff --git a/server/crates/kvitt-server/migrations/0003_device_version_name.sql b/server/crates/kvitt-server/migrations/0003_device_version_name.sql new file mode 100644 index 0000000..3c26475 --- /dev/null +++ b/server/crates/kvitt-server/migrations/0003_device_version_name.sql @@ -0,0 +1,9 @@ +-- Kvitt server schema v3: the app's versionName per device (devices.app_version_name), sent in the +-- X-Kvitt-App-Version-Name header and written like app_version_code (SPEC §14.3.1, §8.2). Do not edit; add a new +-- migration and bump the schema version (SPEC §14.8). Applied on top of 0002_categories_v2.sql. +-- +-- Existing devices keep every column and get NULL until their next request that carries the header. +ALTER TABLE devices ADD COLUMN app_version_name TEXT + CHECK (app_version_name IS NULL OR length(app_version_name) BETWEEN 1 AND 32); + +UPDATE meta SET value = '3' WHERE key = 'schema_version'; diff --git a/server/crates/kvitt-server/src/api/invites.rs b/server/crates/kvitt-server/src/api/invites.rs index 77d4edf..6efc4e8 100644 --- a/server/crates/kvitt-server/src/api/invites.rs +++ b/server/crates/kvitt-server/src/api/invites.rs @@ -9,12 +9,13 @@ use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use base64::Engine as _; use chrono::{DateTime, Duration, Utc}; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use sqlx::SqliteConnection; use uuid::Uuid; use crate::AppState; use crate::api::{ApiJson, IdPath, JsonBody, rows, validate}; -use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, sha256_hex}; +use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, app_version_name, sha256_hex}; use crate::db; use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable}; @@ -84,9 +85,18 @@ pub fn generate_token() -> Result { )) } -/// `/i/`. -pub fn invite_link(public_url: &str, code: &str) -> String { - format!("{public_url}/i/{code}") +/// Query parameter of the landing link and of `kvitt://invite` that names the inviting admin (AC-2.6, S-1 help line). +pub const FROM_PARAM: &str = "from"; + +/// `/i/`, with `?from=` when `from` is given (E-12). +pub fn invite_link(public_url: &str, code: &str, from: Option<&str>) -> String { + match from { + Some(name) => format!( + "{public_url}/i/{code}?{FROM_PARAM}={}", + utf8_percent_encode(name, NON_ALPHANUMERIC) + ), + None => format!("{public_url}/i/{code}"), + } } /// A freshly created invite with its plain code (shown once). @@ -101,14 +111,14 @@ pub struct NewInvite { } impl NewInvite { - /// The wire form with `code` and `link` (E-12 response). - pub fn wire(&self, public_url: &str) -> Invite { + /// The wire form with `code` and `link` (E-12 response); `from` is the inviting admin's display name. + pub fn wire(&self, public_url: &str, from: Option<&str>) -> Invite { Invite { id: self.id, kind: self.kind, target_user_id: self.target_user_id, code: Some(self.code.clone()), - link: Some(invite_link(public_url, &self.code)), + link: Some(invite_link(public_url, &self.code, from)), created_at: self.created_at.clone(), expires_at: self.expires_at.clone(), } @@ -303,14 +313,15 @@ pub async fn accept_invite( let token = generate_token()?; let device_id = Uuid::now_v7(); sqlx::query( - "INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, created_at, last_seen_at) \ - VALUES (?, ?, ?, ?, NULL, ?, ?, ?)", + "INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, app_version_name, \ + created_at, last_seen_at) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?)", ) .bind(device_id.to_string()) .bind(user_id.to_string()) .bind(&device_name) .bind(sha256_hex(&token)) .bind(app_version(&headers)) + .bind(app_version_name(&headers)) .bind(&now_ts) .bind(&now_ts) .execute(&mut *tx) @@ -333,6 +344,7 @@ pub async fn accept_invite( tracing::info!(device_id = %device_id, invite_kind = row.kind, "device paired"); Ok(JsonBody(AcceptInviteResponse { token, + invite_kind: kind, user, device, household, @@ -347,7 +359,7 @@ pub async fn accept_invite( description = "Creating a `partner` invite cancels any pending one (AC-2.1).", request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"), responses( - (status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`/i/`).", body = Invite, content_type = "application/json; charset=utf-8"), + (status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`/i/?from=`).", body = Invite, content_type = "application/json; charset=utf-8"), (status = 400, response = BadRequest), (status = 403, response = Forbidden), (status = 404, response = NotFound), @@ -393,10 +405,14 @@ pub async fn create_invite( .await? } }; + // The link names the admin so S-1 can say whom the code came from (AC-2.6). + let from = rows::user(&mut tx, admin.user_id) + .await? + .map(|user| user.display_name); tx.commit().await?; Ok(( StatusCode::CREATED, - JsonBody(invite.wire(&state.config.public_url)), + JsonBody(invite.wire(&state.config.public_url, from.as_deref())), )) } diff --git a/server/crates/kvitt-server/src/api/landing.rs b/server/crates/kvitt-server/src/api/landing.rs index 1938ce5..896aeb8 100644 --- a/server/crates/kvitt-server/src/api/landing.rs +++ b/server/crates/kvitt-server/src/api/landing.rs @@ -2,16 +2,18 @@ //! //! Swedish when `Accept-Language` prefers `sv`, English otherwise. The page shows the app name, "Download Kvitt" //! linking to the latest APK, "Open in Kvitt" linking to `kvitt://invite?server=…&code=…`, the code in groups of four -//! and one instruction sentence. It never checks the code and holds no personal data. Any code of 1 to 32 -//! characters gets 200; anything else a 404 page. +//! and one instruction sentence. It never checks the code and holds no personal data of its own: the optional +//! `from` query parameter (the inviting admin's display name, set by E-12's link) is only passed on into the +//! `kvitt://invite` link, never shown. Any code of 1 to 32 characters gets 200; anything else a 404 page. -use axum::extract::{Path, State}; +use axum::extract::{Path, RawQuery, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; -use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use crate::AppState; -use crate::api::apk; +use crate::api::invites::FROM_PARAM; +use crate::api::{apk, validate}; /// Longest code the page accepts (E-4). pub const MAX_CODE_CHARS: usize = 32; @@ -123,18 +125,48 @@ letter-spacing:.1em;margin:1.5rem 0}a.button{display:block;margin:.75rem 0;paddi text-decoration:none;font-weight:600;background:#2e7d32;color:#fff}a.secondary{background:#e8f5e9;color:#1b5e20}\ @media (prefers-color-scheme:dark){body{background:#121212;color:#eee}a.secondary{background:#1e3320;color:#c8e6c9}}"; -/// The landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published. -pub fn render(lang: Lang, code: &str, public_url: &str, download_url: Option<&str>) -> String { +/// The `from` value of a raw query string: the first `from=` pair, `+` as space, percent-decoded as UTF-8, trimmed. +/// `None` unless it is a display name of §9.6 (1 to 40 code points, no control characters); a bad value is dropped, +/// never an error, so the page still opens. +pub fn from_param(raw_query: Option<&str>) -> Option { + let value = raw_query? + .split('&') + .find_map(|pair| pair.strip_prefix(FROM_PARAM)?.strip_prefix('='))?; + let decoded = percent_decode_str(&value.replace('+', " ")) + .decode_utf8() + .ok()? + .trim() + .to_owned(); + let length = decoded.chars().count(); + ((1..=validate::DISPLAY_NAME_MAX).contains(&length) && !decoded.chars().any(char::is_control)) + .then_some(decoded) +} + +/// The landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published; `from` the inviting +/// admin's name, passed on into the `kvitt://invite` link only. +pub fn render( + lang: Lang, + code: &str, + public_url: &str, + download_url: Option<&str>, + from: Option<&str>, +) -> String { let copy = match lang { Lang::En => EN, Lang::Sv => SV, }; let code = clean_code(code); - let open_url = format!( + let mut open_url = format!( "kvitt://invite?server={}&code={}", utf8_percent_encode(public_url, NON_ALPHANUMERIC), utf8_percent_encode(&code, NON_ALPHANUMERIC) ); + if let Some(name) = from { + open_url.push_str(&format!( + "&{FROM_PARAM}={}", + utf8_percent_encode(name, NON_ALPHANUMERIC) + )); + } let download = download_url .map(|url| { format!( @@ -186,6 +218,7 @@ fn html(status: StatusCode, body: String) -> Response { security(()), params( ("code" = String, Path), + ("from" = Option, Query, description = "The inviting admin's display name (E-12 link), passed on into the `kvitt://invite` link; an invalid value is ignored."), ), responses( (status = 200, description = "Landing page.", body = String, content_type = "text/html; charset=utf-8"), @@ -196,6 +229,7 @@ pub async fn get_invite_landing( State(state): State, headers: HeaderMap, Path(code): Path, + RawQuery(query): RawQuery, ) -> Response { let len = code.chars().count(); if !(1..=MAX_CODE_CHARS).contains(&len) { @@ -217,7 +251,13 @@ pub async fn get_invite_landing( }; html( StatusCode::OK, - render(lang, &code, &state.config.public_url, download.as_deref()), + render( + lang, + &code, + &state.config.public_url, + download.as_deref(), + from_param(query.as_deref()).as_deref(), + ), ) } @@ -238,8 +278,30 @@ mod tests { #[test] fn html_is_escaped() { - let page = render(Lang::En, "", "https://x", None); + let page = render(Lang::En, "", "https://x", None, Some("Å")); assert!(page.contains("<B>")); assert!(!page.contains("")); + assert!(page.contains("&from=%3Ci%3E%C3%85\""), "{page}"); + assert!(!page.contains("")); + } + + #[test] + fn from_parameter() { + assert_eq!(from_param(None), None); + assert_eq!(from_param(Some("from=Jacob")).as_deref(), Some("Jacob")); + assert_eq!( + from_param(Some("x=1&from=%C3%85sa+Berg")).as_deref(), + Some("Åsa Berg") + ); + assert_eq!(from_param(Some("from=")), None); + assert_eq!(from_param(Some("from=%20%20")), None); + assert_eq!(from_param(Some("from=%FF")), None, "not UTF-8"); + assert_eq!(from_param(Some("from=a%0Ab")), None, "control character"); + assert_eq!(from_param(Some(&format!("from={}", "a".repeat(41)))), None); + assert_eq!( + from_param(Some(&format!("from={}", "å".repeat(40)))).map(|n| n.chars().count()), + Some(40) + ); + assert_eq!(from_param(Some("fromage=1")), None); } } diff --git a/server/crates/kvitt-server/src/api/rows.rs b/server/crates/kvitt-server/src/api/rows.rs index 9f20713..7e1bb37 100644 --- a/server/crates/kvitt-server/src/api/rows.rs +++ b/server/crates/kvitt-server/src/api/rows.rs @@ -129,6 +129,7 @@ struct DeviceRow { user_id: String, name: String, app_version_code: Option, + app_version_name: Option, created_at: String, last_seen_at: String, revoked_at: Option, @@ -142,6 +143,7 @@ impl DeviceRow { user_id: uuid(&self.user_id)?, name: self.name, app_version_code: self.app_version_code, + app_version_name: self.app_version_name, created_at: self.created_at, last_seen_at: self.last_seen_at, revoked_at: self.revoked_at, @@ -152,7 +154,7 @@ impl DeviceRow { macro_rules! device_columns { () => { - "id, user_id, name, app_version_code, created_at, last_seen_at, revoked_at" + "id, user_id, name, app_version_code, app_version_name, created_at, last_seen_at, revoked_at" }; } diff --git a/server/crates/kvitt-server/src/auth.rs b/server/crates/kvitt-server/src/auth.rs index 510726a..11ee6cc 100644 --- a/server/crates/kvitt-server/src/auth.rs +++ b/server/crates/kvitt-server/src/auth.rs @@ -4,7 +4,7 @@ //! 426 `protocol_unsupported`. It runs before the token check, so an outdated app always learns it must update. //! * [`AuthDevice`] is the extractor for authenticated handlers: `Authorization: Bearer `, SHA-256 hex lookup //! of a device; missing, malformed or unknown is 401 `unauthorized`, a revoked device 401 `device_revoked`. -//! `last_seen_at` and `app_version_code` are refreshed at most once per hour per device. +//! `last_seen_at`, `app_version_code` and `app_version_name` are refreshed at most once per hour per device. //! * [`AdminDevice`] additionally requires the `admin` role (403 `forbidden`, SPEC §8.4). use std::fmt::Write as _; @@ -30,12 +30,16 @@ pub const PROTOCOL_HEADER: &str = "x-kvitt-protocol"; pub const PROTOCOL_VERSION: &str = "1"; /// Header carrying the app's `versionCode`. pub const APP_VERSION_HEADER: &str = "x-kvitt-app-version"; +/// Header carrying the app's `versionName` (schema 3, shown on S-20). +pub const APP_VERSION_NAME_HEADER: &str = "x-kvitt-app-version-name"; +/// Longest `versionName` stored (the `devices.app_version_name` CHECK of migration 0003). +pub const APP_VERSION_NAME_MAX: usize = 32; /// Prefix of every device token (SPEC §8.2). pub const TOKEN_PREFIX: &str = "kvt_"; /// Length of a token: `kvt_` plus 43 base64url characters (32 bytes, no padding). pub const TOKEN_LEN: usize = 47; -/// How often `last_seen_at` / `app_version_code` are written at most (SPEC §8.2). +/// How often `last_seen_at` / `app_version_code` / `app_version_name` are written at most (SPEC §8.2). const TOUCH_INTERVAL: Duration = Duration::hours(1); /// Lowercase hex SHA-256 of `text` (token and invite code hashes, SPEC §8.2, §8.3). @@ -103,6 +107,15 @@ fn app_version_code(headers: &HeaderMap) -> Option { .filter(|v| *v >= 0) } +/// The `X-Kvitt-App-Version-Name` value: trimmed, 1 to [`APP_VERSION_NAME_MAX`] characters; anything else is +/// ignored (the stored value is kept), as an unparsable `X-Kvitt-App-Version` is. +pub fn app_version_name(headers: &HeaderMap) -> Option { + let value = headers.get(APP_VERSION_NAME_HEADER)?.to_str().ok()?.trim(); + (1..=APP_VERSION_NAME_MAX) + .contains(&value.chars().count()) + .then(|| value.to_owned()) +} + impl AuthDevice { /// Authenticates the request headers against the database. pub async fn authenticate(headers: &HeaderMap, state: &AppState) -> Result { @@ -141,11 +154,12 @@ impl AuthDevice { if due { let mut tx = crate::db::begin_immediate(&state.db).await?; sqlx::query( - "UPDATE devices SET last_seen_at = ?, app_version_code = COALESCE(?, app_version_code) \ - WHERE id = ?", + "UPDATE devices SET last_seen_at = ?, app_version_code = COALESCE(?, app_version_code), \ + app_version_name = COALESCE(?, app_version_name) WHERE id = ?", ) .bind(format_ts(now)) .bind(app_version_code(headers)) + .bind(app_version_name(headers)) .bind(device_id.to_string()) .execute(&mut *tx) .await?; diff --git a/server/crates/kvitt-server/src/cli/bootstrap.rs b/server/crates/kvitt-server/src/cli/bootstrap.rs index 08e2c56..3d1c5f2 100644 --- a/server/crates/kvitt-server/src/cli/bootstrap.rs +++ b/server/crates/kvitt-server/src/cli/bootstrap.rs @@ -43,7 +43,7 @@ pub fn render(invite: &NewInvite, public_url: &str) -> String { format!( "Code: {}\nLink: {}\nExpires: {}\n", invites::display_code(&invite.code), - invites::invite_link(public_url, &invite.code), + invites::invite_link(public_url, &invite.code, None), invite.expires_at ) } diff --git a/server/crates/kvitt-server/src/db/mod.rs b/server/crates/kvitt-server/src/db/mod.rs index 8c99ccd..932dd33 100644 --- a/server/crates/kvitt-server/src/db/mod.rs +++ b/server/crates/kvitt-server/src/db/mod.rs @@ -7,10 +7,11 @@ pub use pool::{Db, begin_immediate, connect}; pub use sequence::next_server_version; /// The schema version the embedded migrations produce (`meta.schema_version`, SPEC §14.1). -pub const SCHEMA_VERSION: i64 = 2; +pub const SCHEMA_VERSION: i64 = 3; /// The embedded migrations of `migrations/` (`0001_init.sql` is byte-identical to `contracts/schema/server-v1.sql`, -/// `0002_categories_v2.sql` to `contracts/schema/migrations/0002_categories_v2.sql`, SPEC §14.1.1). +/// `0002_categories_v2.sql` and `0003_device_version_name.sql` to their copies in `contracts/schema/migrations/`, +/// SPEC §14.1.1). pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); /// Runs every pending migration. diff --git a/server/crates/kvitt-server/src/openapi.rs b/server/crates/kvitt-server/src/openapi.rs index f20f003..2bf193d 100644 --- a/server/crates/kvitt-server/src/openapi.rs +++ b/server/crates/kvitt-server/src/openapi.rs @@ -6,8 +6,8 @@ //! hold for every operation are applied once here, by [`CommonRules`], so they cannot drift per handler: //! //! * SPEC §14.3.1 headers: every operation takes `X-Kvitt-Protocol` (required on authenticated operations, optional -//! on the unauthenticated ones), `X-Kvitt-App-Version` and `Accept-Language`; every response carries -//! `X-Kvitt-Server-Version`. +//! on the unauthenticated ones), `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`; every +//! response carries `X-Kvitt-Server-Version`. //! * SPEC §14.3.2 always-possible errors: `401`, `426`, `429`, `500`, `503` on authenticated operations and `429`, //! `500`, `503` on unauthenticated ones, unless the operation documents its own. //! * Security: the document-level requirement is `device` (bearer, SPEC §8.2); unauthenticated operations declare @@ -258,6 +258,15 @@ impl Modify for CommonRules { plain_schema(Type::Integer), ), ); + components.parameters.insert( + "AppVersionName".to_owned(), + header_param( + "X-Kvitt-App-Version-Name", + false, + "The app's `versionName`, sent by the app on every call; stored per device like `X-Kvitt-App-Version` (1 to 32 characters, anything else ignored).", + plain_schema(Type::String), + ), + ); components.parameters.insert( "AcceptLanguage".to_owned(), header_param( @@ -339,7 +348,7 @@ fn apply_operation_rules(operation: &mut Operation) { } else { "Protocol" }; - for name in [protocol, "AppVersion", "AcceptLanguage"] { + for name in [protocol, "AppVersion", "AppVersionName", "AcceptLanguage"] { parameters.push(RefOr::Ref(Ref::new(format!( "#/components/parameters/{name}" )))); diff --git a/server/crates/kvitt-server/src/router.rs b/server/crates/kvitt-server/src/router.rs index 9e846e6..22d95e5 100644 --- a/server/crates/kvitt-server/src/router.rs +++ b/server/crates/kvitt-server/src/router.rs @@ -5,7 +5,7 @@ use std::time::Instant; use axum::Router; -use axum::extract::{DefaultBodyLimit, MatchedPath, Path, Request, State}; +use axum::extract::{DefaultBodyLimit, MatchedPath, Path, RawQuery, Request, State}; use axum::http::{HeaderMap, HeaderValue}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; @@ -114,7 +114,7 @@ pub fn router(state: AppState) -> Router { /// `GET /i/`: E-4 with an empty code, which the landing handler answers with its HTML 404 page. async fn empty_invite_code(state: State, headers: HeaderMap) -> Response { - landing::get_invite_landing(state, headers, Path(String::new())).await + landing::get_invite_landing(state, headers, Path(String::new()), RawQuery(None)).await } /// Unknown paths: 404 `not_found` in the error shape. diff --git a/server/crates/kvitt-server/src/wire/device.rs b/server/crates/kvitt-server/src/wire/device.rs index 324a50f..0e9d0db 100644 --- a/server/crates/kvitt-server/src/wire/device.rs +++ b/server/crates/kvitt-server/src/wire/device.rs @@ -16,6 +16,9 @@ pub struct Device { pub name: String, #[schema(schema_with = super::nullable_int, required = true)] pub app_version_code: Option, + /// The app's `versionName` from `X-Kvitt-App-Version-Name` (schema 3); null until a request carried it. + #[schema(required = true)] + pub app_version_name: Option, #[schema(value_type = TimestampSchema)] pub created_at: String, #[schema(value_type = TimestampSchema)] diff --git a/server/crates/kvitt-server/src/wire/invite.rs b/server/crates/kvitt-server/src/wire/invite.rs index 26b19d6..1d9e69e 100644 --- a/server/crates/kvitt-server/src/wire/invite.rs +++ b/server/crates/kvitt-server/src/wire/invite.rs @@ -51,10 +51,12 @@ pub struct AcceptInviteRequest { pub display_name: Option, } -/// E-3 response. The token is shown only here. +/// E-3 response. The token is shown only here. `invite_kind` is the kind of the accepted invite, so the app shows +/// the profile step (S-2) only after a `partner` pairing (AC-2.10). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct AcceptInviteResponse { pub token: String, + pub invite_kind: InviteKind, pub user: User, pub device: Device, pub household: Vec, diff --git a/server/crates/kvitt-server/tests/core_api/devices.rs b/server/crates/kvitt-server/tests/core_api/devices.rs index 1e30a21..d2310b2 100644 --- a/server/crates/kvitt-server/tests/core_api/devices.rs +++ b/server/crates/kvitt-server/tests/core_api/devices.rs @@ -8,7 +8,9 @@ use crate::common::*; #[tokio::test] async fn e15_admin_sees_all_devices_member_own_ordered_by_created_at() { let app = TestApp::household().await; - sqlx::query("UPDATE devices SET app_version_code = 41 WHERE revoked_at IS NULL") + sqlx::query( + "UPDATE devices SET app_version_code = 41, app_version_name = '1.1' WHERE revoked_at IS NULL", + ) .execute(&app.state.db) .await .unwrap(); diff --git a/server/crates/kvitt-server/tests/core_api/invites.rs b/server/crates/kvitt-server/tests/core_api/invites.rs index 13ad6ab..f8af414 100644 --- a/server/crates/kvitt-server/tests/core_api/invites.rs +++ b/server/crates/kvitt-server/tests/core_api/invites.rs @@ -37,6 +37,9 @@ async fn accept(app: &TestApp, body: Value) -> Reply { request .headers_mut() .insert("x-kvitt-app-version", "41".parse().unwrap()); + request + .headers_mut() + .insert("x-kvitt-app-version-name", " 1.1 ".parse().unwrap()); app.send(request).await } @@ -69,6 +72,8 @@ async fn e3_admin_setup_creates_the_admin_and_e2_turns_initialized() { assert_eq!(reply.json["user"]["role"], "admin"); assert_eq!(reply.json["user"]["display_name"], "Jacob"); assert_eq!(reply.json["device"]["app_version_code"], 41); + assert_eq!(reply.json["device"]["app_version_name"], "1.1"); + assert_eq!(reply.json["invite_kind"], "admin_setup"); // The token works; only its hash is stored (§8.2). let me = app.call(Method::GET, "/me", &token, None).await; @@ -94,6 +99,7 @@ async fn e3_partner_creates_the_member_and_device_pairs_an_existing_user() { let reply = accept(&app, accept_body(&code, Some("Anna"))).await; assert_eq!(reply.status, 200, "{}", reply.text()); assert_eq!(reply.json["user"]["role"], "member"); + assert_eq!(reply.json["invite_kind"], "partner"); assert_eq!(reply.json["household"].as_array().unwrap().len(), 2); let member_id = reply.json["user"]["id"].as_str().unwrap().to_owned(); @@ -103,6 +109,7 @@ async fn e3_partner_creates_the_member_and_device_pairs_an_existing_user() { assert_eq!(reply.status, 200, "{}", reply.text()); assert_eq!(reply.json["user"]["id"], json!(member_id)); assert_eq!(reply.json["user"]["display_name"], "Anna"); + assert_eq!(reply.json["invite_kind"], "device"); assert_eq!(reply.json["device"]["is_current"], true); assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM users").await, 2); } @@ -212,7 +219,9 @@ async fn e12_partner_invite_matches_the_example() { assert_eq!(code.len(), 12); assert_eq!( reply.json["link"], - json!(format!("https://icehack.asuscomm.com:8443/i/{code}")) + json!(format!( + "https://icehack.asuscomm.com:8443/i/{code}?from=Jacob" + )) ); assert_eq!(reply.json["created_at"], NOW); assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z"); diff --git a/server/crates/kvitt-server/tests/core_api/landing.rs b/server/crates/kvitt-server/tests/core_api/landing.rs index 073e816..a57a898 100644 --- a/server/crates/kvitt-server/tests/core_api/landing.rs +++ b/server/crates/kvitt-server/tests/core_api/landing.rs @@ -82,3 +82,22 @@ async fn e4_does_not_check_validity_and_404s_outside_1_to_32_characters() { assert_eq!(reply.status, 200); assert!(!reply.text().contains("