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/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 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/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..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": "40462f38d8b4873c28fe3aec0e192229", + "identityHash": "d827ca95e51c22ef1dc56c2eb260f41c", "entities": [ { "tableName": "vault", @@ -171,6 +171,67 @@ } ] }, + { + "tableName": "credit_card", + "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", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "holder", + "columnName": "holder", + "affinity": "TEXT" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "cardNumber.ciphertext", + "columnName": "card_number_ciphertext", + "affinity": "BLOB" + }, + { + "fieldPath": "cardNumber.iv", + "columnName": "card_number_iv", + "affinity": "BLOB" + }, + { + "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 +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, '40462f38d8b4873c28fe3aec0e192229')" + "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/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/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/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..47c2ee1a --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/CreditCardEntity.kt @@ -0,0 +1,33 @@ +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?, + @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 +) 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..bc400155 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapper.kt @@ -0,0 +1,29 @@ +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, + 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 = creditCardEntity.cardNumber?.let { CreditCard.CardNumber(it) }, + 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/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/model/CreditCard.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt new file mode 100644 index 00000000..2b0be44a --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt @@ -0,0 +1,57 @@ +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 cardNumber: CardNumber?, + val cvv: CVV?, + val expirationDate: YearMonth?, +) : Item { + override val itemType: VaultItemType + get() = VaultItemType.CreditCard + + val hasAnyContent: Boolean + get() = !holder.isNullOrBlank() + || cardNumber != null + || cvv != null + || expirationDate != null + + 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/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/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..a714737d --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/CreditCardMapperTest.kt @@ -0,0 +1,164 @@ +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 kotlin.test.assertNull +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(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(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) + } + + @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( + 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), + expirationDate: YearMonth? = YearMonth.of(2030, 12), + ): CreditCard = CreditCard( + id = id, + vaultId = newVaultId(), + name = "Test Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = emptySet(), + note = null, + pinned = false, + holder = holder, + cardNumber = cardNumber, + cvv = cvv, + expirationDate = expirationDate, + ) + + private fun baseProjection( + id: ItemId = newItemId(), + vaultId: de.davis.keygo.core.item.domain.alias.VaultId = newVaultId(), + 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, + cvv = cvv, + expirationDate = expirationDate, + ), + 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..e6062add --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImplTest.kt @@ -0,0 +1,175 @@ +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(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", + 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, + 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..b8d8b608 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CreditCardTest.kt @@ -0,0 +1,65 @@ +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", + 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..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 @@ -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,23 @@ 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", + 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 +91,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/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/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..672585cb --- /dev/null +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/domain/usecase/ItemWithCryptoScopeUseCaseTest.kt @@ -0,0 +1,128 @@ +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", + 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/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..fa3e6b6a --- /dev/null +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/presentation/BroadcastReceiver.kt @@ -0,0 +1,40 @@ +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, + ) + + onDispose { context.unregisterReceiver(receiver) } + } +} \ No newline at end of file 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..d4318372 --- /dev/null +++ b/feature/credit-card/build.gradle.kts @@ -0,0 +1,72 @@ +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 + } + + testFixtures { + enable = true + } + +} + +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) + implementation(projects.core.ui) + + // Koin DI + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.androidx.compose) + 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)) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/feature/credit-card/consumer-rules.pro b/feature/credit-card/consumer-rules.pro new file mode 100644 index 00000000..329956b3 --- /dev/null +++ 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.** 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..4989b496 --- /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 = listOfNotNull(holderFirstname, holderLastname).joinToString(" ").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/CardScanBottomSheet.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt new file mode 100644 index 00000000..de9f6479 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanBottomSheet.kt @@ -0,0 +1,68 @@ +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.compose.ui.platform.LocalContext +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() + + 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, + ) { + 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/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/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..9e328e96 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanViewModel.kt @@ -0,0 +1,62 @@ +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.delay +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?) { + // 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 + } + + _state.value = CardScanUiState.Reading + readJob = viewModelScope.launch { + 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) }, + ) + } + } + + 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/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/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..498bccc6 --- /dev/null +++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/NfcInfoCard.kt @@ -0,0 +1,286 @@ +package de.davis.keygo.feature.credit_card.presentation + +import android.content.Intent +import android.provider.Settings +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.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 +internal fun NfcInfoCard( + state: CardScanUiState, + nfcEnabled: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + 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), + ) + } + } + } + } + } +} + +/** + * 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) } +} + +@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, + ) + + 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() + ) + ) + ), + ) +} + +@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 = {}, + ) + } + } +} + +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..fdfd2457 --- /dev/null +++ b/feature/credit-card/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + 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. + Couldn\'t read this card\'s data. + Something went wrong reading the card. + Scan card + Fill details via NFC + 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/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/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..c5b6100d --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/CreditCardUpsertError.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.item.core.domain.model + +/** + * Credit-card-specific upsert errors + */ +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/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..bc9415ce --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/ItemUpsertError.kt @@ -0,0 +1,17 @@ +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. When inspecting a + * `Set` use `contains`/`is`; when switching on a single instance, + * a sealed `when` gives exhaustive compile-time safety. + */ +sealed 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..16ef62dc --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertCreditCard.kt @@ -0,0 +1,60 @@ +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, + val expirationDate: FieldUpdate, +) : UpsertItem { + companion object { + fun create( + vaultId: VaultId, + name: String, + cardNumber: String? = null, + expirationDate: String? = null, + holder: String? = null, + cvv: String? = null, + note: String? = null, + tags: Set = emptySet(), + ) = UpsertCreditCard( + upsertType = UpsertType.Create(vaultId), + name = FieldUpdate.Set(name), + 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, + 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..6e8c518c --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertItem.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.item.core.domain.model + +import de.davis.keygo.core.item.domain.model.Tag + +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..cb1d266f --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCase.kt @@ -0,0 +1,159 @@ +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 de.davis.keygo.rust.card.CardFormatter +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( + private val creditCardRepository: CreditCardRepository, + private val cardFormatter: CardFormatter, + cryptographicScopeProvider: CryptographicScopeProvider, + 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) + + if (!isValidCardNumber(upsert.cardNumber, allowKeep)) + errors.add(CreditCardUpsertError.InvalidCardNumber) + + if (!isValidCvv(upsert.cvv, upsert.cardNumber)) + errors.add(CreditCardUpsertError.InvalidCvv) + + 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 -> 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.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) + + override fun isEmpty(item: CreditCard, upsert: UpsertCreditCard): Boolean = !item.hasAnyContent + + override fun relocate( + item: CreditCard, + vaultId: VaultId, + keyInformation: KeyInformation, + ): CreditCard = item.copy(vaultId = vaultId, keyInformation = keyInformation) + + override suspend fun CryptographicScope.buildCreate( + upsert: UpsertCreditCard, + itemId: ItemId, + vaultId: VaultId, + keyInformation: KeyInformation, + ): CreditCard = coroutineScope { + 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( + 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(), + cardNumber = encryptedNumber?.await(), + cvv = encryptedCvv?.await(), + expirationDate = upsert.expirationDate.getValue() + ?.toYearMonthOrNull(), // toYearMonthOrNull will not return null, since isValidExpiration ensures the date to be correct + ) + } + + override suspend fun CryptographicScope.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 = upsert.cardNumber.on(existing.cardNumber, encryptedNumber), + cvv = upsert.cvv.on(existing.cvv, encryptedCvv), + expirationDate = when (val exp = upsert.expirationDate) { + FieldUpdate.Keep -> existing.expirationDate + FieldUpdate.Clear -> null + is FieldUpdate.Set -> exp.value.toYearMonthOrNull() + }, + ) + } + + 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..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 @@ -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,84 @@ 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, - ) - } + override fun isEmpty(item: Login, upsert: UpsertLogin): Boolean = + !item.hasAnyContent && !upsert.hasPendingPasskey - return when (updatedLogin) { - is Result.Success -> upsertVaultItem(updatedLogin.success).mapFailure { - setOf(LoginError.DatabaseError(it)) - } + override fun relocate(item: Login, vaultId: VaultId, keyInformation: KeyInformation): Login = + item.copy(vaultId = vaultId, keyInformation = keyInformation) - is Result.Failure -> Result.Failure(setOf(updatedLogin.error)) - } - } - - private suspend fun buildCreate( + override suspend fun CryptographicScope.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) + } + val totp = upsert.totpUriOrSecret.onSet { uriOrSecret -> + async { uriOrSecret.convertTotpUriOrSecretToUri(itemId) } + } - if (!built.hasAnyContent && !upsert.hasPendingPasskey) return Result.Failure(LoginError.EmptyLogin) - built + 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, + ) } - private suspend fun buildUpdate( + override suspend fun CryptographicScope.buildUpdate( upsert: UpsertLogin, - id: ItemId, - targetVaultId: VaultId?, - ): Result = resultBinding { - val existing = loginRepository.getLoginById(id) - ?: return Result.Failure(LoginError.InvalidItemId) - - val sourceVaultKeyInfo = vaultRepository.getKeyInformation(existing.vaultId) - ?: return Result.Failure(LoginError.InvalidVaultId) - val sourceVault = WrappedVaultKeyInformation( - wrappedVaultKey = sourceVaultKeyInfo, - vaultId = existing.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), - ) + 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 +149,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..64497ade --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateOrUpdateItemUseCase.kt @@ -0,0 +1,149 @@ +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 [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 [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 + + /** 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/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/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..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 @@ -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,12 +52,15 @@ 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 { { 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) } @@ -113,6 +117,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/model/InputFieldError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/InputFieldError.kt index 9b1192f7..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 @@ -2,4 +2,6 @@ package de.davis.keygo.feature.item.core.presentation.model sealed interface InputFieldError { data object Empty : InputFieldError -} \ No newline at end of file + data object Invalid : InputFieldError + data object System : InputFieldError +} 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..312f3ed9 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CardNumberInputTransformation.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 androidx.compose.runtime.remember +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject + +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() + } + } +} + +@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 new file mode 100644 index 00000000..9ca09846 --- /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 androidx.compose.runtime.remember +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject + +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 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 new file mode 100644 index 00000000..28d20b11 --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/transformation/CvvInputTransformation.kt @@ -0,0 +1,35 @@ +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() { + 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() + } + } +} + +/** + * 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 new file mode 100644 index 00000000..91f0d46b --- /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 androidx.compose.runtime.remember +import de.davis.keygo.rust.card.CardFormatter +import org.koin.compose.koinInject + +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 { + val cardFormatter = koinInject() + return remember(cardFormatter) { ExpirationDateInputTransformation(cardFormatter) } +} diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index 3c203860..a2ce8e83 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -8,12 +8,23 @@ Domains Tags + Cardholder + Card Number + Card CVV + Card Expiration Date + + MM/YY + Back Edit Edit Delete + Update your Item + Email, phone, or username This field can not be blank - \ No newline at end of file + This input is invalid + Something went wrong. Please try again. + 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..bab7cfaa --- /dev/null +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt @@ -0,0 +1,449 @@ +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.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 +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 cardFormatter = FakeCardFormatter() + 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 vault`() = 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(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) + + assertNotNull(stored.cardNumber) + 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.cardNumber, stored?.cardNumber) + } + + @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) + assertNotNull(stored.cardNumber) + 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 `create without card number stores null cardNumber`() = runTest { + val result = useCase( + UpsertCreditCard.create( + vaultId = defaultVault.id, + name = "My card", + holder = "Alice", + ) + ) + + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + assertNull(stored.cardNumber) + } + + @Test + 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", + cardNumber = "1234567890123456", + ) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, CreditCardUpsertError.InvalidCardNumber) + } + + @Test + 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) + + val result = useCase(UpsertCreditCard.update(itemId = existing.id, cardNumber = clear())) + + assertTrue(result.isSuccess(), "result: $result") + val stored = creditCardRepository.getCreditCardById(existing.id) + assertNull(stored?.cardNumber) + } + + @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() + 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, + cardFormatter: FakeCardFormatter = this.cardFormatter, + ) = CreateNewOrUpdateCreditCardUseCase( + cryptographicScopeProvider = cryptographicScopeProvider, + creditCardRepository = creditCardRepository, + cardFormatter = cardFormatter, + 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", + 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 8cccdca0..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 @@ -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 @@ -22,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 @@ -72,7 +73,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertEquals(LoginError.BlankName, result.error.single()) + assertEquals(ItemUpsertError.BlankName, result.error.single()) } @Test @@ -82,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, @@ -98,7 +99,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertEquals(setOf(LoginError.EmptyLogin), result.error) + assertEquals(setOf(ItemUpsertError.Empty), result.error) } @Test @@ -159,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 @@ -170,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 @@ -195,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 @@ -356,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 @@ -407,7 +408,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - assertContains(result.error, LoginError.InvalidVaultId) + assertContains(result.error, ItemUpsertError.InvalidVaultId) } @Test @@ -555,7 +556,7 @@ class CreateNewOrUpdateLoginUseCaseTest { ) assertTrue(result.isFailure()) - val error = assertIs(result.error.single()) + val error = assertIs(result.error.single()) assertEquals(cause, error.throwable) } @@ -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/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/EditItemScreen.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/EditItemScreen.kt index 1b7da832..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 @@ -39,6 +40,12 @@ private fun ForInit( loginCreated = onCreated, navigateBack = navigateBack, ) + + 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/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 new file mode 100644 index 00000000..d4f17c2c --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -0,0 +1,179 @@ +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.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.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.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 +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 +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 + + +@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, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun CreditCardReadyContent( + state: CreditCardBaseState, + shared: SharedItemState, + onEvent: (CreditCardUiEvent) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val tagsTextFieldState = rememberTextFieldState() + val ccNumberInputTransformation = rememberCardNumberInputTransformation() + val ccNumberOutputTransformation = rememberCardNumberOutputTransformation() + val ccCvvInputTransformation = rememberCvvInputTransformation(state.ccNumberTextFieldState) + val ccExpirationDateInputTransformation = rememberExpirationDateInputTransformation() + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CreateOrModifyItemTopAppBar( + itemType = VaultItemType.CreditCard, + updating = state.updating, + onBackClick = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.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(shared.nameTextFieldState.text), + ) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = stringResource(R.string.submit_content_description), + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + KeyGoItemForm( + nameTextFieldState = shared.nameTextFieldState, + tagsTextFieldState = tagsTextFieldState, + notesTextFieldState = shared.notesTextFieldState, + nameExists = shared.nameExists, + vaultsState = shared.vaultsState, + onVaultSelect = { onEvent(CreditCardUiEvent.ItemUi(ItemUiEvent.OnVaultSelected(it))) }, + assignedTags = shared.itemAssignedTags, + tagsForSuggestions = shared.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_scan_entry") { + CardScanEntry( + onCardRead = { onEvent(CreditCardUiEvent.OnCardScanned(it)) }, + ) + } + + 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)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ), + inputTransformation = ccNumberInputTransformation, + outputTransformation = ccNumberOutputTransformation, + error = state.numberError, + ) + + KeyGoFormField( + state = state.ccCVVTextFieldState, + label = { Text(text = stringResource(ItemCoreR.string.cc_cvv)) }, + isSecure = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ), + inputTransformation = ccCvvInputTransformation, + error = state.cvvError, + ) + + 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 + ), + inputTransformation = ccExpirationDateInputTransformation, + error = state.expirationDateError, + ) + } + } + } + } +} 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..f78435df --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardScreen.kt @@ -0,0 +1,39 @@ +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 + +@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() + + LaunchedEffect(detailPaneInformation) { + viewmodel.init(detailPaneInformation) + } + + ObserveAsEvents(viewmodel.itemCreatedEvent) { + when (it) { + null -> navigateBack() + else -> creditCardCreated(it) + } + } + + CreditCardContent( + state = state, + onEvent = viewmodel::onEvent, + ) +} 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..975288db --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardViewModel.kt @@ -0,0 +1,189 @@ +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.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 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@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, + vaultRepository: VaultRepository, +) : ItemViewModel( + vaultContextRepository = vaultContextRepository, + itemRepository = itemRepository, + observeAllTags = observeAllTags, + vaultRepository = vaultRepository, +) { + + 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, + ), + ) + + override val itemState: Flow = _base + + fun init(information: DetailPaneInformation) { + when (information) { + is DetailPaneInformation.Init.Existing -> + viewModelScope.launch { initWithId(information.id) } + + is DetailPaneInformation.Init.New, + is DetailPaneInformation.Init.TOTP, + is DetailPaneInformation.CreateRaw -> Unit // nothing to prefill + } + } + + private suspend fun initWithId(itemId: ItemId) { + this.itemId = itemId + + itemWithCryptoScope.oneShot( + itemId = itemId, + fetch = creditCardRepository::getCreditCardById, + ) { card -> + val (number, cvv) = coroutineScope { + val number = card.cardNumber?.let { number -> async { number.decrypt() } } + val cvv = card.cvv?.let { secret -> async { secret.decrypt() } } + 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 ?: "") + ccCVVTextFieldState.setTextAndPlaceCursorAtEnd(cvv ?: "") + ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd( + card.expirationDate?.format(CC_EXPIRATION_FORMATTER) ?: "", + ) + setSelectedVaultId(card.vaultId) + setAssignedTags(card.tags) + _base.update { it.copy(updating = true) } + } + } + + override fun onSubmit() { + 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(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, + ) + } + + if (failure.any { it is ItemUpsertError.InvalidVaultId }) { + snackbarManager.sendMessage( + message = SnackbarMessage( + message = ResourceString(R.string.invalid_vault_id), + ), + ) + } + + failure.filterIsInstance() + .firstOrNull() + ?.let { dbError -> + snackbarManager.sendMessage( + message = SnackbarMessage( + message = ResourceString( + R.string.database_error, + dbError.throwable.message ?: "no message", + ), + ), + ) + } + } + } + } + + fun onEvent(event: CreditCardUiEvent) { + when (event) { + is CreditCardUiEvent.ItemUi -> onItemUiEvent(event.event) + is CreditCardUiEvent.OnCardScanned -> applyScannedCard(event.card) + } + } + + 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 new file mode 100644 index 00000000..34220d04 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiEvent.kt @@ -0,0 +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/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..6e8b4738 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -0,0 +1,30 @@ +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.feature.item.core.presentation.model.InputFieldError +import de.davis.keygo.feature.item.create.presentation.model.ItemUiState + +internal typealias CreditCardUiState = ItemUiState + +@Stable +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 cvvError: 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() && hasAnyContent +} 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..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,37 +2,31 @@ 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.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize 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.automirrored.filled.ArrowBack 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 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 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 @@ -47,15 +41,17 @@ 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 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 @@ -65,56 +61,35 @@ 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.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 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 internal fun LoginContent(state: LoginUiState, onEvent: (LoginUiEvent) -> Unit) { - when (state) { - LoginUiState.Loading -> LoginLoadingScaffold( - onBackClick = { onEvent(LoginUiEvent.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 = { - LoginTopAppBar( - 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() @@ -125,9 +100,10 @@ private fun LoginReadyContent( Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - LoginTopAppBar( + CreateOrModifyItemTopAppBar( + itemType = VaultItemType.Login, updating = state.updating, - onBackClick = { onEvent(LoginUiEvent.OnBackClick) }, + onBackClick = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, actions = { IconButton( onClick = { @@ -137,15 +113,17 @@ 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, + enabled = state.canSave(shared.nameTextFieldState.text), ) { Icon( imageVector = Icons.Default.Done, @@ -158,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) @@ -170,11 +148,11 @@ private fun LoginReadyContent( .imePadding() .nestedScroll(scrollBehavior.nestedScrollConnection), nameError = state.nameError, - nameExists = state.nameExists, - vaultsState = vaultsState, - onVaultSelect = { onEvent(LoginUiEvent.OnVaultSelected(it)) }, - onTagSubmitted = { onEvent(LoginUiEvent.OnAddTags(it)) }, - onDeleteTag = { onEvent(LoginUiEvent.OnRemoveTag(it)) }, + 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))) }, ) { item(key = "password_information") { var forceCompact by rememberSaveable { mutableStateOf(false) } @@ -328,7 +306,7 @@ private fun LoginReadyContent( if (state.scanning) { QRScanner( - onClose = { onEvent(LoginUiEvent.OnBackClick) }, + onClose = { onEvent(LoginUiEvent.ItemUi(ItemUiEvent.OnBackClick)) }, success = { onEvent(LoginUiEvent.OnCodesScanned(it)) }, @@ -336,43 +314,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 @@ -382,7 +323,7 @@ private fun LoginContentPreview() { val selectedVaultId = newVaultId() KeyGoTheme { LoginContent( - state = LoginUiState.Ready( + state = ItemUiState.Ready( base = LoginBaseState( strengthScore = PasswordScore.Weak, domains = setOf( @@ -392,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 6d7f47fe..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 @@ -4,19 +4,18 @@ 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 +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 @@ -24,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 @@ -34,12 +33,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.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 @@ -48,22 +47,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 @@ -71,82 +63,41 @@ import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class LoginViewModel( - private val loginWithCryptoScope: LoginWithCryptoScopeUseCase, - private val itemRepository: ItemRepository, - private val vaultContextRepository: VaultContextRepository, + private val itemWithCryptoScope: ItemWithCryptoScopeUseCase, + private val loginRepository: LoginRepository, 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 } @@ -162,19 +113,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) } } @@ -217,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 -> @@ -243,16 +182,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, @@ -293,84 +232,86 @@ internal class LoginViewModel( } } - fun onEvent(event: LoginUiEvent) { - when (event) { - is LoginUiEvent.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(ItemUpsertError.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 ItemUpsertError.InvalidVaultId }) { + snackbarManager.sendMessage( + message = SnackbarMessage( + message = ResourceString(R.string.invalid_vault_id), + ), + ) + } - if (failure.any { it is LoginError.InvalidVaultId }) { - snackbarManager.sendMessage( - message = SnackbarMessage( - message = ResourceString(R.string.invalid_vault_id), + failure.filterIsInstance() + .firstOrNull() + ?.let { dbError -> + snackbarManager.sendMessage( + message = SnackbarMessage( + 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 LoginUiEvent.OnGeneratePasswordClick -> { - _base.update { it.copy(generatePasswordBottomSheetVisible = true) } - } + override fun onBackClick() { + if (_base.value.scanning) { + _base.update { it.copy(scanning = false) } + return + } - is LoginUiEvent.OnBackClick -> { - if (_base.value.scanning) { - _base.update { it.copy(scanning = false) } - return - } + navigateUp() + } + + fun onEvent(event: LoginUiEvent) { + when (event) { + is LoginUiEvent.ItemUi -> onItemUiEvent(event.event) - navigateUp() + is LoginUiEvent.OnGeneratePasswordClick -> { + _base.update { it.copy(generatePasswordBottomSheetVisible = true) } } is LoginUiEvent.OnCloseBottomSheet -> { @@ -479,29 +420,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/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/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 +} 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, +) diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index eb1879a8..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 @@ -52,8 +53,6 @@ \u2022 Before: %s \u2022 After: %s - Update your Item - Submit Generate Password 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) + } +} diff --git a/feature/item/view/build.gradle.kts b/feature/item/view/build.gradle.kts index 6a647354..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) -} \ 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..4131061f --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt @@ -0,0 +1,459 @@ +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.formatted, + 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", + formatted = "4111 1111 1111 1111", + visibleSuffixDigits = 4 + ), + 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..d5914533 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -0,0 +1,261 @@ +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.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 +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, + private val cardFormatter: CardFormatter, +) : 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 { + 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()) + } + + ViewCreditCardState( + name = card.name, + vaultMetadata = vaultMetadata, + holder = card.holder.orEmpty(), + cardNumber = number, + 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 { + val request = 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 -> { + 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, + tags = set(state.value.tags + tag), + ) + } + } + updateCreditCard(request).onFailure { failure -> + _modificationDialogState.update { + dialog.copy( + error = when { + failure.contains(CreditCardUpsertError.InvalidCardNumber) -> + InputFieldError.Invalid + + failure.contains(CreditCardUpsertError.InvalidExpiration) -> + InputFieldError.Invalid + + failure.contains(CreditCardUpsertError.InvalidCvv) -> + InputFieldError.Invalid + + failure.contains(ItemUpsertError.BlankName) -> + InputFieldError.Empty + + failure.contains(ItemUpsertError.Empty) -> + InputFieldError.Empty + + else -> InputFieldError.System + }, + ) + } + }.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") + private const val VISIBLE_CARD_NUMBER_SUFFIX = 4 + } +} 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..924a8983 --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt @@ -0,0 +1,20 @@ +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 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/login/ViewLoginViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginViewModel.kt index 3b9126b2..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 @@ -6,18 +6,19 @@ 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 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 @@ -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() } @@ -284,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, @@ -301,9 +312,11 @@ internal class ViewLoginViewModel( ).onFailure { failure -> _modificationDialogState.update { dialog.copy( - error = if (failure.contains(LoginError.EmptyLogin) - || failure.contains(LoginError.BlankName) - ) InputFieldError.Empty else null, + error = when { + failure.contains(ItemUpsertError.BlankName) || + failure.contains(ItemUpsertError.Empty) -> InputFieldError.Empty + else -> InputFieldError.System + }, ) } }.onSuccess { 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..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 @@ -1,8 +1,23 @@ package de.davis.keygo.feature.item.view.login.model -data class ObfuscatedString(val raw: String) { - val hidden: String - get() = DEFAULT_OBFUSCATION_CHAR.toString().repeat(raw.length) +data class ObfuscatedString( + val raw: String, + val formatted: String = raw, + val visibleSuffixDigits: Int = 0, +) { + 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 = '•' 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..b654b21c --- /dev/null +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt @@ -0,0 +1,29 @@ +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) + + 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) + } finally { + 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 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..d2d70d61 --- /dev/null +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -0,0 +1,144 @@ +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, + 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) + } +} 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/rust/rust-code/bindings/src/card.rs b/rust/rust-code/bindings/src/card.rs new file mode 100644 index 00000000..04e4e7fb --- /dev/null +++ b/rust/rust-code/bindings/src/card.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use lib::card::{Card, format_expiration_after_edit as core_format_expiration_after_edit}; + +#[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 { + Card::parse(&input).digits + } + + pub fn format_number(&self, input: String) -> String { + Card::parse(&input).formatted + } + + pub fn space_indices(&self, input: String) -> Vec { + Card::parse(&input) + .space_indices() + .into_iter() + .map(|i| i as i32) + .collect() + } + + pub fn is_valid(&self, input: String) -> bool { + Card::parse(&input).is_valid() + } + + pub fn cvv_len(&self, input: String) -> i32 { + Card::parse(&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/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/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..5e2eb3ca --- /dev/null +++ b/rust/rust-code/lib/src/card/network.rs @@ -0,0 +1,234 @@ +//! 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], + // 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], + } + } + + /// 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..73c3ed7b --- /dev/null +++ b/rust/rust-code/lib/src/card/number.rs @@ -0,0 +1,270 @@ +//! 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) + } + + /// 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.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()); + } + + #[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/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..2f3e1e75 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt @@ -0,0 +1,26 @@ +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 validResult: (String) -> Boolean = { true } + var cvvLenResult: (String) -> Int = { 3 } + var formatExpirationAfterEditResult: (String, String) -> String = { _, proposed -> proposed } + + 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 isValid(input: String): Boolean = validResult(input) + + override fun cvvLen(input: String): Int = cvvLenResult(input) + + override fun formatExpirationAfterEdit(previous: String, proposed: String): String = + formatExpirationAfterEditResult(previous, proposed) +} 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")