K2: card purchases on Android (Room 2, card cycle, :feature:cards S-23 to S-27)
- :core:model model/Cards.kt per SPEC §14.2.4 "Changes in v0.35"; ContractTest covers the card wire values. - :domain matchCardPurchase (§14.5.8), passing match_card_purchase.json (17 cases) in VectorTest. - :data Room version 2 (card_connections, card_purchases, card_sync_state, MIGRATION_1_2 with its test, 2.json exported); DTOs for E-36 to E-44 round-tripping K1's examples; the card cycle (E-44 push of pending link changes, E-40 pull) after each household cycle and after refresh; CardRepositoryImpl with CP-9 prefill, CP-10 matches, offline-first link/unlink/ignore; a local expense delete unlinks. - :core:fakes FakeCardRepository and fakes/cards scenarios for every state of S-23 to S-27. - :feature:cards S-23 (sheet and "Välj en annan utgift"), S-24, S-25 (Custom Tab, E-38 every 2 s while resumed, return link), S-26 (disconnect dialog), S-27, CardArt with the SJ Prio picture and the drawn fallback; 135 goldens and state-holder tests. - :feature:expense S-7 cardPurchaseId prefill and link on save, S-8 "Från kortköp" line; :feature:account S-5 "Från kortköp"; :feature:settings S-13 "Anslut kort". - :core:design KvittDestination.Cards with credit_card and nav_cards (four destinations, D-172); the bar's labels shrink to fit at large font scales. :app routes, kvitt://cards/connected, bindings. - docs/ux/strings.csv: the card keys of §13.3 plus K2's additions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -144,6 +144,7 @@ dependencies {
|
||||
implementation(project(":feature:settle"))
|
||||
implementation(project(":feature:analytics"))
|
||||
implementation(project(":feature:settings"))
|
||||
implementation(project(":feature:cards"))
|
||||
testImplementation(project(":core:fakes"))
|
||||
testImplementation(testFixtures(project(":core:design")))
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
<!-- The only exported activity (§8.8): launcher, the invite links of AC-2.6 and notification taps. -->
|
||||
<!-- The only exported activity (§8.8): launcher, the invite links of AC-2.6, the card return link and notification taps. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -34,6 +34,13 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="kvitt" android:host="invite" />
|
||||
</intent-filter>
|
||||
<!-- CP-2 (D-173): the card callback page links back with kvitt://cards/connected?attempt=<id>. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="kvitt" android:host="cards" android:path="/connected" />
|
||||
</intent-filter>
|
||||
<!-- Unverified link: the user may get a chooser (AC-2.6). The host is the Gradle property kvitt.publicHost
|
||||
(BuildConfig.PUBLIC_HOST, D-121); no port, since Kvitt is served on 443 (D-124). A filter without a
|
||||
port matches every port of the host. -->
|
||||
|
||||
@@ -14,6 +14,7 @@ import se.hagstedt.kvitt.link.AppLink
|
||||
import se.hagstedt.kvitt.link.OpenTarget
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AccountRepository
|
||||
import se.hagstedt.kvitt.model.CardRepository
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.SyncRepository
|
||||
@@ -25,6 +26,7 @@ class AppViewModel @Inject constructor(
|
||||
session: SessionRepository,
|
||||
private val sync: SyncRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val cards: CardRepository,
|
||||
) : ViewModel() {
|
||||
/** Null until the first emission (the shell shows the empty themed screen meanwhile). */
|
||||
val session: StateFlow<SessionState?> = session.session.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
@@ -39,6 +41,11 @@ class AppViewModel @Inject constructor(
|
||||
/** A notification tap waiting for the paired graph (AC-13.6). */
|
||||
val open: StateFlow<OpenTarget?> = openState.asStateFlow()
|
||||
|
||||
private val cardReturnState = MutableStateFlow<String?>(null)
|
||||
|
||||
/** The attempt of the last card return link (CP-2), until S-25 or S-23 has taken it. */
|
||||
val cardReturn: StateFlow<String?> = cardReturnState.asStateFlow()
|
||||
|
||||
private val askNotificationsState = MutableStateFlow(false)
|
||||
|
||||
/** AC-2.9: true once the full sync after a pairing has finished; the activity then asks for POST_NOTIFICATIONS. */
|
||||
@@ -52,6 +59,7 @@ class AppViewModel @Inject constructor(
|
||||
when (link) {
|
||||
is AppLink.Invite -> inviteState.value = if (session.value is SessionState.Paired) null else link
|
||||
is AppLink.Open -> openState.value = link.target
|
||||
is AppLink.CardConnected -> cardReturnState.value = link.attemptId
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
@@ -60,6 +68,19 @@ class AppViewModel @Inject constructor(
|
||||
openState.value = null
|
||||
}
|
||||
|
||||
fun cardReturnHandled() {
|
||||
cardReturnState.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* A card return link while S-25 is not open (CP-2): ask the attempt once, which writes the connection and follows the
|
||||
* first fetch; `:app` then shows S-23.
|
||||
*/
|
||||
fun checkCardAttempt(attemptId: String) {
|
||||
cardReturnState.value = null
|
||||
viewModelScope.launch { cards.attempt(attemptId) }
|
||||
}
|
||||
|
||||
/** S-1 succeeded: wait for the first successful sync (AC-2.9), then ask for the notification permission. */
|
||||
fun onPaired() {
|
||||
inviteState.value = null
|
||||
|
||||
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.link
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import se.hagstedt.kvitt.MainActivity
|
||||
import se.hagstedt.kvitt.feature.onboarding.InviteLink
|
||||
|
||||
@@ -22,10 +23,17 @@ sealed interface AppLink {
|
||||
|
||||
/** A notification tap. */
|
||||
data class Open(val target: OpenTarget) : AppLink
|
||||
|
||||
/**
|
||||
* The card callback page's link back to the app, `kvitt://cards/connected?attempt=<id>` (CP-2, D-173): S-25 polls
|
||||
* that attempt at once, or S-23 opens and asks it when S-25 is not open.
|
||||
*/
|
||||
data class CardConnected(val attemptId: String) : AppLink
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and
|
||||
* Parses the intents `MainActivity` receives: the card return link `kvitt://cards/connected?attempt=<id>` (CP-2),
|
||||
* `kvitt://invite?server=…&code=…[&from=…]` and
|
||||
* `https://<BuildConfig.PUBLIC_HOST>/i/<CODE>[?from=…]` (AC-2.6, D-124; the manifest's intent filter takes its host from
|
||||
* the `publicHost` placeholder, D-121), both through `:feature:onboarding`'s [InviteLink], the reader S-1 uses for pasted
|
||||
* links, which takes the server from the link itself, and the notification taps this app builds ([openIntent]).
|
||||
@@ -37,11 +45,14 @@ object AppLinks {
|
||||
private const val KIND_HOME = "home"
|
||||
private const val KIND_EXPENSE = "expense"
|
||||
private const val KIND_SETTLEMENT = "settlement"
|
||||
private const val CARD_RETURN = "kvitt://cards/connected"
|
||||
private const val ATTEMPT_MAX = 64
|
||||
private const val ASCII = 128
|
||||
|
||||
fun parse(intent: Intent?): AppLink? {
|
||||
if (intent == null) return null
|
||||
return when (intent.action) {
|
||||
Intent.ACTION_VIEW -> parseUri(intent.dataString)
|
||||
Intent.ACTION_VIEW -> parseCardReturn(intent.dataString) ?: parseUri(intent.dataString)
|
||||
ACTION_OPEN -> parseOpen(intent.getStringExtra(EXTRA_KIND), intent.getStringExtra(EXTRA_ID))
|
||||
else -> null
|
||||
}
|
||||
@@ -53,6 +64,14 @@ object AppLinks {
|
||||
return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code, it.adminName) }
|
||||
}
|
||||
|
||||
/** `kvitt://cards/connected?attempt=<id>`; null for anything else or an attempt id that is not an id (at most 64 of `[0-9A-Za-z-]`). */
|
||||
fun parseCardReturn(uri: String?): AppLink.CardConnected? {
|
||||
val parsed = uri?.trim()?.takeIf { it.startsWith(CARD_RETURN, ignoreCase = true) }?.let(Uri::parse) ?: return null
|
||||
if (!parsed.scheme.equals("kvitt", ignoreCase = true) || !parsed.host.equals("cards", ignoreCase = true) || parsed.path != "/connected") return null
|
||||
val attempt = parsed.getQueryParameter("attempt")?.takeIf { it.length in 1..ATTEMPT_MAX && it.all { c -> (c.isLetterOrDigit() && c.code < ASCII) || c == '-' } } ?: return null
|
||||
return AppLink.CardConnected(attempt)
|
||||
}
|
||||
|
||||
fun parseOpen(kind: String?, id: String?): AppLink.Open? = when (kind) {
|
||||
KIND_HOME -> AppLink.Open(OpenTarget.Home)
|
||||
KIND_EXPENSE -> id?.takeIf { it.isNotEmpty() }?.let { AppLink.Open(OpenTarget.Expense(it)) }
|
||||
|
||||
@@ -44,6 +44,11 @@ import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
|
||||
import se.hagstedt.kvitt.feature.account.AccountEditorScreen
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
|
||||
import se.hagstedt.kvitt.feature.analytics.DrillDownScreen
|
||||
import se.hagstedt.kvitt.feature.cards.CardConnectScreen
|
||||
import se.hagstedt.kvitt.feature.cards.CardConnectionScreen
|
||||
import se.hagstedt.kvitt.feature.cards.CardPickScreen
|
||||
import se.hagstedt.kvitt.feature.cards.CardsScreen
|
||||
import se.hagstedt.kvitt.feature.cards.ConnectCardScreen
|
||||
import se.hagstedt.kvitt.feature.expense.ExpenseDetailScreen
|
||||
import se.hagstedt.kvitt.feature.expense.ExpenseEditorScreen
|
||||
import se.hagstedt.kvitt.feature.home.HomeScreen
|
||||
@@ -147,6 +152,16 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck
|
||||
}
|
||||
}
|
||||
|
||||
// CP-2: the card callback page's return link. S-25 takes it itself (it polls that attempt at once); otherwise the
|
||||
// attempt is asked once here and S-23 opens.
|
||||
val cardReturn by viewModel.cardReturn.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(cardReturn, session is SessionState.Paired, route) {
|
||||
val attempt = cardReturn ?: return@LaunchedEffect
|
||||
if (session !is SessionState.Paired || route == null || route in Routes.ONBOARDING || route == Routes.CARD_CONNECT) return@LaunchedEffect
|
||||
viewModel.checkCardAttempt(attempt)
|
||||
shell.top(KvittDestination.Cards)
|
||||
}
|
||||
|
||||
// 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).
|
||||
// D-108: every destination change is instant (NavTransitions).
|
||||
@@ -163,6 +178,7 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck
|
||||
topLevel(session, shell)
|
||||
accountsAndExpenses(viewModel, shell)
|
||||
settings(shell, updateCheckAvailable)
|
||||
cards(viewModel, shell)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +199,7 @@ private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState,
|
||||
fun top(destination: KvittDestination) {
|
||||
val route = when (destination) {
|
||||
KvittDestination.Home -> Routes.HOME
|
||||
KvittDestination.Cards -> Routes.CARDS
|
||||
KvittDestination.Analytics -> Routes.ANALYTICS
|
||||
KvittDestination.Settings -> Routes.SETTINGS
|
||||
}
|
||||
@@ -249,6 +266,17 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Routes.CARDS) {
|
||||
CardsScreen(
|
||||
onNavigate = shell::top,
|
||||
onConnect = { shell.nav.navigate(Routes.CONNECT_CARD) },
|
||||
onOpenConnection = { shell.nav.navigate(Routes.cardConnection(it)) },
|
||||
onRenew = { shell.nav.navigate(Routes.cardConnect(it, renew = true)) },
|
||||
onCreateExpense = { shell.nav.navigate(Routes.expenseEditor(cardPurchase = it)) },
|
||||
onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
|
||||
snackbarHostState = shell.snackbar,
|
||||
)
|
||||
}
|
||||
composable(Routes.ANALYTICS) {
|
||||
AnalyticsScreen(onOpenDrillDown = { shell.nav.navigate(Routes.drillDown(it)) }, onNavigate = shell::top)
|
||||
}
|
||||
@@ -265,6 +293,7 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
|
||||
onOpenSyncStatus = { shell.nav.navigate(Routes.SYNC_STATUS) },
|
||||
onOpenAbout = { shell.nav.navigate(Routes.ABOUT) },
|
||||
onNavigate = shell::top,
|
||||
onOpenCards = { shell.nav.navigate(Routes.CONNECT_CARD) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SYNC_STATUS) {
|
||||
@@ -293,6 +322,7 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell:
|
||||
},
|
||||
onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) },
|
||||
snackbarHostState = shell.snackbar,
|
||||
onFromCardPurchases = { shell.nav.navigate(Routes.cardPick(it)) },
|
||||
)
|
||||
}
|
||||
composable(Routes.ACCOUNT_EDITOR, arguments = Routes.accountEditorArgs) { entry ->
|
||||
@@ -393,5 +423,39 @@ private fun NavGraphBuilder.settings(shell: Shell, updateCheckAvailable: Boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** Card purchases (D-173): S-24 from S-13, S-25 from S-24 or "Förnya med BankID", S-26, S-27 from S-5. */
|
||||
private fun NavGraphBuilder.cards(viewModel: AppViewModel, shell: Shell) {
|
||||
composable(Routes.CONNECT_CARD) {
|
||||
ConnectCardScreen(
|
||||
onBack = shell::back,
|
||||
onOpenProvider = { shell.nav.navigate(Routes.cardConnect(it)) },
|
||||
onOpenConnection = { shell.nav.navigate(Routes.cardConnection(it)) },
|
||||
)
|
||||
}
|
||||
composable(Routes.CARD_CONNECT, arguments = Routes.cardConnectArgs) {
|
||||
val returned by viewModel.cardReturn.collectAsStateWithLifecycle()
|
||||
CardConnectScreen(
|
||||
onBack = shell::back,
|
||||
onConnected = { shell.top(KvittDestination.Cards) },
|
||||
returnedAttempt = returned,
|
||||
)
|
||||
LaunchedEffect(returned) { if (returned != null) viewModel.cardReturnHandled() }
|
||||
}
|
||||
composable(Routes.CARD_CONNECTION, arguments = Routes.cardConnectionArgs) {
|
||||
CardConnectionScreen(
|
||||
onBack = shell::back,
|
||||
onRenew = { shell.nav.navigate(Routes.cardConnect(it, renew = true)) },
|
||||
onDisconnected = shell::back,
|
||||
)
|
||||
}
|
||||
composable(Routes.CARD_PICK, arguments = Routes.cardPickArgs) {
|
||||
CardPickScreen(
|
||||
onBack = shell::back,
|
||||
onPick = { purchase, account -> shell.nav.navigate(Routes.expenseEditor(account = account, cardPurchase = purchase)) },
|
||||
snackbarHostState = shell.snackbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val ARCHIVE_NAME = "kvitt.categoryArchive.name"
|
||||
private const val ARCHIVE_ARCHIVED = "kvitt.categoryArchive.archived"
|
||||
|
||||
@@ -6,12 +6,17 @@ import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
|
||||
import se.hagstedt.kvitt.feature.analytics.DrillDownArgs
|
||||
import se.hagstedt.kvitt.feature.cards.CardConnectViewModel
|
||||
import se.hagstedt.kvitt.feature.cards.CardConnectionViewModel
|
||||
import se.hagstedt.kvitt.feature.cards.CardPickViewModel
|
||||
import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel
|
||||
import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel
|
||||
import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorViewModel
|
||||
import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel
|
||||
import se.hagstedt.kvitt.feature.settle.SettleArgs
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CardConnectionId
|
||||
import se.hagstedt.kvitt.model.CardPurchaseId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.model.SettlementId
|
||||
@@ -27,16 +32,18 @@ object Routes {
|
||||
const val REMOVED = "removed"
|
||||
|
||||
const val HOME = "home"
|
||||
const val CARDS = "cards"
|
||||
const val ANALYTICS = "analytics"
|
||||
const val SETTINGS = "settings"
|
||||
|
||||
private val ACCOUNT_ID = AccountDetailViewModel.ARG_ACCOUNT_ID
|
||||
private val EXPENSE_ID = ExpenseEditorViewModel.ARG_EXPENSE_ID
|
||||
private val EXPENSE_ACCOUNT_ID = ExpenseEditorViewModel.ARG_ACCOUNT_ID
|
||||
private val EXPENSE_CARD_PURCHASE = ExpenseEditorViewModel.ARG_CARD_PURCHASE_ID
|
||||
|
||||
val ACCOUNT = "account/{$ACCOUNT_ID}"
|
||||
val ACCOUNT_EDITOR = "account_editor?$ACCOUNT_ID={$ACCOUNT_ID}"
|
||||
val EXPENSE_EDITOR = "expense_editor?$EXPENSE_ID={$EXPENSE_ID}&$EXPENSE_ACCOUNT_ID={$EXPENSE_ACCOUNT_ID}"
|
||||
val EXPENSE_EDITOR = "expense_editor?$EXPENSE_ID={$EXPENSE_ID}&$EXPENSE_ACCOUNT_ID={$EXPENSE_ACCOUNT_ID}&$EXPENSE_CARD_PURCHASE={$EXPENSE_CARD_PURCHASE}"
|
||||
val EXPENSE = "expense/{${ExpenseDetailViewModel.ARG_EXPENSE_ID}}"
|
||||
val SETTLE = "settle?${SettleArgs.ACCOUNT_ID}={${SettleArgs.ACCOUNT_ID}}&${SettleArgs.SETTLEMENT_ID}={${SettleArgs.SETTLEMENT_ID}}"
|
||||
val SETTLEMENT = "settlement/{${SettleArgs.SETTLEMENT_ID}}"
|
||||
@@ -52,13 +59,27 @@ object Routes {
|
||||
const val INVITES = "settings/invites"
|
||||
const val ABOUT = "settings/about"
|
||||
|
||||
// Card purchases (S-24 to S-27, D-173).
|
||||
const val CONNECT_CARD = "cards/connect"
|
||||
val CARD_CONNECT = "cards/provider/{${CardConnectViewModel.ARG_PROVIDER_ID}}?${CardConnectViewModel.ARG_RENEW}={${CardConnectViewModel.ARG_RENEW}}" +
|
||||
"&${CardConnectViewModel.ARG_ATTEMPT}={${CardConnectViewModel.ARG_ATTEMPT}}"
|
||||
val CARD_CONNECTION = "cards/connection/{${CardConnectionViewModel.ARG_CONNECTION_ID}}"
|
||||
val CARD_PICK = "cards/pick/{${CardPickViewModel.ARG_ACCOUNT_ID}}"
|
||||
|
||||
/** Routes outside the paired app: the session gate (`KvittApp`) moves between them. */
|
||||
val ONBOARDING = setOf(PAIRING, PROFILE_STEP, REMOVED)
|
||||
|
||||
fun account(id: AccountId) = "account/${encode(id.value)}"
|
||||
fun accountEditor(id: AccountId?) = query("account_editor", ACCOUNT_ID to id?.value)
|
||||
fun expenseEditor(id: ExpenseId? = null, account: AccountId? = null) =
|
||||
query("expense_editor", EXPENSE_ID to id?.value, EXPENSE_ACCOUNT_ID to account?.value)
|
||||
fun expenseEditor(id: ExpenseId? = null, account: AccountId? = null, cardPurchase: CardPurchaseId? = null) =
|
||||
query("expense_editor", EXPENSE_ID to id?.value, EXPENSE_ACCOUNT_ID to account?.value, EXPENSE_CARD_PURCHASE to cardPurchase?.value)
|
||||
fun cardConnect(providerId: String, renew: Boolean = false, attempt: String? = null) = query(
|
||||
"cards/provider/${encode(providerId)}",
|
||||
CardConnectViewModel.ARG_RENEW to if (renew) "true" else null,
|
||||
CardConnectViewModel.ARG_ATTEMPT to attempt,
|
||||
)
|
||||
fun cardConnection(id: CardConnectionId) = "cards/connection/${encode(id.value)}"
|
||||
fun cardPick(account: AccountId) = "cards/pick/${encode(account.value)}"
|
||||
fun expense(id: ExpenseId) = "expense/${encode(id.value)}"
|
||||
fun settle(account: AccountId? = null, editing: SettlementId? = null) =
|
||||
query("settle", SettleArgs.ACCOUNT_ID to account?.value, SettleArgs.SETTLEMENT_ID to editing?.value)
|
||||
@@ -80,7 +101,10 @@ object Routes {
|
||||
|
||||
val accountArgs get() = required(ACCOUNT_ID)
|
||||
val accountEditorArgs get() = optional(ACCOUNT_ID)
|
||||
val expenseEditorArgs get() = optional(EXPENSE_ID, EXPENSE_ACCOUNT_ID)
|
||||
val expenseEditorArgs get() = optional(EXPENSE_ID, EXPENSE_ACCOUNT_ID, EXPENSE_CARD_PURCHASE)
|
||||
val cardConnectArgs get() = required(CardConnectViewModel.ARG_PROVIDER_ID) + optional(CardConnectViewModel.ARG_RENEW, CardConnectViewModel.ARG_ATTEMPT)
|
||||
val cardConnectionArgs get() = required(CardConnectionViewModel.ARG_CONNECTION_ID)
|
||||
val cardPickArgs get() = required(CardPickViewModel.ARG_ACCOUNT_ID)
|
||||
val expenseArgs get() = required(ExpenseDetailViewModel.ARG_EXPENSE_ID)
|
||||
val settleArgs get() = optional(SettleArgs.ACCOUNT_ID, SettleArgs.SETTLEMENT_ID)
|
||||
val settlementArgs get() = required(SettleArgs.SETTLEMENT_ID)
|
||||
|
||||
@@ -21,7 +21,7 @@ class AppViewModelTest {
|
||||
@get:Rule
|
||||
val main = MainDispatcherRule()
|
||||
|
||||
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts)
|
||||
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts, fakes.cards)
|
||||
|
||||
private val link = AppLink.Invite(RELEASE_SERVER_URL, "ABCD2345EFGH", "Jacob")
|
||||
|
||||
|
||||
@@ -117,4 +117,20 @@ class AppLinksTest {
|
||||
assertNull(AppLinks.parseOpen("settlement", ""))
|
||||
assertNull(AppLinks.parseOpen("unknown", "x"))
|
||||
}
|
||||
|
||||
/** CP-2 (D-173): the card callback page's return link; only an id-shaped attempt is accepted. */
|
||||
@Test
|
||||
fun theCardReturnLinkNamesItsAttempt_andTheManifestOpensIt() {
|
||||
assertEquals(AppLink.CardConnected("0192c300-0000-7000-8000-000000000001"), AppLinks.parseCardReturn("kvitt://cards/connected?attempt=0192c300-0000-7000-8000-000000000001"))
|
||||
assertNull(AppLinks.parseCardReturn("kvitt://cards/connected"))
|
||||
assertNull(AppLinks.parseCardReturn("kvitt://cards/other?attempt=abc"))
|
||||
assertNull(AppLinks.parseCardReturn("kvitt://cards/connected?attempt=a%20b"))
|
||||
assertNull(AppLinks.parseCardReturn("https://kvitt.example.org/cards/connected?attempt=abc"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("kvitt://cards/connected?attempt=abc-123"))
|
||||
assertEquals(AppLink.CardConnected("abc-123"), AppLinks.parse(intent))
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val opens = context.packageManager.queryIntentActivities(Intent(intent).addCategory(Intent.CATEGORY_BROWSABLE).setPackage(context.packageName), 0)
|
||||
.any { it.activityInfo.name == "se.hagstedt.kvitt.MainActivity" }
|
||||
assertTrue(opens)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user