Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e478e0d
feat(data): Implement cc entity
OffRange May 22, 2026
bc7c5a4
feat(core-item): Add Credit Card model and repository support
OffRange May 24, 2026
d5b3571
refactor(item): Move top app bar to item core
OffRange May 24, 2026
c44dfcf
refactor(item): extract core item related events
OffRange May 24, 2026
0fbd68c
feat(item): Add basic credit card UI
OffRange May 24, 2026
a66e03e
refactor(item-create): Extract base ItemViewModel and shared UI state
OffRange May 24, 2026
25a562f
refactor(security): Generalize LoginWithCryptoScopeUseCase to ItemWit…
OffRange May 24, 2026
f707247
feat(item-create): Load and decrypt existing credit card
OffRange May 24, 2026
436e598
feat(item-create): Add credit card number formatting and validation
OffRange May 25, 2026
d85f5b5
feat(credit-card): Add dynamic CVV length and progressive number form…
OffRange May 25, 2026
10cfd8b
feat(card): Add expiration date formatting and input transformation
OffRange May 25, 2026
a049027
refactor(credit-card): move field transformations to composable layer
OffRange May 25, 2026
74e1b01
docs: add Rust test and bindgen commands to CLAUDE.md
OffRange May 25, 2026
639f0fb
refactor(card): Modularize card logic and simplify bindings via Card API
OffRange May 25, 2026
81e5468
feat(credit-card): Add NFC card reader implementation
OffRange May 25, 2026
399c566
feat(credit-card): add scan UI
OffRange May 25, 2026
8bc40f7
feat(credit-card): Improve NFC scanner bottom sheet
OffRange May 26, 2026
ffb457b
feat(credit-card): Improve Card scan entry
OffRange May 26, 2026
22e87dd
feat(credit-card): Implement card persistence
OffRange May 26, 2026
9c557bc
fix(database): Allow nullable fields for cc
OffRange May 27, 2026
429590a
fix(credit-card): Improve UI/UX
OffRange May 27, 2026
e45683d
feat(credit-card): Implement view screen
OffRange May 27, 2026
604c8ec
feat(item-view): format and partially reveal credit card numbers
OffRange May 28, 2026
83cad64
fix(broadcastreceiver): registration twice
OffRange May 29, 2026
9cc1d5f
fix(credit-card): safely join card holder name parts
OffRange May 29, 2026
e3aa724
refactor(item-view): memoize obfuscated string calculation
OffRange May 29, 2026
d0bb085
refactor(rust/card): rename is_luhn_valid -> is_valid, accept unknown…
OffRange May 29, 2026
de22d41
refactor(credit-card): remove lastNumbers derived field
OffRange May 29, 2026
c59cd77
feat(credit-card): validate CVV length against card network
OffRange May 29, 2026
ccfc1a3
feat(credit-card): surface CVV validation error in create and view sc…
OffRange May 29, 2026
641e9ea
fix(view): release onHold state in a finally block
OffRange May 29, 2026
10f080e
feat(item-core): add InputFieldError.System for non-field failures
OffRange May 29, 2026
947ed63
fix: surface system errors in modification dialog
OffRange May 29, 2026
b7a2b7a
fix(card-scan): ignore new NFC tags during Success delay
OffRange May 29, 2026
114905a
refactor(item-core): make ItemUpsertError a sealed interface
OffRange May 29, 2026
865af61
fix(credit-card): suppress R8 missing-class error for slf4j
OffRange May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,6 @@ google-services.json
*.hprof

### Kotlin
.kotlin/
.kotlin/

docs/superpowers/
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ 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)
./gradlew assemblePlayStoreDebug # APK build
```

- Flavors: `playStore` (default), `fdroid`. Types: `debug`, `staging`, `release`.
- Rust: `./gradlew :rust:buildRust -PbuildRust=true` (disabled by default)
- CI branch: `v2`

## Tech Stack
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "40462f38d8b4873c28fe3aec0e192229",
"identityHash": "d827ca95e51c22ef1dc56c2eb260f41c",
"entities": [
{
"tableName": "vault",
Expand Down Expand Up @@ -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 )",
Expand Down Expand Up @@ -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')"
]
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<CreditCardProjection?>

@Transaction
@Query("SELECT * FROM credit_card WHERE id = :id")
suspend fun getById(id: ItemId): CreditCardProjection?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -29,6 +33,7 @@ import org.koin.core.annotation.Single
VaultEntity::class,
ItemEntity::class,
LoginEntity::class,
CreditCardEntity::class,
TotpEntity::class,
PasswordEntity::class,
DomainInfoEntity::class,
Expand All @@ -38,13 +43,15 @@ import org.koin.core.annotation.Single
],
version = 1,
)
@TypeConverters(YearMonthConverter::class)
internal abstract class ItemDatabase : RoomDatabase() {

abstract fun vaultDao(): VaultDao

abstract fun itemDao(): ItemDao

abstract fun loginDao(): LoginDao
abstract fun creditCardDao(): CreditCardDao

abstract fun totpDao(): TotpDao

Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
@@ -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?
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<ItemId, Throwable> =
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<CreditCard?> =
creditCardDao.observeById(itemId).map { it?.toDomain() }

override suspend fun getCreditCardById(itemId: ItemId): CreditCard? =
creditCardDao.getById(itemId)?.toDomain()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
Loading
Loading