From e478e0d588a7432484f2e60ea6ffc2880a260a04 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 22 May 2026 13:28:05 +0200 Subject: [PATCH 01/36] feat(data): Implement cc entity --- .../1.json | 74 ++++++++++++++++++- .../local/converter/YearMonthConverter.kt | 17 +++++ .../core/item/data/local/dao/CreditCardDao.kt | 25 +++++++ .../data/local/datasource/ItemDatabase.kt | 10 +++ .../data/local/entity/CreditCardEntity.kt | 35 +++++++++ .../data/local/pojo/CreditCardProjection.kt | 18 +++++ 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/YearMonthConverter.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardProjection.kt diff --git a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json index fb8d77d3..e8ac3099 100644 --- a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json +++ b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "40462f38d8b4873c28fe3aec0e192229", + "identityHash": "acbc1bdd05d20fe72364cc0774dbd1b7", "entities": [ { "tableName": "vault", @@ -171,6 +171,76 @@ } ] }, + { + "tableName": "credit_card", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `holder` TEXT, `last_numbers` TEXT NOT NULL, `expiration_date` INTEGER NOT NULL, `card_number_ciphertext` BLOB NOT NULL, `card_number_iv` BLOB NOT NULL, `cvv_ciphertext` BLOB, `cvv_iv` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "holder", + "columnName": "holder", + "affinity": "TEXT" + }, + { + "fieldPath": "lastNumbers", + "columnName": "last_numbers", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cardNumber.ciphertext", + "columnName": "card_number_ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cardNumber.iv", + "columnName": "card_number_iv", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cvv.ciphertext", + "columnName": "cvv_ciphertext", + "affinity": "BLOB" + }, + { + "fieldPath": "cvv.iv", + "columnName": "cvv_iv", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "item", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "tableName": "totp", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`login_id` BLOB NOT NULL, `issuer` TEXT, `account_name` TEXT, `algorithm` TEXT NOT NULL, `digits` INTEGER NOT NULL, `period` INTEGER NOT NULL, `secret_ciphertext` BLOB NOT NULL, `secret_iv` BLOB NOT NULL, PRIMARY KEY(`login_id`), FOREIGN KEY(`login_id`) REFERENCES `login`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -536,7 +606,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '40462f38d8b4873c28fe3aec0e192229')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'acbc1bdd05d20fe72364cc0774dbd1b7')" ] } } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/YearMonthConverter.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/YearMonthConverter.kt new file mode 100644 index 00000000..640d9f2d --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/converter/YearMonthConverter.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.core.item.data.local.converter + +import androidx.room.TypeConverter +import java.time.YearMonth + +internal object YearMonthConverter { + + @TypeConverter + fun fromYearMonth(yearMonth: YearMonth?): Int? = yearMonth?.let { + yearMonth.year * 100 + yearMonth.monthValue + } + + @TypeConverter + fun fromInt(value: Int?): YearMonth? = value?.let { + YearMonth.of(it / 100, it % 100) + } +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt new file mode 100644 index 00000000..d5bdf500 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.core.item.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import de.davis.keygo.core.item.data.local.entity.CreditCardEntity +import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection +import de.davis.keygo.core.item.domain.alias.ItemId +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface CreditCardDao { + + @Upsert + suspend fun upsert(creditCard: CreditCardEntity) + + @Transaction + @Query("SELECT * FROM credit_card WHERE id = :id") + fun observeById(id: ItemId): Flow + + @Transaction + @Query("SELECT * FROM credit_card WHERE id = :id") + suspend fun getById(id: ItemId): CreditCardProjection? +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt index 46c7ab0d..cbfe872d 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt @@ -4,6 +4,9 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import de.davis.keygo.core.item.data.local.converter.YearMonthConverter +import de.davis.keygo.core.item.data.local.dao.CreditCardDao import de.davis.keygo.core.item.data.local.dao.DomainInfoDao import de.davis.keygo.core.item.data.local.dao.ItemDao import de.davis.keygo.core.item.data.local.dao.LoginDao @@ -12,6 +15,7 @@ import de.davis.keygo.core.item.data.local.dao.PasswordDao import de.davis.keygo.core.item.data.local.dao.TagDao import de.davis.keygo.core.item.data.local.dao.TotpDao import de.davis.keygo.core.item.data.local.dao.VaultDao +import de.davis.keygo.core.item.data.local.entity.CreditCardEntity import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.LoginEntity @@ -29,6 +33,7 @@ import org.koin.core.annotation.Single VaultEntity::class, ItemEntity::class, LoginEntity::class, + CreditCardEntity::class, TotpEntity::class, PasswordEntity::class, DomainInfoEntity::class, @@ -38,6 +43,7 @@ import org.koin.core.annotation.Single ], version = 1, ) +@TypeConverters(YearMonthConverter::class) internal abstract class ItemDatabase : RoomDatabase() { abstract fun vaultDao(): VaultDao @@ -45,6 +51,7 @@ internal abstract class ItemDatabase : RoomDatabase() { abstract fun itemDao(): ItemDao abstract fun loginDao(): LoginDao + abstract fun creditCardDao(): CreditCardDao abstract fun totpDao(): TotpDao @@ -77,6 +84,9 @@ internal class DatabaseModule { @Single fun provideLoginDao(db: ItemDatabase) = db.loginDao() + @Single + fun provideCreditCardDao(db: ItemDatabase) = db.creditCardDao() + @Single fun provideTotpDao(db: ItemDatabase) = db.totpDao() diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt new file mode 100644 index 00000000..96ee9ec5 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.core.item.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import java.time.YearMonth + +@Entity( + tableName = "credit_card", + foreignKeys = [ + ForeignKey( + entity = ItemEntity::class, + parentColumns = ["id"], + childColumns = ["id"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +internal data class CreditCardEntity( + @PrimaryKey + val id: ItemId, + val holder: String?, + @Embedded(prefix = "card_number_") + val cardNumber: EncryptedPayload, + @ColumnInfo(name = "last_numbers") + val lastNumbers: String, + @Embedded(prefix = "cvv_") + val cvv: EncryptedPayload?, + @ColumnInfo(name = "expiration_date") + val expirationDate: YearMonth +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardProjection.kt new file mode 100644 index 00000000..8f31b6ef --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardProjection.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.core.item.data.local.pojo + +import androidx.room.Embedded +import androidx.room.Relation +import de.davis.keygo.core.item.data.local.entity.CreditCardEntity +import de.davis.keygo.core.item.data.local.entity.ItemEntity + +internal data class CreditCardProjection( + @Embedded + val creditCardEntity: CreditCardEntity, + + @Relation( + parentColumn = "id", + entityColumn = "id", + entity = ItemEntity::class, + ) + val item: ItemProjection +) From bc7c5a4a86400c52d4e745cb1262dab083a5dc9a Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 02:10:30 +0200 Subject: [PATCH 02/36] feat(core-item): Add Credit Card model and repository support --- .../core/item/data/mapper/CreditCardMapper.kt | 31 +++ .../repository/CreditCardRepositoryImpl.kt | 43 +++++ .../core/item/domain/model/CreditCard.kt | 52 +++++ .../domain/repository/CreditCardRepository.kt | 13 ++ .../domain/usecase/UpsertVaultItemUseCase.kt | 8 +- core/item/src/main/res/values/strings.xml | 3 +- .../item/data/mapper/CreditCardMapperTest.kt | 141 ++++++++++++++ .../CreditCardRepositoryImplTest.kt | 178 ++++++++++++++++++ .../core/item/domain/model/CreditCardTest.kt | 66 +++++++ .../domain/usecase/UpsertItemUseCaseTest.kt | 54 +++++- .../core/item/FakeCreditCardRepository.kt | 53 ++++++ .../CreateNewOrUpdateLoginUseCaseTest.kt | 3 +- .../create/presentation/EditItemScreen.kt | 3 + 13 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt create mode 100644 core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt new file mode 100644 index 00000000..4267ce8e --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.core.item.data.mapper + +import de.davis.keygo.core.item.data.local.entity.CreditCardEntity +import de.davis.keygo.core.item.data.local.entity.TagEntity +import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection +import de.davis.keygo.core.item.domain.model.CreditCard + +internal fun CreditCard.toCreditCardEntity() = CreditCardEntity( + id = id, + holder = holder, + cardNumber = cardNumber.payload, + lastNumbers = lastNumbers, + cvv = cvv?.payload, + expirationDate = expirationDate +) + +internal fun CreditCardProjection.toDomain() = CreditCard( + id = item.itemEntity.id, + vaultId = item.itemEntity.vaultId, + name = item.itemEntity.name, + keyInformation = item.itemEntity.keyInformation.toDomain(), + tags = item.tags.map(TagEntity::toDomain).toSet(), + note = item.itemEntity.note, + pinned = item.itemEntity.pinned, + + holder = creditCardEntity.holder, + cardNumber = CreditCard.CardNumber(creditCardEntity.cardNumber), + lastNumbers = creditCardEntity.lastNumbers, + cvv = creditCardEntity.cvv?.let { CreditCard.CVV(it) }, + expirationDate = creditCardEntity.expirationDate, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt new file mode 100644 index 00000000..184d15dc --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt @@ -0,0 +1,43 @@ +package de.davis.keygo.core.item.data.repository + +import androidx.room.withTransaction +import de.davis.keygo.core.item.data.local.dao.CreditCardDao +import de.davis.keygo.core.item.data.local.dao.ItemDao +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import de.davis.keygo.core.item.data.mapper.toCreditCardEntity +import de.davis.keygo.core.item.data.mapper.toData +import de.davis.keygo.core.item.data.mapper.toDomain +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class CreditCardRepositoryImpl( + private val database: ItemDatabase, + private val itemDao: ItemDao, + private val creditCardDao: CreditCardDao, +) : CreditCardRepository { + + override suspend fun createOrUpdateCreditCard(card: CreditCard): Result = + runCatching { + database.withTransaction { + itemDao.upsert(card.toData()) + creditCardDao.upsert(card.toCreditCardEntity()) + + card.id + } + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it) }, + ) + + override fun observeCreditCardById(itemId: ItemId): Flow = + creditCardDao.observeById(itemId).map { it?.toDomain() } + + override suspend fun getCreditCardById(itemId: ItemId): CreditCard? = + creditCardDao.getById(itemId)?.toDomain() +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt new file mode 100644 index 00000000..313a0e53 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt @@ -0,0 +1,52 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.processor.annotation.VaultEntity +import java.time.YearMonth + +@VaultEntity(resString = "credit_card", defaultIconType = "CreditCard") +data class CreditCard( + override val id: ItemId, + override val vaultId: VaultId, + override val name: String, + override val keyInformation: KeyInformation, + override val tags: Set, + override val note: String?, + override val pinned: Boolean, + val holder: String?, + val lastNumbers: String, + val cardNumber: CardNumber, + val cvv: CVV?, + val expirationDate: YearMonth, +) : Item { + override val itemType: VaultItemType + get() = VaultItemType.CreditCard + + data class CardNumber( + override val payload: EncryptedPayload, + ) : SecretField { + override val blueprint: SecretBlueprint> = CardNumber + + companion object : SecretBlueprint() { + override val label: String = "credit_card_number" + override val codec: SecretCodec = SecretCodec.StringCodec + + override fun createField(payload: EncryptedPayload): CardNumber = CardNumber(payload) + } + } + + data class CVV( + override val payload: EncryptedPayload, + ) : SecretField { + override val blueprint: SecretBlueprint> = CVV + + companion object : SecretBlueprint() { + override val label: String = "credit_card_cvv" + override val codec: SecretCodec = SecretCodec.StringCodec + + override fun createField(payload: EncryptedPayload): CVV = CVV(payload) + } + } +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt new file mode 100644 index 00000000..6443199e --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.core.item.domain.repository + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow + +interface CreditCardRepository { + suspend fun createOrUpdateCreditCard(card: CreditCard): Result + + fun observeCreditCardById(itemId: ItemId): Flow + suspend fun getCreditCardById(itemId: ItemId): CreditCard? +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt index 76519799..bd9f80c9 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt @@ -1,8 +1,10 @@ package de.davis.keygo.core.item.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.util.Result import org.koin.core.annotation.Single @@ -10,11 +12,11 @@ import org.koin.core.annotation.Single @Single class UpsertVaultItemUseCase( private val loginRepository: LoginRepository, + private val creditCardRepository: CreditCardRepository, ) { suspend operator fun invoke(item: Item): Result = when (item) { - is Login -> { - loginRepository.createOrUpdateLogin(item) - } + is Login -> loginRepository.createOrUpdateLogin(item) + is CreditCard -> creditCardRepository.createOrUpdateCreditCard(item) } } diff --git a/core/item/src/main/res/values/strings.xml b/core/item/src/main/res/values/strings.xml index dde01e25..ba9b3211 100644 --- a/core/item/src/main/res/values/strings.xml +++ b/core/item/src/main/res/values/strings.xml @@ -1,7 +1,8 @@ Password - + Credit Card + Ridiculous Weak Moderate diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt new file mode 100644 index 00000000..ca18775f --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt @@ -0,0 +1,141 @@ +package de.davis.keygo.core.item.data.mapper + +import de.davis.keygo.core.item.data.local.entity.CreditCardEntity +import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.entity.TagEntity +import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection +import de.davis.keygo.core.item.data.local.pojo.ItemProjection +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import java.time.YearMonth +import kotlin.test.Test +import kotlin.test.assertEquals +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +class CreditCardMapperTest { + + @Test + fun `toCreditCardEntity preserves payloads and scalar fields`() { + val cardNumber = EncryptedPayload(byteArrayOf(1), byteArrayOf(2)) + val cvv = EncryptedPayload(byteArrayOf(3), byteArrayOf(4)) + val card = baseCard( + cardNumber = CreditCard.CardNumber(cardNumber), + cvv = CreditCard.CVV(cvv), + ) + + val entity = card.toCreditCardEntity() + + assertEquals(card.id, entity.id) + assertEquals("Alice", entity.holder) + assertEquals(cardNumber, entity.cardNumber) + assertEquals("4242", entity.lastNumbers) + assertEquals(cvv, entity.cvv) + assertEquals(YearMonth.of(2030, 12), entity.expirationDate) + } + + @Test + fun `toCreditCardEntity preserves null holder`() { + val entity = baseCard(holder = null).toCreditCardEntity() + assertEquals(null, entity.holder) + } + + @Test + fun `toDomain maps all fields from projection`() { + val id = newItemId() + val vaultId = newVaultId() + val cardNumber = EncryptedPayload(byteArrayOf(1), byteArrayOf(2)) + val cvv = EncryptedPayload(byteArrayOf(3), byteArrayOf(4)) + val projection = baseProjection( + id = id, + vaultId = vaultId, + cardNumber = cardNumber, + cvv = cvv, + ) + + val card = projection.toDomain() + + assertEquals(id, card.id) + assertEquals(vaultId, card.vaultId) + assertEquals("Test Card", card.name) + assertEquals("note", card.note) + assertEquals(true, card.pinned) + assertEquals("Alice", card.holder) + assertEquals("4242", card.lastNumbers) + assertEquals(cardNumber, card.cardNumber.payload) + assertEquals(cvv, card.cvv?.payload) + assertEquals(YearMonth.of(2030, 12), card.expirationDate) + assertEquals(VaultItemType.CreditCard, card.itemType) + } + + @Test + fun `toDomain maps tag entity values to domain tags`() { + val projection = baseProjection( + tags = setOf( + TagEntity(id = 1, value = "Work", normalized = "work"), + TagEntity(id = 2, value = "Finance", normalized = "finance"), + ), + ) + + val card = projection.toDomain() + + assertEquals(setOf(Tag.of("Work")!!, Tag.of("Finance")!!), card.tags) + } + + private fun baseCard( + id: ItemId = newItemId(), + holder: String? = "Alice", + cardNumber: CreditCard.CardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv: CreditCard.CVV = CreditCard.CVV(EncryptedPayload.EMPTY), + ): CreditCard = CreditCard( + id = id, + vaultId = newVaultId(), + name = "Test Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = holder, + lastNumbers = "4242", + cardNumber = cardNumber, + cvv = cvv, + expirationDate = YearMonth.of(2030, 12), + ) + + private fun baseProjection( + id: ItemId = newItemId(), + vaultId: de.davis.keygo.core.item.domain.alias.VaultId = newVaultId(), + cardNumber: EncryptedPayload = EncryptedPayload.EMPTY, + cvv: EncryptedPayload = EncryptedPayload.EMPTY, + tags: Set = emptySet(), + ): CreditCardProjection = CreditCardProjection( + creditCardEntity = CreditCardEntity( + id = id, + holder = "Alice", + cardNumber = cardNumber, + lastNumbers = "4242", + cvv = cvv, + expirationDate = YearMonth.of(2030, 12), + ), + item = ItemProjection( + itemEntity = ItemEntity( + id = id, + vaultId = vaultId, + name = "Test Card", + note = "note", + itemType = VaultItemType.CreditCard, + pinned = true, + keyInformation = EntityKeyInformation( + wrappedKey = byteArrayOf(), + keyNonce = byteArrayOf(), + ), + ), + tags = tags, + ), + ) +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt new file mode 100644 index 00000000..084626c3 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt @@ -0,0 +1,178 @@ +package de.davis.keygo.core.item.data.repository + +import androidx.room.withTransaction +import de.davis.keygo.core.item.data.local.dao.CreditCardDao +import de.davis.keygo.core.item.data.local.dao.ItemDao +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import de.davis.keygo.core.item.data.local.entity.CreditCardEntity +import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection +import de.davis.keygo.core.item.data.local.pojo.ItemProjection +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import java.time.YearMonth +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +class CreditCardRepositoryImplTest { + + private val database = mockk() + private val itemDao = mockk(relaxed = true) + private val creditCardDao = mockk(relaxed = true) + + private val repository = CreditCardRepositoryImpl( + database = database, + itemDao = itemDao, + creditCardDao = creditCardDao, + ) + + @BeforeTest + fun setUp() { + mockkStatic("androidx.room.RoomDatabaseKt") + coEvery { database.withTransaction(any Any?>()) } coAnswers { + secondArg Any?>().invoke() + } + } + + @AfterTest + fun tearDown() { + unmockkStatic("androidx.room.RoomDatabaseKt") + } + + @Test + fun `createOrUpdateCreditCard returns Success with card id`() = runTest { + val card = testCreditCard() + + val result = repository.createOrUpdateCreditCard(card) + + assertTrue(result.isSuccess()) + assertEquals(card.id, result.success) + } + + @Test + fun `createOrUpdateCreditCard upserts both item and credit card rows`() = runTest { + val card = testCreditCard() + + val result = repository.createOrUpdateCreditCard(card) + + assertTrue(result.isSuccess()) + coVerify(exactly = 1) { itemDao.upsert(any()) } + coVerify(exactly = 1) { creditCardDao.upsert(any()) } + } + + @Test + fun `createOrUpdateCreditCard returns Failure when item upsert throws`() = runTest { + val error = RuntimeException("db error") + coEvery { itemDao.upsert(any()) } throws error + + val result = repository.createOrUpdateCreditCard(testCreditCard()) + + assertTrue(result.isFailure()) + assertEquals(error, result.error) + } + + @Test + fun `createOrUpdateCreditCard returns Failure when credit card upsert throws`() = runTest { + val error = RuntimeException("db error") + coEvery { creditCardDao.upsert(any()) } throws error + + val result = repository.createOrUpdateCreditCard(testCreditCard()) + + assertTrue(result.isFailure()) + assertEquals(error, result.error) + } + + @Test + fun `getCreditCardById maps projection to domain`() = runTest { + val id = newItemId() + coEvery { creditCardDao.getById(id) } returns projection(id) + + val card = repository.getCreditCardById(id) + + assertEquals(id, card?.id) + assertEquals("Alice", card?.holder) + assertEquals("4242", card?.lastNumbers) + assertEquals(YearMonth.of(2030, 12), card?.expirationDate) + } + + @Test + fun `getCreditCardById returns null when row is absent`() = runTest { + val id = newItemId() + coEvery { creditCardDao.getById(id) } returns null + + assertNull(repository.getCreditCardById(id)) + } + + @Test + fun `observeCreditCardById emits mapped domain card`() = runTest { + val id = newItemId() + every { creditCardDao.observeById(id) } returns flowOf(projection(id)) + + val card = repository.observeCreditCardById(id).first() + + assertEquals(id, card?.id) + assertEquals("Alice", card?.holder) + } + + private fun testCreditCard() = CreditCard( + id = newItemId(), + vaultId = newVaultId(), + name = "Test Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = "Alice", + lastNumbers = "4242", + cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv = CreditCard.CVV(EncryptedPayload.EMPTY), + expirationDate = YearMonth.of(2030, 12), + ) + + private fun projection(id: ItemId) = CreditCardProjection( + creditCardEntity = CreditCardEntity( + id = id, + holder = "Alice", + cardNumber = EncryptedPayload.EMPTY, + lastNumbers = "4242", + cvv = EncryptedPayload.EMPTY, + expirationDate = YearMonth.of(2030, 12), + ), + item = ItemProjection( + itemEntity = ItemEntity( + id = id, + vaultId = newVaultId(), + name = "Test Card", + note = null, + itemType = VaultItemType.CreditCard, + pinned = false, + keyInformation = EntityKeyInformation( + wrappedKey = byteArrayOf(), + keyNonce = byteArrayOf(), + ), + ), + tags = emptySet(), + ), + ) +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt new file mode 100644 index 00000000..2a7c601a --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt @@ -0,0 +1,66 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import java.time.YearMonth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class CreditCardTest { + + @Test + fun `itemType is CreditCard`() { + assertEquals(VaultItemType.CreditCard, baseCard().itemType) + } + + @Test + fun `CardNumber equality uses content comparison for payload`() { + val a = CreditCard.CardNumber(EncryptedPayload(byteArrayOf(1, 2), byteArrayOf(3))) + val b = CreditCard.CardNumber(EncryptedPayload(byteArrayOf(1, 2), byteArrayOf(3))) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `CVV inequality when payload differs`() { + val a = CreditCard.CVV(EncryptedPayload(byteArrayOf(1), byteArrayOf(2))) + val b = CreditCard.CVV(EncryptedPayload(byteArrayOf(9), byteArrayOf(2))) + assertNotEquals(a, b) + } + + @Test + fun `CardNumber blueprint round-trips a string through its codec`() { + val codec = CreditCard.CardNumber.codec + val original = "4242424242424242" + assertEquals(original, codec.decode(codec.encode(original))) + } + + @Test + fun `CardNumber blueprint createField wraps the given payload`() { + val payload = EncryptedPayload(byteArrayOf(7), byteArrayOf(8)) + assertEquals(payload, CreditCard.CardNumber.createField(payload).payload) + } + + @Test + fun `CVV blueprint createField wraps the given payload`() { + val payload = EncryptedPayload(byteArrayOf(7), byteArrayOf(8)) + assertEquals(payload, CreditCard.CVV.createField(payload).payload) + } + + private fun baseCard(): CreditCard = CreditCard( + id = newItemId(), + vaultId = newVaultId(), + name = "Test Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = "Alice", + lastNumbers = "4242", + cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv = CreditCard.CVV(EncryptedPayload.EMPTY), + expirationDate = YearMonth.of(2030, 12), + ) +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt index f50b2474..1c46a442 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt @@ -1,8 +1,10 @@ package de.davis.keygo.core.item.domain.usecase +import de.davis.keygo.core.item.FakeCreditCardRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login @@ -12,6 +14,7 @@ import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess import kotlinx.coroutines.test.runTest +import java.time.YearMonth import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -20,7 +23,8 @@ import kotlin.test.assertTrue class UpsertItemUseCaseTest { private val loginRepository = FakeLoginRepository() - private val useCase = UpsertVaultItemUseCase(loginRepository) + private val creditCardRepository = FakeCreditCardRepository() + private val useCase = UpsertVaultItemUseCase(loginRepository, creditCardRepository) private fun testLogin(name: String = "Test") = Login( id = newItemId(), @@ -41,6 +45,24 @@ class UpsertItemUseCaseTest { ), ) + private fun testCreditCard(name: String = "Test Card") = CreditCard( + id = newItemId(), + vaultId = newVaultId(), + name = name, + keyInformation = KeyInformation( + wrappedKey = byteArrayOf(), + keyNonce = byteArrayOf(), + ), + tags = emptySet(), + note = null, + pinned = false, + holder = "Alice", + lastNumbers = "4242", + cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv = CreditCard.CVV(EncryptedPayload.EMPTY), + expirationDate = YearMonth.of(2030, 12), + ) + @Test fun `delegates login to loginRepository`() = runTest { val login = testLogin() @@ -70,4 +92,34 @@ class UpsertItemUseCaseTest { assertTrue(result.isFailure()) assertEquals(error, result.error) } + + @Test + fun `delegates credit card to creditCardRepository`() = runTest { + val card = testCreditCard() + + useCase(card) + + assertNotNull(creditCardRepository.getCreditCardById(card.id)) + } + + @Test + fun `returns success with credit card id`() = runTest { + val card = testCreditCard() + + val result = useCase(card) + + assertTrue(result.isSuccess()) + assertEquals(card.id, result.success) + } + + @Test + fun `returns failure from creditCardRepository`() = runTest { + val error = RuntimeException("db error") + creditCardRepository.createOrUpdateError = error + + val result = useCase(testCreditCard()) + + assertTrue(result.isFailure()) + assertEquals(error, result.error) + } } diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt new file mode 100644 index 00000000..1320b7ed --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt @@ -0,0 +1,53 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * In-memory [CreditCardRepository] for tests. + * + * - Pre-populate via [seed]. + * - Force the next [createOrUpdateCreditCard] call to fail by setting [createOrUpdateError]. + * - Force [createOrUpdateCreditCard] to fail for a specific id by setting [failCreateOrUpdateForId]. + * - Flow-returning methods react to store mutations, so observers see live updates. + */ +class FakeCreditCardRepository : CreditCardRepository { + + internal val store = MutableStateFlow>(emptyMap()) + + /** Error returned by the next [createOrUpdateCreditCard] call (cleared after use). */ + var createOrUpdateError: Throwable? = null + + /** + * If non-null, [createOrUpdateCreditCard] fails when called with this id. + * Persists across calls; the consumer clears it explicitly. + */ + var failCreateOrUpdateForId: Pair? = null + + fun seed(vararg cards: CreditCard) { + store.update { it + cards.associateBy { c -> c.id } } + } + + override suspend fun createOrUpdateCreditCard(card: CreditCard): Result { + failCreateOrUpdateForId?.let { (id, error) -> + if (id == card.id) return Result.Failure(error) + } + createOrUpdateError?.let { + createOrUpdateError = null + return Result.Failure(it) + } + store.update { it + (card.id to card) } + return Result.Success(card.id) + } + + override fun observeCreditCardById(itemId: ItemId): Flow = + store.map { it[itemId] } + + override suspend fun getCreditCardById(itemId: ItemId): CreditCard? = store.value[itemId] +} diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt index 8cccdca0..a6e1807f 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.item.core.domain.usecase +import de.davis.keygo.core.item.FakeCreditCardRepository import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakePasswordStrengthEstimator @@ -756,7 +757,7 @@ class CreateNewOrUpdateLoginUseCaseTest { cryptographicScopeProvider = cryptographicScopeProvider, loginRepository = loginRepository, vaultRepository = vaultRepository, - upsertVaultItem = UpsertVaultItemUseCase(loginRepository), + upsertVaultItem = UpsertVaultItemUseCase(loginRepository, FakeCreditCardRepository()), passwordStrengthEstimator = estimator, totpService = totpService, ) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt index 1b7da832..2ebbe26c 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt @@ -39,6 +39,9 @@ private fun ForInit( loginCreated = onCreated, navigateBack = navigateBack, ) + + // TODO: credit card create screen not yet implemented + VaultItemType.CreditCard -> Unit } } From d5b357138dedf77a8672782045ac082119637b82 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 09:38:36 +0200 Subject: [PATCH 03/36] refactor(item): Move top app bar to item core --- .../component/CreateOrModifyItemTopAppBar.kt | 57 +++++++++++++++++++ .../item/core/src/main/res/values/strings.xml | 2 + .../create/presentation/login/LoginContent.kt | 51 ++--------------- .../create/src/main/res/values/strings.xml | 2 - 4 files changed, 65 insertions(+), 47 deletions(-) create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CreateOrModifyItemTopAppBar.kt diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CreateOrModifyItemTopAppBar.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CreateOrModifyItemTopAppBar.kt new file mode 100644 index 00000000..73a17369 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CreateOrModifyItemTopAppBar.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.feature.item.core.presentation.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.item.generated.presentation.presentation +import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode +import de.davis.keygo.feature.item.core.R +import de.davis.keygo.core.ui.R as CoreUiR + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CreateOrModifyItemTopAppBar( + itemType: VaultItemType, + updating: Boolean, + onBackClick: () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + MediumFlexibleTopAppBar( + title = { + Text( + text = stringResource( + when { + updating -> R.string.update_item + else -> CoreUiR.string.create_new_item + }, + ), + ) + }, + subtitle = { + Text(text = itemType.presentation.first) + }, + navigationIcon = { + if (LocalIsInSinglePaneMode.current) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back_content_description), + ) + } + } + }, + actions = actions, + scrollBehavior = scrollBehavior, + ) +} \ No newline at end of file diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index 3c203860..de5f9586 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -13,6 +13,8 @@ Edit Delete + Update your Item + Email, phone, or username This field can not be blank diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt index d2e36786..773e472a 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt @@ -4,7 +4,6 @@ import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -13,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.QrCodeScanner @@ -22,11 +20,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,10 +43,11 @@ import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Vault import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.presentation.StrengthIndicator -import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.item.core.presentation.component.ChipFormGroup +import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems import de.davis.keygo.feature.item.core.presentation.transformation.rememberSchemeStrippingTransformation @@ -69,7 +66,6 @@ import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet import de.davis.keygo.feature.totp.presentation.component.QRScanner import de.davis.keygo.core.item.R as CoreItemR -import de.davis.keygo.core.ui.R as CoreUiR import de.davis.keygo.feature.item.core.R as ItemCoreR @Composable @@ -93,7 +89,8 @@ private fun LoginLoadingScaffold(onBackClick: () -> Unit) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - LoginTopAppBar( + CreateOrModifyItemTopAppBar( + itemType = VaultItemType.Login, updating = false, onBackClick = onBackClick, ) @@ -125,7 +122,8 @@ private fun LoginReadyContent( Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - LoginTopAppBar( + CreateOrModifyItemTopAppBar( + itemType = VaultItemType.Login, updating = state.updating, onBackClick = { onEvent(LoginUiEvent.OnBackClick) }, actions = { @@ -336,43 +334,6 @@ private fun LoginReadyContent( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun LoginTopAppBar( - updating: Boolean, - onBackClick: () -> Unit, - actions: @Composable RowScope.() -> Unit = {}, - scrollBehavior: TopAppBarScrollBehavior? = null, -) { - MediumFlexibleTopAppBar( - title = { - Text( - text = stringResource( - when { - updating -> R.string.update_item - else -> CoreUiR.string.create_new_item - }, - ), - ) - }, - subtitle = { - Text(text = stringResource(CoreItemR.string.password)) - }, - navigationIcon = { - if (LocalIsInSinglePaneMode.current) { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(ItemCoreR.string.back_content_description), - ) - } - } - }, - actions = actions, - scrollBehavior = scrollBehavior, - ) -} - private val DELIMITERS = setOf(',', ' ') @Preview diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index eb1879a8..238acfb0 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -52,8 +52,6 @@ \u2022 Before: %s \u2022 After: %s - Update your Item - Submit Generate Password From c44dfcfed559efa64c7c4dc08540f0b9182d05e1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 10:20:19 +0200 Subject: [PATCH 04/36] refactor(item): extract core item related events --- .../create/presentation/login/LoginContent.kt | 21 ++++--- .../presentation/login/LoginViewModel.kt | 55 +++++++++++-------- .../presentation/login/model/LoginUiEvent.kt | 12 +--- .../create/presentation/model/ItemUiEvent.kt | 15 +++++ 4 files changed, 61 insertions(+), 42 deletions(-) create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiEvent.kt diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt index 773e472a..9df0b429 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt @@ -62,6 +62,7 @@ import de.davis.keygo.feature.item.create.presentation.login.model.DialogState import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiState +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet import de.davis.keygo.feature.totp.presentation.component.QRScanner @@ -72,7 +73,7 @@ import de.davis.keygo.feature.item.core.R as ItemCoreR internal fun LoginContent(state: LoginUiState, onEvent: (LoginUiEvent) -> Unit) { when (state) { LoginUiState.Loading -> LoginLoadingScaffold( - onBackClick = { onEvent(LoginUiEvent.OnBackClick) }, + onBackClick = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, ) is LoginUiState.Ready -> LoginReadyContent( @@ -125,7 +126,7 @@ private fun LoginReadyContent( CreateOrModifyItemTopAppBar( itemType = VaultItemType.Login, updating = state.updating, - onBackClick = { onEvent(LoginUiEvent.OnBackClick) }, + onBackClick = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, actions = { IconButton( onClick = { @@ -135,13 +136,15 @@ private fun LoginReadyContent( tagsTextFieldState.gatherPendingItems(TAG_DELIMITERS) { strings -> onEvent( - LoginUiEvent.OnAddTags( - strings.mapNotNullTo(mutableSetOf(), Tag::of), + LoginUiEvent.ItemUi( + ItemUiEvent.OnAddTags( + strings.mapNotNullTo(mutableSetOf(), Tag::of), + ) ), ) } - onEvent(LoginUiEvent.OnSubmit) + onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnSubmit)) }, enabled = state.canSave, ) { @@ -170,9 +173,9 @@ private fun LoginReadyContent( nameError = state.nameError, nameExists = state.nameExists, vaultsState = vaultsState, - onVaultSelect = { onEvent(LoginUiEvent.OnVaultSelected(it)) }, - onTagSubmitted = { onEvent(LoginUiEvent.OnAddTags(it)) }, - onDeleteTag = { onEvent(LoginUiEvent.OnRemoveTag(it)) }, + onVaultSelect = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnVaultSelected(it))) }, + onTagSubmitted = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnAddTags(it))) }, + onDeleteTag = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnRemoveTag(it))) }, ) { item(key = "password_information") { var forceCompact by rememberSaveable { mutableStateOf(false) } @@ -326,7 +329,7 @@ private fun LoginReadyContent( if (state.scanning) { QRScanner( - onClose = { onEvent(LoginUiEvent.OnBackClick) }, + onClose = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, success = { onEvent(LoginUiEvent.OnCodesScanned(it)) }, diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index 6d7f47fe..b5455eca 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -39,6 +39,7 @@ import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseStat import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiState import de.davis.keygo.feature.item.create.presentation.login.model.OverrideTotpField +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.rust.totp.TotpService import de.davis.keygo.rust.totp.getInfoFromUriWithResult @@ -293,9 +294,9 @@ internal class LoginViewModel( } } - fun onEvent(event: LoginUiEvent) { + private fun onItemUiEvent(event: ItemUiEvent) { when (event) { - is LoginUiEvent.OnSubmit -> { + is ItemUiEvent.OnSubmit -> { val ready = state.value as? LoginUiState.Ready ?: return val base = ready.base viewModelScope.launch { @@ -360,11 +361,7 @@ internal class LoginViewModel( } } - is LoginUiEvent.OnGeneratePasswordClick -> { - _base.update { it.copy(generatePasswordBottomSheetVisible = true) } - } - - is LoginUiEvent.OnBackClick -> { + is ItemUiEvent.OnBackClick -> { if (_base.value.scanning) { _base.update { it.copy(scanning = false) } return @@ -373,6 +370,33 @@ internal class LoginViewModel( navigateUp() } + is ItemUiEvent.OnAddTags -> { + _base.update { + it.copy(itemAssignedTags = it.itemAssignedTags + event.tags) + } + } + + is ItemUiEvent.OnRemoveTag -> { + _base.update { + it.copy(itemAssignedTags = it.itemAssignedTags.filterNot { tag -> tag == event.tag } + .toSet()) + } + } + + is ItemUiEvent.OnVaultSelected -> { + _selectedVaultId.value = event.vaultId + } + } + } + + fun onEvent(event: LoginUiEvent) { + when (event) { + is LoginUiEvent.ItemUi -> onItemUiEvent(event.event) + + is LoginUiEvent.OnGeneratePasswordClick -> { + _base.update { it.copy(generatePasswordBottomSheetVisible = true) } + } + is LoginUiEvent.OnCloseBottomSheet -> { _base.update { it.copy(generatePasswordBottomSheetVisible = false) } } @@ -479,29 +503,12 @@ internal class LoginViewModel( } } - is LoginUiEvent.OnAddTags -> { - _base.update { - it.copy(itemAssignedTags = it.itemAssignedTags + event.tags) - } - } - - is LoginUiEvent.OnRemoveTag -> { - _base.update { - it.copy(itemAssignedTags = it.itemAssignedTags.filterNot { tag -> tag == event.tag } - .toSet()) - } - } - is LoginUiEvent.OnPasswordGenerated -> { passwordTextFieldState.setTextAndPlaceCursorAtEnd(event.password) _base.update { it.copy(generatePasswordBottomSheetVisible = false) } } - - is LoginUiEvent.OnVaultSelected -> { - _selectedVaultId.value = event.vaultId - } } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt index 72e557ad..3dfd3261 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiEvent.kt @@ -1,23 +1,19 @@ package de.davis.keygo.feature.item.create.presentation.login.model import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.alias.VaultId -import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.feature.item.core.presentation.login.model.FieldType +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent internal sealed interface LoginUiEvent { - data object OnSubmit : LoginUiEvent + data class ItemUi(val event: ItemUiEvent) : LoginUiEvent + data object OnGeneratePasswordClick : LoginUiEvent - data object OnBackClick : LoginUiEvent data object OnCloseBottomSheet : LoginUiEvent data object OnScanCodeRequest : LoginUiEvent data class OnDeleteDomain(val value: String) : LoginUiEvent data class OnAddDomains(val domains: Set) : LoginUiEvent - data class OnRemoveTag(val tag: Tag) : LoginUiEvent - data class OnAddTags(val tags: Set) : LoginUiEvent - data object OnTotpParseErrorDismiss : LoginUiEvent data class OnCodesScanned(val codes: List) : LoginUiEvent @@ -29,6 +25,4 @@ internal sealed interface LoginUiEvent { data object OnOverrideTotpFieldsKept : LoginUiEvent data class OnPasswordGenerated(val password: String) : LoginUiEvent - - data class OnVaultSelected(val vaultId: VaultId) : LoginUiEvent } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiEvent.kt new file mode 100644 index 00000000..8e5d2f02 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiEvent.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.item.create.presentation.model + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Tag + +internal interface ItemUiEvent { + + data object OnSubmit : ItemUiEvent + data object OnBackClick : ItemUiEvent + + data class OnRemoveTag(val tag: Tag) : ItemUiEvent + data class OnAddTags(val tags: Set) : ItemUiEvent + + data class OnVaultSelected(val vaultId: VaultId) : ItemUiEvent +} From 0fbd68c789414dc21ef98a7ecc1f8416cc24a429 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 11:02:19 +0200 Subject: [PATCH 05/36] feat(item): Add basic credit card UI --- .../item/core/src/main/res/values/strings.xml | 7 + .../create/presentation/EditItemScreen.kt | 8 +- .../creditcard/CreditCardContent.kt | 133 ++++++++++++++++++ .../creditcard/CreditCardScreen.kt | 26 ++++ .../creditcard/CreditCardViewModel.kt | 42 ++++++ .../creditcard/model/CreditCardUiEvent.kt | 7 + .../creditcard/model/CreditCardUiState.kt | 22 +++ .../create/src/main/res/values/strings.xml | 1 + 8 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index de5f9586..b72913af 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -8,6 +8,13 @@ Domains Tags + Card Holder + Card Number + Card CVV + Card Expiration Date + + MM/YY + Back Edit Edit diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt index 2ebbe26c..ada933a8 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import de.davis.keygo.feature.item.create.presentation.creditcard.CreditCardScreen import de.davis.keygo.feature.item.create.presentation.login.LoginScreen @Composable @@ -40,8 +41,11 @@ private fun ForInit( navigateBack = navigateBack, ) - // TODO: credit card create screen not yet implemented - VaultItemType.CreditCard -> Unit + VaultItemType.CreditCard -> CreditCardScreen( + detailPaneInformation = info, + creditCardCreated = onCreated, + navigateBack = navigateBack, + ) } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt new file mode 100644 index 00000000..cf294e4e --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -0,0 +1,133 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard + +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar +import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField +import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems +import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.component.FormGroup +import de.davis.keygo.feature.item.create.presentation.component.KeyGoItemForm +import de.davis.keygo.feature.item.create.presentation.component.TAG_DELIMITERS +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiState +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent +import de.davis.keygo.feature.item.core.R as ItemCoreR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CreditCardContent(state: CreditCardUiState, onEvent: (CreditCardUiEvent) -> Unit) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val tagsTextFieldState = rememberTextFieldState() + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CreateOrModifyItemTopAppBar( + itemType = VaultItemType.CreditCard, + updating = false, + onBackClick = {}, + actions = { + IconButton( + onClick = { + tagsTextFieldState.gatherPendingItems(TAG_DELIMITERS) { strings -> + onEvent( + CreditCardUiEvent.ItemUi( + ItemUiEvent.OnAddTags( + strings.mapNotNullTo(mutableSetOf(), Tag::of), + ) + ), + ) + } + + onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnSubmit)) + }, + enabled = state.canSave, + ) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = stringResource(R.string.submit_content_description), + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + KeyGoItemForm( + nameTextFieldState = rememberTextFieldState(), + tagsTextFieldState = tagsTextFieldState, + notesTextFieldState = rememberTextFieldState(), + vaultsState = state.vaultsState, + onVaultSelect = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnVaultSelected(it))) }, + assignedTags = state.itemAssignedTags, + tagsForSuggestions = state.tagsForSuggestion, + onTagSubmitted = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnAddTags(it))) }, + onDeleteTag = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnRemoveTag(it))) }, + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(8.dp) + .imePadding() + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + item(key = "cc_information") { + FormGroup( + title = stringResource(R.string.cc_information), + ) { + KeyGoFormField( + state = state.ccHolderTextFieldState, + label = { Text(text = stringResource(ItemCoreR.string.cc_holder)) } + ) + + KeyGoFormField( + state = state.ccNumberTextFieldState, + label = { Text(text = stringResource(ItemCoreR.string.cc_number)) }, + isSecure = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ) + ) + + KeyGoFormField( + state = state.ccCVVTextFieldState, + label = { Text(text = stringResource(ItemCoreR.string.cc_cvv)) }, + isSecure = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ) + ) + + KeyGoFormField( + state = state.ccExpirationDateTextFieldState, + label = { Text(text = stringResource(ItemCoreR.string.cc_expiration_date)) }, + placeholder = { Text(text = stringResource(ItemCoreR.string.cc_expiration_date_placeholder)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } + } + } + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt new file mode 100644 index 00000000..14611ddf --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import org.koin.androidx.compose.koinViewModel + +@Composable +internal fun CreditCardScreen( + detailPaneInformation: DetailPaneInformation = DetailPaneInformation.Init.New( + itemType = VaultItemType.CreditCard, + ), + creditCardCreated: (ItemId) -> Unit, + navigateBack: () -> Unit, +) { + val viewmodel: CreditCardViewModel = koinViewModel() + val state by viewmodel.state.collectAsStateWithLifecycle() + + CreditCardContent( + state = state, + onEvent = viewmodel::onEvent + ) +} \ No newline at end of file diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt new file mode 100644 index 00000000..ed09853b --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.ViewModel +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiState +import de.davis.keygo.feature.item.create.presentation.model.VaultsState +import kotlinx.coroutines.flow.MutableStateFlow +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class CreditCardViewModel : ViewModel() { + + private val nameTextFieldState = TextFieldState() + private val notesTextFieldState = TextFieldState() + private val ccHolderTextFieldState = TextFieldState() + private val ccNumberTextFieldState = TextFieldState() + private val ccCVVTextFieldState = TextFieldState() + private val ccExpirationDateTextFieldState = TextFieldState() + + val state = MutableStateFlow( + CreditCardUiState( + nameTextFieldState = nameTextFieldState, + notesTextFieldState = notesTextFieldState, + ccHolderTextFieldState = ccHolderTextFieldState, + ccNumberTextFieldState = ccNumberTextFieldState, + ccCVVTextFieldState = ccCVVTextFieldState, + ccExpirationDateTextFieldState = ccExpirationDateTextFieldState, + vaultsState = VaultsState( + vaults = emptyList(), + selectedVaultId = newVaultId() + ) + ) + ) + + fun onEvent(event: CreditCardUiEvent) { + when (event) { + is CreditCardUiEvent.ItemUi -> {} + } + } +} \ No newline at end of file diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt new file mode 100644 index 00000000..7a9d9b3c --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard.model + +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent + +internal sealed interface CreditCardUiEvent { + data class ItemUi(val event: ItemUiEvent) : CreditCardUiEvent +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt new file mode 100644 index 00000000..c3c8ff19 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.feature.item.core.presentation.model.InputFieldError +import de.davis.keygo.feature.item.create.presentation.model.VaultsState + +@Stable +data class CreditCardUiState( + val nameTextFieldState: TextFieldState, + val notesTextFieldState: TextFieldState, + val ccHolderTextFieldState: TextFieldState, + val ccNumberTextFieldState: TextFieldState, + val ccCVVTextFieldState: TextFieldState, + val ccExpirationDateTextFieldState: TextFieldState, + val canSave: Boolean = false, + val numberError: InputFieldError? = null, + val vaultsState: VaultsState, + val tagsForSuggestion: Set = emptySet(), + val itemAssignedTags: Set = emptySet(), +) diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 238acfb0..73d07949 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ General Information Additional Information Password Information + Credit Card Information Domain Information Tag information From a66e03e21188a44a944d5b34f31a39e1ced74d9e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 12:45:51 +0200 Subject: [PATCH 06/36] refactor(item-create): Extract base ItemViewModel and shared UI state --- .../item/create/presentation/ItemViewModel.kt | 173 +++++++++++++ .../component/ItemLoadingScaffold.kt | 56 ++++ .../creditcard/CreditCardContent.kt | 42 ++- .../creditcard/CreditCardScreen.kt | 17 +- .../creditcard/CreditCardViewModel.kt | 73 +++--- .../creditcard/model/CreditCardUiState.kt | 27 +- .../create/presentation/login/LoginContent.kt | 85 +++---- .../presentation/login/LoginViewModel.kt | 240 ++++++------------ .../presentation/login/model/LoginUiState.kt | 21 +- .../create/presentation/model/ItemUiState.kt | 18 ++ .../presentation/model/SharedItemState.kt | 21 ++ 11 files changed, 487 insertions(+), 286 deletions(-) create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/ItemLoadingScaffold.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiState.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/SharedItemState.kt diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt new file mode 100644 index 00000000..376a275b --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/ItemViewModel.kt @@ -0,0 +1,173 @@ +package de.davis.keygo.feature.item.create.presentation + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase +import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState +import de.davis.keygo.feature.item.create.presentation.model.SharedItemState +import de.davis.keygo.feature.item.create.presentation.model.VaultsState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +/** + * Base ViewModel for the item create/edit screens. Owns the machinery every item type shares: the + * selected vault, the assigned-tag set, the observable vault list and tag suggestions, and the + * navigation channel raised once an item is persisted. It also assembles the [ItemUiState.Loading] + * -> [ItemUiState.Ready] [state] flow so subclasses only express their own item-specific state and + * the pure mapping into their UI state. + * + * @param S the subclass' item-specific state (e.g. the form fields) + */ +internal abstract class ItemViewModel( + private val vaultContextRepository: VaultContextRepository, + private val itemRepository: ItemRepository, + observeAllTags: ObserveAllTagsSortedUseCase, + vaultRepository: VaultRepository, +) : ViewModel() { + + protected val nameTextFieldState = TextFieldState() + protected val notesTextFieldState = TextFieldState() + + /** The id of the item being edited, or `null` when creating a new one. */ + protected var itemId: ItemId? = null + + protected val selectedVaultId = MutableStateFlow(null) + private val assignedTags = MutableStateFlow>(emptySet()) + private val nameExists = MutableStateFlow(false) + + private val vaults = combine( + vaultRepository.observeAllVaultMetadata(), + selectedVaultId.filterNotNull(), + ) { metadata, selected -> + VaultsState(vaults = metadata, selectedVaultId = selected) + } + + private val shared: Flow = combine( + vaults, + observeAllTags(), + assignedTags, + nameExists, + ) { vaults, allTags, assigned, nameExists -> + SharedItemState( + nameTextFieldState = nameTextFieldState, + notesTextFieldState = notesTextFieldState, + nameExists = nameExists, + vaultsState = vaults, + itemAssignedTags = assigned, + tagsForSuggestion = (allTags - assigned).toSet(), + ) + }.distinctUntilChanged() + + /** The subclass' item-specific state, paired with [shared] into [ItemUiState.Ready]. */ + protected abstract val itemState: Flow + + /** + * Lazy so the `combine` body — which reads the abstract [itemState] — runs on first collection + * rather than during construction, by which point the subclass is fully initialized (base + * property initializers otherwise run before the subclass'). + */ + val state: StateFlow> by lazy { + combine(itemState, shared) { item, shared -> ItemUiState.Ready(item, shared) } + .onStart { + primeActiveVaultId() + observeNameTextField() + onSubscribed() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ItemUiState.Loading, + ) + } + + private val itemCreatedEventChannel = Channel() + val itemCreatedEvent = itemCreatedEventChannel.receiveAsFlow() + + /** Hook for subclasses to start their own observers when [state] gains its first subscriber. */ + protected open suspend fun onSubscribed() {} + + @OptIn(FlowPreview::class) + private fun observeNameTextField() { + snapshotFlow { nameTextFieldState.text } + .debounce(150.milliseconds) + .combine(selectedVaultId.filterNotNull()) { input, vaultId -> + itemRepository.doesNameExist( + input.toString(), + excludeId = itemId, + vaultId = vaultId, + ) + } + .distinctUntilChanged() + .onEach { exists -> nameExists.value = exists } + .flowOn(Dispatchers.Default) + .launchIn(viewModelScope) + } + + protected fun setSelectedVaultId(vaultId: VaultId) { + selectedVaultId.value = vaultId + } + + protected fun setAssignedTags(tags: Set) { + assignedTags.value = tags + } + + protected fun navigateUp(itemId: ItemId? = null) { + viewModelScope.launch { + itemCreatedEventChannel.send(itemId) + } + } + + private fun primeActiveVaultId() { + viewModelScope.launch { + val activeId = vaultContextRepository.getLastInteractedVaultId() ?: return@launch + selectedVaultId.compareAndSet(null, activeId) + } + } + + /** Persists the item. Raise [navigateUp] with the new id on success. */ + protected abstract fun onSubmit() + + protected open fun onBackClick() { + navigateUp() + } + + /** Handles the [ItemUiEvent]s common to every item type. */ + protected fun onItemUiEvent(event: ItemUiEvent) { + when (event) { + is ItemUiEvent.OnSubmit -> onSubmit() + is ItemUiEvent.OnBackClick -> onBackClick() + is ItemUiEvent.OnVaultSelected -> setSelectedVaultId(event.vaultId) + is ItemUiEvent.OnAddTags -> assignedTags.update { it + event.tags } + is ItemUiEvent.OnRemoveTag -> assignedTags.update { tags -> + tags.filterNot { it == event.tag }.toSet() + } + } + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/ItemLoadingScaffold.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/ItemLoadingScaffold.kt new file mode 100644 index 00000000..1bd5434e --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/ItemLoadingScaffold.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.feature.item.create.presentation.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState + +@Composable +internal fun ItemContentWrapper( + itemType: VaultItemType, + state: ItemUiState, + onBackClick: () -> Unit, + content: @Composable (ItemUiState.Ready) -> Unit +) { + when (state) { + ItemUiState.Loading -> ItemLoadingScaffold( + itemType = itemType, + onBackClick = onBackClick, + ) + + is ItemUiState.Ready -> content(state) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ItemLoadingScaffold(itemType: VaultItemType, onBackClick: () -> Unit) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CreateOrModifyItemTopAppBar( + itemType = itemType, + updating = false, + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + ContainedLoadingIndicator() + } + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index cf294e4e..8f5dd564 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -27,16 +27,39 @@ import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.component.FormGroup +import de.davis.keygo.feature.item.create.presentation.component.ItemContentWrapper import de.davis.keygo.feature.item.create.presentation.component.KeyGoItemForm import de.davis.keygo.feature.item.create.presentation.component.TAG_DELIMITERS +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiState import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent +import de.davis.keygo.feature.item.create.presentation.model.SharedItemState import de.davis.keygo.feature.item.core.R as ItemCoreR -@OptIn(ExperimentalMaterial3Api::class) + @Composable internal fun CreditCardContent(state: CreditCardUiState, onEvent: (CreditCardUiEvent) -> Unit) { + ItemContentWrapper( + itemType = VaultItemType.CreditCard, + state = state, + onBackClick = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, + ) { state -> + CreditCardReadyContent( + state = state.base, + shared = state.shared, + onEvent = onEvent, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreditCardReadyContent( + state: CreditCardBaseState, + shared: SharedItemState, + onEvent: (CreditCardUiEvent) -> Unit, +) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val tagsTextFieldState = rememberTextFieldState() @@ -45,8 +68,8 @@ internal fun CreditCardContent(state: CreditCardUiState, onEvent: (CreditCardUiE topBar = { CreateOrModifyItemTopAppBar( itemType = VaultItemType.CreditCard, - updating = false, - onBackClick = {}, + updating = state.updating, + onBackClick = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, actions = { IconButton( onClick = { @@ -62,7 +85,7 @@ internal fun CreditCardContent(state: CreditCardUiState, onEvent: (CreditCardUiE onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnSubmit)) }, - enabled = state.canSave, + enabled = state.canSave(shared.nameTextFieldState.text), ) { Icon( imageVector = Icons.Default.Done, @@ -75,13 +98,14 @@ internal fun CreditCardContent(state: CreditCardUiState, onEvent: (CreditCardUiE }, ) { innerPadding -> KeyGoItemForm( - nameTextFieldState = rememberTextFieldState(), + nameTextFieldState = shared.nameTextFieldState, tagsTextFieldState = tagsTextFieldState, - notesTextFieldState = rememberTextFieldState(), - vaultsState = state.vaultsState, + notesTextFieldState = shared.notesTextFieldState, + nameExists = shared.nameExists, + vaultsState = shared.vaultsState, onVaultSelect = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnVaultSelected(it))) }, - assignedTags = state.itemAssignedTags, - tagsForSuggestions = state.tagsForSuggestion, + assignedTags = shared.itemAssignedTags, + tagsForSuggestions = shared.tagsForSuggestion, onTagSubmitted = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnAddTags(it))) }, onDeleteTag = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnRemoveTag(it))) }, modifier = Modifier diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt index 14611ddf..f78435df 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt @@ -1,10 +1,12 @@ package de.davis.keygo.feature.item.create.presentation.creditcard import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.presentation.ObserveAsEvents import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import org.koin.androidx.compose.koinViewModel @@ -19,8 +21,19 @@ internal fun CreditCardScreen( val viewmodel: CreditCardViewModel = koinViewModel() val state by viewmodel.state.collectAsStateWithLifecycle() + LaunchedEffect(detailPaneInformation) { + viewmodel.init(detailPaneInformation) + } + + ObserveAsEvents(viewmodel.itemCreatedEvent) { + when (it) { + null -> navigateBack() + else -> creditCardCreated(it) + } + } + CreditCardContent( state = state, - onEvent = viewmodel::onEvent + onEvent = viewmodel::onEvent, ) -} \ No newline at end of file +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index ed09853b..96bcc09b 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -1,42 +1,55 @@ package de.davis.keygo.feature.item.create.presentation.creditcard -import androidx.compose.foundation.text.input.TextFieldState -import androidx.lifecycle.ViewModel -import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase +import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import de.davis.keygo.feature.item.create.presentation.ItemViewModel +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent -import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiState -import de.davis.keygo.feature.item.create.presentation.model.VaultsState +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import org.koin.core.annotation.KoinViewModel @KoinViewModel -internal class CreditCardViewModel : ViewModel() { - - private val nameTextFieldState = TextFieldState() - private val notesTextFieldState = TextFieldState() - private val ccHolderTextFieldState = TextFieldState() - private val ccNumberTextFieldState = TextFieldState() - private val ccCVVTextFieldState = TextFieldState() - private val ccExpirationDateTextFieldState = TextFieldState() - - val state = MutableStateFlow( - CreditCardUiState( - nameTextFieldState = nameTextFieldState, - notesTextFieldState = notesTextFieldState, - ccHolderTextFieldState = ccHolderTextFieldState, - ccNumberTextFieldState = ccNumberTextFieldState, - ccCVVTextFieldState = ccCVVTextFieldState, - ccExpirationDateTextFieldState = ccExpirationDateTextFieldState, - vaultsState = VaultsState( - vaults = emptyList(), - selectedVaultId = newVaultId() - ) - ) - ) +internal class CreditCardViewModel( + vaultContextRepository: VaultContextRepository, + itemRepository: ItemRepository, + observeAllTags: ObserveAllTagsSortedUseCase, + vaultRepository: VaultRepository, +) : ItemViewModel( + vaultContextRepository = vaultContextRepository, + itemRepository = itemRepository, + observeAllTags = observeAllTags, + vaultRepository = vaultRepository, +) { + + private val _base = MutableStateFlow(CreditCardBaseState()) + + override val itemState: Flow = _base + + fun init(information: DetailPaneInformation) { + when (information) { + // TODO: load + decrypt the existing card into _base once a read/decrypt use case + // exists. Decryption requires a cryptographic scope, which lives in a use case. + is DetailPaneInformation.Init.Existing -> itemId = information.id + + is DetailPaneInformation.Init.New, + is DetailPaneInformation.Init.TOTP, + is DetailPaneInformation.CreateRaw -> Unit // nothing to prefill + } + } + + override fun onSubmit() { + // TODO: persist the credit card once a CreateNewOrUpdateCreditCard use case exists. + // Building + encrypting the CreditCard here would put crypto/business logic in the + // ViewModel; the architecture keeps that in a use case (see CreateNewOrUpdateLoginUseCase). + } fun onEvent(event: CreditCardUiEvent) { when (event) { - is CreditCardUiEvent.ItemUi -> {} + is CreditCardUiEvent.ItemUi -> onItemUiEvent(event.event) } } -} \ No newline at end of file +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index c3c8ff19..9807c327 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -2,21 +2,20 @@ package de.davis.keygo.feature.item.create.presentation.creditcard.model import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable -import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.feature.item.core.presentation.model.InputFieldError -import de.davis.keygo.feature.item.create.presentation.model.VaultsState +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState + +internal typealias CreditCardUiState = ItemUiState @Stable -data class CreditCardUiState( - val nameTextFieldState: TextFieldState, - val notesTextFieldState: TextFieldState, - val ccHolderTextFieldState: TextFieldState, - val ccNumberTextFieldState: TextFieldState, - val ccCVVTextFieldState: TextFieldState, - val ccExpirationDateTextFieldState: TextFieldState, - val canSave: Boolean = false, +internal data class CreditCardBaseState( + val ccHolderTextFieldState: TextFieldState = TextFieldState(), + val ccNumberTextFieldState: TextFieldState = TextFieldState(), + val ccCVVTextFieldState: TextFieldState = TextFieldState(), + val ccExpirationDateTextFieldState: TextFieldState = TextFieldState(), val numberError: InputFieldError? = null, - val vaultsState: VaultsState, - val tagsForSuggestion: Set = emptySet(), - val itemAssignedTags: Set = emptySet(), -) + val updating: Boolean = false, +) { + fun canSave(name: CharSequence): Boolean = + name.isNotBlank() && ccNumberTextFieldState.text.isNotBlank() +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt index 9df0b429..8053700b 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt @@ -2,7 +2,6 @@ package de.davis.keygo.feature.item.create.presentation.login import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -10,12 +9,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -28,7 +27,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -53,6 +51,7 @@ import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItem import de.davis.keygo.feature.item.core.presentation.transformation.rememberSchemeStrippingTransformation import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.component.FormGroup +import de.davis.keygo.feature.item.create.presentation.component.ItemContentWrapper import de.davis.keygo.feature.item.create.presentation.component.KeyGoItemForm import de.davis.keygo.feature.item.create.presentation.component.OverrideTotpDialog import de.davis.keygo.feature.item.create.presentation.component.SelectItemForTotpModificationDialog @@ -63,6 +62,8 @@ import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseStat import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiState import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState +import de.davis.keygo.feature.item.create.presentation.model.SharedItemState import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet import de.davis.keygo.feature.totp.presentation.component.QRScanner @@ -71,48 +72,24 @@ import de.davis.keygo.feature.item.core.R as ItemCoreR @Composable internal fun LoginContent(state: LoginUiState, onEvent: (LoginUiEvent) -> Unit) { - when (state) { - LoginUiState.Loading -> LoginLoadingScaffold( - onBackClick = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, - ) - - is LoginUiState.Ready -> LoginReadyContent( + ItemContentWrapper( + itemType = VaultItemType.Login, + state = state, + onBackClick = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, + ) { state -> + LoginReadyContent( state = state.base, - vaultsState = state.vaultsState, + shared = state.shared, onEvent = onEvent, ) } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun LoginLoadingScaffold(onBackClick: () -> Unit) { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - CreateOrModifyItemTopAppBar( - itemType = VaultItemType.Login, - updating = false, - onBackClick = onBackClick, - ) - }, - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - ContainedLoadingIndicator() - } - } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun LoginReadyContent( state: LoginBaseState, - vaultsState: VaultsState, + shared: SharedItemState, onEvent: (LoginUiEvent) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() @@ -146,7 +123,7 @@ private fun LoginReadyContent( onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnSubmit)) }, - enabled = state.canSave, + enabled = state.canSave(shared.nameTextFieldState.text), ) { Icon( imageVector = Icons.Default.Done, @@ -159,11 +136,11 @@ private fun LoginReadyContent( } ) { innerPadding -> KeyGoItemForm( - nameTextFieldState = state.nameTextFieldState, - notesTextFieldState = state.notesTextFieldState, + nameTextFieldState = shared.nameTextFieldState, + notesTextFieldState = shared.notesTextFieldState, tagsTextFieldState = tagsTextFieldState, - tagsForSuggestions = state.tagsForSuggestion, - assignedTags = state.itemAssignedTags, + tagsForSuggestions = shared.tagsForSuggestion, + assignedTags = shared.itemAssignedTags, modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding) @@ -171,8 +148,8 @@ private fun LoginReadyContent( .imePadding() .nestedScroll(scrollBehavior.nestedScrollConnection), nameError = state.nameError, - nameExists = state.nameExists, - vaultsState = vaultsState, + nameExists = shared.nameExists, + vaultsState = shared.vaultsState, onVaultSelect = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnVaultSelected(it))) }, onTagSubmitted = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnAddTags(it))) }, onDeleteTag = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnRemoveTag(it))) }, @@ -346,7 +323,7 @@ private fun LoginContentPreview() { val selectedVaultId = newVaultId() KeyGoTheme { LoginContent( - state = LoginUiState.Ready( + state = ItemUiState.Ready( base = LoginBaseState( strengthScore = PasswordScore.Weak, domains = setOf( @@ -356,17 +333,23 @@ private fun LoginContentPreview() { eTLD1 = "example.com", ), ), - nameExists = true, ), - vaultsState = VaultsState( - vaults = listOf( - VaultMetadata( - vaultId = selectedVaultId, - name = "Vault 1", - icon = Vault.Icon.Default, + shared = SharedItemState( + nameTextFieldState = TextFieldState(), + notesTextFieldState = TextFieldState(), + nameExists = true, + vaultsState = VaultsState( + vaults = listOf( + VaultMetadata( + vaultId = selectedVaultId, + name = "Vault 1", + icon = Vault.Icon.Default, + ), ), + selectedVaultId = selectedVaultId, ), - selectedVaultId = selectedVaultId, + itemAssignedTags = emptySet(), + tagsForSuggestion = emptySet(), ), ), onEvent = {}, diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index b5455eca..495fb355 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -4,10 +4,8 @@ import android.util.Log import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.repository.ItemRepository @@ -34,13 +32,12 @@ import de.davis.keygo.feature.item.core.presentation.login.model.FieldType import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.core.presentation.model.InputFieldError import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.login.model.DialogState import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiEvent -import de.davis.keygo.feature.item.create.presentation.login.model.LoginUiState import de.davis.keygo.feature.item.create.presentation.login.model.OverrideTotpField -import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent -import de.davis.keygo.feature.item.create.presentation.model.VaultsState +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState import de.davis.keygo.rust.totp.TotpService import de.davis.keygo.rust.totp.getInfoFromUriWithResult import de.davis.keygo.rust.totp.getUrlWithResult @@ -49,22 +46,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel @@ -73,81 +63,39 @@ import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class LoginViewModel( private val loginWithCryptoScope: LoginWithCryptoScopeUseCase, - private val itemRepository: ItemRepository, - private val vaultContextRepository: VaultContextRepository, private val passwordStrengthEstimator: PasswordStrengthEstimator, private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, private val getTdlMatchedLogins: GetTdlMatchedLoginsUseCase, private val snackbarManager: SnackbarManager, private val totpService: TotpService, private val registrableDomainResolver: RegistrableDomainResolver, + vaultContextRepository: VaultContextRepository, + itemRepository: ItemRepository, observeAllTags: ObserveAllTagsSortedUseCase, vaultRepository: VaultRepository, -) : ViewModel() { +) : ItemViewModel( + vaultContextRepository = vaultContextRepository, + itemRepository = itemRepository, + observeAllTags = observeAllTags, + vaultRepository = vaultRepository, +) { - private val nameTextFieldState = TextFieldState() private val passwordTextFieldState = TextFieldState() private val _base = MutableStateFlow( LoginBaseState( - nameTextFieldState = nameTextFieldState, passwordTextFieldState = passwordTextFieldState, ) ) - private val _selectedVaultId = MutableStateFlow(null) + override val itemState: Flow = _base - private val allTags = observeAllTags() - - private val vaultsFlow: Flow = combine( - vaultRepository.observeAllVaultMetadata(), - _selectedVaultId.filterNotNull(), - ) { metadata, selected -> - VaultsState(vaults = metadata, selectedVaultId = selected) - }.distinctUntilChanged() - - val state = combine(_base, allTags, vaultsFlow) { base, allTags, vaults -> - LoginUiState.Ready( - base = base.copy(tagsForSuggestion = (allTags - base.itemAssignedTags).toSet()), - vaultsState = vaults - ) - }.onStart { - observeNameTextField() + override suspend fun onSubscribed() { observePasswordTextField() - primeActiveVaultId() - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = LoginUiState.Loading, - ) - - private val itemCreatedEventChannel = Channel() - val itemCreatedEvent = itemCreatedEventChannel.receiveAsFlow() + } - private var itemId: ItemId? = null private var totpSecretInformation: TotpInfo? = null private var totpOriginalUri: String? = null - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - private fun observeNameTextField() { - snapshotFlow { nameTextFieldState.text } - .debounce(150.milliseconds) - .combine(_selectedVaultId.filterNotNull()) { input, vaultId -> - itemRepository.doesNameExist( - input.toString(), - excludeId = itemId, - vaultId = vaultId, - ) - } - .distinctUntilChanged() - .onEach { exists -> - _base.update { - it.copy(nameExists = exists) - } - } - .flowOn(Dispatchers.Default) - .launchIn(viewModelScope) - } - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private fun observePasswordTextField() { snapshotFlow { passwordTextFieldState.text } @@ -163,19 +111,6 @@ internal class LoginViewModel( .launchIn(viewModelScope) } - private fun primeActiveVaultId() { - viewModelScope.launch { - val activeId = vaultContextRepository.getLastInteractedVaultId() ?: return@launch - _selectedVaultId.compareAndSet(null, activeId) - } - } - - private fun navigateUp(itemId: ItemId? = null) { - viewModelScope.launch { - itemCreatedEventChannel.send(itemId) - } - } - fun setPendingPasskeyCount(count: Int) { _base.update { it.copy(pendingPasskeyCount = count) } } @@ -244,16 +179,16 @@ internal class LoginViewModel( } nameTextFieldState.setTextAndPlaceCursorAtEnd(login.name) + notesTextFieldState.setTextAndPlaceCursorAtEnd(login.note ?: "") passwordTextFieldState.setTextAndPlaceCursorAtEnd(decrypted.first ?: "") - _selectedVaultId.update { login.vaultId } + setSelectedVaultId(login.vaultId) + setAssignedTags(login.tags) _base.update { it.copy( totpTextFieldState = TextFieldState(decrypted.second ?: ""), usernameTextFieldState = TextFieldState(login.username ?: ""), domains = login.domainInfos, - itemAssignedTags = login.tags, - notesTextFieldState = TextFieldState(login.note ?: ""), existingPasskeyCount = login.passkeyRPs.size, dialogState = DialogState.None, updating = true, @@ -294,99 +229,80 @@ internal class LoginViewModel( } } - private fun onItemUiEvent(event: ItemUiEvent) { - when (event) { - is ItemUiEvent.OnSubmit -> { - val ready = state.value as? LoginUiState.Ready ?: return - val base = ready.base - viewModelScope.launch { - val upsert = itemId?.let { itemId -> - UpsertLogin.update( - itemId = itemId, - vaultId = ready.vaultsState.selectedVaultId, - name = fieldUpdate(base.nameTextFieldState.text.toString()), - username = fieldUpdate(base.usernameTextFieldState.text.toString()), - domains = set(base.domains), - tags = set(base.itemAssignedTags), - password = fieldUpdate(base.passwordTextFieldState.text.toString()), - totpUriOrSecret = fieldUpdate(base.totpTextFieldState.text.toString()), - note = fieldUpdate(base.notesTextFieldState.text.toString()), - ) - } ?: UpsertLogin.create( - vaultId = ready.vaultsState.selectedVaultId, - name = base.nameTextFieldState.text.toString(), - username = base.usernameTextFieldState.text.toString(), - domains = base.domains, - tags = base.itemAssignedTags, - password = base.passwordTextFieldState.text.toString(), - totpUriOrSecret = base.totpTextFieldState.text.toString(), - note = base.notesTextFieldState.text.toString(), - hasPendingPasskey = base.pendingPasskeyCount > 0, + override fun onSubmit() { + val ready = state.value as? ItemUiState.Ready ?: return + val base = ready.base + val assignedTags = ready.shared.itemAssignedTags + val selectedVaultId = ready.shared.vaultsState.selectedVaultId + viewModelScope.launch { + val upsert = itemId?.let { itemId -> + UpsertLogin.update( + itemId = itemId, + vaultId = selectedVaultId, + name = fieldUpdate(nameTextFieldState.text.toString()), + username = fieldUpdate(base.usernameTextFieldState.text.toString()), + domains = set(base.domains), + tags = set(assignedTags), + password = fieldUpdate(base.passwordTextFieldState.text.toString()), + totpUriOrSecret = fieldUpdate(base.totpTextFieldState.text.toString()), + note = fieldUpdate(notesTextFieldState.text.toString()), + ) + } ?: UpsertLogin.create( + vaultId = selectedVaultId, + name = nameTextFieldState.text.toString(), + username = base.usernameTextFieldState.text.toString(), + domains = base.domains, + tags = assignedTags, + password = base.passwordTextFieldState.text.toString(), + totpUriOrSecret = base.totpTextFieldState.text.toString(), + note = notesTextFieldState.text.toString(), + hasPendingPasskey = base.pendingPasskeyCount > 0, + ) + + createNewOrUpdateLogin( + upsert = upsert, + ).onSuccess { + navigateUp(it) + }.onFailure { failure -> + _base.update { + it.copy( + nameError = if (failure.contains(LoginError.BlankName)) InputFieldError.Empty else null, ) + } - createNewOrUpdateLogin( - upsert = upsert, - ).onSuccess { - navigateUp(it) - }.onFailure { failure -> - _base.update { - it.copy( - nameError = if (failure.contains(LoginError.BlankName)) InputFieldError.Empty else null, - ) - } + if (failure.any { it is LoginError.InvalidVaultId }) { + snackbarManager.sendMessage( + message = SnackbarMessage( + message = ResourceString(R.string.invalid_vault_id), + ), + ) + } - if (failure.any { it is LoginError.InvalidVaultId }) { + if (failure.any { it is LoginError.DatabaseError }) { + failure.filterIsInstance() + .first() + .let { dbError -> snackbarManager.sendMessage( message = SnackbarMessage( - message = ResourceString(R.string.invalid_vault_id), + message = ResourceString( + R.string.database_error, + dbError.throwable.message ?: "no message", + ), ), ) } - - if (failure.any { it is LoginError.DatabaseError }) { - failure.filterIsInstance() - .first() - .let { dbError -> - snackbarManager.sendMessage( - message = SnackbarMessage( - message = ResourceString( - R.string.database_error, - dbError.throwable.message ?: "no message", - ), - ), - ) - } - } - } - } - } - - is ItemUiEvent.OnBackClick -> { - if (_base.value.scanning) { - _base.update { it.copy(scanning = false) } - return - } - - navigateUp() - } - - is ItemUiEvent.OnAddTags -> { - _base.update { - it.copy(itemAssignedTags = it.itemAssignedTags + event.tags) - } - } - - is ItemUiEvent.OnRemoveTag -> { - _base.update { - it.copy(itemAssignedTags = it.itemAssignedTags.filterNot { tag -> tag == event.tag } - .toSet()) } } + } + } - is ItemUiEvent.OnVaultSelected -> { - _selectedVaultId.value = event.vaultId - } + override fun onBackClick() { + if (_base.value.scanning) { + _base.update { it.copy(scanning = false) } + return } + + navigateUp() } fun onEvent(event: LoginUiEvent) { diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt index aa476081..8c9bb4f4 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/model/LoginUiState.kt @@ -4,31 +4,17 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.PasswordScore -import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.feature.item.core.presentation.model.InputFieldError -import de.davis.keygo.feature.item.create.presentation.model.VaultsState +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState -@Stable -internal sealed interface LoginUiState { - data object Loading : LoginUiState - - data class Ready( - val base: LoginBaseState, - val vaultsState: VaultsState, - ) : LoginUiState -} +internal typealias LoginUiState = ItemUiState @Stable internal data class LoginBaseState( - val nameTextFieldState: TextFieldState = TextFieldState(), - val notesTextFieldState: TextFieldState = TextFieldState(), val passwordTextFieldState: TextFieldState = TextFieldState(), val totpTextFieldState: TextFieldState = TextFieldState(), val usernameTextFieldState: TextFieldState = TextFieldState(), val domains: Set = emptySet(), - val tagsForSuggestion: Set = emptySet(), - val itemAssignedTags: Set = emptySet(), - val nameExists: Boolean = false, val strengthScore: PasswordScore = PasswordScore.None, val generatePasswordBottomSheetVisible: Boolean = false, val dialogState: DialogState = DialogState.None, @@ -45,6 +31,5 @@ internal data class LoginBaseState( || existingPasskeyCount > 0 || pendingPasskeyCount > 0 - val canSave: Boolean - get() = nameTextFieldState.text.isNotBlank() && hasAnyContent + fun canSave(name: CharSequence): Boolean = name.isNotBlank() && hasAnyContent } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiState.kt new file mode 100644 index 00000000..4c9aeb41 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/ItemUiState.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.item.create.presentation.model + +import androidx.compose.runtime.Stable + +/** + * UI state common to every item create/edit screen, assembled by [de.davis.keygo.feature.item.create.presentation.ItemViewModel]. + * The item-specific form state [S] is paired with the [SharedItemState]. + */ +@Stable +internal sealed interface ItemUiState { + data object Loading : ItemUiState + + @Stable + data class Ready( + val base: S, + val shared: SharedItemState, + ) : ItemUiState +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/SharedItemState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/SharedItemState.kt new file mode 100644 index 00000000..52cb98cb --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/SharedItemState.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.item.create.presentation.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.model.Tag + +/** + * State shared by every item create/edit screen: the name and notes fields every item has, the selectable + * vaults, and the tags currently assigned to the item alongside the remaining tags offered as + * suggestions. Owned by [de.davis.keygo.feature.item.create.presentation.ItemViewModel] and paired + * with each screen's own item state in [ItemUiState.Ready]. + */ +@Stable +internal data class SharedItemState( + val nameTextFieldState: TextFieldState, + val notesTextFieldState: TextFieldState, + val nameExists: Boolean, + val vaultsState: VaultsState, + val itemAssignedTags: Set, + val tagsForSuggestion: Set, +) From 25a562fc9f66b4153b0cfb238e833f85531f8f64 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 18:55:13 +0200 Subject: [PATCH 07/36] refactor(security): Generalize LoginWithCryptoScopeUseCase to ItemWithCryptoScopeUseCase --- ...eCase.kt => ItemWithCryptoScopeUseCase.kt} | 29 ++-- .../usecase/ItemWithCryptoScopeUseCaseTest.kt | 129 ++++++++++++++++++ .../presentation/login/LoginViewModel.kt | 9 +- .../item/view/login/ViewLoginViewModel.kt | 11 +- 4 files changed, 157 insertions(+), 21 deletions(-) rename core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/{LoginWithCryptoScopeUseCase.kt => ItemWithCryptoScopeUseCase.kt} (71%) create mode 100644 core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/LoginWithCryptoScopeUseCase.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt similarity index 71% rename from core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/LoginWithCryptoScopeUseCase.kt rename to core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt index 25b75581..0ef5ca51 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/LoginWithCryptoScopeUseCase.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCase.kt @@ -2,8 +2,6 @@ package de.davis.keygo.core.security.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Item -import de.davis.keygo.core.item.domain.model.Login -import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider @@ -16,27 +14,28 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @Single -class LoginWithCryptoScopeUseCase( +class ItemWithCryptoScopeUseCase( private val vaultRepository: VaultRepository, - private val loginRepository: LoginRepository, private val cryptoScopeProvider: CryptographicScopeProvider, ) { - suspend fun observe( + suspend fun oneShot( itemId: ItemId, - block: suspend CryptographicScope.(Login) -> R, - ): Flow> = loginRepository.observeLoginById(itemId).map { login -> - login?.let { handleItem(it, block) } - ?: return@map Result.Failure(CryptoScopeError.IdNotFound) + fetch: suspend (ItemId) -> I?, + block: suspend CryptographicScope.(I) -> R, + ): Result { + val item = fetch(itemId) + ?: return Result.Failure(CryptoScopeError.IdNotFound) + return handleItem(item, block) } - suspend fun oneShot( + suspend fun observe( itemId: ItemId, - block: suspend CryptographicScope.(Login) -> R, - ): Result { - val login = loginRepository.getLoginById(itemId) - ?: return Result.Failure(CryptoScopeError.IdNotFound) - return handleItem(login, block) + source: (ItemId) -> Flow, + block: suspend CryptographicScope.(I) -> R, + ): Flow> = source(itemId).map { item -> + item?.let { handleItem(it, block) } + ?: Result.Failure(CryptoScopeError.IdNotFound) } private suspend fun handleItem( diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt new file mode 100644 index 00000000..45330824 --- /dev/null +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt @@ -0,0 +1,129 @@ +package de.davis.keygo.core.security.domain.usecase + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.model.CryptoScopeError +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import java.time.YearMonth +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ItemWithCryptoScopeUseCaseTest { + + private val vaultId = newVaultId() + private val defaultVault = Vault( + id = vaultId, + name = "Default vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) + + private val vaultRepository = FakeVaultRepository() + private val creditCardRepository = FakeCreditCardRepository() + private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository()) + private val useCase = ItemWithCryptoScopeUseCase(vaultRepository, cryptoProvider) + + private fun card(id: ItemId) = CreditCard( + id = id, + vaultId = vaultId, + name = "Test Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = "Alice", + lastNumbers = "4242", + cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv = CreditCard.CVV(EncryptedPayload.EMPTY), + expirationDate = YearMonth.of(2030, 12), + ) + + @BeforeTest + fun setup() = runTest { + vaultRepository.seed(defaultVault) + } + + @Test + fun `oneShot runs block with fetched item and returns success`() = runTest { + val id = newItemId() + creditCardRepository.seed(card(id)) + + val result = useCase.oneShot( + itemId = id, + fetch = creditCardRepository::getCreditCardById, + ) { it.name } + + assertTrue(result.isSuccess()) + assertEquals("Test Card", result.getOrNull()) + } + + @Test + fun `oneShot returns IdNotFound when item is missing`() = runTest { + val result = useCase.oneShot( + itemId = newItemId(), + fetch = creditCardRepository::getCreditCardById, + ) { it.name } + + assertTrue(result.isFailure()) + val failure = assertIs>(result) + assertIs(failure.error) + } + + @Test + fun `oneShot returns IdNotFound when vault key is missing`() = runTest { + val id = newItemId() + creditCardRepository.seed(card(id).copy(vaultId = newVaultId())) + + val result = useCase.oneShot( + itemId = id, + fetch = creditCardRepository::getCreditCardById, + ) { it.name } + + assertTrue(result.isFailure()) + val failure = assertIs>(result) + assertIs(failure.error) + } + + @Test + fun `observe emits success carrying the block result`() = runTest { + val id = newItemId() + creditCardRepository.seed(card(id)) + + val result = useCase.observe( + itemId = id, + source = creditCardRepository::observeCreditCardById, + ) { it.name }.first() + + assertTrue(result.isSuccess()) + assertEquals("Test Card", result.getOrNull()) + } + + @Test + fun `observe emits IdNotFound when item is missing`() = runTest { + val result = useCase.observe( + itemId = newItemId(), + source = creditCardRepository::observeCreditCardById, + ) { it.name }.first() + + assertTrue(result.isFailure()) + val failure = assertIs>(result) + assertIs(failure.error) + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index 495fb355..b142d32c 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -9,12 +9,13 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.GetTdlMatchedLoginsUseCase -import de.davis.keygo.core.security.domain.usecase.LoginWithCryptoScopeUseCase +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver import de.davis.keygo.core.util.domain.snackbar.SnackbarManager @@ -62,7 +63,8 @@ import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class LoginViewModel( - private val loginWithCryptoScope: LoginWithCryptoScopeUseCase, + private val itemWithCryptoScope: ItemWithCryptoScopeUseCase, + private val loginRepository: LoginRepository, private val passwordStrengthEstimator: PasswordStrengthEstimator, private val createNewOrUpdateLogin: CreateNewOrUpdateLoginUseCase, private val getTdlMatchedLogins: GetTdlMatchedLoginsUseCase, @@ -153,8 +155,9 @@ internal class LoginViewModel( private suspend fun initWithId(itemId: ItemId) { this.itemId = itemId - loginWithCryptoScope.oneShot( + itemWithCryptoScope.oneShot( itemId = itemId, + fetch = loginRepository::getLoginById, ) { login -> val decrypted = coroutineScope { val pwdDeferred = login.passwordCredential?.let { pwd -> diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index 3b9126b2..285eccc2 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -6,11 +6,12 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.security.domain.crypto.decrypt -import de.davis.keygo.core.security.domain.usecase.LoginWithCryptoScopeUseCase +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.core.util.fold @@ -68,7 +69,8 @@ internal class ViewLoginViewModel( private val totpGenerator: TotpGenerator, private val registrableDomainResolver: RegistrableDomainResolver, private val totpService: TotpService, - private val observeLoginWithCryptoScope: LoginWithCryptoScopeUseCase, + private val observeLoginWithCryptoScope: ItemWithCryptoScopeUseCase, + private val loginRepository: LoginRepository, private val observeAllTags: ObserveAllTagsSortedUseCase, ) : ViewModel() { @@ -81,7 +83,10 @@ internal class ViewLoginViewModel( .filterNotNull() .distinctUntilChanged() .flatMapLatest { id -> - observeLoginWithCryptoScope.observe(itemId = id) { login -> + observeLoginWithCryptoScope.observe( + itemId = id, + source = loginRepository::observeLoginById, + ) { login -> val (obfuscated, vaultMetadata) = coroutineScope { val obfuscated = login.passwordCredential?.let { pwd -> async { pwd.secret.decrypt().asObfuscatedString() } From f7072472768931e96d2bb41b87586ed7e4b8d5dc Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 24 May 2026 19:03:04 +0200 Subject: [PATCH 08/36] feat(item-create): Load and decrypt existing credit card --- .../creditcard/CreditCardViewModel.kt | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index 96bcc09b..d24967c6 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -1,19 +1,33 @@ package de.davis.keygo.feature.item.create.presentation.creditcard +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import java.time.format.DateTimeFormatter @KoinViewModel internal class CreditCardViewModel( + private val itemWithCryptoScope: ItemWithCryptoScopeUseCase, + private val creditCardRepository: CreditCardRepository, vaultContextRepository: VaultContextRepository, itemRepository: ItemRepository, observeAllTags: ObserveAllTagsSortedUseCase, @@ -31,9 +45,8 @@ internal class CreditCardViewModel( fun init(information: DetailPaneInformation) { when (information) { - // TODO: load + decrypt the existing card into _base once a read/decrypt use case - // exists. Decryption requires a cryptographic scope, which lives in a use case. - is DetailPaneInformation.Init.Existing -> itemId = information.id + is DetailPaneInformation.Init.Existing -> + viewModelScope.launch { initWithId(information.id) } is DetailPaneInformation.Init.New, is DetailPaneInformation.Init.TOTP, @@ -41,6 +54,37 @@ internal class CreditCardViewModel( } } + private suspend fun initWithId(itemId: ItemId) { + this.itemId = itemId + + itemWithCryptoScope.oneShot( + itemId = itemId, + fetch = creditCardRepository::getCreditCardById, + ) { card -> + val (number, cvv) = coroutineScope { + val number = async { card.cardNumber.decrypt() } + val cvv = card.cvv?.let { secret -> async { secret.decrypt() } } + number.await() to cvv?.await() + } + + nameTextFieldState.setTextAndPlaceCursorAtEnd(card.name) + notesTextFieldState.setTextAndPlaceCursorAtEnd(card.note ?: "") + setSelectedVaultId(card.vaultId) + setAssignedTags(card.tags) + _base.update { + it.copy( + ccHolderTextFieldState = TextFieldState(card.holder ?: ""), + ccNumberTextFieldState = TextFieldState(number), + ccCVVTextFieldState = TextFieldState(cvv ?: ""), + ccExpirationDateTextFieldState = TextFieldState( + card.expirationDate.format(EXPIRATION_FORMATTER), + ), + updating = true, + ) + } + } + } + override fun onSubmit() { // TODO: persist the credit card once a CreateNewOrUpdateCreditCard use case exists. // Building + encrypting the CreditCard here would put crypto/business logic in the @@ -52,4 +96,8 @@ internal class CreditCardViewModel( is CreditCardUiEvent.ItemUi -> onItemUiEvent(event.event) } } + + companion object { + private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + } } From 436e5984d40d00d51aece6286e7bcc7721f2c6d1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 25 May 2026 12:44:40 +0200 Subject: [PATCH 09/36] feat(item-create): Add credit card number formatting and validation - Implement credit card network detection, Luhn validation, and formatting logic in Rust. - Add `CardNumberVisualTransformation` and `CardNumberInputTransformation` to handle real-time field formatting and input sanitization. - Update `KeyGoFormField` to support `OutputTransformation`. - Integrate `CardFormatter` into the credit card creation flow via dependency injection. --- .../presentation/component/KeyGoFormField.kt | 3 + .../CardNumberInputTransformation.kt | 18 + .../CardNumberVisualTransformation.kt | 31 ++ .../creditcard/CreditCardContent.kt | 9 +- .../creditcard/CreditCardViewModel.kt | 11 +- .../creditcard/model/CreditCardUiState.kt | 2 + rust/rust-code/bindings/src/card.rs | 36 ++ rust/rust-code/bindings/src/lib.rs | 1 + rust/rust-code/lib/src/card.rs | 452 ++++++++++++++++++ rust/rust-code/lib/src/lib.rs | 1 + .../kotlin/de/davis/keygo/rust/card/Card.kt | 5 + .../de/davis/keygo/rust/di/RustModule.kt | 5 + .../de/davis/keygo/rust/FakeCardFormatter.kt | 19 + 13 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt create mode 100644 rust/rust-code/bindings/src/card.rs create mode 100644 rust/rust-code/lib/src/card.rs create mode 100644 rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt create mode 100644 rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt index 7027aea9..0e870299 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextObfuscationMode @@ -51,6 +52,7 @@ fun KeyGoFormField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), onKeyboardAction: KeyboardActionHandler? = null, inputTransformation: InputTransformation? = TrimTransformation, + outputTransformation: OutputTransformation? = null, interactionSource: MutableInteractionSource? = null ) { val supportingText: @Composable (() -> Unit)? = error?.let { @@ -113,6 +115,7 @@ fun KeyGoFormField( keyboardOptions = keyboardOptions, onKeyboardAction = onKeyboardAction, inputTransformation = inputTransformation, + outputTransformation = outputTransformation, interactionSource = interactionSource ) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt new file mode 100644 index 00000000..2f4d3b1f --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.item.core.presentation.transformation + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.placeCursorAtEnd + +class CardNumberInputTransformation( + private val sanitize: (String) -> String, +) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val original = asCharSequence().toString() + val sanitized = sanitize(original) + if (original != sanitized) { + replace(0, length, sanitized) + placeCursorAtEnd() + } + } +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt new file mode 100644 index 00000000..0eb07bd4 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.item.core.presentation.transformation + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.insert +import androidx.compose.runtime.Composable +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject +import org.koin.core.annotation.Single + +@Single +class CardNumberVisualTransformation( + private val cardFormatter: CardFormatter +) : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + val digits = asCharSequence().toString() + if (digits.isEmpty()) return + + // Each insert shifts later offsets right by one, so add the count of + // spaces already inserted to keep the offsets aligned with the buffer. + cardFormatter.spaceIndices(digits).forEachIndexed { inserted, index -> + val at = index + inserted + insert(at, " ") + } + } +} + +@Composable +fun rememberCardFormatter(): CardNumberVisualTransformation { + return koinInject() +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index 8f5dd564..a2eeccde 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -25,6 +25,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems +import de.davis.keygo.feature.item.core.presentation.transformation.rememberCardFormatter import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.component.FormGroup import de.davis.keygo.feature.item.create.presentation.component.ItemContentWrapper @@ -62,6 +63,7 @@ private fun CreditCardReadyContent( ) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val tagsTextFieldState = rememberTextFieldState() + val ccNumberFormatter = rememberCardFormatter() Scaffold( modifier = Modifier.fillMaxSize(), @@ -127,10 +129,11 @@ private fun CreditCardReadyContent( KeyGoFormField( state = state.ccNumberTextFieldState, label = { Text(text = stringResource(ItemCoreR.string.cc_number)) }, - isSecure = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword - ) + ), + inputTransformation = state.numberInputTransformation, + outputTransformation = ccNumberFormatter, ) KeyGoFormField( @@ -139,7 +142,7 @@ private fun CreditCardReadyContent( isSecure = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword - ) + ), ) KeyGoFormField( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index d24967c6..cf8633df 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -12,9 +12,11 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import de.davis.keygo.feature.item.core.presentation.transformation.CardNumberInputTransformation import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent +import de.davisalessandro.keygo.rust.CardFormatterInterface import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -28,6 +30,7 @@ import java.time.format.DateTimeFormatter internal class CreditCardViewModel( private val itemWithCryptoScope: ItemWithCryptoScopeUseCase, private val creditCardRepository: CreditCardRepository, + cardFormatter: CardFormatterInterface, vaultContextRepository: VaultContextRepository, itemRepository: ItemRepository, observeAllTags: ObserveAllTagsSortedUseCase, @@ -39,7 +42,13 @@ internal class CreditCardViewModel( vaultRepository = vaultRepository, ) { - private val _base = MutableStateFlow(CreditCardBaseState()) + private val cardDigits: (String) -> String = cardFormatter::digits + + private val _base = MutableStateFlow( + CreditCardBaseState( + numberInputTransformation = CardNumberInputTransformation(cardDigits), + ), + ) override val itemState: Flow = _base diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index 9807c327..78f7dd6d 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.item.create.presentation.creditcard.model +import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable import de.davis.keygo.feature.item.core.presentation.model.InputFieldError @@ -13,6 +14,7 @@ internal data class CreditCardBaseState( val ccNumberTextFieldState: TextFieldState = TextFieldState(), val ccCVVTextFieldState: TextFieldState = TextFieldState(), val ccExpirationDateTextFieldState: TextFieldState = TextFieldState(), + val numberInputTransformation: InputTransformation? = null, val numberError: InputFieldError? = null, val updating: Boolean = false, ) { diff --git a/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs new file mode 100644 index 00000000..67796328 --- /dev/null +++ b/rust/rust-code/bindings/src/card.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use lib::card::{ + card_digits as core_card_digits, card_space_indices as core_card_space_indices, + format_card_number as core_format_card_number, luhn_valid as core_luhn_valid, +}; + +#[derive(uniffi::Object)] +pub struct CardFormatter; + +#[uniffi::export] +impl CardFormatter { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn digits(&self, input: String) -> String { + core_card_digits(&input) + } + + pub fn format_number(&self, input: String) -> String { + core_format_card_number(&input) + } + + pub fn space_indices(&self, input: String) -> Vec { + core_card_space_indices(&input) + .into_iter() + .map(|i| i as i32) + .collect() + } + + pub fn is_luhn_valid(&self, input: String) -> bool { + core_luhn_valid(&input) + } +} diff --git a/rust/rust-code/bindings/src/lib.rs b/rust/rust-code/bindings/src/lib.rs index a7d11a59..93e42391 100644 --- a/rust/rust-code/bindings/src/lib.rs +++ b/rust/rust-code/bindings/src/lib.rs @@ -1,4 +1,5 @@ mod account; +mod card; mod item; mod key_derivation; mod key_wrap; diff --git a/rust/rust-code/lib/src/card.rs b/rust/rust-code/lib/src/card.rs new file mode 100644 index 00000000..16072a36 --- /dev/null +++ b/rust/rust-code/lib/src/card.rs @@ -0,0 +1,452 @@ +//! A credit-card number formatter. +//! +//! Detects the card network from the leading digits (the IIN/BIN), groups the +//! number into the spacing convention used by that network, and offers a Luhn +//! checksum check. +//! +//! All public entry points accept "dirty" input — spaces, dashes, and any +//! other non-digit characters are ignored — so they work equally well on a +//! pasted number or on text being typed live into a field. +//! +//! ``` +//! use lib::card::{Card, CardNetwork}; +//! +//! let card = Card::parse("378282246310005"); +//! assert_eq!(card.network, CardNetwork::Amex); +//! assert_eq!(card.formatted, "3782 822463 10005"); +//! assert!(card.is_valid()); +//! ``` + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardNetwork { + Visa, + Mastercard, + Amex, + Discover, + DinersClub, + Jcb, + UnionPay, + Maestro, + Unknown, +} + +impl CardNetwork { + pub fn detect(number: &str) -> CardNetwork { + detect_digits(&digits_only(number)) + } + + pub fn name(self) -> &'static str { + match self { + CardNetwork::Visa => "Visa", + CardNetwork::Mastercard => "Mastercard", + CardNetwork::Amex => "American Express", + CardNetwork::Discover => "Discover", + CardNetwork::DinersClub => "Diners Club", + CardNetwork::Jcb => "JCB", + CardNetwork::UnionPay => "UnionPay", + CardNetwork::Maestro => "Maestro", + CardNetwork::Unknown => "Unknown", + } + } + + pub fn valid_lengths(self) -> &'static [usize] { + match self { + CardNetwork::Visa => &[13, 16, 19], + CardNetwork::Mastercard => &[16], + CardNetwork::Amex => &[15], + CardNetwork::Discover => &[16, 17, 18, 19], + CardNetwork::DinersClub => &[14, 16], + CardNetwork::Jcb => &[16, 17, 18, 19], + CardNetwork::UnionPay => &[16, 17, 18, 19], + CardNetwork::Maestro => &[12, 13, 14, 15, 16, 17, 18, 19], + CardNetwork::Unknown => &[], + } + } + + /// The longest number this network issues. Unknown networks fall back to the global + /// [`MAX_PAN_DIGITS`]. + pub fn max_len(self) -> usize { + self.valid_lengths() + .iter() + .copied() + .max() + .unwrap_or(MAX_PAN_DIGITS) + } + + pub fn cvv_len(self) -> usize { + match self { + CardNetwork::Amex => 4, + _ => 3, + } + } + + /// Group sizes used to space a number of `len` digits. + fn groups(self, len: usize) -> Vec { + match (self, len) { + (CardNetwork::Amex, 15) => vec![4, 6, 5], + (CardNetwork::DinersClub, 14) => vec![4, 6, 4], + _ => groups_of_four(len), + } + } +} + +#[derive(Debug, Clone)] +pub struct Card { + pub network: CardNetwork, + pub digits: String, + pub formatted: String, +} + +impl Card { + pub fn parse(input: &str) -> Card { + let digits = digits_only(input); + let network = detect_digits(&digits); + let formatted = group(&digits, &network.groups(digits.len())); + Card { + network, + digits, + formatted, + } + } + + pub fn is_length_valid(&self) -> bool { + self.network.valid_lengths().contains(&self.digits.len()) + } + + pub fn is_luhn_valid(&self) -> bool { + luhn_valid(&self.digits) + } + + pub fn is_valid(&self) -> bool { + self.network != CardNetwork::Unknown && self.is_length_valid() && self.is_luhn_valid() + } +} + +impl fmt::Display for Card { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.formatted) + } +} + +pub const MAX_PAN_DIGITS: usize = 19; + +/// Strip everything but digits and cap to the detected network's +/// [`CardNetwork::max_len`] (falling back to [`MAX_PAN_DIGITS`] while unknown). +/// This is the raw value a field should *store*; grouping is purely visual. +pub fn card_digits(number: &str) -> String { + let all_digits = digits_only(number); + let max = detect_digits(&all_digits).max_len(); + all_digits.chars().take(max).collect() +} + +/// Format a card number into network-appropriate, space-separated groups. +/// Input is cleaned and capped via [`card_digits`] first. +pub fn format_card_number(number: &str) -> String { + let digits = card_digits(number); + let network = detect_digits(&digits); + group(&digits, &network.groups(digits.len())) +} + +/// Digit offsets at which a grouping space should be inserted for display, +/// e.g. `[4, 8, 12]` for a 16-digit number. +pub fn card_space_indices(number: &str) -> Vec { + let digits = card_digits(number); + let network = detect_digits(&digits); + let mut indices = Vec::new(); + let mut pos = 0; + for size in network.groups(digits.len()) { + pos += size; + if pos < digits.len() { + indices.push(pos); + } + } + indices +} + +pub fn luhn_valid(number: &str) -> bool { + let digits = digits_only(number); + if digits.is_empty() { + return false; + } + let sum: u32 = digits + .bytes() + .rev() + .enumerate() + .map(|(i, b)| { + let d = u32::from(b - b'0'); + if i % 2 == 1 { + let doubled = d * 2; + if doubled > 9 { doubled - 9 } else { doubled } + } else { + d + } + }) + .sum(); + sum % 10 == 0 +} + +// --- internal helpers ------------------------------------------------------- + +fn digits_only(s: &str) -> String { + s.chars().filter(char::is_ascii_digit).collect() +} + +fn prefix(digits: &str, n: usize) -> Option { + digits.get(..n).and_then(|p| p.parse().ok()) +} + +/// Network detection by IIN range. More specific ranges are checked before +/// broader ones so overlapping prefixes resolve correctly. +fn detect_digits(d: &str) -> CardNetwork { + use CardNetwork::*; + if d.is_empty() { + return Unknown; + } + + // American Express: 34, 37 + if matches!(prefix(d, 2), Some(34 | 37)) { + return Amex; + } + // JCB: 3528–3589 (checked before the broader Diners "3" ranges) + if matches!(prefix(d, 4), Some(3528..=3589)) { + return Jcb; + } + // Diners Club: 300–305, 36, 38, 39 + if matches!(prefix(d, 3), Some(300..=305)) || matches!(prefix(d, 2), Some(36 | 38 | 39)) { + return DinersClub; + } + // Visa: 4 + if d.starts_with('4') { + return Visa; + } + // Mastercard: 51–55, 2221–2720 + if matches!(prefix(d, 2), Some(51..=55)) || matches!(prefix(d, 4), Some(2221..=2720)) { + return Mastercard; + } + // Discover: 6011, 644–649, 65, and the 622126–622925 co-brand range + // (checked before UnionPay's broad "62"). + if matches!(prefix(d, 4), Some(6011)) + || matches!(prefix(d, 3), Some(644..=649)) + || matches!(prefix(d, 2), Some(65)) + || matches!(prefix(d, 6), Some(622126..=622925)) + { + return Discover; + } + // UnionPay: 62, 81 + if matches!(prefix(d, 2), Some(62 | 81)) { + return UnionPay; + } + // Maestro: a selection of the common debit ranges + if matches!(prefix(d, 2), Some(50 | 56 | 57 | 58 | 63 | 67 | 69)) { + return Maestro; + } + + Unknown +} + +fn groups_of_four(len: usize) -> Vec { + let mut groups = Vec::new(); + let mut remaining = len; + while remaining > 4 { + groups.push(4); + remaining -= 4; + } + if remaining > 0 { + groups.push(remaining); + } + groups +} + +fn group(digits: &str, sizes: &[usize]) -> String { + let bytes = digits.as_bytes(); + let mut out = String::with_capacity(digits.len() + sizes.len()); + let mut idx = 0; + + for &size in sizes { + if idx >= bytes.len() { + break; + } + if idx > 0 { + out.push(' '); + } + let end = (idx + size).min(bytes.len()); + out.push_str(&digits[idx..end]); + idx = end; + } + + // Safety net for any digits beyond the declared pattern. + while idx < bytes.len() { + out.push(' '); + let end = (idx + 4).min(bytes.len()); + out.push_str(&digits[idx..end]); + idx = end; + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_networks() { + assert_eq!(CardNetwork::detect("4111111111111111"), CardNetwork::Visa); + assert_eq!( + CardNetwork::detect("5500000000000004"), + CardNetwork::Mastercard + ); + assert_eq!( + CardNetwork::detect("2221000000000009"), + CardNetwork::Mastercard + ); + assert_eq!(CardNetwork::detect("378282246310005"), CardNetwork::Amex); + assert_eq!( + CardNetwork::detect("6011000990139424"), + CardNetwork::Discover + ); + assert_eq!( + CardNetwork::detect("30569309025904"), + CardNetwork::DinersClub + ); + assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); + assert_eq!( + CardNetwork::detect("6212345678901232"), + CardNetwork::UnionPay + ); + assert_eq!(CardNetwork::detect(""), CardNetwork::Unknown); + assert_eq!( + CardNetwork::detect("9999999999999999"), + CardNetwork::Unknown + ); + } + + #[test] + fn jcb_beats_diners_in_3500_range() { + // 3530 is JCB even though "3x" is otherwise Diners territory. + assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); + } + + #[test] + fn formats_by_network() { + assert_eq!( + format_card_number("4111111111111111"), + "4111 1111 1111 1111" + ); + assert_eq!(format_card_number("378282246310005"), "3782 822463 10005"); + assert_eq!(format_card_number("30569309025904"), "3056 930902 5904"); + } + + #[test] + fn ignores_existing_separators() { + assert_eq!( + format_card_number("5500-0000 0000_0004"), + "5500 0000 0000 0004" + ); + } + + #[test] + fn formats_partial_input_as_typed() { + assert_eq!(format_card_number("41111"), "4111 1"); + assert_eq!(format_card_number("411111111"), "4111 1111 1"); + } + + #[test] + fn nineteen_digits_trail_in_fours() { + assert_eq!( + format_card_number("4111111111111111123"), + "4111 1111 1111 1111 123" + ); + } + + #[test] + fn caps_visa_at_nineteen() { + // 25 digits in -> Visa keeps its 19-digit maximum. + let formatted = format_card_number("4111111111111111111111111"); + assert_eq!(formatted, "4111 1111 1111 1111 111"); + assert_eq!(digit_count(&formatted), 19); + } + + #[test] + fn caps_amex_at_fifteen() { + // 20 digits in, Amex prefix -> capped to 15 and grouped 4-6-5. + let formatted = format_card_number("37828224631000512345"); + assert_eq!(formatted, "3782 822463 10005"); + assert_eq!(digit_count(&formatted), 15); + } + + #[test] + fn caps_mastercard_at_sixteen() { + let formatted = format_card_number("5500000000000004999"); + assert_eq!(digit_count(&formatted), 16); + } + + #[test] + fn unknown_network_caps_at_global_max() { + // Unrecognised prefix -> falls back to MAX_PAN_DIGITS. + let formatted = format_card_number("9999999999999999999999"); + assert_eq!(digit_count(&formatted), MAX_PAN_DIGITS); + } + + #[test] + fn card_digits_strips_and_caps() { + // Separators removed, no grouping, capped to the network maximum. + assert_eq!(card_digits("4111 1111-1111_1111"), "4111111111111111"); + assert_eq!(card_digits("37828224631000512345"), "378282246310005"); // Amex -> 15 + assert_eq!(card_digits(""), ""); + } + + #[test] + fn space_indices_match_groups() { + assert_eq!(card_space_indices("4111111111111111"), vec![4, 8, 12]); + assert_eq!(card_space_indices("378282246310005"), vec![4, 10]); // Amex 4-6-5 + assert_eq!(card_space_indices("41111"), vec![4]); // partial: "4111 1" + assert_eq!(card_space_indices("411"), Vec::::new()); // single group + assert_eq!(card_space_indices(""), Vec::::new()); + } + + #[test] + fn network_max_lengths() { + assert_eq!(CardNetwork::Amex.max_len(), 15); + assert_eq!(CardNetwork::Mastercard.max_len(), 16); + assert_eq!(CardNetwork::Visa.max_len(), 19); + assert_eq!(CardNetwork::Unknown.max_len(), MAX_PAN_DIGITS); + } + + fn digit_count(s: &str) -> usize { + s.chars().filter(char::is_ascii_digit).count() + } + + #[test] + fn luhn_checks() { + assert!(luhn_valid("4111111111111111")); + assert!(luhn_valid("378282246310005")); + assert!(luhn_valid("6011000990139424")); + assert!(!luhn_valid("4111111111111112")); + assert!(!luhn_valid("")); + } + + #[test] + fn luhn_ignores_separators() { + assert!(luhn_valid("4111 1111 1111 1111")); + } + + #[test] + fn cvv_lengths() { + assert_eq!(CardNetwork::Amex.cvv_len(), 4); + assert_eq!(CardNetwork::Visa.cvv_len(), 3); + assert_eq!(CardNetwork::Mastercard.cvv_len(), 3); + } + + #[test] + fn full_validation() { + assert!(Card::parse("4111 1111 1111 1111").is_valid()); + assert!(Card::parse("378282246310005").is_valid()); + // Visa prefix but wrong length -> not valid. + assert!(!Card::parse("411111111111").is_valid()); + // Right length, but Luhn fails. + assert!(!Card::parse("4111111111111112").is_valid()); + } +} diff --git a/rust/rust-code/lib/src/lib.rs b/rust/rust-code/lib/src/lib.rs index e425be56..c41bab60 100644 --- a/rust/rust-code/lib/src/lib.rs +++ b/rust/rust-code/lib/src/lib.rs @@ -1,3 +1,4 @@ +pub mod card; pub mod crypto; pub mod item; pub mod passkey; diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt b/rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt new file mode 100644 index 00000000..579b3f57 --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.rust.card + +import de.davisalessandro.keygo.rust.CardFormatterInterface + +typealias CardFormatter = CardFormatterInterface diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt index 236f36af..eba638fb 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt @@ -2,6 +2,8 @@ package de.davis.keygo.rust.di import de.davisalessandro.keygo.rust.AccountManager import de.davisalessandro.keygo.rust.AccountManagerInterface +import de.davisalessandro.keygo.rust.CardFormatter +import de.davisalessandro.keygo.rust.CardFormatterInterface import de.davisalessandro.keygo.rust.ItemManager import de.davisalessandro.keygo.rust.ItemManagerInterface import de.davisalessandro.keygo.rust.KeyDeriver @@ -42,4 +44,7 @@ object RustModule { @Single internal fun provideTotpService(): TotpServiceInterface = TotpService() + + @Single + internal fun provideCardFormatter(): CardFormatterInterface = CardFormatter() } \ No newline at end of file diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt new file mode 100644 index 00000000..0564ee07 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.CardFormatterInterface + +class FakeCardFormatter : CardFormatterInterface { + + var digitsResult: (String) -> String = { input -> input.filter(Char::isDigit) } + var formatResult: (String) -> String = { it.chunked(4).joinToString(" ") } + var spaceIndicesResult: (String) -> List = { emptyList() } + var luhnResult: Boolean = true + + override fun digits(input: String): String = digitsResult(input) + + override fun formatNumber(input: String): String = formatResult(input) + + override fun spaceIndices(input: String): List = spaceIndicesResult(input) + + override fun isLuhnValid(input: String): Boolean = luhnResult +} From d85f5b586c5b4a5da5c68c2183c07f20be730a19 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 25 May 2026 14:57:25 +0200 Subject: [PATCH 10/36] feat(credit-card): Add dynamic CVV length and progressive number formatting --- .../transformation/CvvInputTransformation.kt | 18 ++++++++ .../creditcard/CreditCardContent.kt | 1 + .../creditcard/CreditCardViewModel.kt | 32 ++++++++++----- .../creditcard/model/CreditCardUiState.kt | 1 + rust/rust-code/bindings/src/card.rs | 6 ++- rust/rust-code/lib/src/card.rs | 41 ++++++++++++++++--- .../de/davis/keygo/rust/FakeCardFormatter.kt | 3 ++ 7 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt new file mode 100644 index 00000000..11e85a69 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.item.core.presentation.transformation + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.placeCursorAtEnd + +class CvvInputTransformation(private val maxLength: () -> Int) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val original = asCharSequence().toString() + // Never shorten below what was already there; only refuse to grow past the network max. + val cap = maxOf(maxLength(), originalText.length) + val sanitized = original.filter(Char::isDigit).take(cap) + if (original != sanitized) { + replace(0, length, sanitized) + placeCursorAtEnd() + } + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index a2eeccde..0c7ba573 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -143,6 +143,7 @@ private fun CreditCardReadyContent( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword ), + inputTransformation = state.cvvInputTransformation, ) KeyGoFormField( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index cf8633df..803e4556 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -13,6 +13,7 @@ import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.core.presentation.transformation.CardNumberInputTransformation +import de.davis.keygo.feature.item.core.presentation.transformation.CvvInputTransformation import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent @@ -44,9 +45,21 @@ internal class CreditCardViewModel( private val cardDigits: (String) -> String = cardFormatter::digits + private val ccHolderTextFieldState = TextFieldState() + private val ccNumberTextFieldState = TextFieldState() + private val ccCVVTextFieldState = TextFieldState() + private val ccExpirationDateTextFieldState = TextFieldState() + private val _base = MutableStateFlow( CreditCardBaseState( + ccHolderTextFieldState = ccHolderTextFieldState, + ccNumberTextFieldState = ccNumberTextFieldState, + ccCVVTextFieldState = ccCVVTextFieldState, + ccExpirationDateTextFieldState = ccExpirationDateTextFieldState, numberInputTransformation = CardNumberInputTransformation(cardDigits), + cvvInputTransformation = CvvInputTransformation { + cardFormatter.cvvLen(ccNumberTextFieldState.text.toString()) + }, ), ) @@ -78,19 +91,16 @@ internal class CreditCardViewModel( nameTextFieldState.setTextAndPlaceCursorAtEnd(card.name) notesTextFieldState.setTextAndPlaceCursorAtEnd(card.note ?: "") + ccHolderTextFieldState.setTextAndPlaceCursorAtEnd(card.holder ?: "") + // Set the number before the CVV so the network — and thus the CVV cap — is known. + ccNumberTextFieldState.setTextAndPlaceCursorAtEnd(number) + ccCVVTextFieldState.setTextAndPlaceCursorAtEnd(cvv ?: "") + ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd( + card.expirationDate.format(EXPIRATION_FORMATTER), + ) setSelectedVaultId(card.vaultId) setAssignedTags(card.tags) - _base.update { - it.copy( - ccHolderTextFieldState = TextFieldState(card.holder ?: ""), - ccNumberTextFieldState = TextFieldState(number), - ccCVVTextFieldState = TextFieldState(cvv ?: ""), - ccExpirationDateTextFieldState = TextFieldState( - card.expirationDate.format(EXPIRATION_FORMATTER), - ), - updating = true, - ) - } + _base.update { it.copy(updating = true) } } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index 78f7dd6d..c6f32892 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -15,6 +15,7 @@ internal data class CreditCardBaseState( val ccCVVTextFieldState: TextFieldState = TextFieldState(), val ccExpirationDateTextFieldState: TextFieldState = TextFieldState(), val numberInputTransformation: InputTransformation? = null, + val cvvInputTransformation: InputTransformation? = null, val numberError: InputFieldError? = null, val updating: Boolean = false, ) { diff --git a/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs index 67796328..c10a3337 100644 --- a/rust/rust-code/bindings/src/card.rs +++ b/rust/rust-code/bindings/src/card.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use lib::card::{ - card_digits as core_card_digits, card_space_indices as core_card_space_indices, + CardNetwork, card_digits as core_card_digits, card_space_indices as core_card_space_indices, format_card_number as core_format_card_number, luhn_valid as core_luhn_valid, }; @@ -33,4 +33,8 @@ impl CardFormatter { pub fn is_luhn_valid(&self, input: String) -> bool { core_luhn_valid(&input) } + + pub fn cvv_len(&self, input: String) -> i32 { + CardNetwork::detect(&input).cvv_len() as i32 + } } diff --git a/rust/rust-code/lib/src/card.rs b/rust/rust-code/lib/src/card.rs index 16072a36..7a87f297 100644 --- a/rust/rust-code/lib/src/card.rs +++ b/rust/rust-code/lib/src/card.rs @@ -77,16 +77,16 @@ impl CardNetwork { pub fn cvv_len(self) -> usize { match self { - CardNetwork::Amex => 4, + CardNetwork::Unknown | CardNetwork::Amex => 4, _ => 3, } } /// Group sizes used to space a number of `len` digits. fn groups(self, len: usize) -> Vec { - match (self, len) { - (CardNetwork::Amex, 15) => vec![4, 6, 5], - (CardNetwork::DinersClub, 14) => vec![4, 6, 4], + match self { + CardNetwork::Amex => fit_pattern(&[4, 6, 5], len), + CardNetwork::DinersClub => fit_pattern(&[4, 6, 4], len), _ => groups_of_four(len), } } @@ -184,7 +184,7 @@ pub fn luhn_valid(number: &str) -> bool { } }) .sum(); - sum % 10 == 0 + sum.is_multiple_of(10) } // --- internal helpers ------------------------------------------------------- @@ -246,6 +246,26 @@ fn detect_digits(d: &str) -> CardNetwork { Unknown } +/// Truncate a fixed grouping `pattern` (e.g. Amex's `[4, 6, 5]`) to cover exactly +/// `len` digits, so a partially typed number is spaced the same way it will be once +/// complete. Any digits beyond the pattern trail in fours as a safety net. +fn fit_pattern(pattern: &[usize], len: usize) -> Vec { + let mut groups = Vec::new(); + let mut remaining = len; + for &size in pattern { + if remaining == 0 { + break; + } + let take = size.min(remaining); + groups.push(take); + remaining -= take; + } + if remaining > 0 { + groups.extend(groups_of_four(remaining)); + } + groups +} + fn groups_of_four(len: usize) -> Vec { let mut groups = Vec::new(); let mut remaining = len; @@ -353,6 +373,17 @@ mod tests { assert_eq!(format_card_number("411111111"), "4111 1111 1"); } + #[test] + fn amex_groups_progressively_while_typing() { + // Prefix already identifies Amex, so partial input uses 4-6-5, not fours. + assert_eq!(format_card_number("341234"), "3412 34"); + assert_eq!(format_card_number("3412345678"), "3412 345678"); + assert_eq!(format_card_number("34123456789012"), "3412 345678 9012"); + assert_eq!(format_card_number("341234567890123"), "3412 345678 90123"); + assert_eq!(card_space_indices("3412345678"), vec![4]); + assert_eq!(card_space_indices("34123456789012"), vec![4, 10]); + } + #[test] fn nineteen_digits_trail_in_fours() { assert_eq!( diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt index 0564ee07..7926544e 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt @@ -8,6 +8,7 @@ class FakeCardFormatter : CardFormatterInterface { var formatResult: (String) -> String = { it.chunked(4).joinToString(" ") } var spaceIndicesResult: (String) -> List = { emptyList() } var luhnResult: Boolean = true + var cvvLenResult: (String) -> Int = { 3 } override fun digits(input: String): String = digitsResult(input) @@ -16,4 +17,6 @@ class FakeCardFormatter : CardFormatterInterface { override fun spaceIndices(input: String): List = spaceIndicesResult(input) override fun isLuhnValid(input: String): Boolean = luhnResult + + override fun cvvLen(input: String): Int = cvvLenResult(input) } From 10cfd8b8fb518b81fa12c3f7104efeb017585af2 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 25 May 2026 19:07:56 +0200 Subject: [PATCH 11/36] feat(card): Add expiration date formatting and input transformation --- .../ExpirationDateInputTransformation.kt | 28 ++++ .../creditcard/CreditCardContent.kt | 5 +- rust/rust-code/bindings/src/card.rs | 8 +- rust/rust-code/lib/src/card.rs | 130 ++++++++++++++++++ .../de/davis/keygo/rust/FakeCardFormatter.kt | 4 + 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt new file mode 100644 index 00000000..6ff11f6b --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt @@ -0,0 +1,28 @@ +package de.davis.keygo.feature.item.core.presentation.transformation + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.runtime.Composable +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject +import org.koin.core.annotation.Single + +@Single +class ExpirationDateInputTransformation( + private val cardFormatter: CardFormatter +) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val original = asCharSequence().toString() + val formatted = cardFormatter.formatExpirationAfterEdit(originalText.toString(), original) + if (original != formatted) { + replace(0, length, formatted) + placeCursorAtEnd() + } + } +} + +@Composable +fun rememberExpirationDateInputTransformation(): ExpirationDateInputTransformation { + return koinInject() +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index 0c7ba573..e805e5c9 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -26,6 +26,7 @@ import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyIte import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems import de.davis.keygo.feature.item.core.presentation.transformation.rememberCardFormatter +import de.davis.keygo.feature.item.core.presentation.transformation.rememberExpirationDateInputTransformation import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.component.FormGroup import de.davis.keygo.feature.item.create.presentation.component.ItemContentWrapper @@ -64,6 +65,7 @@ private fun CreditCardReadyContent( val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val tagsTextFieldState = rememberTextFieldState() val ccNumberFormatter = rememberCardFormatter() + val ccExpirationDateInputTransformation = rememberExpirationDateInputTransformation() Scaffold( modifier = Modifier.fillMaxSize(), @@ -152,7 +154,8 @@ private fun CreditCardReadyContent( placeholder = { Text(text = stringResource(ItemCoreR.string.cc_expiration_date_placeholder)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number - ) + ), + inputTransformation = ccExpirationDateInputTransformation, ) } } diff --git a/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs index c10a3337..3f87df18 100644 --- a/rust/rust-code/bindings/src/card.rs +++ b/rust/rust-code/bindings/src/card.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use lib::card::{ CardNetwork, card_digits as core_card_digits, card_space_indices as core_card_space_indices, - format_card_number as core_format_card_number, luhn_valid as core_luhn_valid, + format_card_number as core_format_card_number, + format_expiration_after_edit as core_format_expiration_after_edit, + luhn_valid as core_luhn_valid, }; #[derive(uniffi::Object)] @@ -37,4 +39,8 @@ impl CardFormatter { pub fn cvv_len(&self, input: String) -> i32 { CardNetwork::detect(&input).cvv_len() as i32 } + + pub fn format_expiration_after_edit(&self, previous: String, proposed: String) -> String { + core_format_expiration_after_edit(&previous, &proposed) + } } diff --git a/rust/rust-code/lib/src/card.rs b/rust/rust-code/lib/src/card.rs index 7a87f297..b40ecb29 100644 --- a/rust/rust-code/lib/src/card.rs +++ b/rust/rust-code/lib/src/card.rs @@ -165,6 +165,69 @@ pub fn card_space_indices(number: &str) -> Vec { indices } +/// Applies expiration-date formatting to an edit, given the `previous` text and the `proposed` +/// text the user just produced. The separator is auto-inserted while typing forward; deletions +/// erase it together with adjacent digits so the user can never get stuck on `MM/`: +/// +/// - `12/34` -> `12/3` -> `12` -> `1` -> `` (each backspace removes one visible character) +/// - deleting the separator itself also drops the preceding month digit (`12/` -> `1`, `05/` -> `0`) +pub fn format_expiration_after_edit(previous: &str, proposed: &str) -> String { + let deleting = proposed.chars().count() < previous.chars().count(); + // Backspacing the separator leaves the bare month behind; take the month digit with it. + let deleted_separator = + deleting && previous.ends_with(SEPARATOR) && !proposed.ends_with(SEPARATOR); + let source = if deleted_separator { + let mut trimmed = proposed.to_string(); + trimmed.pop(); + trimmed + } else { + proposed.to_string() + }; + format_expiration_inner(&source, !deleting) +} + +const SEPARATOR: char = '/'; + +/// Reformats arbitrary `input` into a partial-or-complete `MM/YY` expiration date. +/// +/// - A leading digit greater than `1` is padded into a complete single-digit month (`5` -> `05`). +/// - A second month digit that would form an invalid month is dropped (`13` -> `1`, `00` -> `0`). +/// - The separator follows a complete month; the year is capped at two digits. +/// +/// `append_trailing_separator` controls whether a complete month with no year yet renders as +/// `MM/` (typing forward) or plain `MM` (deleting, so the user can erase back into the month). +fn format_expiration_inner(input: &str, append_trailing_separator: bool) -> String { + let digits = digits_only(input); + let Some(first) = digits.chars().next() else { + return String::new(); + }; + + let (month, year_digits): (String, &str) = if first > '1' { + // 2..=9 can only be a single-digit month, so pad it and treat it as complete. + (format!("0{first}"), &digits[1..]) + } else { + let second = digits[1..].chars().next(); + let month_complete = match second { + None => false, + Some(s) if first == '0' => ('1'..='9').contains(&s), // 01..=09; 00 is invalid + Some(s) => ('0'..='2').contains(&s), // 10, 11, 12 + }; + if !month_complete { + return first.to_string(); + } + (digits[..2].to_string(), &digits[2..]) + }; + + let year: String = year_digits.chars().take(2).collect(); + if !year.is_empty() { + format!("{month}{SEPARATOR}{year}") + } else if append_trailing_separator { + format!("{month}{SEPARATOR}") + } else { + month + } +} + pub fn luhn_valid(number: &str) -> bool { let digits = digits_only(number); if digits.is_empty() { @@ -480,4 +543,71 @@ mod tests { // Right length, but Luhn fails. assert!(!Card::parse("4111111111111112").is_valid()); } + + #[test] + fn expiration_keeps_a_month_still_being_typed() { + assert_eq!(format_expiration_inner("", true), ""); + assert_eq!(format_expiration_inner("0", true), "0"); + assert_eq!(format_expiration_inner("1", true), "1"); + } + + #[test] + fn expiration_pads_a_leading_digit_greater_than_one() { + assert_eq!(format_expiration_inner("2", true), "02/"); + assert_eq!(format_expiration_inner("5", true), "05/"); + assert_eq!(format_expiration_inner("9", true), "09/"); + } + + #[test] + fn expiration_completes_two_digit_months() { + assert_eq!(format_expiration_inner("10", true), "10/"); + assert_eq!(format_expiration_inner("12", true), "12/"); + assert_eq!(format_expiration_inner("09", true), "09/"); + } + + #[test] + fn expiration_rejects_an_impossible_second_digit() { + assert_eq!(format_expiration_inner("13", true), "1"); + assert_eq!(format_expiration_inner("19", true), "1"); + assert_eq!(format_expiration_inner("00", true), "0"); + } + + #[test] + fn expiration_appends_the_year_after_the_separator() { + assert_eq!(format_expiration_inner("123", true), "12/3"); + assert_eq!(format_expiration_inner("1234", true), "12/34"); + assert_eq!(format_expiration_inner("099", true), "09/9"); + assert_eq!(format_expiration_inner("527", true), "05/27"); + } + + #[test] + fn expiration_caps_the_year_and_strips_non_digits() { + assert_eq!(format_expiration_inner("123456", true), "12/34"); + assert_eq!(format_expiration_inner("1a2", true), "12/"); + assert_eq!(format_expiration_inner("12/34", true), "12/34"); + assert_eq!(format_expiration_inner(" 5 ", true), "05/"); + } + + #[test] + fn expiration_edit_types_forward_and_auto_inserts_the_separator() { + assert_eq!(format_expiration_after_edit("1", "12"), "12/"); + assert_eq!(format_expiration_after_edit("", "5"), "05/"); + assert_eq!(format_expiration_after_edit("12/", "12/3"), "12/3"); + } + + #[test] + fn expiration_edit_deletes_year_digits_then_the_separator_with_the_month() { + // 12/34 -> 12/3 -> 12 -> 1 -> "" (one visible char per backspace) + assert_eq!(format_expiration_after_edit("12/34", "12/3"), "12/3"); + assert_eq!(format_expiration_after_edit("12/3", "12/"), "12"); + assert_eq!(format_expiration_after_edit("12/", "12"), "1"); + assert_eq!(format_expiration_after_edit("12", "1"), "1"); + assert_eq!(format_expiration_after_edit("1", ""), ""); + } + + #[test] + fn expiration_edit_collapses_a_padded_month_on_separator_deletion() { + // 05/ -> delete -> 0 + assert_eq!(format_expiration_after_edit("05/", "05"), "0"); + } } diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt index 7926544e..35cad33b 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt @@ -9,6 +9,7 @@ class FakeCardFormatter : CardFormatterInterface { var spaceIndicesResult: (String) -> List = { emptyList() } var luhnResult: Boolean = true var cvvLenResult: (String) -> Int = { 3 } + var formatExpirationAfterEditResult: (String, String) -> String = { _, proposed -> proposed } override fun digits(input: String): String = digitsResult(input) @@ -19,4 +20,7 @@ class FakeCardFormatter : CardFormatterInterface { override fun isLuhnValid(input: String): Boolean = luhnResult override fun cvvLen(input: String): Int = cvvLenResult(input) + + override fun formatExpirationAfterEdit(previous: String, proposed: String): String = + formatExpirationAfterEditResult(previous, proposed) } From a0490275ed2df60c6430e3c706fd0c0c749f93ac Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 25 May 2026 19:24:11 +0200 Subject: [PATCH 12/36] refactor(credit-card): move field transformations to composable layer Migrates `InputTransformation` and `OutputTransformation` logic from the `CreditCardViewModel` and `CreditCardUiState` directly into the UI layer. This change localizes formatting logic and simplifies the ViewModel. Specific changes include: - Removed transformation properties from `CreditCardUiState`. - Added `rememberCardNumberInputTransformation`, `rememberCardNumberOutputTransformation`, and `rememberCvvInputTransformation` helper functions. - Updated `CreditCardContent` to use these locally remembered transformations. - Removed `CardFormatter` dependency and associated setup from `CreditCardViewModel`. - Refactored `ExpirationDateInputTransformation` and `CardNumberVisualTransformation` to use standard Composable memory patterns instead of singleton injections. --- .../CardNumberInputTransformation.kt | 10 ++++++++++ .../CardNumberVisualTransformation.kt | 8 ++++---- .../transformation/CvvInputTransformation.kt | 17 +++++++++++++++++ .../ExpirationDateInputTransformation.kt | 6 +++--- .../creditcard/CreditCardContent.kt | 14 +++++++++----- .../creditcard/CreditCardViewModel.kt | 10 ---------- .../creditcard/model/CreditCardUiState.kt | 3 --- 7 files changed, 43 insertions(+), 25 deletions(-) diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt index 2f4d3b1f..312f3ed9 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.kt @@ -3,6 +3,10 @@ package de.davis.keygo.feature.item.core.presentation.transformation import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject class CardNumberInputTransformation( private val sanitize: (String) -> String, @@ -16,3 +20,9 @@ class CardNumberInputTransformation( } } } + +@Composable +fun rememberCardNumberInputTransformation(): CardNumberInputTransformation { + val cardFormatter = koinInject() + return remember(cardFormatter) { CardNumberInputTransformation(cardFormatter::digits) } +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt index 0eb07bd4..9ca09846 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberVisualTransformation.kt @@ -4,11 +4,10 @@ import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.insert import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import de.davis.keygo.rust.card.CardFormatter import org.koin.compose.koinInject -import org.koin.core.annotation.Single -@Single class CardNumberVisualTransformation( private val cardFormatter: CardFormatter ) : OutputTransformation { @@ -26,6 +25,7 @@ class CardNumberVisualTransformation( } @Composable -fun rememberCardFormatter(): CardNumberVisualTransformation { - return koinInject() +fun rememberCardNumberOutputTransformation(): CardNumberVisualTransformation { + val cardFormatter = koinInject() + return remember(cardFormatter) { CardNumberVisualTransformation(cardFormatter) } } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt index 11e85a69..28d20b11 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt @@ -2,7 +2,12 @@ package de.davis.keygo.feature.item.core.presentation.transformation import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject class CvvInputTransformation(private val maxLength: () -> Int) : InputTransformation { override fun TextFieldBuffer.transformInput() { @@ -16,3 +21,15 @@ class CvvInputTransformation(private val maxLength: () -> Int) : InputTransforma } } } + +/** + * The CVV cap depends on the card network, which is derived from [numberState]'s current + * digits — so the transformation reads that sibling field live at transform time. + */ +@Composable +fun rememberCvvInputTransformation(numberState: TextFieldState): CvvInputTransformation { + val cardFormatter = koinInject() + return remember(cardFormatter, numberState) { + CvvInputTransformation { cardFormatter.cvvLen(numberState.text.toString()) } + } +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt index 6ff11f6b..91f0d46b 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/ExpirationDateInputTransformation.kt @@ -4,11 +4,10 @@ import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.placeCursorAtEnd import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import de.davis.keygo.rust.card.CardFormatter import org.koin.compose.koinInject -import org.koin.core.annotation.Single -@Single class ExpirationDateInputTransformation( private val cardFormatter: CardFormatter ) : InputTransformation { @@ -24,5 +23,6 @@ class ExpirationDateInputTransformation( @Composable fun rememberExpirationDateInputTransformation(): ExpirationDateInputTransformation { - return koinInject() + val cardFormatter = koinInject() + return remember(cardFormatter) { ExpirationDateInputTransformation(cardFormatter) } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index e805e5c9..75071d09 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -25,7 +25,9 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems -import de.davis.keygo.feature.item.core.presentation.transformation.rememberCardFormatter +import de.davis.keygo.feature.item.core.presentation.transformation.rememberCardNumberInputTransformation +import de.davis.keygo.feature.item.core.presentation.transformation.rememberCardNumberOutputTransformation +import de.davis.keygo.feature.item.core.presentation.transformation.rememberCvvInputTransformation import de.davis.keygo.feature.item.core.presentation.transformation.rememberExpirationDateInputTransformation import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.component.FormGroup @@ -64,7 +66,9 @@ private fun CreditCardReadyContent( ) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val tagsTextFieldState = rememberTextFieldState() - val ccNumberFormatter = rememberCardFormatter() + val ccNumberInputTransformation = rememberCardNumberInputTransformation() + val ccNumberOutputTransformation = rememberCardNumberOutputTransformation() + val ccCvvInputTransformation = rememberCvvInputTransformation(state.ccNumberTextFieldState) val ccExpirationDateInputTransformation = rememberExpirationDateInputTransformation() Scaffold( @@ -134,8 +138,8 @@ private fun CreditCardReadyContent( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword ), - inputTransformation = state.numberInputTransformation, - outputTransformation = ccNumberFormatter, + inputTransformation = ccNumberInputTransformation, + outputTransformation = ccNumberOutputTransformation, ) KeyGoFormField( @@ -145,7 +149,7 @@ private fun CreditCardReadyContent( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword ), - inputTransformation = state.cvvInputTransformation, + inputTransformation = ccCvvInputTransformation, ) KeyGoFormField( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index 803e4556..c1faaa25 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -12,12 +12,9 @@ import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation -import de.davis.keygo.feature.item.core.presentation.transformation.CardNumberInputTransformation -import de.davis.keygo.feature.item.core.presentation.transformation.CvvInputTransformation import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent -import de.davisalessandro.keygo.rust.CardFormatterInterface import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -31,7 +28,6 @@ import java.time.format.DateTimeFormatter internal class CreditCardViewModel( private val itemWithCryptoScope: ItemWithCryptoScopeUseCase, private val creditCardRepository: CreditCardRepository, - cardFormatter: CardFormatterInterface, vaultContextRepository: VaultContextRepository, itemRepository: ItemRepository, observeAllTags: ObserveAllTagsSortedUseCase, @@ -43,8 +39,6 @@ internal class CreditCardViewModel( vaultRepository = vaultRepository, ) { - private val cardDigits: (String) -> String = cardFormatter::digits - private val ccHolderTextFieldState = TextFieldState() private val ccNumberTextFieldState = TextFieldState() private val ccCVVTextFieldState = TextFieldState() @@ -56,10 +50,6 @@ internal class CreditCardViewModel( ccNumberTextFieldState = ccNumberTextFieldState, ccCVVTextFieldState = ccCVVTextFieldState, ccExpirationDateTextFieldState = ccExpirationDateTextFieldState, - numberInputTransformation = CardNumberInputTransformation(cardDigits), - cvvInputTransformation = CvvInputTransformation { - cardFormatter.cvvLen(ccNumberTextFieldState.text.toString()) - }, ), ) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index c6f32892..9807c327 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -1,6 +1,5 @@ package de.davis.keygo.feature.item.create.presentation.creditcard.model -import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable import de.davis.keygo.feature.item.core.presentation.model.InputFieldError @@ -14,8 +13,6 @@ internal data class CreditCardBaseState( val ccNumberTextFieldState: TextFieldState = TextFieldState(), val ccCVVTextFieldState: TextFieldState = TextFieldState(), val ccExpirationDateTextFieldState: TextFieldState = TextFieldState(), - val numberInputTransformation: InputTransformation? = null, - val cvvInputTransformation: InputTransformation? = null, val numberError: InputFieldError? = null, val updating: Boolean = false, ) { From 74e1b01fadd0198e7506261ba0599cfdac937cb3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 25 May 2026 20:04:15 +0200 Subject: [PATCH 13/36] docs: add Rust test and bindgen commands to CLAUDE.md --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd626b8e..d3021c16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ repository. ## Build & Test ```bash +make -C rust/rust-code test # Rust unit tests +make -C rust/rust-code bindgen # Generate uniffi bindings ./gradlew build # Full build ./gradlew test # All unit tests ./gradlew :app:test # Module-specific tests (preferred for small changes) @@ -22,7 +24,6 @@ repository. ``` - Flavors: `playStore` (default), `fdroid`. Types: `debug`, `staging`, `release`. -- Rust: `./gradlew :rust:buildRust -PbuildRust=true` (disabled by default) - CI branch: `v2` ## Tech Stack From 639f0fba169f797726e7a756f55daae24ece05d8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Mon, 25 May 2026 23:15:28 +0200 Subject: [PATCH 14/36] refactor(card): Modularize card logic and simplify bindings via Card API --- rust/rust-code/bindings/src/card.rs | 18 +- rust/rust-code/lib/src/card.rs | 613 ---------------------- rust/rust-code/lib/src/card/expiration.rs | 138 +++++ rust/rust-code/lib/src/card/mod.rs | 37 ++ rust/rust-code/lib/src/card/network.rs | 232 ++++++++ rust/rust-code/lib/src/card/number.rs | 255 +++++++++ 6 files changed, 669 insertions(+), 624 deletions(-) delete mode 100644 rust/rust-code/lib/src/card.rs create mode 100644 rust/rust-code/lib/src/card/expiration.rs create mode 100644 rust/rust-code/lib/src/card/mod.rs create mode 100644 rust/rust-code/lib/src/card/network.rs create mode 100644 rust/rust-code/lib/src/card/number.rs diff --git a/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs index 3f87df18..acb0964f 100644 --- a/rust/rust-code/bindings/src/card.rs +++ b/rust/rust-code/bindings/src/card.rs @@ -1,11 +1,6 @@ use std::sync::Arc; -use lib::card::{ - CardNetwork, card_digits as core_card_digits, card_space_indices as core_card_space_indices, - format_card_number as core_format_card_number, - format_expiration_after_edit as core_format_expiration_after_edit, - luhn_valid as core_luhn_valid, -}; +use lib::card::{Card, format_expiration_after_edit as core_format_expiration_after_edit}; #[derive(uniffi::Object)] pub struct CardFormatter; @@ -18,26 +13,27 @@ impl CardFormatter { } pub fn digits(&self, input: String) -> String { - core_card_digits(&input) + Card::parse(&input).digits } pub fn format_number(&self, input: String) -> String { - core_format_card_number(&input) + Card::parse(&input).formatted } pub fn space_indices(&self, input: String) -> Vec { - core_card_space_indices(&input) + Card::parse(&input) + .space_indices() .into_iter() .map(|i| i as i32) .collect() } pub fn is_luhn_valid(&self, input: String) -> bool { - core_luhn_valid(&input) + Card::parse(&input).is_luhn_valid() } pub fn cvv_len(&self, input: String) -> i32 { - CardNetwork::detect(&input).cvv_len() as i32 + Card::parse(&input).cvv_len() as i32 } pub fn format_expiration_after_edit(&self, previous: String, proposed: String) -> String { diff --git a/rust/rust-code/lib/src/card.rs b/rust/rust-code/lib/src/card.rs deleted file mode 100644 index b40ecb29..00000000 --- a/rust/rust-code/lib/src/card.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! A credit-card number formatter. -//! -//! Detects the card network from the leading digits (the IIN/BIN), groups the -//! number into the spacing convention used by that network, and offers a Luhn -//! checksum check. -//! -//! All public entry points accept "dirty" input — spaces, dashes, and any -//! other non-digit characters are ignored — so they work equally well on a -//! pasted number or on text being typed live into a field. -//! -//! ``` -//! use lib::card::{Card, CardNetwork}; -//! -//! let card = Card::parse("378282246310005"); -//! assert_eq!(card.network, CardNetwork::Amex); -//! assert_eq!(card.formatted, "3782 822463 10005"); -//! assert!(card.is_valid()); -//! ``` - -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CardNetwork { - Visa, - Mastercard, - Amex, - Discover, - DinersClub, - Jcb, - UnionPay, - Maestro, - Unknown, -} - -impl CardNetwork { - pub fn detect(number: &str) -> CardNetwork { - detect_digits(&digits_only(number)) - } - - pub fn name(self) -> &'static str { - match self { - CardNetwork::Visa => "Visa", - CardNetwork::Mastercard => "Mastercard", - CardNetwork::Amex => "American Express", - CardNetwork::Discover => "Discover", - CardNetwork::DinersClub => "Diners Club", - CardNetwork::Jcb => "JCB", - CardNetwork::UnionPay => "UnionPay", - CardNetwork::Maestro => "Maestro", - CardNetwork::Unknown => "Unknown", - } - } - - pub fn valid_lengths(self) -> &'static [usize] { - match self { - CardNetwork::Visa => &[13, 16, 19], - CardNetwork::Mastercard => &[16], - CardNetwork::Amex => &[15], - CardNetwork::Discover => &[16, 17, 18, 19], - CardNetwork::DinersClub => &[14, 16], - CardNetwork::Jcb => &[16, 17, 18, 19], - CardNetwork::UnionPay => &[16, 17, 18, 19], - CardNetwork::Maestro => &[12, 13, 14, 15, 16, 17, 18, 19], - CardNetwork::Unknown => &[], - } - } - - /// The longest number this network issues. Unknown networks fall back to the global - /// [`MAX_PAN_DIGITS`]. - pub fn max_len(self) -> usize { - self.valid_lengths() - .iter() - .copied() - .max() - .unwrap_or(MAX_PAN_DIGITS) - } - - pub fn cvv_len(self) -> usize { - match self { - CardNetwork::Unknown | CardNetwork::Amex => 4, - _ => 3, - } - } - - /// Group sizes used to space a number of `len` digits. - fn groups(self, len: usize) -> Vec { - match self { - CardNetwork::Amex => fit_pattern(&[4, 6, 5], len), - CardNetwork::DinersClub => fit_pattern(&[4, 6, 4], len), - _ => groups_of_four(len), - } - } -} - -#[derive(Debug, Clone)] -pub struct Card { - pub network: CardNetwork, - pub digits: String, - pub formatted: String, -} - -impl Card { - pub fn parse(input: &str) -> Card { - let digits = digits_only(input); - let network = detect_digits(&digits); - let formatted = group(&digits, &network.groups(digits.len())); - Card { - network, - digits, - formatted, - } - } - - pub fn is_length_valid(&self) -> bool { - self.network.valid_lengths().contains(&self.digits.len()) - } - - pub fn is_luhn_valid(&self) -> bool { - luhn_valid(&self.digits) - } - - pub fn is_valid(&self) -> bool { - self.network != CardNetwork::Unknown && self.is_length_valid() && self.is_luhn_valid() - } -} - -impl fmt::Display for Card { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.formatted) - } -} - -pub const MAX_PAN_DIGITS: usize = 19; - -/// Strip everything but digits and cap to the detected network's -/// [`CardNetwork::max_len`] (falling back to [`MAX_PAN_DIGITS`] while unknown). -/// This is the raw value a field should *store*; grouping is purely visual. -pub fn card_digits(number: &str) -> String { - let all_digits = digits_only(number); - let max = detect_digits(&all_digits).max_len(); - all_digits.chars().take(max).collect() -} - -/// Format a card number into network-appropriate, space-separated groups. -/// Input is cleaned and capped via [`card_digits`] first. -pub fn format_card_number(number: &str) -> String { - let digits = card_digits(number); - let network = detect_digits(&digits); - group(&digits, &network.groups(digits.len())) -} - -/// Digit offsets at which a grouping space should be inserted for display, -/// e.g. `[4, 8, 12]` for a 16-digit number. -pub fn card_space_indices(number: &str) -> Vec { - let digits = card_digits(number); - let network = detect_digits(&digits); - let mut indices = Vec::new(); - let mut pos = 0; - for size in network.groups(digits.len()) { - pos += size; - if pos < digits.len() { - indices.push(pos); - } - } - indices -} - -/// Applies expiration-date formatting to an edit, given the `previous` text and the `proposed` -/// text the user just produced. The separator is auto-inserted while typing forward; deletions -/// erase it together with adjacent digits so the user can never get stuck on `MM/`: -/// -/// - `12/34` -> `12/3` -> `12` -> `1` -> `` (each backspace removes one visible character) -/// - deleting the separator itself also drops the preceding month digit (`12/` -> `1`, `05/` -> `0`) -pub fn format_expiration_after_edit(previous: &str, proposed: &str) -> String { - let deleting = proposed.chars().count() < previous.chars().count(); - // Backspacing the separator leaves the bare month behind; take the month digit with it. - let deleted_separator = - deleting && previous.ends_with(SEPARATOR) && !proposed.ends_with(SEPARATOR); - let source = if deleted_separator { - let mut trimmed = proposed.to_string(); - trimmed.pop(); - trimmed - } else { - proposed.to_string() - }; - format_expiration_inner(&source, !deleting) -} - -const SEPARATOR: char = '/'; - -/// Reformats arbitrary `input` into a partial-or-complete `MM/YY` expiration date. -/// -/// - A leading digit greater than `1` is padded into a complete single-digit month (`5` -> `05`). -/// - A second month digit that would form an invalid month is dropped (`13` -> `1`, `00` -> `0`). -/// - The separator follows a complete month; the year is capped at two digits. -/// -/// `append_trailing_separator` controls whether a complete month with no year yet renders as -/// `MM/` (typing forward) or plain `MM` (deleting, so the user can erase back into the month). -fn format_expiration_inner(input: &str, append_trailing_separator: bool) -> String { - let digits = digits_only(input); - let Some(first) = digits.chars().next() else { - return String::new(); - }; - - let (month, year_digits): (String, &str) = if first > '1' { - // 2..=9 can only be a single-digit month, so pad it and treat it as complete. - (format!("0{first}"), &digits[1..]) - } else { - let second = digits[1..].chars().next(); - let month_complete = match second { - None => false, - Some(s) if first == '0' => ('1'..='9').contains(&s), // 01..=09; 00 is invalid - Some(s) => ('0'..='2').contains(&s), // 10, 11, 12 - }; - if !month_complete { - return first.to_string(); - } - (digits[..2].to_string(), &digits[2..]) - }; - - let year: String = year_digits.chars().take(2).collect(); - if !year.is_empty() { - format!("{month}{SEPARATOR}{year}") - } else if append_trailing_separator { - format!("{month}{SEPARATOR}") - } else { - month - } -} - -pub fn luhn_valid(number: &str) -> bool { - let digits = digits_only(number); - if digits.is_empty() { - return false; - } - let sum: u32 = digits - .bytes() - .rev() - .enumerate() - .map(|(i, b)| { - let d = u32::from(b - b'0'); - if i % 2 == 1 { - let doubled = d * 2; - if doubled > 9 { doubled - 9 } else { doubled } - } else { - d - } - }) - .sum(); - sum.is_multiple_of(10) -} - -// --- internal helpers ------------------------------------------------------- - -fn digits_only(s: &str) -> String { - s.chars().filter(char::is_ascii_digit).collect() -} - -fn prefix(digits: &str, n: usize) -> Option { - digits.get(..n).and_then(|p| p.parse().ok()) -} - -/// Network detection by IIN range. More specific ranges are checked before -/// broader ones so overlapping prefixes resolve correctly. -fn detect_digits(d: &str) -> CardNetwork { - use CardNetwork::*; - if d.is_empty() { - return Unknown; - } - - // American Express: 34, 37 - if matches!(prefix(d, 2), Some(34 | 37)) { - return Amex; - } - // JCB: 3528–3589 (checked before the broader Diners "3" ranges) - if matches!(prefix(d, 4), Some(3528..=3589)) { - return Jcb; - } - // Diners Club: 300–305, 36, 38, 39 - if matches!(prefix(d, 3), Some(300..=305)) || matches!(prefix(d, 2), Some(36 | 38 | 39)) { - return DinersClub; - } - // Visa: 4 - if d.starts_with('4') { - return Visa; - } - // Mastercard: 51–55, 2221–2720 - if matches!(prefix(d, 2), Some(51..=55)) || matches!(prefix(d, 4), Some(2221..=2720)) { - return Mastercard; - } - // Discover: 6011, 644–649, 65, and the 622126–622925 co-brand range - // (checked before UnionPay's broad "62"). - if matches!(prefix(d, 4), Some(6011)) - || matches!(prefix(d, 3), Some(644..=649)) - || matches!(prefix(d, 2), Some(65)) - || matches!(prefix(d, 6), Some(622126..=622925)) - { - return Discover; - } - // UnionPay: 62, 81 - if matches!(prefix(d, 2), Some(62 | 81)) { - return UnionPay; - } - // Maestro: a selection of the common debit ranges - if matches!(prefix(d, 2), Some(50 | 56 | 57 | 58 | 63 | 67 | 69)) { - return Maestro; - } - - Unknown -} - -/// Truncate a fixed grouping `pattern` (e.g. Amex's `[4, 6, 5]`) to cover exactly -/// `len` digits, so a partially typed number is spaced the same way it will be once -/// complete. Any digits beyond the pattern trail in fours as a safety net. -fn fit_pattern(pattern: &[usize], len: usize) -> Vec { - let mut groups = Vec::new(); - let mut remaining = len; - for &size in pattern { - if remaining == 0 { - break; - } - let take = size.min(remaining); - groups.push(take); - remaining -= take; - } - if remaining > 0 { - groups.extend(groups_of_four(remaining)); - } - groups -} - -fn groups_of_four(len: usize) -> Vec { - let mut groups = Vec::new(); - let mut remaining = len; - while remaining > 4 { - groups.push(4); - remaining -= 4; - } - if remaining > 0 { - groups.push(remaining); - } - groups -} - -fn group(digits: &str, sizes: &[usize]) -> String { - let bytes = digits.as_bytes(); - let mut out = String::with_capacity(digits.len() + sizes.len()); - let mut idx = 0; - - for &size in sizes { - if idx >= bytes.len() { - break; - } - if idx > 0 { - out.push(' '); - } - let end = (idx + size).min(bytes.len()); - out.push_str(&digits[idx..end]); - idx = end; - } - - // Safety net for any digits beyond the declared pattern. - while idx < bytes.len() { - out.push(' '); - let end = (idx + 4).min(bytes.len()); - out.push_str(&digits[idx..end]); - idx = end; - } - - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_networks() { - assert_eq!(CardNetwork::detect("4111111111111111"), CardNetwork::Visa); - assert_eq!( - CardNetwork::detect("5500000000000004"), - CardNetwork::Mastercard - ); - assert_eq!( - CardNetwork::detect("2221000000000009"), - CardNetwork::Mastercard - ); - assert_eq!(CardNetwork::detect("378282246310005"), CardNetwork::Amex); - assert_eq!( - CardNetwork::detect("6011000990139424"), - CardNetwork::Discover - ); - assert_eq!( - CardNetwork::detect("30569309025904"), - CardNetwork::DinersClub - ); - assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); - assert_eq!( - CardNetwork::detect("6212345678901232"), - CardNetwork::UnionPay - ); - assert_eq!(CardNetwork::detect(""), CardNetwork::Unknown); - assert_eq!( - CardNetwork::detect("9999999999999999"), - CardNetwork::Unknown - ); - } - - #[test] - fn jcb_beats_diners_in_3500_range() { - // 3530 is JCB even though "3x" is otherwise Diners territory. - assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); - } - - #[test] - fn formats_by_network() { - assert_eq!( - format_card_number("4111111111111111"), - "4111 1111 1111 1111" - ); - assert_eq!(format_card_number("378282246310005"), "3782 822463 10005"); - assert_eq!(format_card_number("30569309025904"), "3056 930902 5904"); - } - - #[test] - fn ignores_existing_separators() { - assert_eq!( - format_card_number("5500-0000 0000_0004"), - "5500 0000 0000 0004" - ); - } - - #[test] - fn formats_partial_input_as_typed() { - assert_eq!(format_card_number("41111"), "4111 1"); - assert_eq!(format_card_number("411111111"), "4111 1111 1"); - } - - #[test] - fn amex_groups_progressively_while_typing() { - // Prefix already identifies Amex, so partial input uses 4-6-5, not fours. - assert_eq!(format_card_number("341234"), "3412 34"); - assert_eq!(format_card_number("3412345678"), "3412 345678"); - assert_eq!(format_card_number("34123456789012"), "3412 345678 9012"); - assert_eq!(format_card_number("341234567890123"), "3412 345678 90123"); - assert_eq!(card_space_indices("3412345678"), vec![4]); - assert_eq!(card_space_indices("34123456789012"), vec![4, 10]); - } - - #[test] - fn nineteen_digits_trail_in_fours() { - assert_eq!( - format_card_number("4111111111111111123"), - "4111 1111 1111 1111 123" - ); - } - - #[test] - fn caps_visa_at_nineteen() { - // 25 digits in -> Visa keeps its 19-digit maximum. - let formatted = format_card_number("4111111111111111111111111"); - assert_eq!(formatted, "4111 1111 1111 1111 111"); - assert_eq!(digit_count(&formatted), 19); - } - - #[test] - fn caps_amex_at_fifteen() { - // 20 digits in, Amex prefix -> capped to 15 and grouped 4-6-5. - let formatted = format_card_number("37828224631000512345"); - assert_eq!(formatted, "3782 822463 10005"); - assert_eq!(digit_count(&formatted), 15); - } - - #[test] - fn caps_mastercard_at_sixteen() { - let formatted = format_card_number("5500000000000004999"); - assert_eq!(digit_count(&formatted), 16); - } - - #[test] - fn unknown_network_caps_at_global_max() { - // Unrecognised prefix -> falls back to MAX_PAN_DIGITS. - let formatted = format_card_number("9999999999999999999999"); - assert_eq!(digit_count(&formatted), MAX_PAN_DIGITS); - } - - #[test] - fn card_digits_strips_and_caps() { - // Separators removed, no grouping, capped to the network maximum. - assert_eq!(card_digits("4111 1111-1111_1111"), "4111111111111111"); - assert_eq!(card_digits("37828224631000512345"), "378282246310005"); // Amex -> 15 - assert_eq!(card_digits(""), ""); - } - - #[test] - fn space_indices_match_groups() { - assert_eq!(card_space_indices("4111111111111111"), vec![4, 8, 12]); - assert_eq!(card_space_indices("378282246310005"), vec![4, 10]); // Amex 4-6-5 - assert_eq!(card_space_indices("41111"), vec![4]); // partial: "4111 1" - assert_eq!(card_space_indices("411"), Vec::::new()); // single group - assert_eq!(card_space_indices(""), Vec::::new()); - } - - #[test] - fn network_max_lengths() { - assert_eq!(CardNetwork::Amex.max_len(), 15); - assert_eq!(CardNetwork::Mastercard.max_len(), 16); - assert_eq!(CardNetwork::Visa.max_len(), 19); - assert_eq!(CardNetwork::Unknown.max_len(), MAX_PAN_DIGITS); - } - - fn digit_count(s: &str) -> usize { - s.chars().filter(char::is_ascii_digit).count() - } - - #[test] - fn luhn_checks() { - assert!(luhn_valid("4111111111111111")); - assert!(luhn_valid("378282246310005")); - assert!(luhn_valid("6011000990139424")); - assert!(!luhn_valid("4111111111111112")); - assert!(!luhn_valid("")); - } - - #[test] - fn luhn_ignores_separators() { - assert!(luhn_valid("4111 1111 1111 1111")); - } - - #[test] - fn cvv_lengths() { - assert_eq!(CardNetwork::Amex.cvv_len(), 4); - assert_eq!(CardNetwork::Visa.cvv_len(), 3); - assert_eq!(CardNetwork::Mastercard.cvv_len(), 3); - } - - #[test] - fn full_validation() { - assert!(Card::parse("4111 1111 1111 1111").is_valid()); - assert!(Card::parse("378282246310005").is_valid()); - // Visa prefix but wrong length -> not valid. - assert!(!Card::parse("411111111111").is_valid()); - // Right length, but Luhn fails. - assert!(!Card::parse("4111111111111112").is_valid()); - } - - #[test] - fn expiration_keeps_a_month_still_being_typed() { - assert_eq!(format_expiration_inner("", true), ""); - assert_eq!(format_expiration_inner("0", true), "0"); - assert_eq!(format_expiration_inner("1", true), "1"); - } - - #[test] - fn expiration_pads_a_leading_digit_greater_than_one() { - assert_eq!(format_expiration_inner("2", true), "02/"); - assert_eq!(format_expiration_inner("5", true), "05/"); - assert_eq!(format_expiration_inner("9", true), "09/"); - } - - #[test] - fn expiration_completes_two_digit_months() { - assert_eq!(format_expiration_inner("10", true), "10/"); - assert_eq!(format_expiration_inner("12", true), "12/"); - assert_eq!(format_expiration_inner("09", true), "09/"); - } - - #[test] - fn expiration_rejects_an_impossible_second_digit() { - assert_eq!(format_expiration_inner("13", true), "1"); - assert_eq!(format_expiration_inner("19", true), "1"); - assert_eq!(format_expiration_inner("00", true), "0"); - } - - #[test] - fn expiration_appends_the_year_after_the_separator() { - assert_eq!(format_expiration_inner("123", true), "12/3"); - assert_eq!(format_expiration_inner("1234", true), "12/34"); - assert_eq!(format_expiration_inner("099", true), "09/9"); - assert_eq!(format_expiration_inner("527", true), "05/27"); - } - - #[test] - fn expiration_caps_the_year_and_strips_non_digits() { - assert_eq!(format_expiration_inner("123456", true), "12/34"); - assert_eq!(format_expiration_inner("1a2", true), "12/"); - assert_eq!(format_expiration_inner("12/34", true), "12/34"); - assert_eq!(format_expiration_inner(" 5 ", true), "05/"); - } - - #[test] - fn expiration_edit_types_forward_and_auto_inserts_the_separator() { - assert_eq!(format_expiration_after_edit("1", "12"), "12/"); - assert_eq!(format_expiration_after_edit("", "5"), "05/"); - assert_eq!(format_expiration_after_edit("12/", "12/3"), "12/3"); - } - - #[test] - fn expiration_edit_deletes_year_digits_then_the_separator_with_the_month() { - // 12/34 -> 12/3 -> 12 -> 1 -> "" (one visible char per backspace) - assert_eq!(format_expiration_after_edit("12/34", "12/3"), "12/3"); - assert_eq!(format_expiration_after_edit("12/3", "12/"), "12"); - assert_eq!(format_expiration_after_edit("12/", "12"), "1"); - assert_eq!(format_expiration_after_edit("12", "1"), "1"); - assert_eq!(format_expiration_after_edit("1", ""), ""); - } - - #[test] - fn expiration_edit_collapses_a_padded_month_on_separator_deletion() { - // 05/ -> delete -> 0 - assert_eq!(format_expiration_after_edit("05/", "05"), "0"); - } -} diff --git a/rust/rust-code/lib/src/card/expiration.rs b/rust/rust-code/lib/src/card/expiration.rs new file mode 100644 index 00000000..43352401 --- /dev/null +++ b/rust/rust-code/lib/src/card/expiration.rs @@ -0,0 +1,138 @@ +//! `MM/YY` expiration-date formatting for text being typed or deleted live. + +use super::digits_only; + +const SEPARATOR: char = '/'; + +/// Applies expiration-date formatting to an edit, given the `previous` text and the `proposed` +/// text the user just produced. The separator is auto-inserted while typing forward; deletions +/// erase it together with adjacent digits so the user can never get stuck on `MM/`: +/// +/// - `12/34` -> `12/3` -> `12` -> `1` -> `` (each backspace removes one visible character) +/// - deleting the separator itself also drops the preceding month digit (`12/` -> `1`, `05/` -> `0`) +pub fn format_expiration_after_edit(previous: &str, proposed: &str) -> String { + let deleting = proposed.chars().count() < previous.chars().count(); + // Backspacing the separator leaves the bare month behind; take the month digit with it. + let deleted_separator = + deleting && previous.ends_with(SEPARATOR) && !proposed.ends_with(SEPARATOR); + let source = if deleted_separator { + let mut trimmed = proposed.to_string(); + trimmed.pop(); + trimmed + } else { + proposed.to_string() + }; + format_expiration_inner(&source, !deleting) +} + +/// Reformats arbitrary `input` into a partial-or-complete `MM/YY` expiration date. +/// +/// - A leading digit greater than `1` is padded into a complete single-digit month (`5` -> `05`). +/// - A second month digit that would form an invalid month is dropped (`13` -> `1`, `00` -> `0`). +/// - The separator follows a complete month; the year is capped at two digits. +/// +/// `append_trailing_separator` controls whether a complete month with no year yet renders as +/// `MM/` (typing forward) or plain `MM` (deleting, so the user can erase back into the month). +fn format_expiration_inner(input: &str, append_trailing_separator: bool) -> String { + let digits = digits_only(input); + let Some(first) = digits.chars().next() else { + return String::new(); + }; + + let (month, year_digits): (String, &str) = if first > '1' { + // 2..=9 can only be a single-digit month, so pad it and treat it as complete. + (format!("0{first}"), &digits[1..]) + } else { + let second = digits[1..].chars().next(); + let month_complete = match second { + None => false, + Some(s) if first == '0' => ('1'..='9').contains(&s), // 01..=09; 00 is invalid + Some(s) => ('0'..='2').contains(&s), // 10, 11, 12 + }; + if !month_complete { + return first.to_string(); + } + (digits[..2].to_string(), &digits[2..]) + }; + + let year: String = year_digits.chars().take(2).collect(); + if !year.is_empty() { + format!("{month}{SEPARATOR}{year}") + } else if append_trailing_separator { + format!("{month}{SEPARATOR}") + } else { + month + } +} + +#[cfg(test)] +mod tests { + use super::{format_expiration_after_edit, format_expiration_inner}; + + #[test] + fn expiration_keeps_a_month_still_being_typed() { + assert_eq!(format_expiration_inner("", true), ""); + assert_eq!(format_expiration_inner("0", true), "0"); + assert_eq!(format_expiration_inner("1", true), "1"); + } + + #[test] + fn expiration_pads_a_leading_digit_greater_than_one() { + assert_eq!(format_expiration_inner("2", true), "02/"); + assert_eq!(format_expiration_inner("5", true), "05/"); + assert_eq!(format_expiration_inner("9", true), "09/"); + } + + #[test] + fn expiration_completes_two_digit_months() { + assert_eq!(format_expiration_inner("10", true), "10/"); + assert_eq!(format_expiration_inner("12", true), "12/"); + assert_eq!(format_expiration_inner("09", true), "09/"); + } + + #[test] + fn expiration_rejects_an_impossible_second_digit() { + assert_eq!(format_expiration_inner("13", true), "1"); + assert_eq!(format_expiration_inner("19", true), "1"); + assert_eq!(format_expiration_inner("00", true), "0"); + } + + #[test] + fn expiration_appends_the_year_after_the_separator() { + assert_eq!(format_expiration_inner("123", true), "12/3"); + assert_eq!(format_expiration_inner("1234", true), "12/34"); + assert_eq!(format_expiration_inner("099", true), "09/9"); + assert_eq!(format_expiration_inner("527", true), "05/27"); + } + + #[test] + fn expiration_caps_the_year_and_strips_non_digits() { + assert_eq!(format_expiration_inner("123456", true), "12/34"); + assert_eq!(format_expiration_inner("1a2", true), "12/"); + assert_eq!(format_expiration_inner("12/34", true), "12/34"); + assert_eq!(format_expiration_inner(" 5 ", true), "05/"); + } + + #[test] + fn expiration_edit_types_forward_and_auto_inserts_the_separator() { + assert_eq!(format_expiration_after_edit("1", "12"), "12/"); + assert_eq!(format_expiration_after_edit("", "5"), "05/"); + assert_eq!(format_expiration_after_edit("12/", "12/3"), "12/3"); + } + + #[test] + fn expiration_edit_deletes_year_digits_then_the_separator_with_the_month() { + // 12/34 -> 12/3 -> 12 -> 1 -> "" (one visible char per backspace) + assert_eq!(format_expiration_after_edit("12/34", "12/3"), "12/3"); + assert_eq!(format_expiration_after_edit("12/3", "12/"), "12"); + assert_eq!(format_expiration_after_edit("12/", "12"), "1"); + assert_eq!(format_expiration_after_edit("12", "1"), "1"); + assert_eq!(format_expiration_after_edit("1", ""), ""); + } + + #[test] + fn expiration_edit_collapses_a_padded_month_on_separator_deletion() { + // 05/ -> delete -> 0 + assert_eq!(format_expiration_after_edit("05/", "05"), "0"); + } +} diff --git a/rust/rust-code/lib/src/card/mod.rs b/rust/rust-code/lib/src/card/mod.rs new file mode 100644 index 00000000..1d706f6a --- /dev/null +++ b/rust/rust-code/lib/src/card/mod.rs @@ -0,0 +1,37 @@ +//! Credit-card field helpers, split by concern: +//! +//! - network detection — the issuing [`CardNetwork`] and its metadata (valid +//! lengths, CVV length, grouping convention); +//! - number handling — parse, cap, group, and validate a PAN via [`Card`]; +//! - expiration formatting — render an `MM/YY` date as it is typed or deleted. +//! +//! All entry points accept "dirty" input — spaces, dashes, and any other +//! non-digit characters are ignored — so they work equally well on a pasted +//! value or on text being typed live into a field. +//! +//! ``` +//! use lib::card::{Card, CardNetwork}; +//! +//! let card = Card::parse("378282246310005"); +//! assert_eq!(card.network, CardNetwork::Amex); +//! assert_eq!(card.formatted, "3782 822463 10005"); +//! assert!(card.is_valid()); +//! ``` + +mod expiration; +mod network; +mod number; + +pub use expiration::format_expiration_after_edit; +pub use network::CardNetwork; +pub use number::Card; + +/// The longest PAN any supported network issues; the cap of last resort while +/// the network is still [`CardNetwork::Unknown`]. +pub const MAX_PAN_DIGITS: usize = 19; + +/// Strip everything but ASCII digits. Shared by every submodule, since they all +/// accept "dirty" input. +fn digits_only(s: &str) -> String { + s.chars().filter(char::is_ascii_digit).collect() +} diff --git a/rust/rust-code/lib/src/card/network.rs b/rust/rust-code/lib/src/card/network.rs new file mode 100644 index 00000000..9156c8a2 --- /dev/null +++ b/rust/rust-code/lib/src/card/network.rs @@ -0,0 +1,232 @@ +//! Card-network identification from the leading digits (the IIN/BIN) and the +//! per-network metadata that drives capping, validation, and grouping. + +use super::{MAX_PAN_DIGITS, digits_only}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardNetwork { + Visa, + Mastercard, + Amex, + Discover, + DinersClub, + Jcb, + UnionPay, + Maestro, + Unknown, +} + +impl CardNetwork { + /// Detect the network from a (possibly dirty) number's leading digits. + pub fn detect(number: &str) -> CardNetwork { + Self::from_digits(&digits_only(number)) + } + + /// Detect the network from already-cleaned `digits` by IIN range. More + /// specific ranges are checked before broader ones so overlapping prefixes + /// resolve correctly. + /// + /// Callers that have already stripped non-digits (such as + /// [`Card::parse`](crate::card::Card::parse)) use this directly to avoid a + /// redundant clean; [`CardNetwork::detect`] is the dirty-input wrapper. + pub(super) fn from_digits(digits: &str) -> CardNetwork { + use CardNetwork::*; + if digits.is_empty() { + return Unknown; + } + + // American Express: 34, 37 + if matches!(prefix(digits, 2), Some(34 | 37)) { + return Amex; + } + // JCB: 3528–3589 (checked before the broader Diners "3" ranges) + if matches!(prefix(digits, 4), Some(3528..=3589)) { + return Jcb; + } + // Diners Club: 300–305, 36, 38, 39 + if matches!(prefix(digits, 3), Some(300..=305)) + || matches!(prefix(digits, 2), Some(36 | 38 | 39)) + { + return DinersClub; + } + // Visa: 4 + if digits.starts_with('4') { + return Visa; + } + // Mastercard: 51–55, 2221–2720 + if matches!(prefix(digits, 2), Some(51..=55)) + || matches!(prefix(digits, 4), Some(2221..=2720)) + { + return Mastercard; + } + // Discover: 6011, 644–649, 65, and the 622126–622925 co-brand range + // (checked before UnionPay's broad "62"). + if matches!(prefix(digits, 4), Some(6011)) + || matches!(prefix(digits, 3), Some(644..=649)) + || matches!(prefix(digits, 2), Some(65)) + || matches!(prefix(digits, 6), Some(622126..=622925)) + { + return Discover; + } + // UnionPay: 62, 81 + if matches!(prefix(digits, 2), Some(62 | 81)) { + return UnionPay; + } + // Maestro: a selection of the common debit ranges + if matches!(prefix(digits, 2), Some(50 | 56 | 57 | 58 | 63 | 67 | 69)) { + return Maestro; + } + + Unknown + } + + pub fn name(self) -> &'static str { + match self { + CardNetwork::Visa => "Visa", + CardNetwork::Mastercard => "Mastercard", + CardNetwork::Amex => "American Express", + CardNetwork::Discover => "Discover", + CardNetwork::DinersClub => "Diners Club", + CardNetwork::Jcb => "JCB", + CardNetwork::UnionPay => "UnionPay", + CardNetwork::Maestro => "Maestro", + CardNetwork::Unknown => "Unknown", + } + } + + pub fn valid_lengths(self) -> &'static [usize] { + match self { + CardNetwork::Visa => &[13, 16, 19], + CardNetwork::Mastercard => &[16], + CardNetwork::Amex => &[15], + CardNetwork::Discover => &[16, 17, 18, 19], + CardNetwork::DinersClub => &[14, 16], + CardNetwork::Jcb => &[16, 17, 18, 19], + CardNetwork::UnionPay => &[16, 17, 18, 19], + CardNetwork::Maestro => &[12, 13, 14, 15, 16, 17, 18, 19], + CardNetwork::Unknown => &[], + } + } + + /// The longest number this network issues. Unknown networks fall back to the global + /// [`MAX_PAN_DIGITS`]. + pub fn max_len(self) -> usize { + self.valid_lengths() + .iter() + .copied() + .max() + .unwrap_or(MAX_PAN_DIGITS) + } + + pub fn cvv_len(self) -> usize { + match self { + CardNetwork::Unknown | CardNetwork::Amex => 4, + _ => 3, + } + } + + /// Group sizes used to space a number of `len` digits. + pub(super) fn groups(self, len: usize) -> Vec { + match self { + CardNetwork::Amex => fit_pattern(&[4, 6, 5], len), + CardNetwork::DinersClub => fit_pattern(&[4, 6, 4], len), + _ => groups_of_four(len), + } + } +} + +fn prefix(digits: &str, n: usize) -> Option { + digits.get(..n).and_then(|p| p.parse().ok()) +} + +/// Truncate a fixed grouping `pattern` (e.g. Amex's `[4, 6, 5]`) to cover exactly +/// `len` digits, so a partially typed number is spaced the same way it will be once +/// complete. Any digits beyond the pattern trail in fours as a safety net. +fn fit_pattern(pattern: &[usize], len: usize) -> Vec { + let mut groups = Vec::new(); + let mut remaining = len; + for &size in pattern { + if remaining == 0 { + break; + } + let take = size.min(remaining); + groups.push(take); + remaining -= take; + } + if remaining > 0 { + groups.extend(groups_of_four(remaining)); + } + groups +} + +fn groups_of_four(len: usize) -> Vec { + let mut groups = Vec::new(); + let mut remaining = len; + while remaining > 4 { + groups.push(4); + remaining -= 4; + } + if remaining > 0 { + groups.push(remaining); + } + groups +} + +#[cfg(test)] +mod tests { + use super::CardNetwork; + use crate::card::MAX_PAN_DIGITS; + + #[test] + fn detects_networks() { + assert_eq!(CardNetwork::detect("4111111111111111"), CardNetwork::Visa); + assert_eq!( + CardNetwork::detect("5500000000000004"), + CardNetwork::Mastercard + ); + assert_eq!( + CardNetwork::detect("2221000000000009"), + CardNetwork::Mastercard + ); + assert_eq!(CardNetwork::detect("378282246310005"), CardNetwork::Amex); + assert_eq!( + CardNetwork::detect("6011000990139424"), + CardNetwork::Discover + ); + assert_eq!( + CardNetwork::detect("30569309025904"), + CardNetwork::DinersClub + ); + assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); + assert_eq!( + CardNetwork::detect("6212345678901232"), + CardNetwork::UnionPay + ); + assert_eq!(CardNetwork::detect(""), CardNetwork::Unknown); + assert_eq!( + CardNetwork::detect("9999999999999999"), + CardNetwork::Unknown + ); + } + + #[test] + fn jcb_beats_diners_in_3500_range() { + // 3530 is JCB even though "3x" is otherwise Diners territory. + assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); + } + + #[test] + fn network_max_lengths() { + assert_eq!(CardNetwork::Amex.max_len(), 15); + assert_eq!(CardNetwork::Mastercard.max_len(), 16); + assert_eq!(CardNetwork::Visa.max_len(), 19); + assert_eq!(CardNetwork::Unknown.max_len(), MAX_PAN_DIGITS); + } + + #[test] + fn cvv_lengths() { + assert_eq!(CardNetwork::Amex.cvv_len(), 4); + assert_eq!(CardNetwork::Visa.cvv_len(), 3); + assert_eq!(CardNetwork::Mastercard.cvv_len(), 3); + } +} diff --git a/rust/rust-code/lib/src/card/number.rs b/rust/rust-code/lib/src/card/number.rs new file mode 100644 index 00000000..cf88b428 --- /dev/null +++ b/rust/rust-code/lib/src/card/number.rs @@ -0,0 +1,255 @@ +//! The primary account number (PAN): parse "dirty" input into a cleaned, capped, +//! network-grouped [`Card`], and validate it. + +use std::fmt; + +use super::digits_only; +use super::network::CardNetwork; + +#[derive(Debug, Clone)] +pub struct Card { + pub network: CardNetwork, + pub digits: String, + pub formatted: String, +} + +impl Card { + /// Parse a (possibly dirty) number: strip non-digits, detect the network, + /// cap to that network's maximum length, and group for display. + pub fn parse(input: &str) -> Card { + let cleaned = digits_only(input); + let network = CardNetwork::from_digits(&cleaned); + // Capping never changes detection: the IIN lives in the first six + // digits, well within every network's maximum. + let digits: String = cleaned.chars().take(network.max_len()).collect(); + let formatted = group(&digits, &network.groups(digits.len())); + Card { + network, + digits, + formatted, + } + } + + /// Digit offsets at which a grouping space falls, e.g. `[4, 8, 12]` for a + /// 16-digit number. + pub fn space_indices(&self) -> Vec { + let mut indices = Vec::new(); + let mut pos = 0; + for size in self.network.groups(self.digits.len()) { + pos += size; + if pos < self.digits.len() { + indices.push(pos); + } + } + indices + } + + pub fn cvv_len(&self) -> usize { + self.network.cvv_len() + } + + pub fn is_length_valid(&self) -> bool { + self.network.valid_lengths().contains(&self.digits.len()) + } + + pub fn is_luhn_valid(&self) -> bool { + if self.digits.is_empty() { + return false; + } + let sum: u32 = self + .digits + .bytes() + .rev() + .enumerate() + .map(|(i, b)| { + let d = u32::from(b - b'0'); + if i % 2 == 1 { + let doubled = d * 2; + if doubled > 9 { doubled - 9 } else { doubled } + } else { + d + } + }) + .sum(); + sum.is_multiple_of(10) + } + + pub fn is_valid(&self) -> bool { + self.network != CardNetwork::Unknown && self.is_length_valid() && self.is_luhn_valid() + } +} + +impl fmt::Display for Card { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.formatted) + } +} + +fn group(digits: &str, sizes: &[usize]) -> String { + let bytes = digits.as_bytes(); + let mut out = String::with_capacity(digits.len() + sizes.len()); + let mut idx = 0; + + for &size in sizes { + if idx >= bytes.len() { + break; + } + if idx > 0 { + out.push(' '); + } + let end = (idx + size).min(bytes.len()); + out.push_str(&digits[idx..end]); + idx = end; + } + + // Safety net for any digits beyond the declared pattern. + while idx < bytes.len() { + out.push(' '); + let end = (idx + 4).min(bytes.len()); + out.push_str(&digits[idx..end]); + idx = end; + } + + out +} + +#[cfg(test)] +mod tests { + use super::Card; + use crate::card::MAX_PAN_DIGITS; + + #[test] + fn formats_by_network() { + assert_eq!( + Card::parse("4111111111111111").formatted, + "4111 1111 1111 1111" + ); + assert_eq!( + Card::parse("378282246310005").formatted, + "3782 822463 10005" + ); + assert_eq!(Card::parse("30569309025904").formatted, "3056 930902 5904"); + } + + #[test] + fn ignores_existing_separators() { + assert_eq!( + Card::parse("5500-0000 0000_0004").formatted, + "5500 0000 0000 0004" + ); + } + + #[test] + fn formats_partial_input_as_typed() { + assert_eq!(Card::parse("41111").formatted, "4111 1"); + assert_eq!(Card::parse("411111111").formatted, "4111 1111 1"); + } + + #[test] + fn amex_groups_progressively_while_typing() { + // Prefix already identifies Amex, so partial input uses 4-6-5, not fours. + assert_eq!(Card::parse("341234").formatted, "3412 34"); + assert_eq!(Card::parse("3412345678").formatted, "3412 345678"); + assert_eq!(Card::parse("34123456789012").formatted, "3412 345678 9012"); + assert_eq!( + Card::parse("341234567890123").formatted, + "3412 345678 90123" + ); + assert_eq!(Card::parse("3412345678").space_indices(), vec![4]); + assert_eq!(Card::parse("34123456789012").space_indices(), vec![4, 10]); + } + + #[test] + fn nineteen_digits_trail_in_fours() { + assert_eq!( + Card::parse("4111111111111111123").formatted, + "4111 1111 1111 1111 123" + ); + } + + #[test] + fn caps_visa_at_nineteen() { + // 25 digits in -> Visa keeps its 19-digit maximum. + let card = Card::parse("4111111111111111111111111"); + assert_eq!(card.formatted, "4111 1111 1111 1111 111"); + assert_eq!(card.digits.len(), 19); + } + + #[test] + fn caps_amex_at_fifteen() { + // 20 digits in, Amex prefix -> capped to 15 and grouped 4-6-5. + let card = Card::parse("37828224631000512345"); + assert_eq!(card.formatted, "3782 822463 10005"); + assert_eq!(card.digits.len(), 15); + } + + #[test] + fn caps_mastercard_at_sixteen() { + assert_eq!(Card::parse("5500000000000004999").digits.len(), 16); + } + + #[test] + fn unknown_network_caps_at_global_max() { + // Unrecognised prefix -> falls back to MAX_PAN_DIGITS. + assert_eq!( + Card::parse("9999999999999999999999").digits.len(), + MAX_PAN_DIGITS + ); + } + + #[test] + fn card_digits_strips_and_caps() { + // Separators removed, no grouping, capped to the network maximum. + assert_eq!( + Card::parse("4111 1111-1111_1111").digits, + "4111111111111111" + ); + assert_eq!( + Card::parse("37828224631000512345").digits, + "378282246310005" + ); // Amex -> 15 + assert_eq!(Card::parse("").digits, ""); + } + + #[test] + fn space_indices_match_groups() { + assert_eq!( + Card::parse("4111111111111111").space_indices(), + vec![4, 8, 12] + ); + assert_eq!(Card::parse("378282246310005").space_indices(), vec![4, 10]); // Amex 4-6-5 + assert_eq!(Card::parse("41111").space_indices(), vec![4]); // partial: "4111 1" + assert_eq!(Card::parse("411").space_indices(), Vec::::new()); // single group + assert_eq!(Card::parse("").space_indices(), Vec::::new()); + } + + #[test] + fn luhn_checks() { + assert!(Card::parse("4111111111111111").is_luhn_valid()); + assert!(Card::parse("378282246310005").is_luhn_valid()); + assert!(Card::parse("6011000990139424").is_luhn_valid()); + assert!(!Card::parse("4111111111111112").is_luhn_valid()); + assert!(!Card::parse("").is_luhn_valid()); + } + + #[test] + fn luhn_ignores_separators() { + assert!(Card::parse("4111 1111 1111 1111").is_luhn_valid()); + } + + #[test] + fn cvv_len_surfaces_network_value() { + assert_eq!(Card::parse("378282246310005").cvv_len(), 4); + assert_eq!(Card::parse("4111111111111111").cvv_len(), 3); + } + + #[test] + fn full_validation() { + assert!(Card::parse("4111 1111 1111 1111").is_valid()); + assert!(Card::parse("378282246310005").is_valid()); + // Visa prefix but wrong length -> not valid. + assert!(!Card::parse("411111111111").is_valid()); + // Right length, but Luhn fails. + assert!(!Card::parse("4111111111111112").is_valid()); + } +} From 81e54689083a4c63fe2f95e8d0645d64b5aba277 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 26 May 2026 00:21:20 +0200 Subject: [PATCH 15/36] feat(credit-card): Add NFC card reader implementation --- feature/credit-card/.gitignore | 1 + feature/credit-card/build.gradle.kts | 54 ++++++++++++++++++ feature/credit-card/consumer-rules.pro | 0 feature/credit-card/proguard-rules.pro | 21 +++++++ .../credit_card/ExampleInstrumentedTest.kt | 22 +++++++ .../credit-card/src/main/AndroidManifest.xml | 8 +++ .../credit_card/data/mapper/CardMapper.kt | 17 ++++++ .../repository/CardReaderRepositoryImpl.kt | 57 +++++++++++++++++++ .../feature/credit_card/di/CardModule.kt | 11 ++++ .../credit_card/domain/ApduTransport.kt | 7 +++ .../domain/exception/CardRemovedException.kt | 3 + .../feature/credit_card/domain/model/Card.kt | 9 +++ .../domain/model/CardReadFailure.kt | 8 +++ .../domain/repository/CardReaderRepository.kt | 16 ++++++ .../presentation/IsoDepTransport.kt | 39 +++++++++++++ .../credit_card/presentation/NfcAdapter.kt | 41 +++++++++++++ .../feature/credit_card/ExampleUnitTest.kt | 16 ++++++ gradle/libs.versions.toml | 3 +- settings.gradle.kts | 1 + 19 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 feature/credit-card/.gitignore create mode 100644 feature/credit-card/build.gradle.kts create mode 100644 feature/credit-card/consumer-rules.pro create mode 100644 feature/credit-card/proguard-rules.pro create mode 100644 feature/credit-card/src/androidTest/java/de/davis/keygo/feature/credit_card/ExampleInstrumentedTest.kt create mode 100644 feature/credit-card/src/main/AndroidManifest.xml create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/repository/CardReaderRepositoryImpl.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/di/CardModule.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/ApduTransport.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/exception/CardRemovedException.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/Card.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/CardReadFailure.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/repository/CardReaderRepository.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/IsoDepTransport.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAdapter.kt create mode 100644 feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/ExampleUnitTest.kt diff --git a/feature/credit-card/.gitignore b/feature/credit-card/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/credit-card/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/credit-card/build.gradle.kts b/feature/credit-card/build.gradle.kts new file mode 100644 index 00000000..1ee1f2dd --- /dev/null +++ b/feature/credit-card/build.gradle.kts @@ -0,0 +1,54 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.koin.compiler) +} + +android { + namespace = "de.davis.keygo.feature.credit_card" + compileSdk { + version = release(libs.versions.compileSdk.get().toInt()) + } + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + api(projects.core.util) + + // Koin DI + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.androidx.compose) + implementation(libs.koin.annotations) + + implementation(libs.devnied.emvnfccard) +} \ No newline at end of file diff --git a/feature/credit-card/consumer-rules.pro b/feature/credit-card/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/credit-card/proguard-rules.pro b/feature/credit-card/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/credit-card/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/credit-card/src/androidTest/java/de/davis/keygo/feature/credit_card/ExampleInstrumentedTest.kt b/feature/credit-card/src/androidTest/java/de/davis/keygo/feature/credit_card/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..80fbb9f0 --- /dev/null +++ b/feature/credit-card/src/androidTest/java/de/davis/keygo/feature/credit_card/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.feature.credit_card + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("de.davis.keygo.feature.credit_card.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/credit-card/src/main/AndroidManifest.xml b/feature/credit-card/src/main/AndroidManifest.xml new file mode 100644 index 00000000..016f94ba --- /dev/null +++ b/feature/credit-card/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt new file mode 100644 index 00000000..e64dc675 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.feature.credit_card.data.mapper + +import com.github.devnied.emvnfccard.model.EmvCard +import de.davis.keygo.feature.credit_card.domain.model.Card +import java.time.YearMonth +import java.time.ZoneId + +internal fun EmvCard.toDomain(): Card? { + val digits = cardNumber?.filter(Char::isDigit).orEmpty() + if (digits.isBlank()) return null + + return Card( + holder = "$holderFirstname $holderLastname".trim(), + cardNumber = cardNumber, + expiry = expireDate.toInstant().atZone(ZoneId.systemDefault()).let { YearMonth.from(it) } + ) +} \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/repository/CardReaderRepositoryImpl.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/repository/CardReaderRepositoryImpl.kt new file mode 100644 index 00000000..156334aa --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/repository/CardReaderRepositoryImpl.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.feature.credit_card.data.repository + +import com.github.devnied.emvnfccard.parser.EmvTemplate +import com.github.devnied.emvnfccard.parser.IProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.credit_card.data.mapper.toDomain +import de.davis.keygo.feature.credit_card.domain.ApduTransport +import de.davis.keygo.feature.credit_card.domain.exception.CardRemovedException +import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure +import de.davis.keygo.feature.credit_card.domain.repository.CardReaderRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import kotlin.coroutines.CoroutineContext + +@Single +internal class CardReaderRepositoryImpl : CardReaderRepository { + + override suspend fun read( + transport: ApduTransport, + context: CoroutineContext + ): Result = withContext(context) { + transport.use { channel -> + try { + parser(channel).readEmvCard().toDomain() + ?.let { Result.Success(it) } + ?: Result.Failure(CardReadFailure.NoReadableData) + } catch (e: CancellationException) { + throw e + } catch (_: CardRemovedException) { + Result.Failure(CardReadFailure.TagLost) + } catch (e: Exception) { + Result.Failure(CardReadFailure.Unexpected(e)) + } + } + } + + private fun parser(transport: ApduTransport): EmvTemplate { + val provider = object : IProvider { + override fun transceive(command: ByteArray): ByteArray = transport.transceive(command) + override fun getAt(): ByteArray = transport.historicalBytes + } + + val config = EmvTemplate.Config() + .setContactLess(true) + .setReadAllAids(true) + .setReadTransactions(false) + .setReadAt(true) + .setRemoveDefaultParsers(false) + + return EmvTemplate.Builder() + .setProvider(provider) + .setConfig(config) + .build() + } +} \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/di/CardModule.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/di/CardModule.kt new file mode 100644 index 00000000..89cc559c --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/di/CardModule.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.credit_card.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.credit_card") +object CardModule \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/ApduTransport.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/ApduTransport.kt new file mode 100644 index 00000000..fd8ccd5b --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/ApduTransport.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.feature.credit_card.domain + +interface ApduTransport : AutoCloseable { + + val historicalBytes: ByteArray get() = ByteArray(0) + fun transceive(command: ByteArray): ByteArray +} \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/exception/CardRemovedException.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/exception/CardRemovedException.kt new file mode 100644 index 00000000..36ce3888 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/exception/CardRemovedException.kt @@ -0,0 +1,3 @@ +package de.davis.keygo.feature.credit_card.domain.exception + +class CardRemovedException(cause: Throwable? = null) : Exception(cause) diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/Card.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/Card.kt new file mode 100644 index 00000000..bcd8f34b --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/Card.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.credit_card.domain.model + +import java.time.YearMonth + +data class Card( + val holder: String, + val cardNumber: String, + val expiry: YearMonth, +) diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/CardReadFailure.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/CardReadFailure.kt new file mode 100644 index 00000000..9bcc5e26 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/model/CardReadFailure.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.credit_card.domain.model + +sealed interface CardReadFailure { + data object NotAnEmvCard : CardReadFailure + data object TagLost : CardReadFailure + data object NoReadableData : CardReadFailure + data class Unexpected(val cause: Throwable) : CardReadFailure +} \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/repository/CardReaderRepository.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/repository/CardReaderRepository.kt new file mode 100644 index 00000000..32b4a1cb --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/domain/repository/CardReaderRepository.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.credit_card.domain.repository + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.credit_card.domain.ApduTransport +import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +interface CardReaderRepository { + + suspend fun read( + transport: ApduTransport, + context: CoroutineContext = Dispatchers.IO + ): Result +} \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/IsoDepTransport.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/IsoDepTransport.kt new file mode 100644 index 00000000..e8eb8303 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/IsoDepTransport.kt @@ -0,0 +1,39 @@ +package de.davis.keygo.feature.credit_card.presentation + +import android.nfc.TagLostException +import android.nfc.tech.IsoDep +import de.davis.keygo.feature.credit_card.domain.ApduTransport +import de.davis.keygo.feature.credit_card.domain.exception.CardRemovedException + +internal class IsoDepTransport(private val isoDep: IsoDep) : ApduTransport { + + override val historicalBytes: ByteArray + get() = isoDep.historicalBytes ?: isoDep.hiLayerResponse ?: ByteArray(0) + + override fun transceive(command: ByteArray): ByteArray { + ensureConnected() + try { + return isoDep.transceive(command) + } catch (e: TagLostException) { + throw CardRemovedException(e) + } + } + + override fun close() { + runCatching { isoDep.close() } + } + + private fun ensureConnected() { + if (isoDep.isConnected) return + try { + isoDep.connect() + isoDep.timeout = CONNECT_TIMEOUT_MS + } catch (e: Exception) { + throw CardRemovedException(e) + } + } + + private companion object { + const val CONNECT_TIMEOUT_MS = 5_000 + } +} \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAdapter.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAdapter.kt new file mode 100644 index 00000000..9b3bcaa1 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAdapter.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.feature.credit_card.presentation + +import android.app.Activity +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.Bundle +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + + +@Composable +internal fun ObserveCardReaderEvents(onEvent: suspend (IsoDepTransport?) -> Unit) { + val activity = LocalActivity.current!! + val context = LocalContext.current + val nfcAdapter = remember { NfcAdapter.getDefaultAdapter(context) } + ObserveAsEvents(nfcAdapter.tagFlow(activity), activity) { tag -> + val transport = IsoDep.get(tag)?.let(::IsoDepTransport) + onEvent(transport) + } +} + +private fun NfcAdapter.tagFlow(activity: Activity): Flow = callbackFlow { + val callback = NfcAdapter.ReaderCallback { tag -> trySend(tag) } + val flags = NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK // payment cards aren't NDEF; skip the probe + + val extras = Bundle().apply { + putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) + } + + enableReaderMode(activity, callback, flags, extras) + awaitClose { disableReaderMode(activity) } +} diff --git a/feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/ExampleUnitTest.kt b/feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/ExampleUnitTest.kt new file mode 100644 index 00000000..f3e2f2c6 --- /dev/null +++ b/feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.feature.credit_card + +import org.junit.Assert.* +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 904424da..dba5a8e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,9 +35,9 @@ biometric = "1.4.0-alpha05" navigation = "2.9.7" kotlinpoet = "2.2.0" nbvcxz = "1.5.1" -totp = "2.4.1" passGen = "0.1.0-beta" robolectric = "4.16" +emvnfccard = "3.1.0" [libraries] google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" } @@ -108,6 +108,7 @@ offrange-passgen = { group = "com.github.offrange", name = "passgen", version.re gms-mlkit-barcode-scanning = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version.ref = "gmsMlkitBarcodeScanning" } zxing-barcode-scanning = { group = "com.google.zxing", name = "core", version.ref = "zxingBarcodeScanning" } +devnied-emvnfccard = { group = "com.github.devnied.emvnfccard", name = "library", version.ref = "emvnfccard" } [plugins] google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 012a4c0e..629233f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,3 +45,4 @@ include(":core:identity") include(":feature:vault") include(":feature:auth") include(":feature:autofill") +include(":feature:credit-card") From 399c566a6471ee357979b9c3791c7752724f741b Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 26 May 2026 00:43:19 +0200 Subject: [PATCH 16/36] feat(credit-card): add scan UI Co-Authored-By: Claude Opus 4.7 --- app/build.gradle.kts | 1 + .../util/presentation/BroadcastReceiver.kt | 41 ++++++ feature/credit-card/build.gradle.kts | 14 ++ .../presentation/CardScanBottomSheet.kt | 66 +++++++++ .../presentation/CardScanUiState.kt | 13 ++ .../presentation/CardScanViewModel.kt | 57 ++++++++ .../presentation/NfcAvailability.kt | 12 ++ .../credit_card/presentation/NfcInfoCard.kt | 125 ++++++++++++++++++ .../src/main/res/values/strings.xml | 14 ++ .../presentation/CardScanViewModelTest.kt | 88 ++++++++++++ .../feature/credit_card/FakeApduTransport.kt | 15 +++ .../credit_card/FakeCardReaderRepository.kt | 26 ++++ feature/item/create/build.gradle.kts | 3 + .../creditcard/CreditCardContent.kt | 22 +++ .../creditcard/CreditCardViewModel.kt | 12 +- .../creditcard/ScannedCardFields.kt | 18 +++ .../creditcard/model/CreditCardUiEvent.kt | 2 + .../create/src/main/res/values/strings.xml | 1 + .../creditcard/ScannedCardFieldsTest.kt | 35 +++++ 19 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanUiState.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAvailability.kt create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt create mode 100644 feature/credit-card/src/main/res/values/strings.xml create mode 100644 feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/presentation/CardScanViewModelTest.kt create mode 100644 feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeApduTransport.kt create mode 100644 feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeCardReaderRepository.kt create mode 100644 feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFields.kt create mode 100644 feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFieldsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 621e3d4f..62adde09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { implementation(projects.feature.item.view) implementation(projects.feature.vault) implementation(projects.feature.totp) + implementation(projects.feature.creditCard) implementation(projects.feature.autofill) implementation(projects.migrationCreateAccess) diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt new file mode 100644 index 00000000..0aa808ec --- /dev/null +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.core.util.presentation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +@Composable +fun BroadcastReceiver( + action: String, + flags: Int = ContextCompat.RECEIVER_NOT_EXPORTED, + onReceive: (Intent?) -> Unit +) { + val context = LocalContext.current + val currentOnReceive by rememberUpdatedState(onReceive) + + DisposableEffect(context, action) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + currentOnReceive(intent) + } + } + + val filter = IntentFilter(action) + ContextCompat.registerReceiver( + context, + receiver, + filter, + flags, + ) + context.registerReceiver(receiver, filter) + + onDispose { context.unregisterReceiver(receiver) } + } +} \ No newline at end of file diff --git a/feature/credit-card/build.gradle.kts b/feature/credit-card/build.gradle.kts index 1ee1f2dd..0052b699 100644 --- a/feature/credit-card/build.gradle.kts +++ b/feature/credit-card/build.gradle.kts @@ -23,6 +23,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + testFixtures { + enable = true + } + } kotlin { @@ -51,4 +55,14 @@ dependencies { implementation(libs.koin.annotations) implementation(libs.devnied.emvnfccard) + + testFixturesApi(projects.core.util) + testFixturesImplementation(libs.kotlinx.coroutines.core) + // Required because this module applies kotlin.compose and enables testFixtures (see CLAUDE.md). + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) + + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(testFixtures(projects.feature.creditCard)) } \ No newline at end of file diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt new file mode 100644 index 00000000..74d74ad9 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt @@ -0,0 +1,66 @@ +package de.davis.keygo.feature.credit_card.presentation + +import android.nfc.NfcAdapter +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.util.presentation.BroadcastReceiver +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.credit_card.domain.model.Card +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CardScanBottomSheet( + onCardRead: (Card) -> Unit, + onDismiss: () -> Unit, +) { + val viewModel: CardScanViewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + var nfcEnabled by remember { mutableStateOf(false) } + BroadcastReceiver( + action = NfcAdapter.ACTION_ADAPTER_STATE_CHANGED, + flags = ContextCompat.RECEIVER_EXPORTED, + ) { + nfcEnabled = it?.getIntExtra( + NfcAdapter.EXTRA_ADAPTER_STATE, + NfcAdapter.STATE_OFF + ) == NfcAdapter.STATE_ON + } + + val dismiss = { + viewModel.reset() + onDismiss() + } + + ObserveCardReaderEvents(viewModel::onTransport) + + // Drop any terminal state / in-flight read left over from a previous open in this screen + // session; cardRead only emits for a read completed during THIS open. + LaunchedEffect(Unit) { viewModel.reset() } + + ObserveAsEvents(viewModel.cardRead) { card -> + onCardRead(card) + dismiss() + } + + ModalBottomSheet( + onDismissRequest = dismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + NfcInfoCard( + state = state, + nfcEnabled = nfcEnabled, + onRetry = viewModel::reset, + ) + } +} diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanUiState.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanUiState.kt new file mode 100644 index 00000000..f24f4c7d --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanUiState.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.credit_card.presentation + +import androidx.compose.runtime.Stable +import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure + +@Stable +internal sealed interface CardScanUiState { + data object Ready : CardScanUiState + data object Reading : CardScanUiState + data class Success(val card: Card) : CardScanUiState + data class Failure(val reason: CardReadFailure) : CardScanUiState +} diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt new file mode 100644 index 00000000..7bd68ed5 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt @@ -0,0 +1,57 @@ +package de.davis.keygo.feature.credit_card.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.util.fold +import de.davis.keygo.feature.credit_card.domain.ApduTransport +import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure +import de.davis.keygo.feature.credit_card.domain.repository.CardReaderRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class CardScanViewModel( + private val cardReaderRepository: CardReaderRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(CardScanUiState.Ready) + val state: StateFlow = _state.asStateFlow() + + private val cardReadChannel = Channel() + val cardRead = cardReadChannel.receiveAsFlow() + + private var readJob: Job? = null + + fun onTransport(transport: ApduTransport?) { + // A tag arriving mid-read is ignored; a single read runs to completion. + if (_state.value is CardScanUiState.Reading) return + if (transport == null) { + _state.value = CardScanUiState.Failure(CardReadFailure.NotAnEmvCard) + return + } + + _state.value = CardScanUiState.Reading + readJob = viewModelScope.launch { + cardReaderRepository.read(transport).fold( + onSuccess = { card -> + _state.value = CardScanUiState.Success(card) + cardReadChannel.send(card) + }, + onFailure = { _state.value = CardScanUiState.Failure(it) }, + ) + } + } + + fun reset() { + readJob?.cancel() + readJob = null + _state.value = CardScanUiState.Ready + } +} diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAvailability.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAvailability.kt new file mode 100644 index 00000000..050c5e44 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcAvailability.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.credit_card.presentation + +import android.nfc.NfcAdapter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +fun rememberIsNfcAvailable(): Boolean { + val context = LocalContext.current + return remember { NfcAdapter.getDefaultAdapter(context) != null } +} diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt new file mode 100644 index 00000000..f286f689 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt @@ -0,0 +1,125 @@ +package de.davis.keygo.feature.credit_card.presentation + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Contactless +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.davis.keygo.feature.credit_card.R +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun NfcInfoCard( + state: CardScanUiState, + nfcEnabled: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Column( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when (state) { + CardScanUiState.Ready -> { + Icon( + imageVector = Icons.Default.Contactless, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + if (nfcEnabled) { + Text( + text = stringResource(R.string.card_scan_ready_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.card_scan_ready_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } else { + Text( + text = stringResource(R.string.card_scan_nfc_disabled_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + OutlinedButton( + onClick = { context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS)) }, + ) { + Text(text = stringResource(R.string.card_scan_enable_nfc)) + } + } + } + + CardScanUiState.Reading -> { + CircularWavyProgressIndicator() + Text( + text = stringResource(R.string.card_scan_reading), + style = MaterialTheme.typography.titleMedium, + ) + } + + is CardScanUiState.Success -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.card_scan_success), + style = MaterialTheme.typography.titleMedium, + ) + } + + is CardScanUiState.Failure -> { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(state.reason.messageRes()), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + TextButton(onClick = onRetry) { + Text(text = stringResource(R.string.card_scan_retry)) + } + } + } + } +} + +private fun CardReadFailure.messageRes(): Int = when (this) { + CardReadFailure.NotAnEmvCard -> R.string.card_scan_error_not_emv + CardReadFailure.TagLost -> R.string.card_scan_error_tag_lost + CardReadFailure.NoReadableData -> R.string.card_scan_error_no_data + is CardReadFailure.Unexpected -> R.string.card_scan_error_unexpected +} diff --git a/feature/credit-card/src/main/res/values/strings.xml b/feature/credit-card/src/main/res/values/strings.xml new file mode 100644 index 00000000..745922a9 --- /dev/null +++ b/feature/credit-card/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + Ready to scan + Hold your card flat against the back of the phone. + Reading card… + Card read + NFC is turned off. Turn it on to scan your card. + Turn on NFC + Try again + That doesn\'t look like a payment card. + Card moved too soon — hold it steady and try again. + Couldn\'t read this card\'s data. + Something went wrong reading the card. + diff --git a/feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/presentation/CardScanViewModelTest.kt b/feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/presentation/CardScanViewModelTest.kt new file mode 100644 index 00000000..82e3637b --- /dev/null +++ b/feature/credit-card/src/test/java/de/davis/keygo/feature/credit_card/presentation/CardScanViewModelTest.kt @@ -0,0 +1,88 @@ +package de.davis.keygo.feature.credit_card.presentation + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.credit_card.FakeApduTransport +import de.davis.keygo.feature.credit_card.FakeCardReaderRepository +import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import java.time.YearMonth +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class CardScanViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val card = Card( + holder = "JANE DOE", + cardNumber = "4111111111111111", + expiry = YearMonth.of(2030, 12), + ) + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + @Test + fun startsReady() = runTest(dispatcher) { + val vm = CardScanViewModel(FakeCardReaderRepository(Result.Success(card))) + assertEquals(CardScanUiState.Ready, vm.state.value) + } + + @Test + fun successfulReadGoesReadingThenSuccess() = runTest(dispatcher) { + val vm = CardScanViewModel(FakeCardReaderRepository(Result.Success(card))) + vm.onTransport(FakeApduTransport()) + assertIs(vm.state.value) + advanceUntilIdle() + assertEquals(CardScanUiState.Success(card), vm.state.value) + } + + @Test + fun failedReadEmitsFailure() = runTest(dispatcher) { + val vm = CardScanViewModel( + FakeCardReaderRepository(Result.Failure(CardReadFailure.NoReadableData)), + ) + vm.onTransport(FakeApduTransport()) + advanceUntilIdle() + assertEquals(CardScanUiState.Failure(CardReadFailure.NoReadableData), vm.state.value) + } + + @Test + fun nullTransportEmitsNotAnEmvCard() = runTest(dispatcher) { + val vm = CardScanViewModel(FakeCardReaderRepository(Result.Success(card))) + vm.onTransport(null) + assertEquals(CardScanUiState.Failure(CardReadFailure.NotAnEmvCard), vm.state.value) + } + + @Test + fun ignoresNewTransportWhileReading() = runTest(dispatcher) { + val repository = FakeCardReaderRepository(Result.Success(card)) + val vm = CardScanViewModel(repository) + vm.onTransport(FakeApduTransport()) + vm.onTransport(FakeApduTransport()) + advanceUntilIdle() + assertEquals(1, repository.readCount) + } + + @Test + fun resetReturnsToReady() = runTest(dispatcher) { + val vm = CardScanViewModel( + FakeCardReaderRepository(Result.Failure(CardReadFailure.TagLost)), + ) + vm.onTransport(FakeApduTransport()) + advanceUntilIdle() + vm.reset() + assertEquals(CardScanUiState.Ready, vm.state.value) + } +} diff --git a/feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeApduTransport.kt b/feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeApduTransport.kt new file mode 100644 index 00000000..2bdd24db --- /dev/null +++ b/feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeApduTransport.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.feature.credit_card + +import de.davis.keygo.feature.credit_card.domain.ApduTransport + +class FakeApduTransport : ApduTransport { + + var closed: Boolean = false + private set + + override fun transceive(command: ByteArray): ByteArray = ByteArray(0) + + override fun close() { + closed = true + } +} diff --git a/feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeCardReaderRepository.kt b/feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeCardReaderRepository.kt new file mode 100644 index 00000000..5f264e3a --- /dev/null +++ b/feature/credit-card/src/testFixtures/kotlin/de/davis/keygo/feature/credit_card/FakeCardReaderRepository.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.feature.credit_card + +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.credit_card.domain.ApduTransport +import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure +import de.davis.keygo.feature.credit_card.domain.repository.CardReaderRepository +import kotlin.coroutines.CoroutineContext + +/** In-memory [CardReaderRepository] for tests. Returns [result] and closes the transport. */ +class FakeCardReaderRepository( + var result: Result, +) : CardReaderRepository { + + var readCount: Int = 0 + private set + + override suspend fun read( + transport: ApduTransport, + context: CoroutineContext, + ): Result { + readCount++ + transport.close() + return result + } +} diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index 124d51f8..beb3a1cc 100644 --- a/feature/item/create/build.gradle.kts +++ b/feature/item/create/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(projects.core.security) implementation(projects.feature.item.core) implementation(projects.feature.totp) + implementation(projects.feature.creditCard) implementation(libs.offrange.passgen) @@ -64,6 +65,8 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + testImplementation(libs.kotlin.test) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index 75071d09..2615f9ec 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -13,8 +13,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import de.davis.keygo.feature.credit_card.presentation.CardScanBottomSheet +import de.davis.keygo.feature.credit_card.presentation.rememberIsNfcAvailable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -127,6 +134,21 @@ private fun CreditCardReadyContent( FormGroup( title = stringResource(R.string.cc_information), ) { + // Hardware-presence check only; the sheet handles the NFC-disabled case live. + val nfcAvailable = rememberIsNfcAvailable() + var showScanSheet by remember { mutableStateOf(false) } + + if (nfcAvailable) + TextButton(onClick = { showScanSheet = true }) { + Text(text = stringResource(R.string.cc_scan_card)) + } + + if (showScanSheet) + CardScanBottomSheet( + onCardRead = { onEvent(CreditCardUiEvent.OnCardScanned(it)) }, + onDismiss = { showScanSheet = false }, + ) + KeyGoFormField( state = state.ccHolderTextFieldState, label = { Text(text = stringResource(ItemCoreR.string.cc_holder)) } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index c1faaa25..f3d1f142 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.feature.credit_card.domain.model.Card import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState @@ -22,7 +23,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel -import java.time.format.DateTimeFormatter @KoinViewModel internal class CreditCardViewModel( @@ -86,7 +86,7 @@ internal class CreditCardViewModel( ccNumberTextFieldState.setTextAndPlaceCursorAtEnd(number) ccCVVTextFieldState.setTextAndPlaceCursorAtEnd(cvv ?: "") ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd( - card.expirationDate.format(EXPIRATION_FORMATTER), + card.expirationDate.format(CC_EXPIRATION_FORMATTER), ) setSelectedVaultId(card.vaultId) setAssignedTags(card.tags) @@ -103,10 +103,14 @@ internal class CreditCardViewModel( fun onEvent(event: CreditCardUiEvent) { when (event) { is CreditCardUiEvent.ItemUi -> onItemUiEvent(event.event) + is CreditCardUiEvent.OnCardScanned -> applyScannedCard(event.card) } } - companion object { - private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + private fun applyScannedCard(card: Card) { + val fields = card.toScannedFields() + ccNumberTextFieldState.setTextAndPlaceCursorAtEnd(fields.number) + ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd(fields.expiry) + fields.holder?.let(ccHolderTextFieldState::setTextAndPlaceCursorAtEnd) } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFields.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFields.kt new file mode 100644 index 00000000..291bb2c8 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFields.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard + +import de.davis.keygo.feature.credit_card.domain.model.Card +import java.time.format.DateTimeFormatter + +internal data class ScannedCardFields( + val number: String, + val expiry: String, + val holder: String?, +) + +internal val CC_EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + +internal fun Card.toScannedFields(): ScannedCardFields = ScannedCardFields( + number = cardNumber, + expiry = expiry.format(CC_EXPIRATION_FORMATTER), + holder = holder.takeIf { it.isNotBlank() }, +) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt index 7a9d9b3c..34220d04 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt @@ -1,7 +1,9 @@ package de.davis.keygo.feature.item.create.presentation.creditcard.model +import de.davis.keygo.feature.credit_card.domain.model.Card import de.davis.keygo.feature.item.create.presentation.model.ItemUiEvent internal sealed interface CreditCardUiEvent { data class ItemUi(val event: ItemUiEvent) : CreditCardUiEvent + data class OnCardScanned(val card: Card) : CreditCardUiEvent } diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 73d07949..95252d8b 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -63,4 +63,5 @@ Vault item ID can not be found Vault + Scan card \ No newline at end of file diff --git a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFieldsTest.kt b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFieldsTest.kt new file mode 100644 index 00000000..79baf29a --- /dev/null +++ b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/ScannedCardFieldsTest.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard + +import de.davis.keygo.feature.credit_card.domain.model.Card +import java.time.YearMonth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ScannedCardFieldsTest { + + @Test + fun mapsNumberHolderAndFormatsExpiry() { + val fields = Card( + holder = "JANE DOE", + cardNumber = "4111111111111111", + expiry = YearMonth.of(2030, 12), + ).toScannedFields() + + assertEquals("4111111111111111", fields.number) + assertEquals("12/30", fields.expiry) + assertEquals("JANE DOE", fields.holder) + } + + @Test + fun blankHolderBecomesNull() { + val fields = Card( + holder = " ", + cardNumber = "5555555555554444", + expiry = YearMonth.of(2026, 1), + ).toScannedFields() + + assertNull(fields.holder) + assertEquals("01/26", fields.expiry) + } +} From 8bc40f76c81d7678753bb4b63de3fd8cd9acf14f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 26 May 2026 16:19:18 +0200 Subject: [PATCH 17/36] feat(credit-card): Improve NFC scanner bottom sheet --- feature/credit-card/build.gradle.kts | 5 +- .../presentation/CardScanBottomSheet.kt | 4 +- .../presentation/CardScanViewModel.kt | 4 + .../credit_card/presentation/NfcInfoCard.kt | 303 ++++++++++++++---- .../src/main/res/values/strings.xml | 3 + 5 files changed, 246 insertions(+), 73 deletions(-) diff --git a/feature/credit-card/build.gradle.kts b/feature/credit-card/build.gradle.kts index 0052b699..05eb6873 100644 --- a/feature/credit-card/build.gradle.kts +++ b/feature/credit-card/build.gradle.kts @@ -65,4 +65,7 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(testFixtures(projects.feature.creditCard)) -} \ No newline at end of file + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt index 74d74ad9..de9f6479 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.util.presentation.BroadcastReceiver @@ -26,7 +27,8 @@ fun CardScanBottomSheet( val viewModel: CardScanViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() - var nfcEnabled by remember { mutableStateOf(false) } + val context = LocalContext.current + var nfcEnabled by remember(context) { mutableStateOf(NfcAdapter.getDefaultAdapter(context)?.isEnabled == true) } BroadcastReceiver( action = NfcAdapter.ACTION_ADAPTER_STATE_CHANGED, flags = ContextCompat.RECEIVER_EXPORTED, diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt index 7bd68ed5..e6afd3c9 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt @@ -9,6 +9,7 @@ import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure import de.davis.keygo.feature.credit_card.domain.repository.CardReaderRepository import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,6 +43,9 @@ internal class CardScanViewModel( cardReaderRepository.read(transport).fold( onSuccess = { card -> _state.value = CardScanUiState.Success(card) + + // We have a little delay to finish the animation + delay(500) cardReadChannel.send(card) }, onFailure = { _state.value = CardScanUiState.Failure(it) }, diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt index f286f689..498bccc6 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt @@ -2,31 +2,76 @@ package de.davis.keygo.feature.credit_card.presentation import android.content.Intent import android.provider.Settings -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Contactless import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.davis.keygo.feature.credit_card.R +import de.davis.keygo.feature.credit_card.domain.model.Card import de.davis.keygo.feature.credit_card.domain.model.CardReadFailure +import java.time.YearMonth + +private const val DescriptionLines = 2 +private val IndicatorSize = 56.dp + +// Base gap under the indicator, the gap between title and description, and the +// gap above the action. The indicator gap and the action area share a single +// animated driver: as the action reveals, the gap above the text shrinks by the +// same amount, so the text slides up while the total height stays constant. +private val IndicatorGap = 16.dp +private val TitleGap = 8.dp +private val ActionGap = 16.dp + +private data class ScanContent( + val indicator: Indicator, + val titleRes: Int, + val descriptionRes: Int? = null, + val action: Action? = null, +) + +private sealed interface Indicator { + data class Static(val icon: ImageVector, val isError: Boolean = false) : Indicator + data object Loading : Indicator +} + +private data class Action(val labelRes: Int, val icon: ImageVector, val onClick: () -> Unit) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -37,82 +82,198 @@ internal fun NfcInfoCard( modifier: Modifier = Modifier, ) { val context = LocalContext.current - Column( - modifier = modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - when (state) { - CardScanUiState.Ready -> { - Icon( - imageVector = Icons.Default.Contactless, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - if (nfcEnabled) { - Text( - text = stringResource(R.string.card_scan_ready_title), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.card_scan_ready_message), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } else { - Text( - text = stringResource(R.string.card_scan_nfc_disabled_message), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - OutlinedButton( - onClick = { context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS)) }, - ) { - Text(text = stringResource(R.string.card_scan_enable_nfc)) + val onEnableNfc = { context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS)) } + + // 0 = no action (text sits lower under the indicator), 1 = action shown + // (text slid up, action revealed below). Hoisted out of AnimatedContent so a + // single value drives the slide across the content crossfade. + val actionProgress by animateFloatAsState( + targetValue = if (state.hasAction(nfcEnabled)) 1f else 0f, + label = "action-progress", + ) + + AnimatedContent( + targetState = state to nfcEnabled, + modifier = modifier.fillMaxWidth(), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentAlignment = Alignment.TopCenter, + label = "card-scan-content", + ) { (state, nfcEnabled) -> + val content = state.toContent(nfcEnabled, onRetry, onEnableNfc) + val actionArea = ActionGap + ButtonDefaults.MinHeight + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ScanIndicator(content.indicator) + + // The unused action area is folded into the gap above the text, so + // the text rests lower until an action claims that space back. + // actionProgress is read in the layout phase (see animatedHeight) so + // the slide re-measures without recomposing this content each frame. + Spacer(Modifier.animatedHeight { IndicatorGap + actionArea * (1f - actionProgress) }) + + Text( + text = stringResource(content.titleRes), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(TitleGap)) + + // Always reserve the description slot so states without one keep the + // same height as states with one. + Text( + text = content.descriptionRes?.let { stringResource(it) }.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + minLines = DescriptionLines, + ) + + // Grows from zero to the action's full height as the action reveals; + // the button is anchored to the bottom and clipped while it slides in. + Box( + modifier = Modifier + .fillMaxWidth() + .clipToBounds() + .animatedHeight { actionArea * actionProgress }, + contentAlignment = Alignment.BottomCenter, + ) { + content.action?.let { action -> + OutlinedButton(onClick = action.onClick) { + Icon( + imageVector = action.icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(action.labelRes), + modifier = Modifier.padding(start = 8.dp), + ) } } } + } + } +} - CardScanUiState.Reading -> { - CircularWavyProgressIndicator() - Text( - text = stringResource(R.string.card_scan_reading), - style = MaterialTheme.typography.titleMedium, - ) - } +/** + * Fixes the element's height to [height], reading it in the layout phase. Used + * for animated heights so an animating value re-measures without recomposing the + * wrapped content each frame. Mirrors `Modifier.height` otherwise. + */ +private fun Modifier.animatedHeight(height: () -> Dp) = layout { measurable, constraints -> + val h = height().roundToPx().coerceAtLeast(0) + val placeable = measurable.measure(constraints.copy(minHeight = h, maxHeight = h)) + layout(placeable.width, h) { placeable.place(0, 0) } +} - is CardScanUiState.Success -> { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource(R.string.card_scan_success), - style = MaterialTheme.typography.titleMedium, - ) - } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ScanIndicator(indicator: Indicator) { + Box( + modifier = Modifier.size(IndicatorSize), + contentAlignment = Alignment.Center, + ) { + when (indicator) { + is Indicator.Static -> Icon( + imageVector = indicator.icon, + contentDescription = null, + modifier = Modifier.size(IndicatorSize), + tint = if (indicator.isError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + ) - is CardScanUiState.Failure -> { - Icon( - imageVector = Icons.Default.ErrorOutline, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.error, - ) - Text( - text = stringResource(state.reason.messageRes()), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, + Indicator.Loading -> CircularWavyProgressIndicator( + modifier = Modifier.size(IndicatorSize), + ) + } + } +} + +// Mirrors which states in [toContent] carry an action, without building the +// content — drives the reveal animation from the latest target state. +private fun CardScanUiState.hasAction(nfcEnabled: Boolean): Boolean = when (this) { + CardScanUiState.Ready -> !nfcEnabled + is CardScanUiState.Failure -> true + else -> false +} + +private fun CardScanUiState.toContent( + nfcEnabled: Boolean, + onRetry: () -> Unit, + onEnableNfc: () -> Unit, +): ScanContent = when (this) { + CardScanUiState.Ready -> if (nfcEnabled) ScanContent( + indicator = Indicator.Static(Icons.Default.Contactless), + titleRes = R.string.card_scan_ready_title, + descriptionRes = R.string.card_scan_ready_message, + ) else ScanContent( + indicator = Indicator.Static(Icons.Default.ErrorOutline, isError = true), + titleRes = R.string.card_scan_nfc_disabled_title, + descriptionRes = R.string.card_scan_nfc_disabled_message, + action = Action(R.string.card_scan_enable_nfc, Icons.Default.Settings, onEnableNfc), + ) + + CardScanUiState.Reading -> ScanContent( + indicator = Indicator.Loading, + titleRes = R.string.card_scan_reading, + descriptionRes = R.string.card_scan_reading_message, + ) + + is CardScanUiState.Success -> ScanContent( + indicator = Indicator.Static(Icons.Default.CheckCircle), + titleRes = R.string.card_scan_success, + ) + + is CardScanUiState.Failure -> ScanContent( + indicator = Indicator.Static(Icons.Default.ErrorOutline, isError = true), + titleRes = R.string.card_scan_error_title, + descriptionRes = reason.messageRes(), + action = Action(R.string.card_scan_retry, Icons.Default.Refresh, onRetry), + ) +} + +private class UserStateProvider : PreviewParameterProvider { + + data class State(val cardState: CardScanUiState, val nfcEnabled: Boolean = true) + + override val values = sequenceOf( + State(cardState = CardScanUiState.Ready, nfcEnabled = false), + State(cardState = CardScanUiState.Ready), + State(cardState = CardScanUiState.Reading), + State(cardState = CardScanUiState.Failure(CardReadFailure.NoReadableData)), + State( + cardState = CardScanUiState.Success( + Card( + holder = "", + cardNumber = "", + expiry = YearMonth.now() ) - TextButton(onClick = onRetry) { - Text(text = stringResource(R.string.card_scan_retry)) - } - } + ) + ), + ) +} + +@Preview +@Composable +private fun NfcInfoCardPreview( + @PreviewParameter(UserStateProvider::class) + state: UserStateProvider.State +) { + MaterialTheme { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + NfcInfoCard( + state = state.cardState, + nfcEnabled = state.nfcEnabled, + onRetry = {}, + ) } } } diff --git a/feature/credit-card/src/main/res/values/strings.xml b/feature/credit-card/src/main/res/values/strings.xml index 745922a9..614c804e 100644 --- a/feature/credit-card/src/main/res/values/strings.xml +++ b/feature/credit-card/src/main/res/values/strings.xml @@ -3,9 +3,12 @@ Ready to scan Hold your card flat against the back of the phone. Reading card… + Keep your card still until it\'s done. Card read + NFC is off NFC is turned off. Turn it on to scan your card. Turn on NFC + Couldn\'t read card Try again That doesn\'t look like a payment card. Card moved too soon — hold it steady and try again. From ffb457b9975bd2e29ccf95e8ffd3d87075cc93e1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 26 May 2026 18:15:21 +0200 Subject: [PATCH 18/36] feat(credit-card): Improve Card scan entry --- feature/credit-card/build.gradle.kts | 1 + .../credit_card/presentation/CardScanEntry.kt | 95 +++++++++++++++++++ .../src/main/res/values/strings.xml | 2 + .../creditcard/CreditCardContent.kt | 32 ++----- .../create/src/main/res/values/strings.xml | 1 - 5 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt diff --git a/feature/credit-card/build.gradle.kts b/feature/credit-card/build.gradle.kts index 05eb6873..d4318372 100644 --- a/feature/credit-card/build.gradle.kts +++ b/feature/credit-card/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.androidx.material3) api(projects.core.util) + implementation(projects.core.ui) // Koin DI implementation(project.dependencies.platform(libs.koin.bom)) diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt new file mode 100644 index 00000000..3c5fd499 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt @@ -0,0 +1,95 @@ +package de.davis.keygo.feature.credit_card.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Contactless +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.components.KeyGoCardProperties +import de.davis.keygo.feature.credit_card.R +import de.davis.keygo.feature.credit_card.domain.model.Card + +@Composable +fun CardScanEntry( + onCardRead: (Card) -> Unit, + modifier: Modifier = Modifier, +) { + // Hardware-presence check only; the sheet handles the NFC-disabled case live. + if (!rememberIsNfcAvailable()) return + + var showScanSheet by remember { mutableStateOf(false) } + + ScanCardPrompt(onClick = { showScanSheet = true }, modifier = modifier) + + if (showScanSheet) + CardScanBottomSheet( + onCardRead = onCardRead, + onDismiss = { showScanSheet = false }, + ) +} + +@Composable +private fun ScanCardPrompt( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + KeyGoCard( + modifier = modifier + .fillMaxWidth() + .clip(CardDefaults.elevatedShape) + .clickable(onClick = onClick), + properties = KeyGoCardProperties.elevated(), + leadingItem = { + Icon( + imageVector = Icons.Default.Contactless, + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + }, + title = { + Text( + text = stringResource(R.string.card_scan_entry_title), + style = MaterialTheme.typography.titleMedium, + ) + }, + trailingItem = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) { + Text( + text = stringResource(R.string.card_scan_entry_subtitle), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Preview +@Composable +private fun ScanCardPromptPreview() { + MaterialTheme { + Surface { + ScanCardPrompt(onClick = {}) + } + } +} diff --git a/feature/credit-card/src/main/res/values/strings.xml b/feature/credit-card/src/main/res/values/strings.xml index 614c804e..fdfd2457 100644 --- a/feature/credit-card/src/main/res/values/strings.xml +++ b/feature/credit-card/src/main/res/values/strings.xml @@ -14,4 +14,6 @@ Card moved too soon — hold it steady and try again. Couldn\'t read this card\'s data. Something went wrong reading the card. + Scan card + Fill details via NFC diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index 2615f9ec..15c45ccf 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -9,19 +9,13 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import de.davis.keygo.feature.credit_card.presentation.CardScanBottomSheet -import de.davis.keygo.feature.credit_card.presentation.rememberIsNfcAvailable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -29,6 +23,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.credit_card.presentation.CardScanEntry import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.component.gatherPendingItems @@ -64,7 +59,7 @@ internal fun CreditCardContent(state: CreditCardUiState, onEvent: (CreditCardUiE } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun CreditCardReadyContent( state: CreditCardBaseState, @@ -130,25 +125,16 @@ private fun CreditCardReadyContent( .imePadding() .nestedScroll(scrollBehavior.nestedScrollConnection), ) { + item(key = "cc_scan_entry") { + CardScanEntry( + onCardRead = { onEvent(CreditCardUiEvent.OnCardScanned(it)) }, + ) + } + item(key = "cc_information") { FormGroup( title = stringResource(R.string.cc_information), ) { - // Hardware-presence check only; the sheet handles the NFC-disabled case live. - val nfcAvailable = rememberIsNfcAvailable() - var showScanSheet by remember { mutableStateOf(false) } - - if (nfcAvailable) - TextButton(onClick = { showScanSheet = true }) { - Text(text = stringResource(R.string.cc_scan_card)) - } - - if (showScanSheet) - CardScanBottomSheet( - onCardRead = { onEvent(CreditCardUiEvent.OnCardScanned(it)) }, - onDismiss = { showScanSheet = false }, - ) - KeyGoFormField( state = state.ccHolderTextFieldState, label = { Text(text = stringResource(ItemCoreR.string.cc_holder)) } diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 95252d8b..73d07949 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -63,5 +63,4 @@ Vault item ID can not be found Vault - Scan card \ No newline at end of file From 22e87dd010f187c579dcb5833d153444fa3aea7f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 26 May 2026 19:11:42 +0200 Subject: [PATCH 19/36] feat(credit-card): Implement card persistence Co-Authored-By: Claude Opus 4.7 --- .../domain/model/CreditCardUpsertError.kt | 12 + .../item/core/domain/model/ItemUpsertError.kt | 18 + .../item/core/domain/model/LoginError.kt | 12 - .../core/domain/model/UpsertCreditCard.kt | 61 +++ .../item/core/domain/model/UpsertItem.kt | 11 + .../item/core/domain/model/UpsertLogin.kt | 10 +- .../CreateNewOrUpdateCreditCardUseCase.kt | 141 +++++++ .../usecase/CreateNewOrUpdateLoginUseCase.kt | 228 ++++------- .../usecase/CreateOrUpdateItemUseCase.kt | 151 ++++++++ .../CreateNewOrUpdateCreditCardUseCaseTest.kt | 355 ++++++++++++++++++ .../CreateNewOrUpdateLoginUseCaseTest.kt | 24 +- .../creditcard/CreditCardViewModel.kt | 79 +++- .../creditcard/model/CreditCardUiState.kt | 4 +- .../presentation/login/LoginViewModel.kt | 10 +- .../create/src/main/res/values/strings.xml | 1 + .../item/view/login/ViewLoginViewModel.kt | 6 +- 16 files changed, 924 insertions(+), 199 deletions(-) create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt delete mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/LoginError.kt create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt create mode 100644 feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt create mode 100644 feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt new file mode 100644 index 00000000..32c109ba --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.item.core.domain.model + +/** + * Credit-card-specific upsert errors, surfaced through the shared [ItemUpsertError] channel. + * + * Card-number format validation (length, Luhn) is intentionally not modelled here: the entry form + * gates a non-blank number, and a blank number is caught generically as [ItemUpsertError.Empty]. + */ +sealed interface CreditCardUpsertError : ItemUpsertError { + /** The expiration field could not be parsed as an `MM/yy` year-month. */ + data object InvalidExpiration : CreditCardUpsertError +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt new file mode 100644 index 00000000..e417bd8e --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.item.core.domain.model + +import de.davis.keygo.core.security.domain.model.CryptoScopeError + +/** + * Errors surfaced when creating or updating any vault item. Intentionally an open (non-sealed) + * interface so item-type-specific errors can contribute their own variants while sharing this + * common return channel. Call sites inspect it with `contains`/`is` checks rather than exhaustive + * `when`. + */ +interface ItemUpsertError { + data object BlankName : ItemUpsertError + data object Empty : ItemUpsertError + data object InvalidVaultId : ItemUpsertError + data object InvalidItemId : ItemUpsertError + data class CryptoError(val error: CryptoScopeError) : ItemUpsertError + data class DatabaseError(val throwable: Throwable) : ItemUpsertError +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/LoginError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/LoginError.kt deleted file mode 100644 index c2d72e2a..00000000 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/LoginError.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.davis.keygo.feature.item.core.domain.model - -import de.davis.keygo.core.security.domain.model.CryptoScopeError - -sealed interface LoginError { - data object BlankName : LoginError - data object EmptyLogin : LoginError - data object InvalidVaultId : LoginError - data object InvalidItemId : LoginError - data class CryptoError(val error: CryptoScopeError) : LoginError - data class DatabaseError(val throwable: Throwable) : LoginError -} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt new file mode 100644 index 00000000..4d82114c --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt @@ -0,0 +1,61 @@ +package de.davis.keygo.feature.item.core.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Tag + +@ConsistentCopyVisibility +data class UpsertCreditCard private constructor( + override val upsertType: UpsertType, + override val name: FieldUpdate, + override val note: FieldUpdate, + override val tags: FieldUpdate>, + val holder: FieldUpdate, + val cardNumber: FieldUpdate, + val cvv: FieldUpdate, + /** Raw `MM/yy` text; parsed to a `YearMonth` inside the use case. */ + val expirationDate: FieldUpdate, +) : UpsertItem { + companion object { + fun create( + vaultId: VaultId, + name: String, + cardNumber: String, + expirationDate: String, + holder: String? = null, + cvv: String? = null, + note: String? = null, + tags: Set = emptySet(), + ) = UpsertCreditCard( + upsertType = UpsertType.Create(vaultId), + name = FieldUpdate.Set(name), + cardNumber = FieldUpdate.Set(cardNumber), + expirationDate = FieldUpdate.Set(expirationDate), + holder = if (!holder.isNullOrBlank()) FieldUpdate.Set(holder) else FieldUpdate.Clear, + cvv = if (!cvv.isNullOrBlank()) FieldUpdate.Set(cvv) else FieldUpdate.Clear, + note = if (!note.isNullOrBlank()) FieldUpdate.Set(note) else FieldUpdate.Clear, + tags = if (tags.isNotEmpty()) FieldUpdate.Set(tags) else FieldUpdate.Clear, + ) + + fun update( + itemId: ItemId, + vaultId: VaultId? = null, + name: FieldUpdate = keep(), + cardNumber: FieldUpdate = keep(), + expirationDate: FieldUpdate = keep(), + holder: FieldUpdate = keep(), + cvv: FieldUpdate = keep(), + note: FieldUpdate = keep(), + tags: FieldUpdate> = keep(), + ) = UpsertCreditCard( + upsertType = UpsertType.Update(itemId, vaultId), + name = name, + cardNumber = cardNumber, + expirationDate = expirationDate, + holder = holder, + cvv = cvv, + note = note, + tags = tags, + ) + } +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt new file mode 100644 index 00000000..655f685b --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.item.core.domain.model + +import de.davis.keygo.core.item.domain.model.Tag + +/** Common surface every item-type upsert input shares. */ +interface UpsertItem { + val upsertType: UpsertType + val name: FieldUpdate + val tags: FieldUpdate> + val note: FieldUpdate +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt index 1e11a31f..328b76cc 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertLogin.kt @@ -7,16 +7,16 @@ import de.davis.keygo.core.item.domain.model.Tag @ConsistentCopyVisibility data class UpsertLogin private constructor( - val upsertType: UpsertType, - val name: FieldUpdate, + override val upsertType: UpsertType, + override val name: FieldUpdate, val password: FieldUpdate, val totpUriOrSecret: FieldUpdate, val username: FieldUpdate, val domains: FieldUpdate>, - val tags: FieldUpdate>, - val note: FieldUpdate, + override val tags: FieldUpdate>, + override val note: FieldUpdate, val hasPendingPasskey: Boolean = false, -) { +) : UpsertItem { companion object { fun create( vaultId: VaultId, diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt new file mode 100644 index 00000000..1b0ac122 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt @@ -0,0 +1,141 @@ +package de.davis.keygo.feature.item.core.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.encrypt +import de.davis.keygo.feature.item.core.domain.model.CreditCardUpsertError +import de.davis.keygo.feature.item.core.domain.model.FieldUpdate +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertCreditCard +import de.davis.keygo.feature.item.core.domain.model.UpsertType +import de.davis.keygo.feature.item.core.domain.model.getValue +import de.davis.keygo.feature.item.core.domain.model.on +import de.davis.keygo.feature.item.core.domain.model.onSet +import de.davis.keygo.feature.item.core.domain.model.withoutClearingOn +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.koin.core.annotation.Single +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +@Single +class CreateNewOrUpdateCreditCardUseCase( + cryptographicScopeProvider: CryptographicScopeProvider, + private val creditCardRepository: CreditCardRepository, + vaultRepository: VaultRepository, + upsertVaultItem: UpsertVaultItemUseCase, +) : CreateOrUpdateItemUseCase( + cryptographicScopeProvider = cryptographicScopeProvider, + vaultRepository = vaultRepository, + upsertVaultItem = upsertVaultItem, +) { + + override fun validate(upsert: UpsertCreditCard): Set { + val errors = mutableSetOf() + val allowKeep = upsert.upsertType is UpsertType.Update + + if (!isPresent(upsert.name, allowKeep)) + errors.add(ItemUpsertError.BlankName) + + if (!isValidExpiration(upsert.expirationDate, allowKeep)) + errors.add(CreditCardUpsertError.InvalidExpiration) + + return errors + } + + private fun isPresent(field: FieldUpdate, allowKeep: Boolean): Boolean = when (field) { + is FieldUpdate.Keep -> allowKeep + is FieldUpdate.Clear -> false + is FieldUpdate.Set -> field.value.isNotBlank() + } + + private fun isValidExpiration(field: FieldUpdate, allowKeep: Boolean): Boolean = + when (field) { + is FieldUpdate.Keep -> allowKeep + is FieldUpdate.Clear -> false + is FieldUpdate.Set -> field.value.toYearMonthOrNull() != null + } + + override suspend fun fetchExisting(id: ItemId): CreditCard? = + creditCardRepository.getCreditCardById(id) + + override fun isEmpty(item: CreditCard, upsert: UpsertCreditCard): Boolean = + item.lastNumbers.isBlank() + + override fun relocate( + item: CreditCard, + vaultId: VaultId, + keyInformation: KeyInformation, + ): CreditCard = item.copy(vaultId = vaultId, keyInformation = keyInformation) + + context(scope: CryptographicScope) + override suspend fun buildCreate( + upsert: UpsertCreditCard, + itemId: ItemId, + vaultId: VaultId, + keyInformation: KeyInformation, + ): CreditCard = coroutineScope { + val number = upsert.cardNumber.getValue().orEmpty() + val encryptedNumber = async { CreditCard.CardNumber.encrypt(number) } + val encryptedCvv = upsert.cvv.onSet { cvv -> async { CreditCard.CVV.encrypt(cvv) } } + + CreditCard( + id = itemId, + vaultId = vaultId, + name = upsert.name.getValue()!!, + keyInformation = keyInformation, + tags = upsert.tags.getValue().orEmpty(), + note = upsert.note.getValue(), + pinned = false, + holder = upsert.holder.getValue(), + lastNumbers = number.toLastNumbers(), + cardNumber = encryptedNumber.await(), + cvv = encryptedCvv?.await(), + expirationDate = upsert.expirationDate.getValue()!!.toYearMonthOrNull()!!, + ) + } + + context(scope: CryptographicScope) + override suspend fun buildUpdate( + upsert: UpsertCreditCard, + existing: CreditCard, + ): CreditCard = coroutineScope { + val encryptedNumber = upsert.cardNumber.onSet { num -> + async { CreditCard.CardNumber.encrypt(num) } + } + val encryptedCvv = upsert.cvv.onSet { cvv -> async { CreditCard.CVV.encrypt(cvv) } } + + existing.copy( + name = upsert.name.withoutClearingOn(existing.name), + note = upsert.note.on(existing.note), + tags = upsert.tags.on(existing.tags).orEmpty(), + holder = upsert.holder.on(existing.holder), + cardNumber = encryptedNumber?.await() ?: existing.cardNumber, + lastNumbers = upsert.cardNumber.getValue()?.toLastNumbers() ?: existing.lastNumbers, + cvv = upsert.cvv.on(existing.cvv, encryptedCvv), + expirationDate = upsert.expirationDate.getValue()?.toYearMonthOrNull() + ?: existing.expirationDate, + ) + } + + private fun String.toLastNumbers(): String = filter(Char::isDigit).takeLast(4) + + private fun String.toYearMonthOrNull(): YearMonth? = try { + YearMonth.parse(this, EXPIRATION_FORMATTER) + } catch (_: DateTimeParseException) { + null + } + + companion object { + // "yy" parses into the 2000-2099 range, which is correct for card expirations. + private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + } +} diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt index 782f4e33..e247bb7c 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt @@ -2,8 +2,8 @@ package de.davis.keygo.feature.item.core.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId -import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordSecret @@ -14,15 +14,9 @@ import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.encrypt -import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation -import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation -import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation -import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.fold -import de.davis.keygo.core.util.mapFailure -import de.davis.keygo.core.util.resultBinding import de.davis.keygo.feature.item.core.domain.model.FieldUpdate -import de.davis.keygo.feature.item.core.domain.model.LoginError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError import de.davis.keygo.feature.item.core.domain.model.UpsertLogin import de.davis.keygo.feature.item.core.domain.model.UpsertType import de.davis.keygo.feature.item.core.domain.model.getValue @@ -31,23 +25,24 @@ import de.davis.keygo.feature.item.core.domain.model.onSet import de.davis.keygo.feature.item.core.domain.model.withoutClearingOn import de.davis.keygo.rust.totp.TotpService import de.davis.keygo.rust.totp.getInfoFromUriWithResult -import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.koin.core.annotation.Single -import kotlin.contracts.ExperimentalContracts @Single class CreateNewOrUpdateLoginUseCase( - private val cryptographicScopeProvider: CryptographicScopeProvider, + cryptographicScopeProvider: CryptographicScopeProvider, private val loginRepository: LoginRepository, - private val vaultRepository: VaultRepository, - private val upsertVaultItem: UpsertVaultItemUseCase, + vaultRepository: VaultRepository, + upsertVaultItem: UpsertVaultItemUseCase, private val passwordStrengthEstimator: PasswordStrengthEstimator, private val totpService: TotpService, +) : CreateOrUpdateItemUseCase( + cryptographicScopeProvider = cryptographicScopeProvider, + vaultRepository = vaultRepository, + upsertVaultItem = upsertVaultItem, ) { - @OptIn(ExperimentalContracts::class) private fun isValid(field: FieldUpdate, allowKeep: Boolean = false): Boolean = when (field) { is FieldUpdate.Keep -> allowKeep @@ -55,166 +50,83 @@ class CreateNewOrUpdateLoginUseCase( is FieldUpdate.Set -> field.value.isNotBlank() } - private fun validate(upsert: UpsertLogin): Set { - val errors = mutableSetOf() + override fun validate(upsert: UpsertLogin): Set { + val errors = mutableSetOf() val allowKeep = upsert.upsertType is UpsertType.Update if (!isValid(field = upsert.name, allowKeep = allowKeep)) - errors.add(LoginError.BlankName) + errors.add(ItemUpsertError.BlankName) return errors } - suspend operator fun invoke(upsert: UpsertLogin): Result> { - val errors = validate(upsert) - if (errors.isNotEmpty()) return Result.Failure(errors) + override suspend fun fetchExisting(id: ItemId): Login? = loginRepository.getLoginById(id) - val updatedLogin = when (upsert.upsertType) { - is UpsertType.Create -> buildCreate(upsert, upsert.upsertType.vaultId) - is UpsertType.Update -> buildUpdate( - upsert = upsert, - id = upsert.upsertType.id, - targetVaultId = upsert.upsertType.targetVaultId, - ) - } - - return when (updatedLogin) { - is Result.Success -> upsertVaultItem(updatedLogin.success).mapFailure { - setOf(LoginError.DatabaseError(it)) - } + override fun isEmpty(item: Login, upsert: UpsertLogin): Boolean = + !item.hasAnyContent && !upsert.hasPendingPasskey - is Result.Failure -> Result.Failure(setOf(updatedLogin.error)) - } - } + override fun relocate(item: Login, vaultId: VaultId, keyInformation: KeyInformation): Login = + item.copy(vaultId = vaultId, keyInformation = keyInformation) - private suspend fun buildCreate( + context(scope: CryptographicScope) + override suspend fun buildCreate( upsert: UpsertLogin, + itemId: ItemId, vaultId: VaultId, - ): Result = resultBinding { - val itemId = newItemId() - - val vaultKeyInformation = vaultRepository.getKeyInformation(vaultId) - ?: return Result.Failure(LoginError.InvalidVaultId) - val aad = ItemAad(itemId = itemId, vaultId = vaultId) - - val built = cryptographicScopeProvider.itemScope( - wrappedVaultKeyInformation = WrappedVaultKeyInformation( - wrappedVaultKey = vaultKeyInformation, - vaultId = vaultId, - ), - wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), - ) { - coroutineScope { - val newPasswordCredential = when (val pw = upsert.password) { - FieldUpdate.Keep, - FieldUpdate.Clear -> null - - is FieldUpdate.Set -> { - val encrypted = async { PasswordSecret.encrypt(pw.value) } - val strength = async { passwordStrengthEstimator(pw.value) } - PasswordCredential( - secret = encrypted.await(), - score = strength.await(), - ) - } - } - val totp = upsert.totpUriOrSecret.onSet { uriOrSecret -> - async { uriOrSecret.convertTotpUriOrSecretToUri(itemId) } - } - - val wrappedItemKey = async { wrapCurrentItemKey() } - - Login( - id = itemId, - name = upsert.name.getValue()!!, - username = upsert.username.getValue(), - domainInfos = upsert.domains.getValue().orEmpty(), - tags = upsert.tags.getValue().orEmpty(), - passwordCredential = newPasswordCredential, - totp = totp?.await(), - note = upsert.note.getValue(), - pinned = false, - keyInformation = wrappedItemKey.await(), - vaultId = vaultId, - ) + keyInformation: KeyInformation, + ): Login = coroutineScope { + val newPasswordCredential = when (val pw = upsert.password) { + FieldUpdate.Keep, + FieldUpdate.Clear -> null + + is FieldUpdate.Set -> { + val encrypted = async { PasswordSecret.encrypt(pw.value) } + val strength = async { passwordStrengthEstimator(pw.value) } + PasswordCredential(secret = encrypted.await(), score = strength.await()) } - }.bind(LoginError::CryptoError) - - if (!built.hasAnyContent && !upsert.hasPendingPasskey) return Result.Failure(LoginError.EmptyLogin) - built - } - - private suspend fun buildUpdate( - upsert: UpsertLogin, - id: ItemId, - targetVaultId: VaultId?, - ): Result = resultBinding { - val existing = loginRepository.getLoginById(id) - ?: return Result.Failure(LoginError.InvalidItemId) + } + val totp = upsert.totpUriOrSecret.onSet { uriOrSecret -> + async { uriOrSecret.convertTotpUriOrSecretToUri(itemId) } + } - val sourceVaultKeyInfo = vaultRepository.getKeyInformation(existing.vaultId) - ?: return Result.Failure(LoginError.InvalidVaultId) - val sourceVault = WrappedVaultKeyInformation( - wrappedVaultKey = sourceVaultKeyInfo, - vaultId = existing.vaultId, + Login( + id = itemId, + name = upsert.name.getValue()!!, + username = upsert.username.getValue(), + domainInfos = upsert.domains.getValue().orEmpty(), + tags = upsert.tags.getValue().orEmpty(), + passwordCredential = newPasswordCredential, + totp = totp?.await(), + note = upsert.note.getValue(), + pinned = false, + keyInformation = keyInformation, + vaultId = vaultId, ) + } - val login = cryptographicScopeProvider.itemScope( - wrappedVaultKeyInformation = sourceVault, - wrappedItemKeyInformation = existing.wrappedItemKeyInformation(), - ) { - coroutineScope { - val newPasswordCredential = when (val pw = upsert.password) { - is FieldUpdate.Keep -> existing.passwordCredential - is FieldUpdate.Clear -> null - is FieldUpdate.Set -> { - val encrypted = async { PasswordSecret.encrypt(pw.value) } - val strength = async { passwordStrengthEstimator(pw.value) } - PasswordCredential( - secret = encrypted.await(), - score = strength.await(), - ) - } - } - val totp = upsert.totpUriOrSecret.onSet { uriOrSecret -> - async { uriOrSecret.convertTotpUriOrSecretToUri(existing.id) } - } - - existing.copy( - name = upsert.name.withoutClearingOn(existing.name), - username = upsert.username.on(existing.username), - domainInfos = upsert.domains.on(existing.domainInfos).orEmpty(), - tags = upsert.tags.on(existing.tags).orEmpty(), - passwordCredential = newPasswordCredential, - totp = upsert.totpUriOrSecret.on(existing.totp, totp), - note = upsert.note.on(existing.note), - ) + context(scope: CryptographicScope) + override suspend fun buildUpdate(upsert: UpsertLogin, existing: Login): Login = coroutineScope { + val newPasswordCredential = when (val pw = upsert.password) { + is FieldUpdate.Keep -> existing.passwordCredential + is FieldUpdate.Clear -> null + is FieldUpdate.Set -> { + val encrypted = async { PasswordSecret.encrypt(pw.value) } + val strength = async { passwordStrengthEstimator(pw.value) } + PasswordCredential(secret = encrypted.await(), score = strength.await()) } - }.bind(LoginError::CryptoError) - - if (!login.hasAnyContent) return Result.Failure(LoginError.EmptyLogin) - - if (targetVaultId == null || targetVaultId == existing.vaultId) - return@resultBinding login - - // Vault changed during edit: rewrap the item key under the destination vault. Encrypted - // secrets are bound only to the item id (see CryptographicScopeImpl.buildDataAad), so - // they remain valid under the same item key — no re-encryption needed. - val destinationVaultKeyInfo = vaultRepository.getKeyInformation(targetVaultId) - ?: return Result.Failure(LoginError.InvalidVaultId) - - val rewrapped = cryptographicScopeProvider.rewrapItemKey( - sourceVault = sourceVault, - sourceItem = existing.wrappedItemKeyInformation(), - destinationVault = WrappedVaultKeyInformation( - wrappedVaultKey = destinationVaultKeyInfo, - vaultId = targetVaultId, - ), - ).bind(LoginError::CryptoError) + } + val totp = upsert.totpUriOrSecret.onSet { uriOrSecret -> + async { uriOrSecret.convertTotpUriOrSecretToUri(existing.id) } + } - login.copy( - vaultId = targetVaultId, - keyInformation = rewrapped, + existing.copy( + name = upsert.name.withoutClearingOn(existing.name), + username = upsert.username.on(existing.username), + domainInfos = upsert.domains.on(existing.domainInfos).orEmpty(), + tags = upsert.tags.on(existing.tags).orEmpty(), + passwordCredential = newPasswordCredential, + totp = upsert.totpUriOrSecret.on(existing.totp, totp), + note = upsert.note.on(existing.note), ) } @@ -236,9 +148,9 @@ class CreateNewOrUpdateLoginUseCase( // not valid uri - treat it as secret Totp( loginId = itemId, - secret = Totp.Secret.encrypt(this@convertTotpUriOrSecretToUri) + secret = Totp.Secret.encrypt(this@convertTotpUriOrSecretToUri), ) - } + }, ) } } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt new file mode 100644 index 00000000..f147c448 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt @@ -0,0 +1,151 @@ +package de.davis.keygo.feature.item.core.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.mapFailure +import de.davis.keygo.core.util.resultBinding +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertItem +import de.davis.keygo.feature.item.core.domain.model.UpsertType +import de.davisalessandro.keygo.rust.ItemAad + +/** + * Shared create/update orchestration for every vault item type. Owns the scaffolding that is + * identical across types: name/field validation routing, crypto-scope provisioning, item-key + * wrapping on create, vault-move rewrap on update, persistence and error mapping. Subclasses + * supply only the type-specific seams (build + encrypt fields, fetch, emptiness, relocate). + * + * Secrets are bound to the item id, so moving an item between vaults rewraps only the item key + * and never re-encrypts payloads. + * + * @param U the type's upsert input + * @param I the persisted domain item + */ +abstract class CreateOrUpdateItemUseCase( + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val vaultRepository: VaultRepository, + private val upsertVaultItem: UpsertVaultItemUseCase, +) { + + /** Type-specific field validation. An empty set means valid. */ + protected abstract fun validate(upsert: U): Set + + /** Loads the persisted item for an update, or null when the id is unknown. */ + protected abstract suspend fun fetchExisting(id: ItemId): I? + + /** Builds a brand-new item inside [scope]; assign the supplied [keyInformation] to it. */ + context(scope: CryptographicScope) + protected abstract suspend fun buildCreate( + upsert: U, + itemId: ItemId, + vaultId: VaultId, + keyInformation: KeyInformation, + ): I + + /** Applies [upsert] onto [existing] inside [scope]. */ + context(scope: CryptographicScope) + protected abstract suspend fun buildUpdate(upsert: U, existing: I): I + + /** True when the built item has no meaningful content and should be rejected as [ItemUpsertError.Empty]. */ + protected open fun isEmpty(item: I, upsert: U): Boolean = false + + /** Returns a copy of [item] moved to [vaultId] with the re-wrapped [keyInformation]. */ + protected abstract fun relocate(item: I, vaultId: VaultId, keyInformation: KeyInformation): I + + suspend operator fun invoke(upsert: U): Result> { + val errors = validate(upsert) + if (errors.isNotEmpty()) return Result.Failure(errors) + + val built = when (val type = upsert.upsertType) { + is UpsertType.Create -> create(upsert, type.vaultId) + is UpsertType.Update -> update(upsert, type.id, type.targetVaultId) + } + + return when (built) { + is Result.Success -> upsertVaultItem(built.success).mapFailure { + setOf(ItemUpsertError.DatabaseError(it)) + } + + is Result.Failure -> Result.Failure(setOf(built.error)) + } + } + + private suspend fun create( + upsert: U, + vaultId: VaultId, + ): Result = resultBinding { + val itemId = newItemId() + + val vaultKeyInformation = vaultRepository.getKeyInformation(vaultId) + ?: return Result.Failure(ItemUpsertError.InvalidVaultId) + val aad = ItemAad(itemId = itemId, vaultId = vaultId) + + val item = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vaultKeyInformation, + vaultId = vaultId, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + buildCreate(upsert, itemId, vaultId, wrapCurrentItemKey()) + }.bind(ItemUpsertError::CryptoError) + + if (isEmpty(item, upsert)) return Result.Failure(ItemUpsertError.Empty) + item + } + + private suspend fun update( + upsert: U, + id: ItemId, + targetVaultId: VaultId?, + ): Result = resultBinding { + val existing = fetchExisting(id) + ?: return Result.Failure(ItemUpsertError.InvalidItemId) + + val sourceVaultKeyInfo = vaultRepository.getKeyInformation(existing.vaultId) + ?: return Result.Failure(ItemUpsertError.InvalidVaultId) + val sourceVault = WrappedVaultKeyInformation( + wrappedVaultKey = sourceVaultKeyInfo, + vaultId = existing.vaultId, + ) + + val item = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = sourceVault, + wrappedItemKeyInformation = existing.wrappedItemKeyInformation(), + ) { + buildUpdate(upsert, existing) + }.bind(ItemUpsertError::CryptoError) + + if (isEmpty(item, upsert)) return Result.Failure(ItemUpsertError.Empty) + + if (targetVaultId == null || targetVaultId == existing.vaultId) + return@resultBinding item + + // Vault changed during edit: rewrap the item key under the destination vault. Encrypted + // secrets are bound only to the item id, so they remain valid under the same item key. + val destinationVaultKeyInfo = vaultRepository.getKeyInformation(targetVaultId) + ?: return Result.Failure(ItemUpsertError.InvalidVaultId) + + val rewrapped = cryptographicScopeProvider.rewrapItemKey( + sourceVault = sourceVault, + sourceItem = existing.wrappedItemKeyInformation(), + destinationVault = WrappedVaultKeyInformation( + wrappedVaultKey = destinationVaultKeyInfo, + vaultId = targetVaultId, + ), + ).bind(ItemUpsertError::CryptoError) + + relocate(item, targetVaultId, rewrapped) + } +} diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt new file mode 100644 index 00000000..bc491fc2 --- /dev/null +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt @@ -0,0 +1,355 @@ +package de.davis.keygo.feature.item.core.domain.usecase + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.feature.item.core.domain.model.CreditCardUpsertError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertCreditCard +import de.davis.keygo.feature.item.core.domain.model.clear +import de.davis.keygo.feature.item.core.domain.model.set +import kotlinx.coroutines.test.runTest +import java.time.YearMonth +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CreateNewOrUpdateCreditCardUseCaseTest { + + private val defaultVault = Vault( + id = newVaultId(), + name = "Default vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) + + private val vaultRepository = FakeVaultRepository() + private val creditCardRepository = FakeCreditCardRepository() + + private val cryptoProvider = + FakeCryptographicScopeProvider(FakeItemRepository(FakeLoginRepository())) + private val useCase = makeUseCase() + + @BeforeTest + fun setupVault() = runTest { + vaultRepository.seed(defaultVault) + } + + @Test + fun `create with blank name returns BlankName error`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "", + cardNumber = "4111111111111111", + expirationDate = "05/30", + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, ItemUpsertError.BlankName) + } + + @Test + fun `create with unparseable expiration returns InvalidExpiration error`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "4111111111111111", + expirationDate = "not-a-date", + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, CreditCardUpsertError.InvalidExpiration) + } + + @Test + fun `create stores card with name parsed expiration and derived last numbers`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "4111111111111111", + expirationDate = "05/30", + holder = "ALICE SMITH", + ) + ) + + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + assertEquals("My card", stored.name) + assertEquals("ALICE SMITH", stored.holder) + assertEquals(YearMonth.of(2030, 5), stored.expirationDate) + assertEquals("1111", stored.lastNumbers) + assertEquals(defaultVault.id, stored.vaultId) + } + + @Test + fun `create without cvv stores null cvv`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "4111111111111111", + expirationDate = "05/30", + cvv = null, + ) + ) + + assertNull(storedById(result.getOrNull())?.cvv) + } + + @Test + fun `create routes card number and cvv through the crypto scope`() = runTest { + val plaintextNumber = "4111111111111111" + val plaintextCvv = "123" + + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = plaintextNumber, + expirationDate = "05/30", + cvv = plaintextCvv, + ) + ) + + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + + val labels = cryptoProvider.encryptCalls.map { it.label } + assertContains(labels, CreditCard.CardNumber.label) + assertContains(labels, CreditCard.CVV.label) + + val numberCall = cryptoProvider.encryptCalls.single { it.label == CreditCard.CardNumber.label } + assertContentEquals(plaintextNumber.encodeToByteArray(), numberCall.plaintext) + + assertFalse(stored.cardNumber.payload.ciphertext.contentEquals(plaintextNumber.encodeToByteArray())) + assertContentEquals( + FakeCryptographicScopeProvider.transform(plaintextNumber.encodeToByteArray()), + stored.cardNumber.payload.ciphertext, + ) + assertContentEquals(FakeCryptographicScopeProvider.IV, stored.cardNumber.payload.iv) + } + + @Test + fun `repository failure on create wraps error in DatabaseError`() = runTest { + creditCardRepository.createOrUpdateError = RuntimeException("disk full") + + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "4111111111111111", + expirationDate = "05/30", + ) + ) + + assertTrue(result.isFailure()) + val error = assertIs(result.error.single()) + assertEquals("disk full", error.throwable.message) + } + + @Test + fun `update with unknown id returns InvalidItemId error`() = runTest { + val result = useCase(UpsertCreditCard.update(itemId = newItemId())) + + assertTrue(result.isFailure()) + assertContains(result.error, ItemUpsertError.InvalidItemId) + } + + @Test + fun `update with new name replaces name and keeps other fields`() = runTest { + val existing = testCard(name = "Old name") + creditCardRepository.seed(existing) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, name = set("New name"))) + + assertTrue(result.isSuccess(), "result: $result") + val stored = creditCardRepository.getCreditCardById(existing.id) + assertEquals("New name", stored?.name) + assertEquals(existing.lastNumbers, stored?.lastNumbers) + } + + @Test + fun `update with new card number recomputes last numbers`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + useCase(UpsertCreditCard.update(itemId = existing.id, cardNumber = set("5555444433332222"))) + + assertEquals("2222", creditCardRepository.getCreditCardById(existing.id)?.lastNumbers) + } + + @Test + fun `update clearing cvv sets cvv to null`() = runTest { + val existing = testCard(cvv = CreditCard.CVV(EncryptedPayload.EMPTY)) + creditCardRepository.seed(existing) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, cvv = clear())) + + assertTrue(result.isSuccess(), "result: $result") + assertNull(creditCardRepository.getCreditCardById(existing.id)?.cvv) + } + + @Test + fun `update with Keep on cvv preserves existing cvv unchanged`() = runTest { + val existingPayload = EncryptedPayload( + ciphertext = byteArrayOf(0x01, 0x02, 0x03), + iv = byteArrayOf(0x04, 0x05, 0x06), + ) + val existing = testCard(cvv = CreditCard.CVV(existingPayload)) + creditCardRepository.seed(existing) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, name = set("x"))) + + assertTrue(result.isSuccess(), "result: $result") + val stored = creditCardRepository.getCreditCardById(existing.id) + assertNotNull(stored) + val storedCvv = stored.cvv + assertNotNull(storedCvv) + assertContentEquals(existingPayload.ciphertext, storedCvv.payload.ciphertext) + assertContentEquals(existingPayload.iv, storedCvv.payload.iv) + } + + @Test + fun `update with new card number routes the new number through the crypto scope`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + val plaintextNumber = "5555444433332222" + val result = useCase(UpsertCreditCard.update(itemId = existing.id, cardNumber = set(plaintextNumber))) + + assertTrue(result.isSuccess(), "result: $result") + + val numberCall = cryptoProvider.encryptCalls.single { it.label == CreditCard.CardNumber.label } + assertContentEquals(plaintextNumber.encodeToByteArray(), numberCall.plaintext) + + val stored = creditCardRepository.getCreditCardById(existing.id) + assertNotNull(stored) + assertContentEquals( + FakeCryptographicScopeProvider.transform(plaintextNumber.encodeToByteArray()), + stored.cardNumber.payload.ciphertext, + ) + assertContentEquals(FakeCryptographicScopeProvider.IV, stored.cardNumber.payload.iv) + } + + @Test + fun `update with blank Set name returns BlankName error`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, name = set(""))) + + assertTrue(result.isFailure()) + assertContains(result.error, ItemUpsertError.BlankName) + } + + @Test + fun `create with invalid month expiration returns InvalidExpiration error`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "4111111111111111", + expirationDate = "13/30", + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, CreditCardUpsertError.InvalidExpiration) + } + + @Test + fun `update with same vaultId does not rewrap the item key`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + useCase(UpsertCreditCard.update(itemId = existing.id, vaultId = defaultVault.id, name = set("y"))) + + assertTrue(cryptoProvider.rewrapCalls.isEmpty()) + } + + @Test + fun `update with different vaultId rewraps item key under the destination vault`() = runTest { + val otherVault = Vault( + id = newVaultId(), + name = "Other vault", + keyInformation = KeyInformation(wrappedKey = byteArrayOf(0x0A), keyNonce = byteArrayOf(0x0B)), + icon = Vault.Icon.Default, + ) + vaultRepository.seed(otherVault) + + val existing = testCard().copy( + keyInformation = KeyInformation(wrappedKey = byteArrayOf(0x11), keyNonce = byteArrayOf(0x33)), + ) + creditCardRepository.seed(existing) + + val rewrapped = KeyInformation(wrappedKey = byteArrayOf(0xAA.toByte()), keyNonce = byteArrayOf(0xCC.toByte())) + cryptoProvider.rewrapResult = Result.Success(rewrapped) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, vaultId = otherVault.id)) + + assertTrue(result.isSuccess(), "result: $result") + val stored = creditCardRepository.getCreditCardById(existing.id) + assertNotNull(stored) + assertEquals(otherVault.id, stored.vaultId) + assertContentEquals(rewrapped.wrappedKey, stored.keyInformation.wrappedKey) + } + + private fun makeUseCase( + cryptographicScopeProvider: CryptographicScopeProvider = cryptoProvider, + ) = CreateNewOrUpdateCreditCardUseCase( + cryptographicScopeProvider = cryptographicScopeProvider, + creditCardRepository = creditCardRepository, + vaultRepository = vaultRepository, + upsertVaultItem = UpsertVaultItemUseCase(FakeLoginRepository(), creditCardRepository), + ) + + private fun testCard( + name: String = "Test card", + cvv: CreditCard.CVV? = null, + ) = CreditCard( + id = newItemId(), + vaultId = defaultVault.id, + name = name, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = "Test Holder", + lastNumbers = "1111", + cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv = cvv, + expirationDate = YearMonth.of(2030, 5), + ) + + private suspend fun storedById(id: ItemId?): CreditCard? { + id ?: return null + return creditCardRepository.getCreditCardById(id) + } +} diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt index a6e1807f..766a05f6 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt @@ -23,7 +23,7 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import de.davis.keygo.feature.item.core.domain.model.LoginError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError import de.davis.keygo.feature.item.core.domain.model.UpsertLogin import de.davis.keygo.feature.item.core.domain.model.clear import de.davis.keygo.feature.item.core.domain.model.set @@ -73,7 +73,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertEquals(LoginError.BlankName, result.error.single()) + assertEquals(ItemUpsertError.BlankName, result.error.single()) } @Test @@ -83,11 +83,11 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertEquals(LoginError.BlankName, result.error.single()) + assertEquals(ItemUpsertError.BlankName, result.error.single()) } @Test - fun `create with no password no totp no username returns EmptyLogin`() = runTest { + fun `create with no password no totp no username returns Empty`() = runTest { val result = useCase( UpsertLogin.create( vaultId = defaultVault.id, @@ -99,7 +99,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertEquals(setOf(LoginError.EmptyLogin), result.error) + assertEquals(setOf(ItemUpsertError.Empty), result.error) } @Test @@ -160,7 +160,7 @@ class CreateNewOrUpdateLoginUseCaseTest { val result = useCase(UpsertLogin.update(itemId = existing.id, name = clear())) assertTrue(result.isFailure()) - assertEquals(LoginError.BlankName, result.error.single()) + assertEquals(ItemUpsertError.BlankName, result.error.single()) } @Test @@ -171,7 +171,7 @@ class CreateNewOrUpdateLoginUseCaseTest { val result = useCase(UpsertLogin.update(itemId = existing.id, name = set(""))) assertTrue(result.isFailure()) - assertEquals(LoginError.BlankName, result.error.single()) + assertEquals(ItemUpsertError.BlankName, result.error.single()) } @Test @@ -196,14 +196,14 @@ class CreateNewOrUpdateLoginUseCaseTest { } @Test - fun `update clearing the only credential returns EmptyLogin`() = runTest { + fun `update clearing the only credential returns Empty`() = runTest { val existing = testLogin(username = null, totp = null) loginRepository.seed(existing) val result = useCase(UpsertLogin.update(itemId = existing.id, password = clear())) assertTrue(result.isFailure()) - assertEquals(setOf(LoginError.EmptyLogin), result.error) + assertEquals(setOf(ItemUpsertError.Empty), result.error) } // Success — Create @@ -357,7 +357,7 @@ class CreateNewOrUpdateLoginUseCaseTest { val result = useCase(UpsertLogin.update(itemId = newItemId())) assertTrue(result.isFailure()) - assertContains(result.error, LoginError.InvalidItemId) + assertContains(result.error, ItemUpsertError.InvalidItemId) } // Vault move on update @@ -408,7 +408,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertContains(result.error, LoginError.InvalidVaultId) + assertContains(result.error, ItemUpsertError.InvalidVaultId) } @Test @@ -556,7 +556,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - val error = assertIs(result.error.single()) + val error = assertIs(result.error.single()) assertEquals(cause, error.throwable) } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index f3d1f142..e4f82e4a 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -11,11 +11,25 @@ import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.security.domain.crypto.decrypt import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage +import de.davis.keygo.core.util.domain.snackbar.SnackbarManager +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString import de.davis.keygo.feature.credit_card.domain.model.Card +import de.davis.keygo.feature.item.core.domain.model.CreditCardUpsertError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertCreditCard +import de.davis.keygo.feature.item.core.domain.model.fieldUpdate +import de.davis.keygo.feature.item.core.domain.model.set +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation +import de.davis.keygo.feature.item.core.presentation.model.InputFieldError +import de.davis.keygo.feature.item.create.R import de.davis.keygo.feature.item.create.presentation.ItemViewModel import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardUiEvent +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -28,6 +42,8 @@ import org.koin.core.annotation.KoinViewModel internal class CreditCardViewModel( private val itemWithCryptoScope: ItemWithCryptoScopeUseCase, private val creditCardRepository: CreditCardRepository, + private val createNewOrUpdateCreditCard: CreateNewOrUpdateCreditCardUseCase, + private val snackbarManager: SnackbarManager, vaultContextRepository: VaultContextRepository, itemRepository: ItemRepository, observeAllTags: ObserveAllTagsSortedUseCase, @@ -95,9 +111,66 @@ internal class CreditCardViewModel( } override fun onSubmit() { - // TODO: persist the credit card once a CreateNewOrUpdateCreditCard use case exists. - // Building + encrypting the CreditCard here would put crypto/business logic in the - // ViewModel; the architecture keeps that in a use case (see CreateNewOrUpdateLoginUseCase). + val ready = state.value as? ItemUiState.Ready ?: return + val assignedTags = ready.shared.itemAssignedTags + val selectedVaultId = ready.shared.vaultsState.selectedVaultId + viewModelScope.launch { + val upsert = itemId?.let { id -> + UpsertCreditCard.update( + itemId = id, + vaultId = selectedVaultId, + name = fieldUpdate(nameTextFieldState.text.toString()), + cardNumber = fieldUpdate(ccNumberTextFieldState.text.toString()), + cvv = fieldUpdate(ccCVVTextFieldState.text.toString()), + expirationDate = fieldUpdate(ccExpirationDateTextFieldState.text.toString()), + holder = fieldUpdate(ccHolderTextFieldState.text.toString()), + note = fieldUpdate(notesTextFieldState.text.toString()), + tags = set(assignedTags), + ) + } ?: UpsertCreditCard.create( + vaultId = selectedVaultId, + name = nameTextFieldState.text.toString(), + cardNumber = ccNumberTextFieldState.text.toString(), + expirationDate = ccExpirationDateTextFieldState.text.toString(), + holder = ccHolderTextFieldState.text.toString(), + cvv = ccCVVTextFieldState.text.toString(), + note = notesTextFieldState.text.toString(), + tags = assignedTags, + ) + + createNewOrUpdateCreditCard(upsert).onSuccess { + navigateUp(it) + }.onFailure { failure -> + _base.update { + it.copy( + numberError = if (failure.contains(ItemUpsertError.Empty)) InputFieldError.Empty else null, + ) + } + + if (failure.any { it is ItemUpsertError.InvalidVaultId }) + snackbarManager.sendMessage( + message = SnackbarMessage(message = ResourceString(R.string.invalid_vault_id)), + ) + + if (failure.contains(CreditCardUpsertError.InvalidExpiration)) + snackbarManager.sendMessage( + message = SnackbarMessage(message = ResourceString(R.string.cc_invalid_expiration)), + ) + + failure.filterIsInstance() + .firstOrNull() + ?.let { dbError -> + snackbarManager.sendMessage( + message = SnackbarMessage( + message = ResourceString( + R.string.database_error, + dbError.throwable.message ?: "no message", + ), + ), + ) + } + } + } } fun onEvent(event: CreditCardUiEvent) { diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index 9807c327..c22348dc 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -17,5 +17,7 @@ internal data class CreditCardBaseState( val updating: Boolean = false, ) { fun canSave(name: CharSequence): Boolean = - name.isNotBlank() && ccNumberTextFieldState.text.isNotBlank() + name.isNotBlank() && + ccNumberTextFieldState.text.isNotBlank() && + ccExpirationDateTextFieldState.text.isNotBlank() } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index b142d32c..578b7f6a 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -23,7 +23,7 @@ import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.core.util.presentation.UIText.Companion.ResourceString -import de.davis.keygo.feature.item.core.domain.model.LoginError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError import de.davis.keygo.feature.item.core.domain.model.UpsertLogin import de.davis.keygo.feature.item.core.domain.model.fieldUpdate import de.davis.keygo.feature.item.core.domain.model.resolveTotpDomain @@ -269,11 +269,11 @@ internal class LoginViewModel( }.onFailure { failure -> _base.update { it.copy( - nameError = if (failure.contains(LoginError.BlankName)) InputFieldError.Empty else null, + nameError = if (failure.contains(ItemUpsertError.BlankName)) InputFieldError.Empty else null, ) } - if (failure.any { it is LoginError.InvalidVaultId }) { + if (failure.any { it is ItemUpsertError.InvalidVaultId }) { snackbarManager.sendMessage( message = SnackbarMessage( message = ResourceString(R.string.invalid_vault_id), @@ -281,8 +281,8 @@ internal class LoginViewModel( ) } - if (failure.any { it is LoginError.DatabaseError }) { - failure.filterIsInstance() + if (failure.any { it is ItemUpsertError.DatabaseError }) { + failure.filterIsInstance() .first() .let { dbError -> snackbarManager.sendMessage( diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 73d07949..d7cb753a 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -61,6 +61,7 @@ An unexpected database error has occurred: %s Vault item ID can not be found + Enter a valid expiration date (MM/YY) Vault \ No newline at end of file diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index 285eccc2..4594e929 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -18,7 +18,7 @@ import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess -import de.davis.keygo.feature.item.core.domain.model.LoginError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError import de.davis.keygo.feature.item.core.domain.model.UpsertLogin import de.davis.keygo.feature.item.core.domain.model.fieldUpdate import de.davis.keygo.feature.item.core.domain.model.onSet @@ -306,8 +306,8 @@ internal class ViewLoginViewModel( ).onFailure { failure -> _modificationDialogState.update { dialog.copy( - error = if (failure.contains(LoginError.EmptyLogin) - || failure.contains(LoginError.BlankName) + error = if (failure.contains(ItemUpsertError.Empty) + || failure.contains(ItemUpsertError.BlankName) ) InputFieldError.Empty else null, ) } From 9c557bcf1887ce59cdf831c6ae7b48fbc91391ac Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 27 May 2026 15:56:00 +0200 Subject: [PATCH 20/36] fix(database): Allow nullable fields for cc --- .../1.json | 18 +++----- .../data/local/entity/CreditCardEntity.kt | 6 +-- .../core/item/data/mapper/CreditCardMapper.kt | 4 +- .../core/item/domain/model/CreditCard.kt | 13 ++++-- .../item/data/mapper/CreditCardMapperTest.kt | 44 +++++++++++++++---- .../creditcard/CreditCardViewModel.kt | 8 ++-- 6 files changed, 62 insertions(+), 31 deletions(-) diff --git a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json index e8ac3099..0351c8fd 100644 --- a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json +++ b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "acbc1bdd05d20fe72364cc0774dbd1b7", + "identityHash": "277f0df15cf3c39f8a3683c868c6303d", "entities": [ { "tableName": "vault", @@ -173,7 +173,7 @@ }, { "tableName": "credit_card", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `holder` TEXT, `last_numbers` TEXT NOT NULL, `expiration_date` INTEGER NOT NULL, `card_number_ciphertext` BLOB NOT NULL, `card_number_iv` BLOB NOT NULL, `cvv_ciphertext` BLOB, `cvv_iv` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `holder` TEXT, `last_numbers` TEXT, `expiration_date` INTEGER, `card_number_ciphertext` BLOB, `card_number_iv` BLOB, `cvv_ciphertext` BLOB, `cvv_iv` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -189,26 +189,22 @@ { "fieldPath": "lastNumbers", "columnName": "last_numbers", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "expirationDate", "columnName": "expiration_date", - "affinity": "INTEGER", - "notNull": true + "affinity": "INTEGER" }, { "fieldPath": "cardNumber.ciphertext", "columnName": "card_number_ciphertext", - "affinity": "BLOB", - "notNull": true + "affinity": "BLOB" }, { "fieldPath": "cardNumber.iv", "columnName": "card_number_iv", - "affinity": "BLOB", - "notNull": true + "affinity": "BLOB" }, { "fieldPath": "cvv.ciphertext", @@ -606,7 +602,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'acbc1bdd05d20fe72364cc0774dbd1b7')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '277f0df15cf3c39f8a3683c868c6303d')" ] } } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt index 96ee9ec5..de8a22bd 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt @@ -25,11 +25,11 @@ internal data class CreditCardEntity( val id: ItemId, val holder: String?, @Embedded(prefix = "card_number_") - val cardNumber: EncryptedPayload, + val cardNumber: EncryptedPayload?, @ColumnInfo(name = "last_numbers") - val lastNumbers: String, + val lastNumbers: String?, @Embedded(prefix = "cvv_") val cvv: EncryptedPayload?, @ColumnInfo(name = "expiration_date") - val expirationDate: YearMonth + val expirationDate: YearMonth? ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt index 4267ce8e..4cb190a2 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt @@ -8,7 +8,7 @@ import de.davis.keygo.core.item.domain.model.CreditCard internal fun CreditCard.toCreditCardEntity() = CreditCardEntity( id = id, holder = holder, - cardNumber = cardNumber.payload, + cardNumber = cardNumber?.payload, lastNumbers = lastNumbers, cvv = cvv?.payload, expirationDate = expirationDate @@ -24,7 +24,7 @@ internal fun CreditCardProjection.toDomain() = CreditCard( pinned = item.itemEntity.pinned, holder = creditCardEntity.holder, - cardNumber = CreditCard.CardNumber(creditCardEntity.cardNumber), + cardNumber = creditCardEntity.cardNumber?.let { CreditCard.CardNumber(it) }, lastNumbers = creditCardEntity.lastNumbers, cvv = creditCardEntity.cvv?.let { CreditCard.CVV(it) }, expirationDate = creditCardEntity.expirationDate, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt index 313a0e53..ef64cb80 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt @@ -16,14 +16,21 @@ data class CreditCard( override val note: String?, override val pinned: Boolean, val holder: String?, - val lastNumbers: String, - val cardNumber: CardNumber, + val lastNumbers: String?, + val cardNumber: CardNumber?, val cvv: CVV?, - val expirationDate: YearMonth, + val expirationDate: YearMonth?, ) : Item { override val itemType: VaultItemType get() = VaultItemType.CreditCard + + val hasAnyContent: Boolean + get() = !holder.isNullOrBlank() + || (cardNumber != null && !lastNumbers.isNullOrBlank()) + || cvv != null + || expirationDate != null + data class CardNumber( override val payload: EncryptedPayload, ) : SecretField { diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt index ca18775f..d23d5815 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt @@ -16,6 +16,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import java.time.YearMonth import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation class CreditCardMapperTest { @@ -67,12 +68,37 @@ class CreditCardMapperTest { assertEquals(true, card.pinned) assertEquals("Alice", card.holder) assertEquals("4242", card.lastNumbers) - assertEquals(cardNumber, card.cardNumber.payload) + assertEquals(cardNumber, card.cardNumber?.payload) assertEquals(cvv, card.cvv?.payload) assertEquals(YearMonth.of(2030, 12), card.expirationDate) assertEquals(VaultItemType.CreditCard, card.itemType) } + @Test + fun `toCreditCardEntity with null cardNumber stores null payload`() { + val entity = baseCard(cardNumber = null).toCreditCardEntity() + assertEquals(null, entity.cardNumber) + assertEquals(null, entity.lastNumbers) + } + + @Test + fun `toCreditCardEntity with null expirationDate stores null`() { + val entity = baseCard(expirationDate = null).toCreditCardEntity() + assertEquals(null, entity.expirationDate) + } + + @Test + fun `toDomain with null cardNumber in entity maps to null CardNumber`() { + val projection = baseProjection(cardNumber = null) + assertNull(projection.toDomain().cardNumber) + } + + @Test + fun `toDomain with null expirationDate in entity maps to null`() { + val projection = baseProjection(expirationDate = null) + assertNull(projection.toDomain().expirationDate) + } + @Test fun `toDomain maps tag entity values to domain tags`() { val projection = baseProjection( @@ -90,8 +116,9 @@ class CreditCardMapperTest { private fun baseCard( id: ItemId = newItemId(), holder: String? = "Alice", - cardNumber: CreditCard.CardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), - cvv: CreditCard.CVV = CreditCard.CVV(EncryptedPayload.EMPTY), + cardNumber: CreditCard.CardNumber? = CreditCard.CardNumber(EncryptedPayload.EMPTY), + cvv: CreditCard.CVV? = CreditCard.CVV(EncryptedPayload.EMPTY), + expirationDate: YearMonth? = YearMonth.of(2030, 12), ): CreditCard = CreditCard( id = id, vaultId = newVaultId(), @@ -101,26 +128,27 @@ class CreditCardMapperTest { note = null, pinned = false, holder = holder, - lastNumbers = "4242", + lastNumbers = cardNumber?.let { "4242" }, cardNumber = cardNumber, cvv = cvv, - expirationDate = YearMonth.of(2030, 12), + expirationDate = expirationDate, ) private fun baseProjection( id: ItemId = newItemId(), vaultId: de.davis.keygo.core.item.domain.alias.VaultId = newVaultId(), - cardNumber: EncryptedPayload = EncryptedPayload.EMPTY, + cardNumber: EncryptedPayload? = EncryptedPayload.EMPTY, cvv: EncryptedPayload = EncryptedPayload.EMPTY, + expirationDate: YearMonth? = YearMonth.of(2030, 12), tags: Set = emptySet(), ): CreditCardProjection = CreditCardProjection( creditCardEntity = CreditCardEntity( id = id, holder = "Alice", cardNumber = cardNumber, - lastNumbers = "4242", + lastNumbers = cardNumber?.let { "4242" }, cvv = cvv, - expirationDate = YearMonth.of(2030, 12), + expirationDate = expirationDate, ), item = ItemProjection( itemEntity = ItemEntity( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index e4f82e4a..07ec726c 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -90,19 +90,19 @@ internal class CreditCardViewModel( fetch = creditCardRepository::getCreditCardById, ) { card -> val (number, cvv) = coroutineScope { - val number = async { card.cardNumber.decrypt() } + val number = card.cardNumber?.let { number -> async { number.decrypt() } } val cvv = card.cvv?.let { secret -> async { secret.decrypt() } } - number.await() to cvv?.await() + number?.await() to cvv?.await() } nameTextFieldState.setTextAndPlaceCursorAtEnd(card.name) notesTextFieldState.setTextAndPlaceCursorAtEnd(card.note ?: "") ccHolderTextFieldState.setTextAndPlaceCursorAtEnd(card.holder ?: "") // Set the number before the CVV so the network — and thus the CVV cap — is known. - ccNumberTextFieldState.setTextAndPlaceCursorAtEnd(number) + ccNumberTextFieldState.setTextAndPlaceCursorAtEnd(number ?: "") ccCVVTextFieldState.setTextAndPlaceCursorAtEnd(cvv ?: "") ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd( - card.expirationDate.format(CC_EXPIRATION_FORMATTER), + card.expirationDate?.format(CC_EXPIRATION_FORMATTER) ?: "", ) setSelectedVaultId(card.vaultId) setAssignedTags(card.tags) From 429590a243a3c24f8220abcabc6a0b8dfbe26547 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 27 May 2026 15:56:57 +0200 Subject: [PATCH 21/36] fix(credit-card): Improve UI/UX --- .../domain/model/CreditCardUpsertError.kt | 7 +- .../item/core/domain/model/ItemUpsertError.kt | 6 +- .../core/domain/model/UpsertCreditCard.kt | 9 ++- .../item/core/domain/model/UpsertItem.kt | 1 - .../CreateNewOrUpdateCreditCardUseCase.kt | 52 +++++++++----- .../usecase/CreateNewOrUpdateLoginUseCase.kt | 9 +-- .../usecase/CreateOrUpdateItemUseCase.kt | 10 ++- .../presentation/component/KeyGoFormField.kt | 1 + .../presentation/model/InputFieldError.kt | 1 + .../item/core/src/main/res/values/strings.xml | 1 + .../CreateNewOrUpdateCreditCardUseCaseTest.kt | 70 +++++++++++++++++-- .../creditcard/CreditCardContent.kt | 2 + .../creditcard/CreditCardViewModel.kt | 15 ++-- .../creditcard/model/CreditCardUiState.kt | 12 +++- .../presentation/login/LoginViewModel.kt | 24 +++---- .../create/src/main/res/values/strings.xml | 1 - 16 files changed, 149 insertions(+), 72 deletions(-) diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt index 32c109ba..8863c55e 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt @@ -1,12 +1,9 @@ package de.davis.keygo.feature.item.core.domain.model /** - * Credit-card-specific upsert errors, surfaced through the shared [ItemUpsertError] channel. - * - * Card-number format validation (length, Luhn) is intentionally not modelled here: the entry form - * gates a non-blank number, and a blank number is caught generically as [ItemUpsertError.Empty]. + * Credit-card-specific upsert errors */ sealed interface CreditCardUpsertError : ItemUpsertError { - /** The expiration field could not be parsed as an `MM/yy` year-month. */ data object InvalidExpiration : CreditCardUpsertError + data object InvalidCardNumber : CreditCardUpsertError } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt index e417bd8e..1c644470 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt @@ -3,10 +3,8 @@ package de.davis.keygo.feature.item.core.domain.model import de.davis.keygo.core.security.domain.model.CryptoScopeError /** - * Errors surfaced when creating or updating any vault item. Intentionally an open (non-sealed) - * interface so item-type-specific errors can contribute their own variants while sharing this - * common return channel. Call sites inspect it with `contains`/`is` checks rather than exhaustive - * `when`. + * Errors surfaced when creating or updating any vault item. Call sites inspect it with + * `contains`/`is` checks rather than exhaustive `when`. */ interface ItemUpsertError { data object BlankName : ItemUpsertError diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt index 4d82114c..16ef62dc 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt @@ -13,15 +13,14 @@ data class UpsertCreditCard private constructor( val holder: FieldUpdate, val cardNumber: FieldUpdate, val cvv: FieldUpdate, - /** Raw `MM/yy` text; parsed to a `YearMonth` inside the use case. */ val expirationDate: FieldUpdate, ) : UpsertItem { companion object { fun create( vaultId: VaultId, name: String, - cardNumber: String, - expirationDate: String, + cardNumber: String? = null, + expirationDate: String? = null, holder: String? = null, cvv: String? = null, note: String? = null, @@ -29,8 +28,8 @@ data class UpsertCreditCard private constructor( ) = UpsertCreditCard( upsertType = UpsertType.Create(vaultId), name = FieldUpdate.Set(name), - cardNumber = FieldUpdate.Set(cardNumber), - expirationDate = FieldUpdate.Set(expirationDate), + cardNumber = if (!cardNumber.isNullOrBlank()) FieldUpdate.Set(cardNumber) else FieldUpdate.Clear, + expirationDate = if (!expirationDate.isNullOrBlank()) FieldUpdate.Set(expirationDate) else FieldUpdate.Clear, holder = if (!holder.isNullOrBlank()) FieldUpdate.Set(holder) else FieldUpdate.Clear, cvv = if (!cvv.isNullOrBlank()) FieldUpdate.Set(cvv) else FieldUpdate.Clear, note = if (!note.isNullOrBlank()) FieldUpdate.Set(note) else FieldUpdate.Clear, diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt index 655f685b..6e8c518c 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt @@ -2,7 +2,6 @@ package de.davis.keygo.feature.item.core.domain.model import de.davis.keygo.core.item.domain.model.Tag -/** Common surface every item-type upsert input shares. */ interface UpsertItem { val upsertType: UpsertType val name: FieldUpdate diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt index 1b0ac122..075437b3 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt @@ -19,6 +19,7 @@ import de.davis.keygo.feature.item.core.domain.model.getValue import de.davis.keygo.feature.item.core.domain.model.on import de.davis.keygo.feature.item.core.domain.model.onSet import de.davis.keygo.feature.item.core.domain.model.withoutClearingOn +import de.davis.keygo.rust.card.CardFormatter import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.koin.core.annotation.Single @@ -28,8 +29,9 @@ import java.time.format.DateTimeParseException @Single class CreateNewOrUpdateCreditCardUseCase( - cryptographicScopeProvider: CryptographicScopeProvider, private val creditCardRepository: CreditCardRepository, + private val cardFormatter: CardFormatter, + cryptographicScopeProvider: CryptographicScopeProvider, vaultRepository: VaultRepository, upsertVaultItem: UpsertVaultItemUseCase, ) : CreateOrUpdateItemUseCase( @@ -48,6 +50,9 @@ class CreateNewOrUpdateCreditCardUseCase( if (!isValidExpiration(upsert.expirationDate, allowKeep)) errors.add(CreditCardUpsertError.InvalidExpiration) + if (!isValidCardNumber(upsert.cardNumber, allowKeep)) + errors.add(CreditCardUpsertError.InvalidCardNumber) + return errors } @@ -60,15 +65,21 @@ class CreateNewOrUpdateCreditCardUseCase( private fun isValidExpiration(field: FieldUpdate, allowKeep: Boolean): Boolean = when (field) { is FieldUpdate.Keep -> allowKeep - is FieldUpdate.Clear -> false + is FieldUpdate.Clear -> true is FieldUpdate.Set -> field.value.toYearMonthOrNull() != null } + private fun isValidCardNumber(field: FieldUpdate, allowKeep: Boolean): Boolean = + when (field) { + is FieldUpdate.Keep -> allowKeep + is FieldUpdate.Clear -> true + is FieldUpdate.Set -> cardFormatter.isLuhnValid(field.value) + } + override suspend fun fetchExisting(id: ItemId): CreditCard? = creditCardRepository.getCreditCardById(id) - override fun isEmpty(item: CreditCard, upsert: UpsertCreditCard): Boolean = - item.lastNumbers.isBlank() + override fun isEmpty(item: CreditCard, upsert: UpsertCreditCard): Boolean = !item.hasAnyContent override fun relocate( item: CreditCard, @@ -76,15 +87,15 @@ class CreateNewOrUpdateCreditCardUseCase( keyInformation: KeyInformation, ): CreditCard = item.copy(vaultId = vaultId, keyInformation = keyInformation) - context(scope: CryptographicScope) - override suspend fun buildCreate( + override suspend fun CryptographicScope.buildCreate( upsert: UpsertCreditCard, itemId: ItemId, vaultId: VaultId, keyInformation: KeyInformation, ): CreditCard = coroutineScope { - val number = upsert.cardNumber.getValue().orEmpty() - val encryptedNumber = async { CreditCard.CardNumber.encrypt(number) } + val number = upsert.cardNumber.getValue() + val encryptedNumber = + number?.let { number -> async { CreditCard.CardNumber.encrypt(number) } } val encryptedCvv = upsert.cvv.onSet { cvv -> async { CreditCard.CVV.encrypt(cvv) } } CreditCard( @@ -96,15 +107,15 @@ class CreateNewOrUpdateCreditCardUseCase( note = upsert.note.getValue(), pinned = false, holder = upsert.holder.getValue(), - lastNumbers = number.toLastNumbers(), - cardNumber = encryptedNumber.await(), + lastNumbers = number?.toLastNumbers(), + cardNumber = encryptedNumber?.await(), cvv = encryptedCvv?.await(), - expirationDate = upsert.expirationDate.getValue()!!.toYearMonthOrNull()!!, + expirationDate = upsert.expirationDate.getValue() + ?.toYearMonthOrNull(), // toYearMonthOrNull will not return null, since isValidExpiration ensures the date to be correct ) } - context(scope: CryptographicScope) - override suspend fun buildUpdate( + override suspend fun CryptographicScope.buildUpdate( upsert: UpsertCreditCard, existing: CreditCard, ): CreditCard = coroutineScope { @@ -118,11 +129,18 @@ class CreateNewOrUpdateCreditCardUseCase( note = upsert.note.on(existing.note), tags = upsert.tags.on(existing.tags).orEmpty(), holder = upsert.holder.on(existing.holder), - cardNumber = encryptedNumber?.await() ?: existing.cardNumber, - lastNumbers = upsert.cardNumber.getValue()?.toLastNumbers() ?: existing.lastNumbers, + cardNumber = upsert.cardNumber.on(existing.cardNumber, encryptedNumber), + lastNumbers = when (val cn = upsert.cardNumber) { + FieldUpdate.Keep -> existing.lastNumbers + FieldUpdate.Clear -> null + is FieldUpdate.Set -> cn.value.toLastNumbers() + }, cvv = upsert.cvv.on(existing.cvv, encryptedCvv), - expirationDate = upsert.expirationDate.getValue()?.toYearMonthOrNull() - ?: existing.expirationDate, + expirationDate = when (val exp = upsert.expirationDate) { + FieldUpdate.Keep -> existing.expirationDate + FieldUpdate.Clear -> null + is FieldUpdate.Set -> exp.value.toYearMonthOrNull() + }, ) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt index e247bb7c..0105f595 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCase.kt @@ -68,8 +68,7 @@ class CreateNewOrUpdateLoginUseCase( override fun relocate(item: Login, vaultId: VaultId, keyInformation: KeyInformation): Login = item.copy(vaultId = vaultId, keyInformation = keyInformation) - context(scope: CryptographicScope) - override suspend fun buildCreate( + override suspend fun CryptographicScope.buildCreate( upsert: UpsertLogin, itemId: ItemId, vaultId: VaultId, @@ -104,8 +103,10 @@ class CreateNewOrUpdateLoginUseCase( ) } - context(scope: CryptographicScope) - override suspend fun buildUpdate(upsert: UpsertLogin, existing: Login): Login = coroutineScope { + override suspend fun CryptographicScope.buildUpdate( + upsert: UpsertLogin, + existing: Login + ): Login = coroutineScope { val newPasswordCredential = when (val pw = upsert.password) { is FieldUpdate.Keep -> existing.passwordCredential is FieldUpdate.Clear -> null diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt index f147c448..64497ade 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt @@ -44,18 +44,16 @@ abstract class CreateOrUpdateItemUseCase( /** Loads the persisted item for an update, or null when the id is unknown. */ protected abstract suspend fun fetchExisting(id: ItemId): I? - /** Builds a brand-new item inside [scope]; assign the supplied [keyInformation] to it. */ - context(scope: CryptographicScope) - protected abstract suspend fun buildCreate( + /** Builds a brand-new item inside [CryptographicScope]; assign the supplied [keyInformation] to it. */ + protected abstract suspend fun CryptographicScope.buildCreate( upsert: U, itemId: ItemId, vaultId: VaultId, keyInformation: KeyInformation, ): I - /** Applies [upsert] onto [existing] inside [scope]. */ - context(scope: CryptographicScope) - protected abstract suspend fun buildUpdate(upsert: U, existing: I): I + /** Applies [upsert] onto [existing] inside [CryptographicScope]. */ + protected abstract suspend fun CryptographicScope.buildUpdate(upsert: U, existing: I): I /** True when the built item has no meaningful content and should be rejected as [ItemUpsertError.Empty]. */ protected open fun isEmpty(item: I, upsert: U): Boolean = false diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt index 0e870299..076c42b6 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt @@ -59,6 +59,7 @@ fun KeyGoFormField( { val text = when (error) { InputFieldError.Empty -> stringResource(R.string.field_blank) + InputFieldError.Invalid -> stringResource(R.string.field_invalid) } Text(text = text, color = MaterialTheme.colorScheme.error) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt index 9b1192f7..cd8a9298 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt @@ -2,4 +2,5 @@ package de.davis.keygo.feature.item.core.presentation.model sealed interface InputFieldError { data object Empty : InputFieldError + data object Invalid : InputFieldError } \ No newline at end of file diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index b72913af..f195744d 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -25,4 +25,5 @@ Email, phone, or username This field can not be blank + This input is invalid \ No newline at end of file diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt index bc491fc2..c8f6f44f 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt @@ -4,6 +4,7 @@ import de.davis.keygo.core.item.FakeCreditCardRepository import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.rust.FakeCardFormatter import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId @@ -50,6 +51,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { private val cryptoProvider = FakeCryptographicScopeProvider(FakeItemRepository(FakeLoginRepository())) + private val cardFormatter = FakeCardFormatter() private val useCase = makeUseCase() @BeforeTest @@ -148,12 +150,13 @@ class CreateNewOrUpdateCreditCardUseCaseTest { val numberCall = cryptoProvider.encryptCalls.single { it.label == CreditCard.CardNumber.label } assertContentEquals(plaintextNumber.encodeToByteArray(), numberCall.plaintext) - assertFalse(stored.cardNumber.payload.ciphertext.contentEquals(plaintextNumber.encodeToByteArray())) + assertNotNull(stored.cardNumber) + assertFalse(stored.cardNumber!!.payload.ciphertext.contentEquals(plaintextNumber.encodeToByteArray())) assertContentEquals( FakeCryptographicScopeProvider.transform(plaintextNumber.encodeToByteArray()), - stored.cardNumber.payload.ciphertext, + stored.cardNumber!!.payload.ciphertext, ) - assertContentEquals(FakeCryptographicScopeProvider.IV, stored.cardNumber.payload.iv) + assertContentEquals(FakeCryptographicScopeProvider.IV, stored.cardNumber!!.payload.iv) } @Test @@ -251,11 +254,12 @@ class CreateNewOrUpdateCreditCardUseCaseTest { val stored = creditCardRepository.getCreditCardById(existing.id) assertNotNull(stored) + assertNotNull(stored.cardNumber) assertContentEquals( FakeCryptographicScopeProvider.transform(plaintextNumber.encodeToByteArray()), - stored.cardNumber.payload.ciphertext, + stored.cardNumber!!.payload.ciphertext, ) - assertContentEquals(FakeCryptographicScopeProvider.IV, stored.cardNumber.payload.iv) + assertContentEquals(FakeCryptographicScopeProvider.IV, stored.cardNumber!!.payload.iv) } @Test @@ -284,6 +288,60 @@ class CreateNewOrUpdateCreditCardUseCaseTest { assertContains(result.error, CreditCardUpsertError.InvalidExpiration) } + @Test + fun `create without card number stores null cardNumber and null lastNumbers`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + holder = "Alice", + ) + ) + + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + assertNull(stored.cardNumber) + assertNull(stored.lastNumbers) + } + + @Test + fun `create with non-Luhn card number returns InvalidCardNumber error`() = runTest { + val result = makeUseCase(cardFormatter = FakeCardFormatter().also { it.luhnResult = false })( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "1234567890123456", + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, CreditCardUpsertError.InvalidCardNumber) + } + + @Test + fun `update clearing card number sets cardNumber and lastNumbers to null`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, cardNumber = clear())) + + assertTrue(result.isSuccess(), "result: $result") + val stored = creditCardRepository.getCreditCardById(existing.id) + assertNull(stored?.cardNumber) + assertNull(stored?.lastNumbers) + } + + @Test + fun `update clearing expiration date sets expirationDate to null`() = runTest { + val existing = testCard() + creditCardRepository.seed(existing) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, expirationDate = clear())) + + assertTrue(result.isSuccess(), "result: $result") + assertNull(creditCardRepository.getCreditCardById(existing.id)?.expirationDate) + } + @Test fun `update with same vaultId does not rewrap the item key`() = runTest { val existing = testCard() @@ -323,9 +381,11 @@ class CreateNewOrUpdateCreditCardUseCaseTest { private fun makeUseCase( cryptographicScopeProvider: CryptographicScopeProvider = cryptoProvider, + cardFormatter: FakeCardFormatter = this.cardFormatter, ) = CreateNewOrUpdateCreditCardUseCase( cryptographicScopeProvider = cryptographicScopeProvider, creditCardRepository = creditCardRepository, + cardFormatter = cardFormatter, vaultRepository = vaultRepository, upsertVaultItem = UpsertVaultItemUseCase(FakeLoginRepository(), creditCardRepository), ) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index 15c45ccf..e42079d7 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -148,6 +148,7 @@ private fun CreditCardReadyContent( ), inputTransformation = ccNumberInputTransformation, outputTransformation = ccNumberOutputTransformation, + error = state.numberError, ) KeyGoFormField( @@ -168,6 +169,7 @@ private fun CreditCardReadyContent( keyboardType = KeyboardType.Number ), inputTransformation = ccExpirationDateInputTransformation, + error = state.expirationDateError, ) } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index 07ec726c..5be4efac 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -143,19 +143,18 @@ internal class CreditCardViewModel( }.onFailure { failure -> _base.update { it.copy( - numberError = if (failure.contains(ItemUpsertError.Empty)) InputFieldError.Empty else null, + numberError = if (failure.contains(CreditCardUpsertError.InvalidCardNumber)) InputFieldError.Invalid else null, + expirationDateError = if (failure.contains(CreditCardUpsertError.InvalidExpiration)) InputFieldError.Invalid else null, ) } - if (failure.any { it is ItemUpsertError.InvalidVaultId }) + if (failure.any { it is ItemUpsertError.InvalidVaultId }) { snackbarManager.sendMessage( - message = SnackbarMessage(message = ResourceString(R.string.invalid_vault_id)), - ) - - if (failure.contains(CreditCardUpsertError.InvalidExpiration)) - snackbarManager.sendMessage( - message = SnackbarMessage(message = ResourceString(R.string.cc_invalid_expiration)), + message = SnackbarMessage( + message = ResourceString(R.string.invalid_vault_id), + ), ) + } failure.filterIsInstance() .firstOrNull() diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index c22348dc..ad8c9cb4 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -14,10 +14,16 @@ internal data class CreditCardBaseState( val ccCVVTextFieldState: TextFieldState = TextFieldState(), val ccExpirationDateTextFieldState: TextFieldState = TextFieldState(), val numberError: InputFieldError? = null, + val expirationDateError: InputFieldError? = null, val updating: Boolean = false, ) { + + val hasAnyContent: Boolean + get() = ccHolderTextFieldState.text.isNotBlank() + || ccNumberTextFieldState.text.isNotBlank() + || ccCVVTextFieldState.text.isNotBlank() + || ccExpirationDateTextFieldState.text.isNotBlank() + fun canSave(name: CharSequence): Boolean = - name.isNotBlank() && - ccNumberTextFieldState.text.isNotBlank() && - ccExpirationDateTextFieldState.text.isNotBlank() + name.isNotBlank() && hasAnyContent } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt index 578b7f6a..3668199e 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginViewModel.kt @@ -281,20 +281,18 @@ internal class LoginViewModel( ) } - if (failure.any { it is ItemUpsertError.DatabaseError }) { - failure.filterIsInstance() - .first() - .let { dbError -> - snackbarManager.sendMessage( - message = SnackbarMessage( - message = ResourceString( - R.string.database_error, - dbError.throwable.message ?: "no message", - ), + failure.filterIsInstance() + .firstOrNull() + ?.let { dbError -> + snackbarManager.sendMessage( + message = SnackbarMessage( + message = ResourceString( + R.string.database_error, + dbError.throwable.message ?: "no message", ), - ) - } - } + ), + ) + } } } } diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index d7cb753a..73d07949 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -61,7 +61,6 @@ An unexpected database error has occurred: %s Vault item ID can not be found - Enter a valid expiration date (MM/YY) Vault \ No newline at end of file From e45683d917296975bcd1a06c0698f619d0026a7c Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 27 May 2026 17:12:30 +0200 Subject: [PATCH 22/36] feat(credit-card): Implement view screen --- .../keygo/core/item/data/local/dao/ItemDao.kt | 3 + .../data/repository/ItemRepositoryImpl.kt | 3 + .../item/domain/repository/ItemRepository.kt | 1 + .../keygo/core/item/FakeItemRepository.kt | 9 + .../item/core/src/main/res/values/strings.xml | 2 +- feature/item/view/build.gradle.kts | 2 +- .../feature/item/view/ViewVaultItemScreen.kt | 22 +- .../item/view/ViewVaultItemViewModel.kt | 40 ++ .../view/creditcard/ViewCreditCardContent.kt | 455 ++++++++++++++++++ .../view/creditcard/ViewCreditCardScreen.kt | 31 ++ .../creditcard/ViewCreditCardViewModel.kt | 240 +++++++++ .../creditcard/model/CreditCardFieldType.kt | 10 + .../creditcard/model/ModificationDialog.kt | 11 + .../creditcard/model/ViewCreditCardState.kt | 21 + .../creditcard/model/ViewCreditCardUiEvent.kt | 12 + .../item/view/login/ViewLoginContent.kt | 21 +- .../davis/keygo/feature/item/view/onHold.kt | 27 ++ .../item/view/src/main/res/values/strings.xml | 5 + 18 files changed, 893 insertions(+), 22 deletions(-) create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemViewModel.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardScreen.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/CreditCardFieldType.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ModificationDialog.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardUiEvent.kt create mode 100644 feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt index 6686c2a3..f3efadee 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt @@ -26,6 +26,9 @@ internal interface ItemDao { @Query("SELECT name FROM item WHERE id = :id") suspend fun getNameById(id: ItemId): String? + @Query("SELECT item_type FROM item WHERE id = :id") + suspend fun getItemTypeById(id: ItemId): VaultItemType? + @Query("SELECT EXISTS(SELECT 1 FROM item WHERE name = :name AND (:excludeId IS NULL OR id != :excludeId) AND (:vaultId IS NULL OR vault_id = :vaultId))") suspend fun existsName( name: String, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt index 4de3c12c..f02b3154 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt @@ -46,6 +46,9 @@ internal class ItemRepositoryImpl( override suspend fun getItemName(itemId: ItemId): String? = itemDao.getNameById(itemId) + override suspend fun getItemType(itemId: ItemId): VaultItemType? = + itemDao.getItemTypeById(itemId) + override suspend fun doesNameExist( name: String, excludeId: ItemId?, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt index c9e6c791..5c0bd942 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt @@ -19,6 +19,7 @@ interface ItemRepository { suspend fun createOrUpdateVaultItem(item: Item): ItemId suspend fun getItemName(itemId: ItemId): String? + suspend fun getItemType(itemId: ItemId): VaultItemType? suspend fun doesNameExist( name: String, excludeId: ItemId? = null, diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt index 7bb1c184..fb77c885 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt @@ -30,6 +30,12 @@ class FakeItemRepository( private val allStores = loginRepository.store private val envelopes = mutableMapOf() + private val itemTypes = mutableMapOf() + + /** Register the [type] returned by [getItemType] for [itemId]. */ + fun seedItemType(itemId: ItemId, type: VaultItemType) { + itemTypes[itemId] = type + } fun seedEnvelope(envelope: ItemKeyEnvelope) { envelopes[envelope.itemId] = envelope @@ -69,6 +75,9 @@ class FakeItemRepository( override suspend fun getItemName(itemId: ItemId): String? = allStores.value[itemId]?.name + override suspend fun getItemType(itemId: ItemId): VaultItemType? = + itemTypes[itemId] ?: allStores.value[itemId]?.itemType + override suspend fun doesNameExist( name: String, excludeId: ItemId?, diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index f195744d..75c4a6c6 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -8,7 +8,7 @@ Domains Tags - Card Holder + Cardholder Card Number Card CVV Card Expiration Date diff --git a/feature/item/view/build.gradle.kts b/feature/item/view/build.gradle.kts index 6a647354..176db9eb 100644 --- a/feature/item/view/build.gradle.kts +++ b/feature/item/view/build.gradle.kts @@ -65,4 +65,4 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemScreen.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemScreen.kt index fc291410..e1530e94 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemScreen.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemScreen.kt @@ -1,12 +1,30 @@ package de.davis.keygo.feature.item.view import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.item.core.presentation.model.NavigationEvent +import de.davis.keygo.feature.item.view.creditcard.ViewCreditCardScreen import de.davis.keygo.feature.item.view.login.ViewLoginScreen +import org.koin.androidx.compose.koinViewModel @Composable fun ViewVaultItemScreen(itemId: ItemId, navigate: (NavigationEvent) -> Unit) { - // TODO: figure out what type the id is - ViewLoginScreen(itemId = itemId, navigate = navigate) + val currentId by rememberUpdatedState(itemId) + val viewModel: ViewVaultItemViewModel = koinViewModel() + LaunchedEffect(currentId) { + viewModel.init(currentId) + } + + val itemType by viewModel.itemType.collectAsStateWithLifecycle() + + when (itemType) { + VaultItemType.Login -> ViewLoginScreen(itemId = itemId, navigate = navigate) + VaultItemType.CreditCard -> ViewCreditCardScreen(itemId = itemId, navigate = navigate) + null -> Unit + } } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemViewModel.kt new file mode 100644 index 00000000..aef41002 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/ViewVaultItemViewModel.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.item.view + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class ViewVaultItemViewModel( + private val itemRepository: ItemRepository, +) : ViewModel() { + + private val _itemId = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val itemType: StateFlow = _itemId + .filterNotNull() + .distinctUntilChanged() + .mapLatest { itemRepository.getItemType(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null, + ) + + fun init(itemId: ItemId) { + _itemId.update { itemId } + } +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt new file mode 100644 index 00000000..17ee65f6 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt @@ -0,0 +1,455 @@ +package de.davis.keygo.feature.item.view.creditcard + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.NoteAdd +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumFlexibleTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode +import de.davis.keygo.feature.item.core.presentation.component.CopyToClipboardButton +import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField +import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormSuggestionField +import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransformation +import de.davis.keygo.feature.item.view.R +import de.davis.keygo.feature.item.view.creditcard.model.CreditCardFieldType +import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState +import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardUiEvent +import de.davis.keygo.feature.item.view.login.model.ObfuscatedString +import de.davis.keygo.feature.item.view.onHold +import de.davis.keygo.core.item.R as CoreItemR +import de.davis.keygo.core.ui.R as CoreUiR +import de.davis.keygo.feature.item.core.R as ItemCoreR + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUiEvent) -> Unit) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold( + topBar = { + MediumFlexibleTopAppBar( + title = { + Text(text = state.name) + }, + subtitle = { + state.vaultMetadata?.let { metadata -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSecondaryContainer + ) { + val textStyle = LocalTextStyle.current + val size = with(LocalDensity.current) { + if (textStyle.fontSize.isSp) textStyle.fontSize.toDp() + else 24.dp + } + Icon( + imageVector = metadata.icon.toImageVector(), + contentDescription = null, + modifier = Modifier.size(size), + ) + Text(text = metadata.name) + } + Text(text = "•") + Text(text = stringResource(CoreItemR.string.credit_card)) + } + } + }, + navigationIcon = { + if (LocalIsInSinglePaneMode.current) { + IconButton(onClick = { onEvent(ViewCreditCardUiEvent.OnBackClick) }) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(ItemCoreR.string.back_content_description), + ) + } + } + }, + actions = { + IconButton( + onClick = { onEvent(ViewCreditCardUiEvent.OnPinClick) }, + ) { + Icon( + imageVector = if (state.pinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, + contentDescription = null, + ) + } + + IconButton( + onClick = { onEvent(ViewCreditCardUiEvent.OnEditRequest) }, + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(ItemCoreR.string.edit_content_description), + ) + } + }, + scrollBehavior = scrollBehavior, + ) + } + ) { innerPadding -> + val name = stringResource(ItemCoreR.string.name) + val cardholder = stringResource(ItemCoreR.string.cc_holder) + val cardNumber = stringResource(ItemCoreR.string.cc_number) + val cvv = stringResource(ItemCoreR.string.cc_cvv) + val expiration = stringResource(ItemCoreR.string.cc_expiration_date) + val tags = stringResource(ItemCoreR.string.tags) + val note = stringResource(ItemCoreR.string.note) + + var isCardNumberHidden by rememberSaveable { mutableStateOf(true) } + var isCvvHidden by rememberSaveable { mutableStateOf(true) } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(start = 8.dp, end = 8.dp, top = 8.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + entry( + title = name, + leadingIcon = Icons.Default.Badge, + ) { + Text(text = state.name) + } + + if (state.holder.isNotBlank()) { + entry( + title = cardholder, + leadingIcon = Icons.Default.Person, + ) { + Text(text = state.holder) + } + } + + val cardNum = state.cardNumber + if (cardNum != null) { + entry( + title = cardNumber, + leadingIcon = Icons.Default.CreditCard, + modifier = Modifier.onHold { + isCardNumberHidden = !it + }, + trailingContent = { + CopyToClipboardButton(cardNum.raw) + }, + ) { + val scrollState = rememberScrollState() + Text( + text = if (isCardNumberHidden) cardNum.hidden else cardNum.raw, + maxLines = 1, + modifier = Modifier.horizontalScroll(scrollState), + ) + } + } + + val cvvVal = state.cvv + if (cvvVal != null) { + entry( + title = cvv, + leadingIcon = Icons.Default.Pin, + modifier = Modifier.onHold { + isCvvHidden = !it + }, + trailingContent = { + CopyToClipboardButton(cvvVal.raw) + }, + ) { + val scrollState = rememberScrollState() + Text( + text = if (isCvvHidden) cvvVal.hidden else cvvVal.raw, + maxLines = 1, + modifier = Modifier.horizontalScroll(scrollState), + ) + } + } + + if (state.expirationDate.isNotBlank()) { + entry( + title = expiration, + leadingIcon = Icons.Default.CalendarMonth, + ) { + Text(text = state.expirationDate) + } + } + + if (state.tags.isNotEmpty()) { + entry( + title = tags, + leadingIcon = Icons.Default.Sell, + ) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.tags.forEach { + key(it.display) { + AssistChip( + onClick = {}, + label = { Text(text = it.display) }, + ) + } + } + } + } + } + + if (state.note.isNotBlank()) { + entry( + title = note, + leadingIcon = Icons.AutoMirrored.Default.Notes, + ) { + Text(text = state.note) + } + } + + item(key = "actions") { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.holder.isBlank()) { + AddChip( + fieldType = CreditCardFieldType.Holder, + onClick = { onEvent(ViewCreditCardUiEvent.OnModifyFieldRequest(it)) }, + ) + } + + if (state.cardNumber == null) { + AddChip( + fieldType = CreditCardFieldType.CardNumber, + onClick = { onEvent(ViewCreditCardUiEvent.OnModifyFieldRequest(it)) }, + ) + } + + if (state.cvv == null) { + AddChip( + fieldType = CreditCardFieldType.Cvv, + onClick = { onEvent(ViewCreditCardUiEvent.OnModifyFieldRequest(it)) }, + ) + } + + if (state.expirationDate.isBlank()) { + AddChip( + fieldType = CreditCardFieldType.Expiration, + onClick = { onEvent(ViewCreditCardUiEvent.OnModifyFieldRequest(it)) }, + ) + } + + AddChip( + fieldType = CreditCardFieldType.Tag, + onClick = { onEvent(ViewCreditCardUiEvent.OnModifyFieldRequest(it)) }, + ) + + if (state.note.isBlank()) { + AddChip( + fieldType = CreditCardFieldType.Note, + onClick = { onEvent(ViewCreditCardUiEvent.OnModifyFieldRequest(it)) }, + ) + } + } + } + } + + state.modificationDialog?.let { dialog -> + val textFieldInputState = rememberTextFieldState(dialog.initialValue) + AlertDialog( + onDismissRequest = { onEvent(ViewCreditCardUiEvent.OnCloseDialog) }, + confirmButton = { + TextButton( + onClick = { + onEvent( + ViewCreditCardUiEvent.OnSubmitModification( + textFieldInputState.text.toString(), + ), + ) + }, + ) { + Text(text = stringResource(CoreUiR.string.add)) + } + }, + modifier = Modifier.fillMaxWidth(), + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + title = { + Text(text = stringResource(CoreUiR.string.add)) + }, + text = { + when (dialog.fieldType) { + CreditCardFieldType.Tag -> KeyGoFormSuggestionField( + suggestions = dialog.tagsToSuggest.mapTo(mutableSetOf()) { it.display }, + onSuggestionSelected = { + textFieldInputState.setTextAndPlaceCursorAtEnd(it) + }, + state = textFieldInputState, + label = { + Text(text = dialog.fieldType.addLabel()) + }, + modifier = Modifier.fillMaxWidth(), + ) + + else -> KeyGoFormField( + state = textFieldInputState, + label = { + Text(text = dialog.fieldType.addLabel()) + }, + modifier = Modifier.fillMaxWidth(), + isSecure = dialog.fieldType.isSensitive, + inputTransformation = if (!dialog.fieldType.isSensitive) TrimTransformation else null, + error = dialog.error, + ) + } + }, + ) + } + } +} + +@Composable +private fun AddChip(fieldType: CreditCardFieldType, onClick: (CreditCardFieldType) -> Unit) { + AssistChip( + onClick = { onClick(fieldType) }, + label = { Text(text = fieldType.addLabel()) }, + leadingIcon = { + Icon( + imageVector = fieldType.addIcon(), + contentDescription = null, + ) + }, + ) +} + +@Composable +private fun CreditCardFieldType.addLabel(): String { + return when (this) { + CreditCardFieldType.Holder -> stringResource(R.string.add_holder) + CreditCardFieldType.CardNumber -> stringResource(R.string.add_card_number) + CreditCardFieldType.Cvv -> stringResource(R.string.add_cvv) + CreditCardFieldType.Expiration -> stringResource(R.string.add_expiration) + CreditCardFieldType.Tag -> stringResource(R.string.add_tag) + CreditCardFieldType.Note -> stringResource(R.string.add_note) + } +} + +@Composable +private fun CreditCardFieldType.addIcon(): ImageVector { + return when (this) { + CreditCardFieldType.Holder -> Icons.Default.PersonAdd + CreditCardFieldType.CardNumber -> Icons.Default.CreditCard + CreditCardFieldType.Cvv -> Icons.Default.Pin + CreditCardFieldType.Expiration -> Icons.Default.CalendarMonth + CreditCardFieldType.Tag -> Icons.Default.Sell + CreditCardFieldType.Note -> Icons.AutoMirrored.Default.NoteAdd + } +} + +private fun LazyListScope.entry( + title: String, + leadingIcon: ImageVector, + modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + item(key = title) { + KeyGoCard( + title = { + Text(text = title) + }, + leadingItem = { + Icon( + imageVector = leadingIcon, + contentDescription = null, + ) + }, + trailingItem = trailingContent, + modifier = modifier.animateItem(), + ) { + content() + } + } +} + +@Preview +@Composable +private fun ViewCreditCardContentPreview() { + MaterialTheme { + CompositionLocalProvider( + LocalIsInSinglePaneMode provides true + ) { + ViewCreditCardContent( + state = ViewCreditCardState( + name = "My Visa", + holder = "John Doe", + cardNumber = ObfuscatedString("4111111111111111"), + cvv = ObfuscatedString("123"), + expirationDate = "12/26", + note = "Main travel card.", + ), + onEvent = {}, + ) + } + } +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardScreen.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardScreen.kt new file mode 100644 index 00000000..28d9531e --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardScreen.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.feature.item.view.creditcard + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.item.core.presentation.model.NavigationEvent +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ViewCreditCardScreen(itemId: ItemId, navigate: (NavigationEvent) -> Unit) { + val currentId by rememberUpdatedState(itemId) + val viewModel: ViewCreditCardViewModel = koinViewModel() + LaunchedEffect(currentId) { + viewModel.init(currentId) + } + + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(viewModel.navigationEvent) { + navigate(it) + } + + ViewCreditCardContent( + state = state, + onEvent = viewModel::onEvent, + ) +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt new file mode 100644 index 00000000..39e887db --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -0,0 +1,240 @@ +package de.davis.keygo.feature.item.view.creditcard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.security.domain.crypto.decrypt +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.item.core.domain.model.CreditCardUpsertError +import de.davis.keygo.feature.item.core.domain.model.ItemUpsertError +import de.davis.keygo.feature.item.core.domain.model.UpsertCreditCard +import de.davis.keygo.feature.item.core.domain.model.fieldUpdate +import de.davis.keygo.feature.item.core.domain.model.onSet +import de.davis.keygo.feature.item.core.domain.model.set +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase +import de.davis.keygo.feature.item.core.presentation.model.InputFieldError +import de.davis.keygo.feature.item.core.presentation.model.NavigationEvent +import de.davis.keygo.feature.item.view.creditcard.model.CreditCardFieldType +import de.davis.keygo.feature.item.view.creditcard.model.ModificationDialog +import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState +import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardUiEvent +import de.davis.keygo.feature.item.view.login.model.asObfuscatedString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import java.time.format.DateTimeFormatter + +@KoinViewModel +internal class ViewCreditCardViewModel( + private val itemRepository: ItemRepository, + private val vaultRepository: VaultRepository, + private val creditCardRepository: CreditCardRepository, + private val updateCreditCard: CreateNewOrUpdateCreditCardUseCase, + private val observeCreditCardWithCryptoScope: ItemWithCryptoScopeUseCase, + private val observeAllTags: ObserveAllTagsSortedUseCase, + private val sort: SortUseCase, +) : ViewModel() { + + private val _modificationDialogState = MutableStateFlow(null) + private val _itemId = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val _stateWithoutModification: Flow = _itemId + .filterNotNull() + .distinctUntilChanged() + .flatMapLatest { id -> + observeCreditCardWithCryptoScope.observe( + itemId = id, + source = creditCardRepository::observeCreditCardById, + ) { card -> + val (number, cvv, vaultMetadata) = coroutineScope { + val number = card.cardNumber?.let { async { it.decrypt().asObfuscatedString() } } + val cvv = card.cvv?.let { async { it.decrypt().asObfuscatedString() } } + val meta = async { vaultRepository.getVaultMetadata(card.vaultId) } + Triple(number?.await(), cvv?.await(), meta.await()) + } + + ViewCreditCardState( + name = card.name, + vaultMetadata = vaultMetadata, + holder = card.holder.orEmpty(), + cardNumber = number, + lastNumbers = card.lastNumbers.orEmpty(), + cvv = cvv, + expirationDate = card.expirationDate?.format(EXPIRATION_FORMATTER).orEmpty(), + tags = sort(card.tags) { it.display }.toSet(), + note = card.note.orEmpty(), + pinned = card.pinned, + ) + } + .map { it.getOrNull() } + .filterNotNull() + }.flowOn(Dispatchers.Default) + + val state = combine( + _stateWithoutModification, + _modificationDialogState, + ) { base, modificationDialog -> + base.copy(modificationDialog = modificationDialog) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ViewCreditCardState(), + ) + + private val navigationEventChannel = Channel() + val navigationEvent = navigationEventChannel.receiveAsFlow() + + fun init(itemId: ItemId) { + _itemId.update { itemId } + } + + fun onEvent(event: ViewCreditCardUiEvent) { + when (event) { + ViewCreditCardUiEvent.OnBackClick -> viewModelScope.launch { + navigationEventChannel.send(NavigationEvent.NavigateBack) + } + + ViewCreditCardUiEvent.OnPinClick -> _itemId.value?.let { id -> + viewModelScope.launch { + itemRepository.setPinned(id, !state.value.pinned) + } + } + + ViewCreditCardUiEvent.OnEditRequest -> _itemId.value?.let { id -> + viewModelScope.launch { + navigationEventChannel.send( + NavigationEvent.NavigateToEdit(VaultItemType.CreditCard, id), + ) + } + } + + ViewCreditCardUiEvent.OnCloseDialog -> _modificationDialogState.update { null } + + is ViewCreditCardUiEvent.OnModifyFieldRequest -> viewModelScope.launch { + val fieldType = event.fieldType + val current = state.value + val initialValue = when (fieldType) { + CreditCardFieldType.Holder -> current.holder + CreditCardFieldType.CardNumber -> current.cardNumber?.raw.orEmpty() + CreditCardFieldType.Cvv -> current.cvv?.raw.orEmpty() + CreditCardFieldType.Expiration -> current.expirationDate + CreditCardFieldType.Note -> current.note + CreditCardFieldType.Tag -> "" + } + val tagsToSuggest = + if (fieldType == CreditCardFieldType.Tag) observeAllTags().first().toSet() - current.tags + else emptySet() + + _modificationDialogState.update { + ModificationDialog( + fieldType = fieldType, + initialValue = initialValue, + tagsToSuggest = tagsToSuggest, + ) + } + } + + is ViewCreditCardUiEvent.OnSubmitModification -> { + val dialog = _modificationDialogState.value ?: return + val newText = fieldUpdate(event.input) + + _itemId.value?.let { id -> + viewModelScope.launch { + updateCreditCard( + when (dialog.fieldType) { + CreditCardFieldType.Holder -> UpsertCreditCard.update( + itemId = id, + holder = newText, + ) + + CreditCardFieldType.CardNumber -> UpsertCreditCard.update( + itemId = id, + cardNumber = newText, + ) + + CreditCardFieldType.Cvv -> UpsertCreditCard.update( + itemId = id, + cvv = newText, + ) + + CreditCardFieldType.Expiration -> UpsertCreditCard.update( + itemId = id, + expirationDate = newText, + ) + + CreditCardFieldType.Note -> UpsertCreditCard.update( + itemId = id, + note = newText, + ) + + CreditCardFieldType.Tag -> newText.onSet { raw -> + Tag.of(raw)?.let { tag -> + UpsertCreditCard.update( + itemId = id, + tags = set(state.value.tags + tag), + ) + } + } ?: return@launch + }, + ).onFailure { failure -> + _modificationDialogState.update { + dialog.copy( + error = when { + failure.contains(CreditCardUpsertError.InvalidCardNumber) -> + InputFieldError.Invalid + + failure.contains(CreditCardUpsertError.InvalidExpiration) -> + InputFieldError.Invalid + + failure.contains(ItemUpsertError.BlankName) -> + InputFieldError.Empty + + failure.contains(ItemUpsertError.Empty) -> + InputFieldError.Empty + + else -> null + }, + ) + } + }.onSuccess { + _modificationDialogState.update { null } + } + } + } + } + } + } + + companion object { + // "yy" parses into the 2000-2099 range, matching card expirations. + private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + } +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/CreditCardFieldType.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/CreditCardFieldType.kt new file mode 100644 index 00000000..e550ec18 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/CreditCardFieldType.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.item.view.creditcard.model + +enum class CreditCardFieldType(val isSensitive: Boolean = false) { + Holder, + CardNumber(isSensitive = true), + Cvv(isSensitive = true), + Expiration, + Tag, + Note, +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ModificationDialog.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ModificationDialog.kt new file mode 100644 index 00000000..fc43060c --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ModificationDialog.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.item.view.creditcard.model + +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.feature.item.core.presentation.model.InputFieldError + +data class ModificationDialog( + val fieldType: CreditCardFieldType, + val initialValue: String, + val error: InputFieldError? = null, + val tagsToSuggest: Set = emptySet(), +) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt new file mode 100644 index 00000000..ed4c14cb --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.item.view.creditcard.model + +import androidx.compose.runtime.Immutable +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.feature.item.view.login.model.ObfuscatedString + +@Immutable +data class ViewCreditCardState( + val name: String = "", + val vaultMetadata: VaultMetadata? = null, + val holder: String = "", + val cardNumber: ObfuscatedString? = null, + val lastNumbers: String = "", + val cvv: ObfuscatedString? = null, + val expirationDate: String = "", + val tags: Set = emptySet(), + val note: String = "", + val modificationDialog: ModificationDialog? = null, + val pinned: Boolean = false, +) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardUiEvent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardUiEvent.kt new file mode 100644 index 00000000..42904253 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardUiEvent.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.feature.item.view.creditcard.model + +sealed interface ViewCreditCardUiEvent { + + data object OnBackClick : ViewCreditCardUiEvent + data object OnPinClick : ViewCreditCardUiEvent + data object OnEditRequest : ViewCreditCardUiEvent + + data object OnCloseDialog : ViewCreditCardUiEvent + data class OnSubmitModification(val input: String) : ViewCreditCardUiEvent + data class OnModifyFieldRequest(val fieldType: CreditCardFieldType) : ViewCreditCardUiEvent +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt index b4f8d421..b65d8345 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt @@ -93,6 +93,7 @@ import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.feature.item.view.login.model.TotpState import de.davis.keygo.feature.item.view.login.model.ViewLoginState import de.davis.keygo.feature.item.view.login.model.ViewLoginUiEvent +import de.davis.keygo.feature.item.view.onHold import de.davis.keygo.feature.totp.domain.model.TotpValue import de.davis.keygo.feature.totp.presentation.component.QRScanner import de.davis.keygo.core.item.R as CoreItemR @@ -231,24 +232,8 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) entry( title = password, leadingIcon = Icons.Default.Password, - modifier = Modifier.pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown() - isPasswordHidden = false - - val pointerId = down.id - do { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } - if (change == null || change.changedToUpIgnoreConsumed()) { - break - } - - change.consume() - } while (true) - - isPasswordHidden = true - } + modifier = Modifier.onHold { + isPasswordHidden = !it }, trailingContent = { CopyToClipboardButton(pwd.raw) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt new file mode 100644 index 00000000..07be8bb2 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt @@ -0,0 +1,27 @@ +package de.davis.keygo.feature.item.view + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput + +fun Modifier.onHold(onHold: (Boolean) -> Unit) = this.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + onHold(true) + + val pointerId = down.id + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + if (change == null || change.changedToUpIgnoreConsumed()) { + break + } + + change.consume() + } while (true) + + onHold(false) + } +} \ No newline at end of file diff --git a/feature/item/view/src/main/res/values/strings.xml b/feature/item/view/src/main/res/values/strings.xml index feaf5139..a363d812 100644 --- a/feature/item/view/src/main/res/values/strings.xml +++ b/feature/item/view/src/main/res/values/strings.xml @@ -5,4 +5,9 @@ Add Domain Add Tag Add Note + + Add Cardholder + Add Card Number + Add CVV + Add Expiration \ No newline at end of file From 604c8ec5a1fbda6d5c33a691161cfd1b3ae641c3 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 28 May 2026 15:19:13 +0200 Subject: [PATCH 23/36] feat(item-view): format and partially reveal credit card numbers Update `ObfuscatedString` to support custom formatting and visible suffixes, allowing card numbers to maintain their grouping (e.g., spaces) while masking digits. Key changes include: - Enhanced `ObfuscatedString` to preserve non-digit characters and reveal a specified number of suffix digits. - Injected `CardFormatter` into `ViewCreditCardViewModel` to handle network-specific card formatting. - Updated the UI to display the formatted string instead of raw digits when the card number is revealed. - Added comprehensive unit tests for `ObfuscatedString` logic and `ViewCreditCardViewModel` state production. --- .gitignore | 4 +- feature/item/view/build.gradle.kts | 6 + .../view/creditcard/ViewCreditCardContent.kt | 8 +- .../creditcard/ViewCreditCardViewModel.kt | 18 ++- .../item/view/login/model/ObfuscatedString.kt | 20 ++- .../creditcard/ViewCreditCardViewModelTest.kt | 145 ++++++++++++++++++ .../view/login/model/ObfuscatedStringTest.kt | 61 ++++++++ 7 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt create mode 100644 feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt diff --git a/.gitignore b/.gitignore index 0f9cb1be..eb9501b8 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,6 @@ google-services.json *.hprof ### Kotlin -.kotlin/ \ No newline at end of file +.kotlin/ + +docs/superpowers/ \ No newline at end of file diff --git a/feature/item/view/build.gradle.kts b/feature/item/view/build.gradle.kts index 176db9eb..293d4020 100644 --- a/feature/item/view/build.gradle.kts +++ b/feature/item/view/build.gradle.kts @@ -63,6 +63,12 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt index 17ee65f6..4131061f 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt @@ -194,7 +194,7 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi ) { val scrollState = rememberScrollState() Text( - text = if (isCardNumberHidden) cardNum.hidden else cardNum.raw, + text = if (isCardNumberHidden) cardNum.hidden else cardNum.formatted, maxLines = 1, modifier = Modifier.horizontalScroll(scrollState), ) @@ -443,7 +443,11 @@ private fun ViewCreditCardContentPreview() { state = ViewCreditCardState( name = "My Visa", holder = "John Doe", - cardNumber = ObfuscatedString("4111111111111111"), + cardNumber = ObfuscatedString( + "4111111111111111", + formatted = "4111 1111 1111 1111", + visibleSuffixDigits = 4 + ), cvv = ObfuscatedString("123"), expirationDate = "12/26", note = "Main travel card.", diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt index 39e887db..31c04eb1 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -28,7 +28,9 @@ import de.davis.keygo.feature.item.view.creditcard.model.CreditCardFieldType import de.davis.keygo.feature.item.view.creditcard.model.ModificationDialog import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardUiEvent +import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.feature.item.view.login.model.asObfuscatedString +import de.davis.keygo.rust.card.CardFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -60,6 +62,7 @@ internal class ViewCreditCardViewModel( private val observeCreditCardWithCryptoScope: ItemWithCryptoScopeUseCase, private val observeAllTags: ObserveAllTagsSortedUseCase, private val sort: SortUseCase, + private val cardFormatter: CardFormatter, ) : ViewModel() { private val _modificationDialogState = MutableStateFlow(null) @@ -75,7 +78,16 @@ internal class ViewCreditCardViewModel( source = creditCardRepository::observeCreditCardById, ) { card -> val (number, cvv, vaultMetadata) = coroutineScope { - val number = card.cardNumber?.let { async { it.decrypt().asObfuscatedString() } } + val number = card.cardNumber?.let { + async { + val raw = it.decrypt() + ObfuscatedString( + raw = raw, + formatted = cardFormatter.formatNumber(raw), + visibleSuffixDigits = VISIBLE_CARD_NUMBER_SUFFIX, + ) + } + } val cvv = card.cvv?.let { async { it.decrypt().asObfuscatedString() } } val meta = async { vaultRepository.getVaultMetadata(card.vaultId) } Triple(number?.await(), cvv?.await(), meta.await()) @@ -150,7 +162,8 @@ internal class ViewCreditCardViewModel( CreditCardFieldType.Tag -> "" } val tagsToSuggest = - if (fieldType == CreditCardFieldType.Tag) observeAllTags().first().toSet() - current.tags + if (fieldType == CreditCardFieldType.Tag) observeAllTags().first() + .toSet() - current.tags else emptySet() _modificationDialogState.update { @@ -236,5 +249,6 @@ internal class ViewCreditCardViewModel( companion object { // "yy" parses into the 2000-2099 range, matching card expirations. private val EXPIRATION_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("MM/yy") + private const val VISIBLE_CARD_NUMBER_SUFFIX = 4 } } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt index 2616e135..7d599734 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt @@ -1,8 +1,24 @@ package de.davis.keygo.feature.item.view.login.model -data class ObfuscatedString(val raw: String) { +data class ObfuscatedString( + val raw: String, + val formatted: String = raw, + val visibleSuffixDigits: Int = 0, +) { val hidden: String - get() = DEFAULT_OBFUSCATION_CHAR.toString().repeat(raw.length) + get() { + val totalDigits = formatted.count(Char::isDigit) + val reveal = if (totalDigits > visibleSuffixDigits) visibleSuffixDigits else 0 + var digitsSeen = 0 + return buildString(formatted.length) { + for (c in formatted) { + if (c.isDigit()) { + digitsSeen++ + append(if (totalDigits - digitsSeen < reveal) c else DEFAULT_OBFUSCATION_CHAR) + } else append(c) + } + } + } companion object { private const val DEFAULT_OBFUSCATION_CHAR: Char = '•' diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt new file mode 100644 index 00000000..5c14353f --- /dev/null +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -0,0 +1,145 @@ +package de.davis.keygo.feature.item.view.creditcard + +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase +import de.davis.keygo.feature.item.view.login.model.ObfuscatedString +import de.davis.keygo.rust.FakeCardFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ViewCreditCardViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val vaultId = newVaultId() + private val itemId = newItemId() + + private val vault = Vault( + id = vaultId, + name = "Test Vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) + + // Encrypt "4111111111111111" using the fake's XOR transform so decrypt produces the original + private val encryptedCardNumber = CreditCard.CardNumber( + payload = EncryptedPayload( + ciphertext = FakeCryptographicScopeProvider.transform( + "4111111111111111".encodeToByteArray() + ), + iv = FakeCryptographicScopeProvider.IV, + ) + ) + + private val creditCard = CreditCard( + id = itemId, + vaultId = vaultId, + name = "Test Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = null, + lastNumbers = "1111", + cardNumber = encryptedCardNumber, + cvv = null, + expirationDate = null, + ) + + private val vaultRepository = FakeVaultRepository() + private val creditCardRepository = FakeCreditCardRepository() + private val itemRepository = FakeItemRepository() + private val cryptoProvider = FakeCryptographicScopeProvider(itemRepository) + private val cardFormatter = FakeCardFormatter().apply { + formatResult = { input -> input.chunked(4).joinToString(" ") } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + vaultRepository.seed(vault) + creditCardRepository.seed(creditCard) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `card number formatted according to card network`() = runTest(dispatcher) { + val cardNumber = awaitCardNumber() + assertEquals("4111 1111 1111 1111", cardNumber.formatted) + } + + @Test + fun `card number raw is unformatted digits`() = runTest(dispatcher) { + val cardNumber = awaitCardNumber() + assertEquals("4111111111111111", cardNumber.raw) + } + + @Test + fun `card number hidden reveals only the last four digits`() = runTest(dispatcher) { + val cardNumber = awaitCardNumber() + assertEquals("•••• •••• •••• 1111", cardNumber.hidden) + } + + private suspend fun awaitCardNumber(): ObfuscatedString { + val vm = makeViewModel().also { it.init(itemId) } + try { + val state = vm.state.first { it.cardNumber != null } + return state.cardNumber!! + } finally { + vm.viewModelScope.cancel() + } + } + + private fun makeViewModel(): ViewCreditCardViewModel { + val sort = SortUseCase() + val observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sort) + val cryptoScopeUseCase = ItemWithCryptoScopeUseCase(vaultRepository, cryptoProvider) + val upsertVaultItem = UpsertVaultItemUseCase(FakeLoginRepository(), creditCardRepository) + val updateCreditCard = CreateNewOrUpdateCreditCardUseCase( + creditCardRepository = creditCardRepository, + cardFormatter = cardFormatter, + cryptographicScopeProvider = cryptoProvider, + vaultRepository = vaultRepository, + upsertVaultItem = upsertVaultItem, + ) + return ViewCreditCardViewModel( + itemRepository = itemRepository, + vaultRepository = vaultRepository, + creditCardRepository = creditCardRepository, + updateCreditCard = updateCreditCard, + observeCreditCardWithCryptoScope = cryptoScopeUseCase, + observeAllTags = observeAllTags, + sort = sort, + cardFormatter = cardFormatter, + ) + } +} diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt new file mode 100644 index 00000000..4a3589d6 --- /dev/null +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedStringTest.kt @@ -0,0 +1,61 @@ +package de.davis.keygo.feature.item.view.login.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ObfuscatedStringTest { + + @Test + fun `formatted defaults to raw`() { + val obs = ObfuscatedString("4111111111111111") + assertEquals("4111111111111111", obs.formatted) + } + + @Test + fun `hidden is all bullets when no formatted string given`() { + val obs = ObfuscatedString("4111111111111111") + assertEquals("•".repeat(16), obs.hidden) + } + + @Test + fun `hidden preserves spaces from formatted string`() { + val obs = ObfuscatedString(raw = "4111111111111111", formatted = "4111 1111 1111 1111") + assertEquals("•••• •••• •••• ••••", obs.hidden) + } + + @Test + fun `raw is unaffected by formatted`() { + val obs = ObfuscatedString(raw = "4111111111111111", formatted = "4111 1111 1111 1111") + assertEquals("4111111111111111", obs.raw) + } + + @Test + fun `hidden reveals last digits for visa grouping`() { + val obs = ObfuscatedString( + raw = "4111111111111234", + formatted = "4111 1111 1111 1234", + visibleSuffixDigits = 4, + ) + assertEquals("•••• •••• •••• 1234", obs.hidden) + } + + @Test + fun `hidden reveals last digits for amex grouping`() { + val obs = ObfuscatedString( + raw = "378282246310005", + formatted = "3782 822463 10005", + visibleSuffixDigits = 4, + ) + assertEquals("•••• •••••• •0005", obs.hidden) + } + + @Test + fun `hidden masks everything when suffix is at least total digits`() { + val obs = ObfuscatedString( + raw = "1234", + formatted = "1234", + visibleSuffixDigits = 4, + ) + assertEquals("••••", obs.hidden) + } +} From 83cad64088b4281c3ae7e4f84729458375b52538 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 12:44:03 +0200 Subject: [PATCH 24/36] fix(broadcastreceiver): registration twice --- .../de/davis/keygo/core/util/presentation/BroadcastReceiver.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt index 0aa808ec..fa3e6b6a 100644 --- a/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt @@ -34,7 +34,6 @@ fun BroadcastReceiver( filter, flags, ) - context.registerReceiver(receiver, filter) onDispose { context.unregisterReceiver(receiver) } } From 9cc1d5fff1a3680d6c38b9ef4d9439aa2deb518c Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 12:44:56 +0200 Subject: [PATCH 25/36] fix(credit-card): safely join card holder name parts --- .../davis/keygo/feature/credit_card/data/mapper/CardMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt index e64dc675..4989b496 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/data/mapper/CardMapper.kt @@ -10,7 +10,7 @@ internal fun EmvCard.toDomain(): Card? { if (digits.isBlank()) return null return Card( - holder = "$holderFirstname $holderLastname".trim(), + holder = listOfNotNull(holderFirstname, holderLastname).joinToString(" ").trim(), cardNumber = cardNumber, expiry = expireDate.toInstant().atZone(ZoneId.systemDefault()).let { YearMonth.from(it) } ) From e3aa7243dc5eb1a7e729334b7512948b5baba406 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 12:45:21 +0200 Subject: [PATCH 26/36] refactor(item-view): memoize obfuscated string calculation --- .../item/view/login/model/ObfuscatedString.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt index 7d599734..7c677e6a 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ObfuscatedString.kt @@ -5,20 +5,19 @@ data class ObfuscatedString( val formatted: String = raw, val visibleSuffixDigits: Int = 0, ) { - val hidden: String - get() { - val totalDigits = formatted.count(Char::isDigit) - val reveal = if (totalDigits > visibleSuffixDigits) visibleSuffixDigits else 0 - var digitsSeen = 0 - return buildString(formatted.length) { - for (c in formatted) { - if (c.isDigit()) { - digitsSeen++ - append(if (totalDigits - digitsSeen < reveal) c else DEFAULT_OBFUSCATION_CHAR) - } else append(c) - } + val hidden: String by lazy { + val totalDigits = formatted.count(Char::isDigit) + val reveal = if (totalDigits > visibleSuffixDigits) visibleSuffixDigits else 0 + var digitsSeen = 0 + buildString(formatted.length) { + for (c in formatted) { + if (c.isDigit()) { + digitsSeen++ + append(if (totalDigits - digitsSeen < reveal) c else DEFAULT_OBFUSCATION_CHAR) + } else append(c) } } + } companion object { private const val DEFAULT_OBFUSCATION_CHAR: Char = '•' From d0bb085f5369e953828f0f4b8de3177e64773ccc Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 15:22:26 +0200 Subject: [PATCH 27/36] refactor(rust/card): rename is_luhn_valid -> is_valid, accept unknown networks Unknown IINs now fall back to the full plausible PAN length range so a card can be validated on length + Luhn alone without requiring a recognised network. Renames the public binding and fake accordingly. Co-Authored-By: Claude Sonnet 4.6 --- rust/rust-code/bindings/src/card.rs | 4 ++-- rust/rust-code/lib/src/card/network.rs | 4 +++- rust/rust-code/lib/src/card/number.rs | 17 ++++++++++++++++- .../de/davis/keygo/rust/FakeCardFormatter.kt | 4 ++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs index acb0964f..04e4e7fb 100644 --- a/rust/rust-code/bindings/src/card.rs +++ b/rust/rust-code/bindings/src/card.rs @@ -28,8 +28,8 @@ impl CardFormatter { .collect() } - pub fn is_luhn_valid(&self, input: String) -> bool { - Card::parse(&input).is_luhn_valid() + pub fn is_valid(&self, input: String) -> bool { + Card::parse(&input).is_valid() } pub fn cvv_len(&self, input: String) -> i32 { diff --git a/rust/rust-code/lib/src/card/network.rs b/rust/rust-code/lib/src/card/network.rs index 9156c8a2..5e2eb3ca 100644 --- a/rust/rust-code/lib/src/card/network.rs +++ b/rust/rust-code/lib/src/card/network.rs @@ -104,7 +104,9 @@ impl CardNetwork { CardNetwork::Jcb => &[16, 17, 18, 19], CardNetwork::UnionPay => &[16, 17, 18, 19], CardNetwork::Maestro => &[12, 13, 14, 15, 16, 17, 18, 19], - CardNetwork::Unknown => &[], + // Unrecognised IIN: accept the full plausible PAN range so an unknown network + // is still structurally validatable by length + Luhn (see `Card::is_valid`). + CardNetwork::Unknown => &[12, 13, 14, 15, 16, 17, 18, 19], } } diff --git a/rust/rust-code/lib/src/card/number.rs b/rust/rust-code/lib/src/card/number.rs index cf88b428..73c3ed7b 100644 --- a/rust/rust-code/lib/src/card/number.rs +++ b/rust/rust-code/lib/src/card/number.rs @@ -74,8 +74,12 @@ impl Card { sum.is_multiple_of(10) } + /// Structurally valid: a length the (possibly [`Unknown`](CardNetwork::Unknown)) network + /// accepts and a passing Luhn check. The network is deliberately *not* gated on being + /// recognised — an unrecognised IIN is still acceptable as long as its length is plausible + /// and the checksum holds. pub fn is_valid(&self) -> bool { - self.network != CardNetwork::Unknown && self.is_length_valid() && self.is_luhn_valid() + self.is_length_valid() && self.is_luhn_valid() } } @@ -252,4 +256,15 @@ mod tests { // Right length, but Luhn fails. assert!(!Card::parse("4111111111111112").is_valid()); } + + #[test] + fn unknown_network_validates_on_length_and_luhn() { + // 9-prefix is an unrecognised network, but a plausible-length, Luhn-valid number is + // still accepted: validity is not gated on recognising the network. + let card = Card::parse("9999999999999995"); + assert_eq!(card.network, crate::card::CardNetwork::Unknown); + assert!(card.is_valid()); + // 11 digits is shorter than any plausible PAN -> rejected on length alone. + assert!(!Card::parse("99999999995").is_valid()); + } } diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt index 35cad33b..2f3e1e75 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt @@ -7,7 +7,7 @@ class FakeCardFormatter : CardFormatterInterface { var digitsResult: (String) -> String = { input -> input.filter(Char::isDigit) } var formatResult: (String) -> String = { it.chunked(4).joinToString(" ") } var spaceIndicesResult: (String) -> List = { emptyList() } - var luhnResult: Boolean = true + var validResult: (String) -> Boolean = { true } var cvvLenResult: (String) -> Int = { 3 } var formatExpirationAfterEditResult: (String, String) -> String = { _, proposed -> proposed } @@ -17,7 +17,7 @@ class FakeCardFormatter : CardFormatterInterface { override fun spaceIndices(input: String): List = spaceIndicesResult(input) - override fun isLuhnValid(input: String): Boolean = luhnResult + override fun isValid(input: String): Boolean = validResult(input) override fun cvvLen(input: String): Int = cvvLenResult(input) From de22d416645ef2d9aa4191fcda8e96c1edd2ccfe Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 15:22:45 +0200 Subject: [PATCH 28/36] refactor(credit-card): remove lastNumbers derived field lastNumbers was a redundant cache of the last four digits of the card number. The full (encrypted) card number is already stored; deriving a preview string from ciphertext is the ViewModel's job. Removes the column from the Room schema, entity, mapper, domain model, and all test fixtures that referenced it. Co-Authored-By: Claude Sonnet 4.6 --- .../1.json | 11 +++-------- .../core/item/data/local/entity/CreditCardEntity.kt | 2 -- .../keygo/core/item/data/mapper/CreditCardMapper.kt | 2 -- .../davis/keygo/core/item/domain/model/CreditCard.kt | 4 +--- .../core/item/data/mapper/CreditCardMapperTest.kt | 5 ----- .../data/repository/CreditCardRepositoryImplTest.kt | 3 --- .../keygo/core/item/domain/model/CreditCardTest.kt | 1 - .../core/item/domain/usecase/UpsertItemUseCaseTest.kt | 1 - .../domain/usecase/ItemWithCryptoScopeUseCaseTest.kt | 1 - 9 files changed, 4 insertions(+), 26 deletions(-) diff --git a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json index 0351c8fd..7f75af62 100644 --- a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json +++ b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "277f0df15cf3c39f8a3683c868c6303d", + "identityHash": "d827ca95e51c22ef1dc56c2eb260f41c", "entities": [ { "tableName": "vault", @@ -173,7 +173,7 @@ }, { "tableName": "credit_card", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `holder` TEXT, `last_numbers` TEXT, `expiration_date` INTEGER, `card_number_ciphertext` BLOB, `card_number_iv` BLOB, `cvv_ciphertext` BLOB, `cvv_iv` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `holder` TEXT, `expiration_date` INTEGER, `card_number_ciphertext` BLOB, `card_number_iv` BLOB, `cvv_ciphertext` BLOB, `cvv_iv` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -186,11 +186,6 @@ "columnName": "holder", "affinity": "TEXT" }, - { - "fieldPath": "lastNumbers", - "columnName": "last_numbers", - "affinity": "TEXT" - }, { "fieldPath": "expirationDate", "columnName": "expiration_date", @@ -602,7 +597,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '277f0df15cf3c39f8a3683c868c6303d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd827ca95e51c22ef1dc56c2eb260f41c')" ] } } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt index de8a22bd..47c2ee1a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt @@ -26,8 +26,6 @@ internal data class CreditCardEntity( val holder: String?, @Embedded(prefix = "card_number_") val cardNumber: EncryptedPayload?, - @ColumnInfo(name = "last_numbers") - val lastNumbers: String?, @Embedded(prefix = "cvv_") val cvv: EncryptedPayload?, @ColumnInfo(name = "expiration_date") diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt index 4cb190a2..bc400155 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt @@ -9,7 +9,6 @@ internal fun CreditCard.toCreditCardEntity() = CreditCardEntity( id = id, holder = holder, cardNumber = cardNumber?.payload, - lastNumbers = lastNumbers, cvv = cvv?.payload, expirationDate = expirationDate ) @@ -25,7 +24,6 @@ internal fun CreditCardProjection.toDomain() = CreditCard( holder = creditCardEntity.holder, cardNumber = creditCardEntity.cardNumber?.let { CreditCard.CardNumber(it) }, - lastNumbers = creditCardEntity.lastNumbers, cvv = creditCardEntity.cvv?.let { CreditCard.CVV(it) }, expirationDate = creditCardEntity.expirationDate, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt index ef64cb80..2b0be44a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt @@ -16,7 +16,6 @@ data class CreditCard( override val note: String?, override val pinned: Boolean, val holder: String?, - val lastNumbers: String?, val cardNumber: CardNumber?, val cvv: CVV?, val expirationDate: YearMonth?, @@ -24,10 +23,9 @@ data class CreditCard( override val itemType: VaultItemType get() = VaultItemType.CreditCard - val hasAnyContent: Boolean get() = !holder.isNullOrBlank() - || (cardNumber != null && !lastNumbers.isNullOrBlank()) + || cardNumber != null || cvv != null || expirationDate != null diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt index d23d5815..a714737d 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt @@ -35,7 +35,6 @@ class CreditCardMapperTest { assertEquals(card.id, entity.id) assertEquals("Alice", entity.holder) assertEquals(cardNumber, entity.cardNumber) - assertEquals("4242", entity.lastNumbers) assertEquals(cvv, entity.cvv) assertEquals(YearMonth.of(2030, 12), entity.expirationDate) } @@ -67,7 +66,6 @@ class CreditCardMapperTest { assertEquals("note", card.note) assertEquals(true, card.pinned) assertEquals("Alice", card.holder) - assertEquals("4242", card.lastNumbers) assertEquals(cardNumber, card.cardNumber?.payload) assertEquals(cvv, card.cvv?.payload) assertEquals(YearMonth.of(2030, 12), card.expirationDate) @@ -78,7 +76,6 @@ class CreditCardMapperTest { fun `toCreditCardEntity with null cardNumber stores null payload`() { val entity = baseCard(cardNumber = null).toCreditCardEntity() assertEquals(null, entity.cardNumber) - assertEquals(null, entity.lastNumbers) } @Test @@ -128,7 +125,6 @@ class CreditCardMapperTest { note = null, pinned = false, holder = holder, - lastNumbers = cardNumber?.let { "4242" }, cardNumber = cardNumber, cvv = cvv, expirationDate = expirationDate, @@ -146,7 +142,6 @@ class CreditCardMapperTest { id = id, holder = "Alice", cardNumber = cardNumber, - lastNumbers = cardNumber?.let { "4242" }, cvv = cvv, expirationDate = expirationDate, ), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt index 084626c3..e6062add 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt @@ -112,7 +112,6 @@ class CreditCardRepositoryImplTest { assertEquals(id, card?.id) assertEquals("Alice", card?.holder) - assertEquals("4242", card?.lastNumbers) assertEquals(YearMonth.of(2030, 12), card?.expirationDate) } @@ -144,7 +143,6 @@ class CreditCardRepositoryImplTest { note = null, pinned = false, holder = "Alice", - lastNumbers = "4242", cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), @@ -155,7 +153,6 @@ class CreditCardRepositoryImplTest { id = id, holder = "Alice", cardNumber = EncryptedPayload.EMPTY, - lastNumbers = "4242", cvv = EncryptedPayload.EMPTY, expirationDate = YearMonth.of(2030, 12), ), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt index 2a7c601a..b8d8b608 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt @@ -58,7 +58,6 @@ class CreditCardTest { note = null, pinned = false, holder = "Alice", - lastNumbers = "4242", cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt index 1c46a442..0aa1cd59 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt @@ -57,7 +57,6 @@ class UpsertItemUseCaseTest { note = null, pinned = false, holder = "Alice", - lastNumbers = "4242", cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt index 45330824..672585cb 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt @@ -49,7 +49,6 @@ class ItemWithCryptoScopeUseCaseTest { note = null, pinned = false, holder = "Alice", - lastNumbers = "4242", cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = CreditCard.CVV(EncryptedPayload.EMPTY), expirationDate = YearMonth.of(2030, 12), From c59cd77814ee8e0184e9469c5e9a7d1edbc36ef3 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 15:22:53 +0200 Subject: [PATCH 29/36] feat(credit-card): validate CVV length against card network Adds InvalidCvv to CreditCardUpsertError and enforces it in CreateNewOrUpdateCreditCardUseCase: when both the card number and CVV are being set, the CVV must match the length reported by the formatter for that network. Updating a CVV without supplying a new card number is left unchecked because the network is unknown in that context. Co-Authored-By: Claude Sonnet 4.6 --- .../domain/model/CreditCardUpsertError.kt | 1 + .../CreateNewOrUpdateCreditCardUseCase.kt | 18 ++--- .../CreateNewOrUpdateCreditCardUseCaseTest.kt | 74 ++++++++++++++----- 3 files changed, 64 insertions(+), 29 deletions(-) diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt index 8863c55e..c5b6100d 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt @@ -6,4 +6,5 @@ package de.davis.keygo.feature.item.core.domain.model sealed interface CreditCardUpsertError : ItemUpsertError { data object InvalidExpiration : CreditCardUpsertError data object InvalidCardNumber : CreditCardUpsertError + data object InvalidCvv : CreditCardUpsertError } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt index 075437b3..cb1d266f 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt @@ -53,6 +53,9 @@ class CreateNewOrUpdateCreditCardUseCase( if (!isValidCardNumber(upsert.cardNumber, allowKeep)) errors.add(CreditCardUpsertError.InvalidCardNumber) + if (!isValidCvv(upsert.cvv, upsert.cardNumber)) + errors.add(CreditCardUpsertError.InvalidCvv) + return errors } @@ -73,9 +76,14 @@ class CreateNewOrUpdateCreditCardUseCase( when (field) { is FieldUpdate.Keep -> allowKeep is FieldUpdate.Clear -> true - is FieldUpdate.Set -> cardFormatter.isLuhnValid(field.value) + is FieldUpdate.Set -> cardFormatter.isValid(field.value) } + private fun isValidCvv(cvv: FieldUpdate, cardNumber: FieldUpdate): Boolean { + if (cvv !is FieldUpdate.Set || cardNumber !is FieldUpdate.Set) return true + return cvv.value.length == cardFormatter.cvvLen(cardNumber.value) + } + override suspend fun fetchExisting(id: ItemId): CreditCard? = creditCardRepository.getCreditCardById(id) @@ -107,7 +115,6 @@ class CreateNewOrUpdateCreditCardUseCase( note = upsert.note.getValue(), pinned = false, holder = upsert.holder.getValue(), - lastNumbers = number?.toLastNumbers(), cardNumber = encryptedNumber?.await(), cvv = encryptedCvv?.await(), expirationDate = upsert.expirationDate.getValue() @@ -130,11 +137,6 @@ class CreateNewOrUpdateCreditCardUseCase( tags = upsert.tags.on(existing.tags).orEmpty(), holder = upsert.holder.on(existing.holder), cardNumber = upsert.cardNumber.on(existing.cardNumber, encryptedNumber), - lastNumbers = when (val cn = upsert.cardNumber) { - FieldUpdate.Keep -> existing.lastNumbers - FieldUpdate.Clear -> null - is FieldUpdate.Set -> cn.value.toLastNumbers() - }, cvv = upsert.cvv.on(existing.cvv, encryptedCvv), expirationDate = when (val exp = upsert.expirationDate) { FieldUpdate.Keep -> existing.expirationDate @@ -144,8 +146,6 @@ class CreateNewOrUpdateCreditCardUseCase( ) } - private fun String.toLastNumbers(): String = filter(Char::isDigit).takeLast(4) - private fun String.toYearMonthOrNull(): YearMonth? = try { YearMonth.parse(this, EXPIRATION_FORMATTER) } catch (_: DateTimeParseException) { diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt index c8f6f44f..bab7cfaa 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt @@ -90,7 +90,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { } @Test - fun `create stores card with name parsed expiration and derived last numbers`() = runTest { + fun `create stores card with name parsed expiration and vault`() = runTest { val result = useCase( UpsertCreditCard.create( vaultId = defaultVault.id, @@ -106,7 +106,6 @@ class CreateNewOrUpdateCreditCardUseCaseTest { assertEquals("My card", stored.name) assertEquals("ALICE SMITH", stored.holder) assertEquals(YearMonth.of(2030, 5), stored.expirationDate) - assertEquals("1111", stored.lastNumbers) assertEquals(defaultVault.id, stored.vaultId) } @@ -195,17 +194,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { assertTrue(result.isSuccess(), "result: $result") val stored = creditCardRepository.getCreditCardById(existing.id) assertEquals("New name", stored?.name) - assertEquals(existing.lastNumbers, stored?.lastNumbers) - } - - @Test - fun `update with new card number recomputes last numbers`() = runTest { - val existing = testCard() - creditCardRepository.seed(existing) - - useCase(UpsertCreditCard.update(itemId = existing.id, cardNumber = set("5555444433332222"))) - - assertEquals("2222", creditCardRepository.getCreditCardById(existing.id)?.lastNumbers) + assertEquals(existing.cardNumber, stored?.cardNumber) } @Test @@ -289,7 +278,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { } @Test - fun `create without card number stores null cardNumber and null lastNumbers`() = runTest { + fun `create without card number stores null cardNumber`() = runTest { val result = useCase( UpsertCreditCard.create( vaultId = defaultVault.id, @@ -301,12 +290,11 @@ class CreateNewOrUpdateCreditCardUseCaseTest { val stored = storedById(result.getOrNull()) assertNotNull(stored) assertNull(stored.cardNumber) - assertNull(stored.lastNumbers) } @Test - fun `create with non-Luhn card number returns InvalidCardNumber error`() = runTest { - val result = makeUseCase(cardFormatter = FakeCardFormatter().also { it.luhnResult = false })( + fun `create with invalid card number returns InvalidCardNumber error`() = runTest { + val result = makeUseCase(cardFormatter = FakeCardFormatter().also { it.validResult = { false } })( UpsertCreditCard.create( vaultId = defaultVault.id, name = "My card", @@ -319,7 +307,55 @@ class CreateNewOrUpdateCreditCardUseCaseTest { } @Test - fun `update clearing card number sets cardNumber and lastNumbers to null`() = runTest { + fun `create with cvv length not matching the network returns InvalidCvv error`() = runTest { + // Card number sets the network; the fake reports an expected CVV length of 4, so a + // 3-digit CVV is rejected. + val formatter = FakeCardFormatter().also { it.cvvLenResult = { 4 } } + val result = makeUseCase(cardFormatter = formatter)( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "378282246310005", + cvv = "123", + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, CreditCardUpsertError.InvalidCvv) + } + + @Test + fun `create with cvv length matching the network succeeds`() = runTest { + val formatter = FakeCardFormatter().also { it.cvvLenResult = { 4 } } + val result = makeUseCase(cardFormatter = formatter)( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + cardNumber = "378282246310005", + cvv = "1234", + ) + ) + + assertTrue(result.isSuccess(), "result: $result") + } + + @Test + fun `update of cvv alone is not length-checked when the card number is kept`() = runTest { + // No card number in the upsert means the network is unknown here, so CVV length is not + // enforced even though the fake would report a different expected length. + val existing = testCard() + creditCardRepository.seed(existing) + val formatter = FakeCardFormatter().also { it.cvvLenResult = { 4 } } + + val result = makeUseCase(cardFormatter = formatter)( + UpsertCreditCard.update(itemId = existing.id, cvv = set("123")), + ) + + assertTrue(result.isSuccess(), "result: $result") + } + + @Test + fun `update clearing card number sets cardNumber to null`() = runTest { val existing = testCard() creditCardRepository.seed(existing) @@ -328,7 +364,6 @@ class CreateNewOrUpdateCreditCardUseCaseTest { assertTrue(result.isSuccess(), "result: $result") val stored = creditCardRepository.getCreditCardById(existing.id) assertNull(stored?.cardNumber) - assertNull(stored?.lastNumbers) } @Test @@ -402,7 +437,6 @@ class CreateNewOrUpdateCreditCardUseCaseTest { note = null, pinned = false, holder = "Test Holder", - lastNumbers = "1111", cardNumber = CreditCard.CardNumber(EncryptedPayload.EMPTY), cvv = cvv, expirationDate = YearMonth.of(2030, 5), From ccfc1a38b43ebcdeea0b529becadb8b66d38ddc4 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 15:23:00 +0200 Subject: [PATCH 30/36] feat(credit-card): surface CVV validation error in create and view screens Propagates InvalidCvv from the use case to the CVV input field in both CreditCardContent (create flow) and ViewCreditCardViewModel (view/edit flow). Adds cvvError to CreditCardBaseState and removes the now-unused lastNumbers field from ViewCreditCardState. Co-Authored-By: Claude Sonnet 4.6 --- .../item/create/presentation/creditcard/CreditCardContent.kt | 1 + .../create/presentation/creditcard/CreditCardViewModel.kt | 1 + .../create/presentation/creditcard/model/CreditCardUiState.kt | 1 + .../feature/item/view/creditcard/ViewCreditCardViewModel.kt | 4 +++- .../feature/item/view/creditcard/model/ViewCreditCardState.kt | 1 - .../item/view/creditcard/ViewCreditCardViewModelTest.kt | 1 - 6 files changed, 6 insertions(+), 3 deletions(-) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index e42079d7..d4f17c2c 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -159,6 +159,7 @@ private fun CreditCardReadyContent( keyboardType = KeyboardType.NumberPassword ), inputTransformation = ccCvvInputTransformation, + error = state.cvvError, ) KeyGoFormField( diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt index 5be4efac..975288db 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -144,6 +144,7 @@ internal class CreditCardViewModel( _base.update { it.copy( numberError = if (failure.contains(CreditCardUpsertError.InvalidCardNumber)) InputFieldError.Invalid else null, + cvvError = if (failure.contains(CreditCardUpsertError.InvalidCvv)) InputFieldError.Invalid else null, expirationDateError = if (failure.contains(CreditCardUpsertError.InvalidExpiration)) InputFieldError.Invalid else null, ) } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index ad8c9cb4..6e8b4738 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -14,6 +14,7 @@ internal data class CreditCardBaseState( val ccCVVTextFieldState: TextFieldState = TextFieldState(), val ccExpirationDateTextFieldState: TextFieldState = TextFieldState(), val numberError: InputFieldError? = null, + val cvvError: InputFieldError? = null, val expirationDateError: InputFieldError? = null, val updating: Boolean = false, ) { diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt index 31c04eb1..bb8a6a62 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -98,7 +98,6 @@ internal class ViewCreditCardViewModel( vaultMetadata = vaultMetadata, holder = card.holder.orEmpty(), cardNumber = number, - lastNumbers = card.lastNumbers.orEmpty(), cvv = cvv, expirationDate = card.expirationDate?.format(EXPIRATION_FORMATTER).orEmpty(), tags = sort(card.tags) { it.display }.toSet(), @@ -227,6 +226,9 @@ internal class ViewCreditCardViewModel( failure.contains(CreditCardUpsertError.InvalidExpiration) -> InputFieldError.Invalid + failure.contains(CreditCardUpsertError.InvalidCvv) -> + InputFieldError.Invalid + failure.contains(ItemUpsertError.BlankName) -> InputFieldError.Empty diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt index ed4c14cb..924a8983 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt @@ -11,7 +11,6 @@ data class ViewCreditCardState( val vaultMetadata: VaultMetadata? = null, val holder: String = "", val cardNumber: ObfuscatedString? = null, - val lastNumbers: String = "", val cvv: ObfuscatedString? = null, val expirationDate: String = "", val tags: Set = emptySet(), diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index 5c14353f..d2d70d61 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -65,7 +65,6 @@ class ViewCreditCardViewModelTest { note = null, pinned = false, holder = null, - lastNumbers = "1111", cardNumber = encryptedCardNumber, cvv = null, expirationDate = null, From 641e9ea5bcb085fe934eb697a4e60210651b8334 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 15:23:06 +0200 Subject: [PATCH 31/36] fix(view): release onHold state in a finally block Moves the onHold(false) call into a finally block so the held state is always cleared even when the pointer-input coroutine is cancelled (e.g. by navigation or recomposition). Co-Authored-By: Claude Sonnet 4.6 --- .../davis/keygo/feature/item/view/onHold.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt index 07be8bb2..b654b21c 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt @@ -11,17 +11,19 @@ fun Modifier.onHold(onHold: (Boolean) -> Unit) = this.pointerInput(Unit) { val down = awaitFirstDown() onHold(true) - val pointerId = down.id - do { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } - if (change == null || change.changedToUpIgnoreConsumed()) { - break - } + try { + val pointerId = down.id + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } + if (change == null || change.changedToUpIgnoreConsumed()) { + break + } - change.consume() - } while (true) - - onHold(false) + change.consume() + } while (true) + } finally { + onHold(false) + } } } \ No newline at end of file From 10f080eb00308cab8761191c054526fb26975a3f Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 16:02:43 +0200 Subject: [PATCH 32/36] feat(item-core): add InputFieldError.System for non-field failures --- .../feature/item/core/presentation/component/KeyGoFormField.kt | 1 + .../feature/item/core/presentation/model/InputFieldError.kt | 3 ++- feature/item/core/src/main/res/values/strings.xml | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt index 076c42b6..fbf6b55d 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt @@ -60,6 +60,7 @@ fun KeyGoFormField( val text = when (error) { InputFieldError.Empty -> stringResource(R.string.field_blank) InputFieldError.Invalid -> stringResource(R.string.field_invalid) + InputFieldError.System -> stringResource(R.string.field_system) } Text(text = text, color = MaterialTheme.colorScheme.error) } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt index cd8a9298..0d5c6fe4 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt @@ -3,4 +3,5 @@ package de.davis.keygo.feature.item.core.presentation.model sealed interface InputFieldError { data object Empty : InputFieldError data object Invalid : InputFieldError -} \ No newline at end of file + data object System : InputFieldError +} diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index 75c4a6c6..a2ce8e83 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -26,4 +26,5 @@ This field can not be blank This input is invalid - \ No newline at end of file + Something went wrong. Please try again. + From 947ed6302577c250506e16deed38af4952275131 Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 16:06:27 +0200 Subject: [PATCH 33/36] fix: surface system errors in modification dialog --- .../creditcard/ViewCreditCardViewModel.kt | 69 ++++++++++--------- .../item/view/login/ViewLoginViewModel.kt | 28 +++++--- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt index bb8a6a62..d5914533 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -180,43 +180,48 @@ internal class ViewCreditCardViewModel( _itemId.value?.let { id -> viewModelScope.launch { - updateCreditCard( - when (dialog.fieldType) { - CreditCardFieldType.Holder -> UpsertCreditCard.update( - itemId = id, - holder = newText, - ) + val request = when (dialog.fieldType) { + CreditCardFieldType.Holder -> UpsertCreditCard.update( + itemId = id, + holder = newText, + ) - CreditCardFieldType.CardNumber -> UpsertCreditCard.update( - itemId = id, - cardNumber = newText, - ) + CreditCardFieldType.CardNumber -> UpsertCreditCard.update( + itemId = id, + cardNumber = newText, + ) - CreditCardFieldType.Cvv -> UpsertCreditCard.update( - itemId = id, - cvv = newText, - ) + CreditCardFieldType.Cvv -> UpsertCreditCard.update( + itemId = id, + cvv = newText, + ) - CreditCardFieldType.Expiration -> UpsertCreditCard.update( - itemId = id, - expirationDate = newText, - ) + CreditCardFieldType.Expiration -> UpsertCreditCard.update( + itemId = id, + expirationDate = newText, + ) + + CreditCardFieldType.Note -> UpsertCreditCard.update( + itemId = id, + note = newText, + ) - CreditCardFieldType.Note -> UpsertCreditCard.update( + CreditCardFieldType.Tag -> { + val raw = newText.onSet { it } ?: run { + _modificationDialogState.update { dialog.copy(error = InputFieldError.Empty) } + return@launch + } + val tag = Tag.of(raw) ?: run { + _modificationDialogState.update { dialog.copy(error = InputFieldError.Invalid) } + return@launch + } + UpsertCreditCard.update( itemId = id, - note = newText, + tags = set(state.value.tags + tag), ) - - CreditCardFieldType.Tag -> newText.onSet { raw -> - Tag.of(raw)?.let { tag -> - UpsertCreditCard.update( - itemId = id, - tags = set(state.value.tags + tag), - ) - } - } ?: return@launch - }, - ).onFailure { failure -> + } + } + updateCreditCard(request).onFailure { failure -> _modificationDialogState.update { dialog.copy( error = when { @@ -235,7 +240,7 @@ internal class ViewCreditCardViewModel( failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty - else -> null + else -> InputFieldError.System }, ) } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index 4594e929..cab65063 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt @@ -289,14 +289,20 @@ internal class ViewLoginViewModel( ) } ?: return@launch - FieldType.Tag -> newText.onSet { raw -> - Tag.of(raw)?.let { tag -> - UpsertLogin.update( - itemId = id, - tags = set(state.value.tags + tag), - ) + FieldType.Tag -> { + val raw = newText.onSet { it } ?: run { + _modificationDialogState.update { dialog.copy(error = InputFieldError.Empty) } + return@launch } - } ?: return@launch + val tag = Tag.of(raw) ?: run { + _modificationDialogState.update { dialog.copy(error = InputFieldError.Invalid) } + return@launch + } + UpsertLogin.update( + itemId = id, + tags = set(state.value.tags + tag), + ) + } FieldType.Note -> UpsertLogin.update( itemId = id, @@ -306,9 +312,11 @@ internal class ViewLoginViewModel( ).onFailure { failure -> _modificationDialogState.update { dialog.copy( - error = if (failure.contains(ItemUpsertError.Empty) - || failure.contains(ItemUpsertError.BlankName) - ) InputFieldError.Empty else null, + error = when { + failure.contains(ItemUpsertError.BlankName) || + failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty + else -> InputFieldError.System + }, ) } }.onSuccess { From b7a2b7aee17badd0beb85ed38eb311273a83b98c Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 16:18:27 +0200 Subject: [PATCH 34/36] fix(card-scan): ignore new NFC tags during Success delay --- .../feature/credit_card/presentation/CardScanViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt index e6afd3c9..9e328e96 100644 --- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt @@ -31,8 +31,9 @@ internal class CardScanViewModel( private var readJob: Job? = null fun onTransport(transport: ApduTransport?) { - // A tag arriving mid-read is ignored; a single read runs to completion. - if (_state.value is CardScanUiState.Reading) return + // Ignore new tags while a read is in progress or while the success animation plays. + val current = _state.value + if (current is CardScanUiState.Reading || current is CardScanUiState.Success) return if (transport == null) { _state.value = CardScanUiState.Failure(CardReadFailure.NotAnEmvCard) return From 114905a83f9288139844d701397a239cdc0a526d Mon Sep 17 00:00:00 2001 From: Davis Date: Fri, 29 May 2026 16:22:22 +0200 Subject: [PATCH 35/36] refactor(item-core): make ItemUpsertError a sealed interface --- .../feature/item/core/domain/model/ItemUpsertError.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt index 1c644470..bc9415ce 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt @@ -3,10 +3,11 @@ package de.davis.keygo.feature.item.core.domain.model import de.davis.keygo.core.security.domain.model.CryptoScopeError /** - * Errors surfaced when creating or updating any vault item. Call sites inspect it with - * `contains`/`is` checks rather than exhaustive `when`. + * Errors surfaced when creating or updating any vault item. When inspecting a + * `Set` use `contains`/`is`; when switching on a single instance, + * a sealed `when` gives exhaustive compile-time safety. */ -interface ItemUpsertError { +sealed interface ItemUpsertError { data object BlankName : ItemUpsertError data object Empty : ItemUpsertError data object InvalidVaultId : ItemUpsertError From 865af616ce8cbbf58f90b08017a88cd7c4de013e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 30 May 2026 15:59:46 +0200 Subject: [PATCH 36/36] fix(credit-card): suppress R8 missing-class error for slf4j The devnied emvnfccard library logs through slf4j-api but ships no slf4j binding, so org.slf4j.impl.StaticLoggerBinder is absent at build time. R8 full-mode treats the missing class as a hard error, breaking the minified app build (and CodeQL autobuild's assemble). Add -dontwarn org.slf4j.** to the module's consumer rules so it propagates into the app's R8 run. slf4j degrades to a no-op logger at runtime, so there is no behavioral impact. Co-Authored-By: Claude Opus 4.8 --- feature/credit-card/consumer-rules.pro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/feature/credit-card/consumer-rules.pro b/feature/credit-card/consumer-rules.pro index e69de29b..329956b3 100644 --- a/feature/credit-card/consumer-rules.pro +++ b/feature/credit-card/consumer-rules.pro @@ -0,0 +1,4 @@ +# The devnied emvnfccard library logs through slf4j-api but ships no slf4j +# binding, so org.slf4j.impl.StaticLoggerBinder is absent at build time. slf4j +# degrades to a no-op logger at runtime; tell R8 not to fail on the missing class. +-dontwarn org.slf4j.**