diff --git a/core/item/build.gradle.kts b/core/item/build.gradle.kts index 8706cf38..661e780b 100644 --- a/core/item/build.gradle.kts +++ b/core/item/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.io.mockk) + testImplementation(libs.androidx.sqlite.bundled) testFixturesImplementation(libs.kotlinx.coroutines.core) testFixturesApi(projects.core.util) 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 248bee59..fb8d77d3 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": "ad09e26be7e4b8a0df65d9ba9e325f37", + "identityHash": "40462f38d8b4873c28fe3aec0e192229", "entities": [ { "tableName": "vault", @@ -431,11 +431,112 @@ ] } ] + }, + { + "tableName": "tag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `value` TEXT NOT NULL, `normalized` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalized", + "columnName": "normalized", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tag_normalized", + "unique": true, + "columnNames": [ + "normalized" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tag_normalized` ON `${TABLE_NAME}` (`normalized`)" + } + ] + }, + { + "tableName": "tag_cross_ref", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`item_id` BLOB NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`item_id`, `tag_id`), FOREIGN KEY(`item_id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tag`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tag_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "item_id", + "tag_id" + ] + }, + "indices": [ + { + "name": "index_tag_cross_ref_tag_id", + "unique": false, + "columnNames": [ + "tag_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tag_cross_ref_tag_id` ON `${TABLE_NAME}` (`tag_id`)" + } + ], + "foreignKeys": [ + { + "table": "item", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "item_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tag", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tag_id" + ], + "referencedColumns": [ + "id" + ] + } + ] } ], "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, 'ad09e26be7e4b8a0df65d9ba9e325f37')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '40462f38d8b4873c28fe3aec0e192229')" ] } } \ 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 ef594a21..6686c2a3 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 @@ -38,16 +38,28 @@ internal interface ItemDao { @Query( """ - SELECT i.id, i.name, i.item_type AS itemType, i.pinned, - (name LIKE '%' || :query || '%') AS matchedName, - (note LIKE '%' || :query || '%') AS matchedNote + WITH tag_matches AS ( + SELECT DISTINCT x.item_id + FROM tag_cross_ref x + JOIN tag t ON t.id = x.tag_id + WHERE t.normalized LIKE '%' || :normalizedQuery || '%' + ) + SELECT + i.id, i.name, i.item_type AS itemType, i.pinned, + (i.name LIKE '%' || :query || '%') AS matchedName, + (i.note LIKE '%' || :query || '%') AS matchedNote, + (tm.item_id IS NOT NULL) AS matchedTag FROM item i - WHERE (:itemType IS NULL OR item_type = :itemType) - AND (name LIKE '%' || :query || '%' OR COALESCE(note, '') LIKE '%' || :query || '%') + LEFT JOIN tag_matches tm ON tm.item_id = i.id + WHERE (:itemType IS NULL OR i.item_type = :itemType) + AND (i.name LIKE '%' || :query || '%' + OR i.note LIKE '%' || :query || '%' + OR tm.item_id IS NOT NULL) """ ) suspend fun searchItem( query: String, + normalizedQuery: String, itemType: VaultItemType? = null, ): List diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt new file mode 100644 index 00000000..d211e7dd --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt @@ -0,0 +1,69 @@ +package de.davis.keygo.core.item.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import de.davis.keygo.core.item.data.local.entity.TagCrossRef +import de.davis.keygo.core.item.data.local.entity.TagEntity +import de.davis.keygo.core.item.data.local.pojo.ItemTagProjection +import de.davis.keygo.core.item.domain.alias.ItemId +import kotlinx.coroutines.flow.Flow + +@Dao +internal abstract class TagDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun insertIgnore(tag: TagEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun insertCrossRef(ref: TagCrossRef) + + @Query("DELETE FROM tag_cross_ref WHERE item_id = :itemId") + protected abstract suspend fun clearCrossRefsForItem(itemId: ItemId) + + @Query("SELECT id FROM tag WHERE normalized = :normalized") + internal abstract suspend fun findIdByNormalized(normalized: String): Long? + + @Query("SELECT * FROM tag WHERE EXISTS (SELECT 1 FROM tag_cross_ref WHERE tag_id = tag.id)") + abstract fun observeAllTags(): Flow> + + @Query( + """ + SELECT DISTINCT x.item_id FROM tag_cross_ref x + JOIN tag t ON t.id = x.tag_id + WHERE t.normalized IN (:normalizedValues) + """ + ) + abstract fun observeItemIdsWithAnyTag(normalizedValues: Set): Flow> + + @Query( + """ + SELECT x.item_id AS itemId, t.value AS value FROM tag_cross_ref x + JOIN tag t ON t.id = x.tag_id + """ + ) + abstract fun observeItemTags(): Flow> + + @Query("SELECT tag_id FROM tag_cross_ref WHERE item_id = :itemId") + abstract suspend fun tagIdsForItem(itemId: ItemId): List + + @Query( + "DELETE FROM tag WHERE id IN (:tagIds) " + + "AND NOT EXISTS (SELECT 1 FROM tag_cross_ref WHERE tag_id = tag.id)" + ) + abstract suspend fun pruneOrphans(tagIds: List) + + @Transaction + open suspend fun syncTags(itemId: ItemId, tags: Set) { + val previousTagIds = tagIdsForItem(itemId) + clearCrossRefsForItem(itemId) + for (tag in tags) { + insertIgnore(tag) + val tagId = findIdByNormalized(tag.normalized) ?: continue + insertCrossRef(TagCrossRef(itemId = itemId, tagId = tagId)) + } + if (previousTagIds.isNotEmpty()) pruneOrphans(previousTagIds) + } +} 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 57cb1558..46c7ab0d 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 @@ -9,11 +9,14 @@ import de.davis.keygo.core.item.data.local.dao.ItemDao import de.davis.keygo.core.item.data.local.dao.LoginDao import de.davis.keygo.core.item.data.local.dao.PasskeyDao 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.DomainInfoEntity import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.LoginEntity +import de.davis.keygo.core.item.data.local.entity.TagCrossRef +import de.davis.keygo.core.item.data.local.entity.TagEntity import de.davis.keygo.core.item.data.local.entity.VaultEntity import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity @@ -30,6 +33,8 @@ import org.koin.core.annotation.Single PasswordEntity::class, DomainInfoEntity::class, PasskeyEntity::class, + TagEntity::class, + TagCrossRef::class, ], version = 1, ) @@ -48,6 +53,8 @@ internal abstract class ItemDatabase : RoomDatabase() { abstract fun domainInfoDao(): DomainInfoDao abstract fun passkeyDao(): PasskeyDao + + abstract fun tagDao(): TagDao } @Module @@ -81,4 +88,7 @@ internal class DatabaseModule { @Single fun providePasskeyDao(db: ItemDatabase) = db.passkeyDao() + + @Single + fun provideTagDao(db: ItemDatabase) = db.tagDao() } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/TagCrossRef.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/TagCrossRef.kt new file mode 100644 index 00000000..c6c43bf9 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/TagCrossRef.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.core.item.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import de.davis.keygo.core.item.domain.alias.ItemId + +@Entity( + tableName = "tag_cross_ref", + primaryKeys = ["item_id", "tag_id"], + foreignKeys = [ + ForeignKey( + entity = ItemEntity::class, + parentColumns = ["id"], + childColumns = ["item_id"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = TagEntity::class, + parentColumns = ["id"], + childColumns = ["tag_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("tag_id")], +) +internal data class TagCrossRef( + @ColumnInfo(name = "item_id") + val itemId: ItemId, + @ColumnInfo(name = "tag_id") + val tagId: Long, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/TagEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/TagEntity.kt new file mode 100644 index 00000000..e653d2ba --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/TagEntity.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.core.item.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "tag", + indices = [Index(value = ["normalized"], unique = true)], +) +internal data class TagEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val value: String, + val normalized: String, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/ItemProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/ItemProjection.kt new file mode 100644 index 00000000..b651ce0a --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/ItemProjection.kt @@ -0,0 +1,23 @@ +package de.davis.keygo.core.item.data.local.pojo + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.entity.TagCrossRef +import de.davis.keygo.core.item.data.local.entity.TagEntity + +internal data class ItemProjection( + @Embedded + val itemEntity: ItemEntity, + @Relation( + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = TagCrossRef::class, + parentColumn = "item_id", + entityColumn = "tag_id", + ), + ) + val tags: Set = emptySet(), +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/ItemTagProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/ItemTagProjection.kt new file mode 100644 index 00000000..33bd4eba --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/ItemTagProjection.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.core.item.data.local.pojo + +import de.davis.keygo.core.item.domain.alias.ItemId + +internal data class ItemTagProjection( + val itemId: ItemId, + val value: String, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt index 8f811d28..42cb70e6 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt @@ -9,5 +9,6 @@ internal data class LightweightItemSearchResult( val itemType: VaultItemType, val matchedName: Boolean, val matchedNote: Boolean, + val matchedTag: Boolean, val pinned: Boolean ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt index d8bcf2dd..642d634e 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginProjection.kt @@ -17,8 +17,9 @@ internal data class LoginProjection( @Relation( parentColumn = "id", entityColumn = "id", + entity = ItemEntity::class, ) - val itemEntity: ItemEntity, + val item: ItemProjection, @Relation( parentColumn = "id", diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/DomainInfoMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/DomainInfoMapper.kt index de1d524c..164f3ad0 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/DomainInfoMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/DomainInfoMapper.kt @@ -1,8 +1,7 @@ package de.davis.keygo.core.item.data.mapper import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity -import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Login -internal fun Login.toDomainInfoEntities(loginId: ItemId): Set = - domainInfos.map { it.toData(loginId) }.toSet() \ No newline at end of file +internal fun Login.toDomainInfoEntities(): Set = + domainInfos.map { it.toData(this.id) }.toSet() \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt index 84c632ed..192e9299 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt @@ -33,6 +33,7 @@ internal fun LightweightItemSearchResult.toDomain() = LiteItemSearchResult( itemType = itemType, matchedName = matchedName, matchedNote = matchedNote, + matchedTag = matchedTag, pinned = pinned, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt index c37fe948..cee8d945 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.item.data.mapper import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity import de.davis.keygo.core.item.data.local.entity.LoginEntity +import de.davis.keygo.core.item.data.local.entity.TagEntity import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity import de.davis.keygo.core.item.data.local.pojo.LightweightLogin import de.davis.keygo.core.item.data.local.pojo.LoginProjection @@ -36,11 +37,12 @@ internal fun LoginProjection.toDomain(): Login = Login( totp = totp?.toDomain(), passkeyRPs = rpEntity.map { it.rp }.toSet(), domainInfos = domains.map(DomainInfoEntity::toDomain).toSet(), - vaultId = itemEntity.vaultId, - name = itemEntity.name, - note = itemEntity.note, - keyInformation = itemEntity.keyInformation.toDomain(), - pinned = itemEntity.pinned, + vaultId = item.itemEntity.vaultId, + name = item.itemEntity.name, + note = item.itemEntity.note, + keyInformation = item.itemEntity.keyInformation.toDomain(), + tags = item.tags.map(TagEntity::toDomain).toSet(), + pinned = item.itemEntity.pinned, ) internal fun LightweightLogin.toDomain(): LiteLogin = LiteLogin( diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TagMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TagMapper.kt new file mode 100644 index 00000000..36465b35 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/TagMapper.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.core.item.data.mapper + +import de.davis.keygo.core.item.data.local.entity.TagEntity +import de.davis.keygo.core.item.domain.model.Tag + +internal fun TagEntity.toDomain(): Tag = Tag.of(value)!! + +internal fun Tag.toData(): TagEntity = TagEntity( + value = display, + normalized = normalized, +) + +internal fun Iterable.toTagEntities(): Set = + asSequence() + .map { it.toData() } + .distinctBy { it.normalized } + .toSet() 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 2d685fca..4de3c12c 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 @@ -1,6 +1,10 @@ package de.davis.keygo.core.item.data.repository +import androidx.room.withTransaction import de.davis.keygo.core.item.data.local.dao.ItemDao +import de.davis.keygo.core.item.data.local.dao.TagDao +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import de.davis.keygo.core.item.data.local.entity.TagEntity import de.davis.keygo.core.item.data.local.pojo.LightweightItem import de.davis.keygo.core.item.data.local.pojo.LightweightItemSearchResult import de.davis.keygo.core.item.data.local.pojo.MovableItemPojo @@ -12,6 +16,7 @@ import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult import de.davis.keygo.core.item.domain.repository.ItemRepository @@ -23,10 +28,16 @@ import org.koin.core.annotation.Single @Single internal class ItemRepositoryImpl( + private val database: ItemDatabase, private val itemDao: ItemDao, + private val tagDao: TagDao, ) : ItemRepository { - override suspend fun deleteItem(itemId: ItemId) = itemDao.delete(itemId) + override suspend fun deleteItem(itemId: ItemId): Unit = database.withTransaction { + val tagIds = tagDao.tagIdsForItem(itemId) + itemDao.delete(itemId) + if (tagIds.isNotEmpty()) tagDao.pruneOrphans(tagIds) + } override suspend fun createOrUpdateVaultItem(item: Item): ItemId { itemDao.upsert(item.toData()) @@ -44,12 +55,25 @@ internal class ItemRepositoryImpl( override suspend fun searchVaultItem( query: String, itemType: VaultItemType?, - ): List = itemDao.searchItem(query, itemType) + ): List = itemDao.searchItem(query, Tag.normalize(query), itemType) .map(LightweightItemSearchResult::toDomain) override suspend fun setPinned(itemId: ItemId, pinned: Boolean) = itemDao.setPinned(itemId, pinned) + override fun observeAllTags(): Flow> = + tagDao.observeAllTags().map { it.map(TagEntity::toDomain) } + + override fun observeItemIdsForTags(tags: Set): Flow> = + tagDao.observeItemIdsWithAnyTag(tags.mapTo(mutableSetOf()) { it.normalized }) + .map { it.toSet() } + + override fun observeTagsByItem(): Flow>> = + tagDao.observeItemTags().map { rows -> + rows.groupBy { it.itemId } + .mapValues { (_, pairs) -> pairs.mapNotNullTo(mutableSetOf()) { Tag.of(it.value) } } + } + override fun observeLiteVaultItems(vaultId: VaultId?): Flow> = itemDao.observeLiteItems(vaultId).map { it.map(LightweightItem::toDomain) } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt index dae07b84..b7d93e90 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt @@ -5,6 +5,7 @@ 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 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.datasource.ItemDatabase import de.davis.keygo.core.item.data.local.pojo.LightweightLogin @@ -14,6 +15,7 @@ import de.davis.keygo.core.item.data.mapper.toDomain import de.davis.keygo.core.item.data.mapper.toDomainInfoEntities import de.davis.keygo.core.item.data.mapper.toLoginEntity import de.davis.keygo.core.item.data.mapper.toPasswordEntity +import de.davis.keygo.core.item.data.mapper.toTagEntities 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.DomainInfo @@ -35,6 +37,7 @@ internal class LoginRepositoryImpl( private val passwordDao: PasswordDao, private val domainInfoDao: DomainInfoDao, private val totpDao: TotpDao, + private val tagDao: TagDao, ) : LoginRepository { override suspend fun createOrUpdateLogin(login: Login): Result = @@ -50,7 +53,8 @@ internal class LoginRepositoryImpl( totpDao.upsert(it) } ?: totpDao.delete(login.id) - domainInfoDao.syncForLogin(login.id, login.toDomainInfoEntities(login.id)) + domainInfoDao.syncForLogin(login.id, login.toDomainInfoEntities()) + tagDao.syncTags(login.id, login.tags.toTagEntities()) login.id } @@ -79,7 +83,8 @@ internal class LoginRepositoryImpl( requirePassword: Boolean, requireUsername: Boolean, limit: Int, - ): List = getLoginsByTLDs(setOf(etld1), requireTotp, requirePassword, requireUsername, limit) + ): List = + getLoginsByTLDs(setOf(etld1), requireTotp, requirePassword, requireUsername, limit) override suspend fun getLoginsByTLDs( etld1s: Set, @@ -88,7 +93,8 @@ internal class LoginRepositoryImpl( requireUsername: Boolean, limit: Int, ): List = - loginDao.getByTLDs(etld1s, requireTotp, requirePassword, requireUsername, limit).map(LightweightLogin::toDomain) + loginDao.getByTLDs(etld1s, requireTotp, requirePassword, requireUsername, limit) + .map(LightweightLogin::toDomain) override suspend fun getLoginById(itemId: ItemId): Login? = loginDao.getById(itemId)?.toDomain() diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt index c8f30065..a3a62044 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt @@ -11,5 +11,6 @@ sealed interface Item : LiteItem { val vaultId: VaultId override val name: String val keyInformation: KeyInformation + val tags: Set val note: String? } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt index 8006809b..19694327 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Login.kt @@ -17,6 +17,7 @@ data class Login( override val vaultId: VaultId, override val name: String, override val keyInformation: KeyInformation, + override val tags: Set = emptySet(), override val note: String?, override val pinned: Boolean, ) : Item { diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Tag.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Tag.kt new file mode 100644 index 00000000..6b5ff6a0 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Tag.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.core.item.domain.model + +class Tag private constructor(val display: String) { + + val normalized: String = display.lowercase() + + override fun equals(other: Any?): Boolean = + this === other || (other is Tag && other.normalized == normalized) + + override fun hashCode(): Int = normalized.hashCode() + + override fun toString(): String = display + + companion object { + fun of(raw: String): Tag? = raw.trim().takeIf { it.isNotEmpty() }?.let(::Tag) + + fun normalize(raw: String): String = raw.trim().lowercase() + } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt index 450fa2bb..330d175a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt @@ -10,4 +10,5 @@ data class LiteItemSearchResult( override val pinned: Boolean, val matchedName: Boolean, val matchedNote: Boolean, + val matchedTag: Boolean, ) : LiteItem 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 10af3475..c9e6c791 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 @@ -5,6 +5,7 @@ import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -31,6 +32,12 @@ interface ItemRepository { suspend fun setPinned(itemId: ItemId, pinned: Boolean) + fun observeAllTags(): Flow> + + fun observeItemIdsForTags(tags: Set): Flow> + + fun observeTagsByItem(): Flow>> + fun observeLiteVaultItems(vaultId: VaultId? = null): Flow> suspend fun getItemKeyEnvelope(itemId: ItemId): ItemKeyEnvelope? diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt new file mode 100644 index 00000000..ae2f88b8 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCase.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.core.item.domain.usecase + +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class ObserveAllTagsSortedUseCase( + private val itemRepository: ItemRepository, + private val sortUseCase: SortUseCase, +) { + operator fun invoke(): Flow> = + itemRepository.observeAllTags().map { tags -> sortUseCase(tags) { it.display } } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt new file mode 100644 index 00000000..9d3fe715 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt @@ -0,0 +1,124 @@ +package de.davis.keygo.core.item.data.local.dao + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +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.entity.VaultEntity +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.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +internal class ItemDaoSearchTest { + + private lateinit var db: ItemDatabase + private lateinit var itemDao: ItemDao + + private val vaultId: VaultId = newVaultId() + + @BeforeTest + fun setUp() = runBlocking { + db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), ItemDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + itemDao = db.itemDao() + db.vaultDao().insert( + VaultEntity( + id = vaultId, + name = "Vault", + icon = Vault.Icon.Person, + createdAt = 0L, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + ) + ) + } + + @AfterTest + fun tearDown() = db.close() + + private suspend fun insertItem( + name: String, + note: String? = null, + tags: Set = emptySet(), + ): ItemId { + val id = newItemId() + itemDao.upsert( + ItemEntity( + id = id, + vaultId = vaultId, + name = name, + note = note, + itemType = VaultItemType.Login, + pinned = false, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + ) + ) + if (tags.isNotEmpty()) + db.tagDao().syncTags( + id, + tags.map { TagEntity(value = it, normalized = it.lowercase()) }.toSet(), + ) + return id + } + + @Test + fun `searchItem matches an item by tag value only`() = runTest { + val id = insertItem(name = "Chase", note = null, tags = setOf("Banking")) + + val results = itemDao.searchItem(query = "Bank", normalizedQuery = "bank") + + assertEquals(1, results.size) + val r = results.single() + assertEquals(id, r.id) + assertFalse(r.matchedName) + assertFalse(r.matchedNote) + assertTrue(r.matchedTag) + } + + @Test + fun `searchItem still matches by name and sets matchedName`() = runTest { + insertItem(name = "Bank of Earth") + + val r = itemDao.searchItem(query = "Bank", normalizedQuery = "bank").single() + + assertTrue(r.matchedName) + assertFalse(r.matchedTag) + } + + @Test + fun `searchItem does not return items with no name note or tag match`() = runTest { + insertItem(name = "Email", note = "personal", tags = setOf("Mail")) + + assertTrue(itemDao.searchItem(query = "Bank", normalizedQuery = "bank").isEmpty()) + } + + @Test + fun `searchItem tag match still passes an explicit itemType filter`() = runTest { + insertItem(name = "Chase", tags = setOf("Bank")) + + assertEquals( + 1, + itemDao.searchItem( + query = "Bank", + normalizedQuery = "bank", + itemType = VaultItemType.Login, + ).size, + ) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt new file mode 100644 index 00000000..796aaa4d --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt @@ -0,0 +1,212 @@ +package de.davis.keygo.core.item.data.local.dao + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +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.entity.VaultEntity +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.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +internal class TagDaoTest { + + private lateinit var db: ItemDatabase + private lateinit var tagDao: TagDao + + private val vaultId: VaultId = newVaultId() + + @BeforeTest + fun setUp() = runBlocking { + db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), ItemDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + tagDao = db.tagDao() + db.vaultDao().insert( + VaultEntity( + id = vaultId, + name = "Vault", + icon = Vault.Icon.Person, + createdAt = 0L, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + ) + ) + } + + @AfterTest + fun tearDown() { + db.close() + } + + private suspend fun insertItem(id: ItemId = newItemId()): ItemId { + db.itemDao().upsert( + ItemEntity( + id = id, + vaultId = vaultId, + name = "Item", + note = null, + itemType = VaultItemType.Login, + pinned = false, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + ) + ) + return id + } + + private fun tag(value: String) = TagEntity(value = value, normalized = value.lowercase()) + + @Test + fun `same value is stored once across items`() = runTest { + val a = insertItem() + val b = insertItem() + + tagDao.syncTags(a, setOf(tag("Work"))) + tagDao.syncTags(b, setOf(tag("Work"))) + + val id1 = tagDao.findIdByNormalized("work") + assertEquals(2, tagDao.tagIdsForItem(a).size + tagDao.tagIdsForItem(b).size) + assertEquals(id1, tagDao.tagIdsForItem(a).single()) + assertEquals(id1, tagDao.tagIdsForItem(b).single()) + } + + @Test + fun `dedup is case-insensitive via normalized key`() = runTest { + val item = insertItem() + + tagDao.syncTags(item, setOf(tag("MyTag"))) + val firstId = tagDao.tagIdsForItem(item).single() + + tagDao.syncTags(item, setOf(tag("mytag"))) + + assertEquals(firstId, tagDao.tagIdsForItem(item).single()) + } + + @Test + fun `deleting an item cascades its cross-refs`() = runTest { + val item = insertItem() + tagDao.syncTags(item, setOf(tag("Work"))) + + db.itemDao().delete(item) + + assertEquals(emptyList(), tagDao.tagIdsForItem(item)) + } + + @Test + fun `orphan tag pruned when no item references it, shared tag kept`() = runTest { + val a = insertItem() + val b = insertItem() + tagDao.syncTags(a, setOf(tag("Solo"), tag("Shared"))) + tagDao.syncTags(b, setOf(tag("Shared"))) + + tagDao.syncTags(a, emptySet()) + + assertNull(tagDao.findIdByNormalized("solo")) + assertEquals(tagDao.findIdByNormalized("shared"), tagDao.tagIdsForItem(b).single()) + } + + @Test + fun `deleting an item prunes its now-orphan tags`() = runTest { + val item = insertItem() + tagDao.syncTags(item, setOf(tag("Temp"))) + val tagIds = tagDao.tagIdsForItem(item) + + db.itemDao().delete(item) + tagDao.pruneOrphans(tagIds) + + assertNull(tagDao.findIdByNormalized("temp")) + } + + @Test + fun `observeItemIdsWithAnyTag returns ids of items having any of the values`() = runTest { + val a = insertItem() + val b = insertItem() + val c = insertItem() + tagDao.syncTags(a, setOf(tag("Bank"))) + tagDao.syncTags(b, setOf(tag("Work"), tag("Bank"))) + tagDao.syncTags(c, setOf(tag("Personal"))) + + val ids = tagDao.observeItemIdsWithAnyTag(setOf("bank")).first().toSet() + + assertEquals(setOf(a, b), ids) + } + + @Test + fun `observeItemIdsWithAnyTag unions multiple values`() = runTest { + val a = insertItem() + val b = insertItem() + val c = insertItem() + tagDao.syncTags(a, setOf(tag("Bank"))) + tagDao.syncTags(b, setOf(tag("Work"))) + tagDao.syncTags(c, setOf(tag("Personal"))) + + val ids = tagDao.observeItemIdsWithAnyTag(setOf("bank", "work")).first().toSet() + + assertEquals(setOf(a, b), ids) + } + + @Test + fun `observeItemIdsWithAnyTag returns empty when no tag matches`() = runTest { + val a = insertItem() + tagDao.syncTags(a, setOf(tag("Bank"))) + + assertEquals(emptyList(), tagDao.observeItemIdsWithAnyTag(setOf("nope")).first()) + } + + @Test + fun `observeItemIdsWithAnyTag matches the stored normalized form regardless of display casing`() = + runTest { + val a = insertItem() + tagDao.syncTags(a, setOf(tag("Bank"))) + + // Caller is required to pass normalized (lower-cased, trimmed) values. + assertEquals(listOf(a), tagDao.observeItemIdsWithAnyTag(setOf("bank")).first()) + assertEquals(emptyList(), tagDao.observeItemIdsWithAnyTag(setOf("Bank")).first()) + } + + @Test + fun `observeItemTags emits one row per item-tag pair using display value`() = runTest { + val a = insertItem() + val b = insertItem() + tagDao.syncTags(a, setOf(tag("Bank"), tag("Work"))) + tagDao.syncTags(b, setOf(tag("Bank"))) + + val pairs = tagDao.observeItemTags().first().map { it.itemId to it.value }.toSet() + + assertEquals( + setOf( + a to "Bank", + a to "Work", + b to "Bank", + ), + pairs, + ) + } + + @Test + fun `observeItemTags omits items that have no tags`() = runTest { + val a = insertItem() + insertItem() // untagged item must not appear + + tagDao.syncTags(a, setOf(tag("Solo"))) + + val pairs = tagDao.observeItemTags().first().map { it.itemId to it.value } + + assertEquals(listOf(a to "Solo"), pairs) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt index e0c1b5bd..648fa2b0 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/DomainMapperTest.kt @@ -87,7 +87,7 @@ class DomainMapperTest { DomainInfo(value = "https://other.com", eTLD1 = "other.com"), ) - val entities = login(id = id, domainInfos = infos).toDomainInfoEntities(id) + val entities = login(id = id, domainInfos = infos).toDomainInfoEntities() assertEquals(2, entities.size) } @@ -100,14 +100,14 @@ class DomainMapperTest { DomainInfo(value = "https://b.com", eTLD1 = "b.com"), ) - val entities = login(id = id, domainInfos = infos).toDomainInfoEntities(id) + val entities = login(id = id, domainInfos = infos).toDomainInfoEntities() assertTrue(entities.all { it.loginId == id }) } @Test fun `toDomainInfoEntities on empty domainInfos returns empty set`() { - val entities = login(domainInfos = emptySet()).toDomainInfoEntities(newItemId()) + val entities = login(domainInfos = emptySet()).toDomainInfoEntities() assertTrue(entities.isEmpty()) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt index b23c3865..97b4ab59 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt @@ -111,6 +111,7 @@ class ItemMapperTest { itemType = VaultItemType.Login, matchedName = true, matchedNote = false, + matchedTag = true, pinned = false, ) @@ -121,6 +122,7 @@ class ItemMapperTest { assertEquals(VaultItemType.Login, result.itemType) assertTrue(result.matchedName) assertFalse(result.matchedNote) + assertTrue(result.matchedTag) assertFalse(result.pinned) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt index 6e7c61a9..4c1065fe 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapperTest.kt @@ -2,7 +2,9 @@ package de.davis.keygo.core.item.data.mapper import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.LoginEntity +import de.davis.keygo.core.item.data.local.entity.TagEntity import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity +import de.davis.keygo.core.item.data.local.pojo.ItemProjection import de.davis.keygo.core.item.data.local.pojo.LoginProjection import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId @@ -13,6 +15,7 @@ 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.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType import kotlin.test.Test import kotlin.test.assertEquals @@ -49,22 +52,59 @@ class LoginMapperTest { assertEquals(payload, entity.password) } + @Test + fun `toDomain maps tag entity values to domain tags`() { + val id = newItemId() + val projection = baseProjection( + id = id, + passwordEntity = null, + tags = setOf( + TagEntity(id = 1, value = "Work", normalized = "work"), + TagEntity(id = 2, value = "Email", normalized = "email"), + ), + ) + + val login = projection.toDomain() + + assertEquals(setOf(Tag.of("Work")!!, Tag.of("Email")!!), login.tags) + } + + @Test + fun `toTagEntities dedups case-insensitively, preserving display of first occurrence`() { + val tags = listOfNotNull( + Tag.of(" Work "), + Tag.of("work"), + Tag.of("Email"), + ) + + val result = tags.toTagEntities() + + assertEquals( + listOf("Work" to "work", "Email" to "email"), + result.map { it.value to it.normalized }, + ) + } + private fun baseProjection( id: ItemId = newItemId(), passwordEntity: PasswordEntity?, + tags: Set = emptySet(), ): LoginProjection = LoginProjection( loginEntity = LoginEntity(id = id, username = "alice"), - itemEntity = ItemEntity( - id = id, - vaultId = newVaultId(), - name = "Test", - note = null, - itemType = VaultItemType.Login, - pinned = false, - keyInformation = EntityKeyInformation( - wrappedKey = byteArrayOf(), - keyNonce = byteArrayOf(), + item = ItemProjection( + itemEntity = ItemEntity( + id = id, + vaultId = newVaultId(), + name = "Test", + note = null, + itemType = VaultItemType.Login, + pinned = false, + keyInformation = EntityKeyInformation( + wrappedKey = byteArrayOf(), + keyNonce = byteArrayOf(), + ), ), + tags = tags, ), passwordEntity = passwordEntity, rpEntity = emptyList(), diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImplTest.kt new file mode 100644 index 00000000..a15c2b29 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImplTest.kt @@ -0,0 +1,94 @@ +package de.davis.keygo.core.item.data.repository + +import androidx.room.withTransaction +import de.davis.keygo.core.item.data.local.dao.ItemDao +import de.davis.keygo.core.item.data.local.dao.TagDao +import de.davis.keygo.core.item.data.local.datasource.ItemDatabase +import de.davis.keygo.core.item.data.local.pojo.ItemTagProjection +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.Tag +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +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 kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ItemRepositoryImplTest { + + private val database = mockk() + private val itemDao = mockk(relaxed = true) + private val tagDao = mockk(relaxed = true) + + private val repository = ItemRepositoryImpl( + database = database, + itemDao = itemDao, + tagDao = tagDao, + ) + + @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 `deleteItem captures tag ids, deletes item, then prunes those tags`() = runTest { + val id = newItemId() + coEvery { tagDao.tagIdsForItem(id) } returns listOf(7L, 9L) + + repository.deleteItem(id) + + coVerifyOrder { + tagDao.tagIdsForItem(id) + itemDao.delete(id) + tagDao.pruneOrphans(listOf(7L, 9L)) + } + } + + @Test + fun `deleteItem skips prune when item had no tags`() = runTest { + val id = newItemId() + coEvery { tagDao.tagIdsForItem(id) } returns emptyList() + + repository.deleteItem(id) + + coVerify(exactly = 0) { tagDao.pruneOrphans(any()) } + } + + @Test + fun `observeTagsByItem groups rows into a tag set per item`() = runTest { + val a = newItemId() + val b = newItemId() + every { tagDao.observeItemTags() } returns flowOf( + listOf( + ItemTagProjection(a, "Bank"), + ItemTagProjection(a, "Work"), + ItemTagProjection(b, "Bank"), + ) + ) + + val result = repository.observeTagsByItem().first() + + assertEquals( + mapOf( + a to setOf(Tag.of("Bank")!!, Tag.of("Work")!!), + b to setOf(Tag.of("Bank")!!), + ), + result, + ) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt index eaac23c5..76e72f35 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImplTest.kt @@ -5,8 +5,10 @@ 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 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.datasource.ItemDatabase +import de.davis.keygo.core.item.data.local.entity.TagEntity import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity import de.davis.keygo.core.item.data.local.entity.credential.TotpEntity import de.davis.keygo.core.item.domain.alias.ItemId @@ -18,6 +20,7 @@ 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.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Totp import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess @@ -41,6 +44,7 @@ class LoginRepositoryImplTest { private val passwordDao = mockk(relaxed = true) private val domainInfoDao = mockk(relaxed = true) private val totpDao = mockk(relaxed = true) + private val tagDao = mockk(relaxed = true) private val repository = LoginRepositoryImpl( database = database, @@ -49,6 +53,7 @@ class LoginRepositoryImplTest { passwordDao = passwordDao, domainInfoDao = domainInfoDao, totpDao = totpDao, + tagDao = tagDao, ) @BeforeTest @@ -170,11 +175,44 @@ class LoginRepositoryImplTest { assertEquals(error, result.error) } + @Test + fun `createOrUpdateLogin syncs tags as normalized entities`() = runTest { + val login = testLogin( + totpProvider = null, + tags = setOfNotNull(Tag.of(" Work "), Tag.of("work"), Tag.of("Email")), + ) + + val result = repository.createOrUpdateLogin(login) + + assertTrue(result.isSuccess()) + coVerify(exactly = 1) { + tagDao.syncTags( + login.id, + setOf( + TagEntity(value = "Work", normalized = "work"), + TagEntity(value = "Email", normalized = "email"), + ), + ) + } + } + + @Test + fun `createOrUpdateLogin returns Failure when tag sync throws`() = runTest { + val error = RuntimeException("db error") + coEvery { tagDao.syncTags(any(), any()) } throws error + + val result = repository.createOrUpdateLogin(testLogin(totpProvider = null)) + + assertTrue(result.isFailure()) + assertEquals(error, result.error) + } + private fun testLogin( passwordCredential: PasswordCredential? = PasswordCredential( secret = PasswordSecret(EncryptedPayload.EMPTY), score = PasswordScore.Strong, ), + tags: Set = emptySet(), totpProvider: ((ItemId) -> Totp)? = null, ): Login { val id = newItemId() @@ -189,6 +227,7 @@ class LoginRepositoryImplTest { pinned = false, vaultId = newVaultId(), keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = tags, ) } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/TagTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/TagTest.kt new file mode 100644 index 00000000..8eb48b6c --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/TagTest.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.core.item.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TagTest { + + @Test + fun `of trims surrounding whitespace and preserves inner casing`() { + val tag = Tag.of(" Banking ") + + assertEquals("Banking", tag?.display) + } + + @Test + fun `normalized is the lowercased display`() { + val tag = Tag.of("BankING")!! + + assertEquals("banking", tag.normalized) + } + + @Test + fun `of returns null for an empty string`() { + assertNull(Tag.of("")) + } + + @Test + fun `of returns null for whitespace-only input`() { + assertNull(Tag.of(" ")) + } + + @Test + fun `normalize lowercases and trims independently of constructing a Tag`() { + assertEquals("banking", Tag.normalize(" BankING ")) + } + + @Test + fun `equality is case-insensitive on the trimmed value`() { + assertEquals(Tag.of("Bank")!!, Tag.of("bank")!!) + assertEquals(Tag.of("Bank")!!, Tag.of(" BANK ")!!) + } + + @Test + fun `hashCode is the same for tags equal under normalization`() { + assertEquals(Tag.of("Bank")!!.hashCode(), Tag.of("bank")!!.hashCode()) + } + + @Test + fun `different normalized values are not equal`() { + assertNotEquals(Tag.of("Bank")!!, Tag.of("Work")!!) + } + + @Test + fun `Set of Tag dedupes by normalized form and keeps the first display seen`() { + val tags = setOf(Tag.of("Bank")!!, Tag.of("bank")!!, Tag.of("BANK")!!) + + assertEquals(1, tags.size) + assertEquals("Bank", tags.single().display) + } + + @Test + fun `Tag is not equal to a String with the same value`() { + assertTrue(Tag.of("Bank")!!.equals("Bank").not()) + } + + @Test + fun `toString returns the display form`() { + assertEquals("Banking", Tag.of("Banking")!!.toString()) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt new file mode 100644 index 00000000..38c05918 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/ObserveAllTagsSortedUseCaseTest.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.core.item.domain.usecase + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.alias.newVaultId +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.Tag +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ObserveAllTagsSortedUseCaseTest { + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val useCase = ObserveAllTagsSortedUseCase( + itemRepository = itemRepository, + sortUseCase = SortUseCase(), + ) + + private fun tag(value: String): Tag = Tag.of(value)!! + + private fun login(name: String, tags: Set) = Login( + username = null, + domainInfos = emptySet(), + passwordCredential = null, + totp = null, + vaultId = newVaultId(), + name = name, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + tags = tags, + note = null, + pinned = false, + ) + + @Test + fun `emits all distinct tags sorted naturally and case-insensitively`() = runTest { + loginRepository.seed( + login("a", setOf(tag("Zebra"), tag("apple"))), + login("b", setOf(tag("item10"), tag("item2"))), + ) + + val result = useCase().first() + + assertEquals(listOf(tag("apple"), tag("item2"), tag("item10"), tag("Zebra")), result) + } + + @Test + fun `emits empty list when no tags exist`() = runTest { + val result = useCase().first() + assertEquals(emptyList(), result) + } +} 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 ab31aeb8..7bb1c184 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 @@ -6,6 +6,7 @@ import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.ItemKeyEnvelope import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult import de.davis.keygo.core.item.domain.repository.ItemRepository @@ -13,6 +14,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.Result import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map /** * In-memory [ItemRepository] for tests. @@ -40,6 +42,26 @@ class FakeItemRepository( */ var failMoveForId: Pair? = null + override fun observeAllTags(): Flow> = + allStores.map { store -> store.values.flatMap { item -> item.tags }.distinct() } + + override fun observeItemIdsForTags(tags: Set): Flow> { + val targetNormalized = tags.mapTo(mutableSetOf()) { it.normalized } + return allStores.map { store -> + store.values + .filter { item -> item.tags.any { it.normalized in targetNormalized } } + .map { it.id } + .toSet() + } + } + + override fun observeTagsByItem(): Flow>> = + allStores.map { store -> + store.values + .filter { item -> item.tags.isNotEmpty() } + .associate { item -> item.id to item.tags.toSet() } + } + override suspend fun deleteItem(itemId: ItemId) = Unit override suspend fun createOrUpdateVaultItem(item: Item): ItemId = item.id diff --git a/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt new file mode 100644 index 00000000..878c0c71 --- /dev/null +++ b/core/util/src/main/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCase.kt @@ -0,0 +1,64 @@ +package de.davis.keygo.core.util.domain.usecase + +import org.koin.core.annotation.Single +import java.text.Collator + +@Single +class SortUseCase { + + private val collator = ThreadLocal.withInitial { + Collator.getInstance().apply { + strength = Collator.PRIMARY // case- and accent-insensitive grouping + } + } + + operator fun invoke( + items: Iterable, + ascending: Boolean = true, + selector: (T) -> String, + ): List { + val prepared = items.map { item -> + item to selector(item).split(CHUNK_REGEX) + } + + val comparator = Comparator>> { a, b -> + val cmp = compareParts(a.second, b.second) + if (ascending) cmp else -cmp + } + + return prepared.sortedWith(comparator).map { it.first } + } + + private fun compareParts(parts1: List, parts2: List): Int { + val len = minOf(parts1.size, parts2.size) + + for (i in 0 until len) { + val p1 = parts1[i] + val p2 = parts2[i] + + // Guard against empty chunks produced by splitting blank/empty names + if (p1.isEmpty() || p2.isEmpty()) return p1.length - p2.length + + val cmp = when { + // split regex guarantees entire chunk is digits if first char is + p1[0].isDigit() && p2[0].isDigit() -> compareNumeric(p1, p2) + else -> collator.get()!!.compare(p1, p2) // locale-aware: ä, ö, ü, &, / etc. + } + + if (cmp != 0) return cmp + } + + return parts1.size.compareTo(parts2.size) + } + + private fun compareNumeric(a: String, b: String): Int { + // Fast path: different lengths means different magnitudes — no parsing + if (a.length != b.length) return a.length - b.length + // Same length: lexicographic order == numeric order for digit-only strings + return a.compareTo(b) + } + + companion object { + private val CHUNK_REGEX = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)") + } +} diff --git a/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt new file mode 100644 index 00000000..54ff26ef --- /dev/null +++ b/core/util/src/test/kotlin/de/davis/keygo/core/util/domain/usecase/SortUseCaseTest.kt @@ -0,0 +1,59 @@ +package de.davis.keygo.core.util.domain.usecase + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SortUseCaseTest { + + private val sort = SortUseCase() + + private operator fun SortUseCase.invoke( + items: Iterable, + ascending: Boolean = true, + ) = invoke(items, ascending) { it } + + @Test + fun `sorts ascending case-insensitively by selector`() { + val result = sort(listOf("banana", "Apple", "cherry")) + assertEquals(listOf("Apple", "banana", "cherry"), result) + } + + @Test + fun `natural numeric ordering puts item2 before item10`() { + val result = sort(listOf("item10", "item2", "item1")) + assertEquals(listOf("item1", "item2", "item10"), result) + } + + @Test + fun `descending reverses the natural order`() { + val result = sort(listOf("item2", "item10", "item1"), ascending = false) + assertEquals(listOf("item10", "item2", "item1"), result) + } + + @Test + fun `blank and empty selector values sort first`() { + val result = sort(listOf("b", "", "a")) + assertEquals(listOf("", "a", "b"), result) + } + + @Test + fun `sorts by a non-identity selector`() { + data class Box(val name: String, val n: Int) + + val result = sort(listOf(Box("z", 1), Box("a", 2))) { it.name } + assertEquals(listOf(Box("a", 2), Box("z", 1)), result) + } + + @Test + fun `equal sort keys preserve input order (stable)`() { + data class Box(val name: String, val tag: Int) + + val result = sort(listOf(Box("x", 1), Box("x", 2), Box("x", 3))) { it.name } + assertEquals(listOf(1, 2, 3), result.map { it.tag }) + } + + @Test + fun `empty input returns empty list`() { + assertEquals(emptyList(), sort(emptyList())) + } +} diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index a6045495..37297644 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -37,13 +37,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - testOptions { - unitTests { - isIncludeAndroidResources = true - isReturnDefaultValues = true - } - } - testFixtures { enable = true } 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 9b892b93..1e11a31f 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 @@ -3,6 +3,7 @@ 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.DomainInfo +import de.davis.keygo.core.item.domain.model.Tag @ConsistentCopyVisibility data class UpsertLogin private constructor( @@ -12,6 +13,7 @@ data class UpsertLogin private constructor( val totpUriOrSecret: FieldUpdate, val username: FieldUpdate, val domains: FieldUpdate>, + val tags: FieldUpdate>, val note: FieldUpdate, val hasPendingPasskey: Boolean = false, ) { @@ -23,6 +25,7 @@ data class UpsertLogin private constructor( totpUriOrSecret: String? = null, username: String? = null, domains: Set = emptySet(), + tags: Set = emptySet(), note: String? = null, hasPendingPasskey: Boolean = false, ) = UpsertLogin( @@ -33,6 +36,7 @@ data class UpsertLogin private constructor( totpUriOrSecret = if (!totpUriOrSecret.isNullOrBlank()) FieldUpdate.Set(totpUriOrSecret) else FieldUpdate.Clear, username = if (!username.isNullOrBlank()) FieldUpdate.Set(username) else FieldUpdate.Clear, domains = if (domains.isNotEmpty()) FieldUpdate.Set(domains) else FieldUpdate.Clear, + tags = if (tags.isNotEmpty()) FieldUpdate.Set(tags) else FieldUpdate.Clear, hasPendingPasskey = hasPendingPasskey, ) @@ -44,6 +48,7 @@ data class UpsertLogin private constructor( totpUriOrSecret: FieldUpdate = keep(), username: FieldUpdate = keep(), domains: FieldUpdate> = keep(), + tags: FieldUpdate> = keep(), note: FieldUpdate = keep(), ) = UpsertLogin( upsertType = UpsertType.Update(itemId, vaultId), @@ -51,6 +56,7 @@ data class UpsertLogin private constructor( password = password, note = note, totpUriOrSecret = totpUriOrSecret, + tags = tags, username = username, domains = domains, ) 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 5560b10a..782f4e33 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 @@ -129,6 +129,7 @@ class CreateNewOrUpdateLoginUseCase( 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(), @@ -183,6 +184,7 @@ class CreateNewOrUpdateLoginUseCase( 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), diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt index c8475ecb..a4ef53af 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/ChipFormGroup.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldLabelScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -64,6 +65,18 @@ import androidx.compose.ui.unit.dp import de.davis.keygo.feature.item.core.R import kotlinx.coroutines.flow.collectLatest +@Stable +sealed interface ChipFormMode { + + val suggestions: Set + + class Simple : ChipFormMode { + override val suggestions: Set = emptySet() + } + + data class WithSuggestions(override val suggestions: Set) : ChipFormMode +} + @Composable fun ChipFormGroup( title: String, @@ -74,6 +87,7 @@ fun ChipFormGroup( onDelete: (T) -> Unit, modifier: Modifier = Modifier, state: TextFieldState = rememberTextFieldState(), + mode: ChipFormMode = ChipFormMode.Simple(), onEdit: ((T, Set) -> Unit)? = null, label: @Composable (TextFieldLabelScope.() -> Unit)? = null, prefix: @Composable (() -> Unit)? = null, @@ -115,8 +129,7 @@ fun ChipFormGroup( if (keepLast && text.none { it in delimiters }) return - val items = text.split(*delimiters.toCharArray()) - .filter { it.isNotBlank() } + val items = text.splitChipInput(delimiters) val (itemsToSubmit, keep) = if (!keepLast || text.last() in delimiters) items to "" else items.dropLast(1) to items.last() @@ -200,45 +213,65 @@ fun ChipFormGroup( ) } - KeyGoFormField( - state = state, - label = label, - modifier = modifier - .fillMaxWidth() - .onPreviewKeyEvent { - if (it.key != Key.Backspace) return@onPreviewKeyEvent false - if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - - // Only trigger "un-chip" logic if the field is empty and there are items to remove - if (state.text.isNotBlank() || items.isEmpty()) return@onPreviewKeyEvent false - - // Cancel editing if in edit mode - if (editingItem != null) { - cancelEdit() - return@onPreviewKeyEvent true - } + val modifier = modifier + .fillMaxWidth() + .onPreviewKeyEvent { + if (it.key != Key.Backspace) return@onPreviewKeyEvent false + if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - // Pop the last item and restore its text representation to the field - val lastItem = items.last() - val text = textOf(lastItem) - onDelete(lastItem) - state.setTextAndPlaceCursorAtEnd(text) - true - }, - prefix = prefix, - placeholder = placeholder, - lineLimits = lineLimits, - keyboardOptions = effectiveKeyboardOptions, - // Flush pending text as chips before forwarding the keyboard action. - onKeyboardAction = KeyboardActionHandler { defaultAction -> - if (hasText) - handleText(state.text.toString(), keepLast = false) - - onKeyboardAction?.onKeyboardAction(defaultAction) ?: defaultAction() - }, - inputTransformation = combinedTransformation, - interactionSource = interactionSource - ) + // Only trigger "un-chip" logic if the field is empty and there are items to remove + if (state.text.isNotBlank() || items.isEmpty()) return@onPreviewKeyEvent false + + // Cancel editing if in edit mode + if (editingItem != null) { + cancelEdit() + return@onPreviewKeyEvent true + } + + // Pop the last item and restore its text representation to the field + val lastItem = items.last() + val text = textOf(lastItem) + onDelete(lastItem) + state.setTextAndPlaceCursorAtEnd(text) + true + } + + // Flush pending text as chips before forwarding the keyboard action. + val keyboardActionHandler = KeyboardActionHandler { defaultAction -> + if (hasText) handleText(state.text.toString(), keepLast = false) + + onKeyboardAction?.onKeyboardAction(defaultAction) ?: defaultAction() + } + + when (mode) { + is ChipFormMode.Simple -> KeyGoFormField( + state = state, + label = label, + modifier = modifier, + prefix = prefix, + placeholder = placeholder, + lineLimits = lineLimits, + keyboardOptions = effectiveKeyboardOptions, + onKeyboardAction = keyboardActionHandler, + inputTransformation = combinedTransformation, + interactionSource = interactionSource + ) + + is ChipFormMode.WithSuggestions -> KeyGoFormSuggestionField( + suggestions = mode.suggestions.map(textOf).toSet(), + state = state, + onSuggestionSelected = { handleText(it, keepLast = false) }, + label = label, + modifier = modifier, + prefix = prefix, + placeholder = placeholder, + lineLimits = lineLimits, + keyboardOptions = effectiveKeyboardOptions, + onKeyboardAction = keyboardActionHandler, + inputTransformation = combinedTransformation, + interactionSource = interactionSource, + ) + } } } @@ -304,6 +337,26 @@ private fun MenuChip( } } +private fun CharSequence.splitChipInput(delimiters: Set): List = + split(delimiters = delimiters.toCharArray()) + .mapNotNull { it.trim().takeIf(String::isNotBlank) } + +/** + * Splits [TextFieldState.text] on each character in [delimiters], trims each segment, + * and filters out blank entries. Invokes [block] with the resulting set only when at + * least one non-blank item is found; does nothing if the text produces no valid items. + * + * @param delimiters Characters used as split points. + * @param block Receives the set of trimmed, non-blank items; only called when non-empty. + */ +fun TextFieldState.gatherPendingItems(delimiters: Set, block: (Set) -> Unit) { + val pending = text + .splitChipInput(delimiters) + .toSet() + + if (pending.isNotEmpty()) block(pending) +} + @Preview @Composable private fun ChipFormGroupPreview() { diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormSuggestionField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormSuggestionField.kt new file mode 100644 index 00000000..2496dd1a --- /dev/null +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormSuggestionField.kt @@ -0,0 +1,93 @@ +package de.davis.keygo.feature.item.core.presentation.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +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.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldLabelScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun KeyGoFormSuggestionField( + suggestions: Set, + onSuggestionSelected: (String) -> Unit, + state: TextFieldState, + modifier: Modifier = Modifier, + label: @Composable (TextFieldLabelScope.() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + onKeyboardAction: KeyboardActionHandler? = null, + inputTransformation: InputTransformation? = null, + interactionSource: MutableInteractionSource? = null, +) { + val suggestions = suggestions + .filter { it.startsWith(state.text, ignoreCase = true) } + .toSet() + val (allowExpanded, setExpanded) = remember { mutableStateOf(false) } + val expanded = allowExpanded && suggestions.isNotEmpty() + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = setExpanded + ) { + KeyGoFormField( + state = state, + label = label, + modifier = modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable), + prefix = prefix, + placeholder = placeholder, + lineLimits = lineLimits, + keyboardOptions = keyboardOptions, + onKeyboardAction = onKeyboardAction, + inputTransformation = inputTransformation, + interactionSource = interactionSource, + trailingContent = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.SecondaryEditable), + ) + } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { setExpanded(false) } + ) { + suggestions.forEachIndexed { index, suggestion -> + DropdownMenuItem( + shape = MenuDefaults.itemShape(index, suggestions.size).shape, + text = { + Text( + text = suggestion, + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + state.clearText() + onSuggestionSelected(suggestion) + setExpanded(false) + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} \ No newline at end of file diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/FieldType.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/FieldType.kt index 365dd771..9921fca6 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/FieldType.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/FieldType.kt @@ -6,5 +6,6 @@ enum class FieldType(val isSensitive: Boolean = false) { Totp, Username, Domain, + Tag, Note, } diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml index d23a456b..3c203860 100644 --- a/feature/item/core/src/main/res/values/strings.xml +++ b/feature/item/core/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Passkey TOTP Domains + Tags Back Edit diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index aad2fe51..124d51f8 100644 --- a/feature/item/create/build.gradle.kts +++ b/feature/item/create/build.gradle.kts @@ -66,4 +66,4 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt index a5baa437..8c37f574 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt @@ -33,11 +33,14 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.alias.newVaultId +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.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties 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.ChipFormMode import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.model.InputFieldError import de.davis.keygo.feature.item.create.R @@ -48,9 +51,14 @@ import de.davis.keygo.feature.item.core.R as ItemCoreR @Composable fun KeyGoItemForm( nameTextFieldState: TextFieldState, + tagsTextFieldState: TextFieldState, notesTextFieldState: TextFieldState, vaultsState: VaultsState?, onVaultSelect: (VaultId) -> Unit, + assignedTags: Set, + tagsForSuggestions: Set, + onTagSubmitted: (Set) -> Unit, + onDeleteTag: (Tag) -> Unit, modifier: Modifier = Modifier, nameError: InputFieldError? = null, nameExists: Boolean = false, @@ -112,7 +120,29 @@ fun KeyGoItemForm( content() - item(key = "additional_information") { + item(key = "tags") { + ChipFormGroup( + title = stringResource(R.string.tag_information), + items = assignedTags, + textOf = { it.display }, + containsForInput = { input -> + val normalized = Tag.normalize(input) + assignedTags.any { tag -> tag.normalized == normalized } + }, + onSubmit = { strings -> + onTagSubmitted(strings.mapNotNullTo(mutableSetOf(), Tag::of)) + }, + onDelete = { onDeleteTag(it) }, + label = { + Text(text = stringResource(R.string.add_tags)) + }, + state = tagsTextFieldState, + delimiters = TAG_DELIMITERS, + mode = ChipFormMode.WithSuggestions(suggestions = tagsForSuggestions) + ) + } + + item(key = "notes") { FormGroup( title = stringResource(R.string.additional_information), modifier = Modifier @@ -150,6 +180,8 @@ internal fun FormGroup( ) } +internal val TAG_DELIMITERS = setOf(',') + @Preview @Composable private fun KeyGoItemFormPreview() { @@ -160,6 +192,7 @@ private fun KeyGoItemFormPreview() { KeyGoItemForm( nameTextFieldState = remember { TextFieldState() }, notesTextFieldState = remember { TextFieldState() }, + tagsTextFieldState = remember { TextFieldState() }, vaultsState = remember { val id = newVaultId() VaultsState( @@ -178,7 +211,11 @@ private fun KeyGoItemFormPreview() { ) }, onVaultSelect = {}, - nameExists = true + nameExists = true, + assignedTags = setOf(Tag.of("Tag 1")!!, Tag.of("Tag 2")!!, Tag.of("Tag 3")!!), + tagsForSuggestions = emptySet(), + onTagSubmitted = {}, + onDeleteTag = {}, ) { item(key = "password_information") { FormGroup( 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 7047881b..d2e36786 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 @@ -44,6 +44,7 @@ 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.DomainInfo 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.presentation.StrengthIndicator @@ -51,12 +52,14 @@ 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.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.KeyGoItemForm import de.davis.keygo.feature.item.create.presentation.component.OverrideTotpDialog import de.davis.keygo.feature.item.create.presentation.component.SelectItemForTotpModificationDialog +import de.davis.keygo.feature.item.create.presentation.component.TAG_DELIMITERS import de.davis.keygo.feature.item.create.presentation.component.TotpParseErrorDialog import de.davis.keygo.feature.item.create.presentation.login.model.DialogState import de.davis.keygo.feature.item.create.presentation.login.model.LoginBaseState @@ -116,6 +119,7 @@ private fun LoginReadyContent( ) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val domainTextFieldState = rememberTextFieldState() + val tagsTextFieldState = rememberTextFieldState() val schemeTransformation = rememberSchemeStrippingTransformation() val detectedScheme by schemeTransformation.detectedScheme Scaffold( @@ -127,13 +131,18 @@ private fun LoginReadyContent( actions = { IconButton( onClick = { - val pending = domainTextFieldState.text.toString() - .split(delimiters = DELIMITERS.toCharArray()) - .filter { it.isNotBlank() } - .toSet() - if (pending.isNotEmpty()) { - onEvent(LoginUiEvent.OnAddDomains(pending)) + domainTextFieldState.gatherPendingItems(DELIMITERS) { + onEvent(LoginUiEvent.OnAddDomains(it)) } + + tagsTextFieldState.gatherPendingItems(TAG_DELIMITERS) { strings -> + onEvent( + LoginUiEvent.OnAddTags( + strings.mapNotNullTo(mutableSetOf(), Tag::of), + ), + ) + } + onEvent(LoginUiEvent.OnSubmit) }, enabled = state.canSave, @@ -151,6 +160,9 @@ private fun LoginReadyContent( KeyGoItemForm( nameTextFieldState = state.nameTextFieldState, notesTextFieldState = state.notesTextFieldState, + tagsTextFieldState = tagsTextFieldState, + tagsForSuggestions = state.tagsForSuggestion, + assignedTags = state.itemAssignedTags, modifier = Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding) @@ -161,6 +173,8 @@ private fun LoginReadyContent( nameExists = state.nameExists, vaultsState = vaultsState, onVaultSelect = { onEvent(LoginUiEvent.OnVaultSelected(it)) }, + onTagSubmitted = { onEvent(LoginUiEvent.OnAddTags(it)) }, + onDeleteTag = { onEvent(LoginUiEvent.OnRemoveTag(it)) }, ) { item(key = "password_information") { var forceCompact by rememberSaveable { mutableStateOf(false) } 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 5a9a1ffe..6d7f47fe 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 @@ -13,6 +13,7 @@ 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.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 @@ -79,6 +80,7 @@ internal class LoginViewModel( private val snackbarManager: SnackbarManager, private val totpService: TotpService, private val registrableDomainResolver: RegistrableDomainResolver, + observeAllTags: ObserveAllTagsSortedUseCase, vaultRepository: VaultRepository, ) : ViewModel() { @@ -93,6 +95,8 @@ internal class LoginViewModel( private val _selectedVaultId = MutableStateFlow(null) + private val allTags = observeAllTags() + private val vaultsFlow: Flow = combine( vaultRepository.observeAllVaultMetadata(), _selectedVaultId.filterNotNull(), @@ -100,8 +104,11 @@ internal class LoginViewModel( VaultsState(vaults = metadata, selectedVaultId = selected) }.distinctUntilChanged() - val state = combine(_base, vaultsFlow) { base, vaults -> - LoginUiState.Ready(base = base, vaultsState = vaults) + val state = combine(_base, allTags, vaultsFlow) { base, allTags, vaults -> + LoginUiState.Ready( + base = base.copy(tagsForSuggestion = (allTags - base.itemAssignedTags).toSet()), + vaultsState = vaults + ) }.onStart { observeNameTextField() observePasswordTextField() @@ -244,6 +251,7 @@ internal class LoginViewModel( 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, @@ -298,6 +306,7 @@ internal class LoginViewModel( 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()), @@ -307,6 +316,7 @@ internal class LoginViewModel( 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(), @@ -469,6 +479,19 @@ 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 { 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 33b1548a..72e557ad 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 @@ -2,6 +2,7 @@ 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 internal sealed interface LoginUiEvent { @@ -14,6 +15,9 @@ internal sealed interface 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 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 dbe3308b..aa476081 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,6 +4,7 @@ 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 @@ -25,6 +26,8 @@ internal data class LoginBaseState( 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, @@ -37,10 +40,10 @@ internal data class LoginBaseState( ) { val hasAnyContent: Boolean get() = passwordTextFieldState.text.isNotBlank() - || totpTextFieldState.text.isNotBlank() - || usernameTextFieldState.text.isNotBlank() - || existingPasskeyCount > 0 - || pendingPasskeyCount > 0 + || totpTextFieldState.text.isNotBlank() + || usernameTextFieldState.text.isNotBlank() + || existingPasskeyCount > 0 + || pendingPasskeyCount > 0 val canSave: Boolean get() = nameTextFieldState.text.isNotBlank() && hasAnyContent diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index cadc3ecc..eb1879a8 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Additional Information Password Information Domain Information + Tag information Override TOTP fields? The current item has the following TOTP fields that differ from the fields specified in the totp information. Select fields you want to override. @@ -57,6 +58,7 @@ Generate Password Add domains + Add tags An unexpected database error has occurred: %s Vault item ID can not be found 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 6e71a5bc..b4f8d421 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 @@ -18,6 +18,7 @@ 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.foundation.text.input.then import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -37,6 +38,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Sell import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip @@ -81,6 +83,7 @@ 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.login.model.FieldType import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransformation import de.davis.keygo.feature.item.core.presentation.transformation.rememberSchemeStrippingTransformation @@ -171,6 +174,7 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) val totp = stringResource(ItemCoreR.string.totp) val username = stringResource(ItemCoreR.string.login_identifier) val domains = stringResource(ItemCoreR.string.domains) + val tags = stringResource(ItemCoreR.string.tags) val note = stringResource(ItemCoreR.string.note) var isPasswordHidden by rememberSaveable { mutableStateOf(true) } @@ -320,6 +324,27 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) } } + 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, @@ -353,6 +378,11 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) onClick = { onEvent(ViewLoginUiEvent.OnModifyFieldRequest(it)) }, ) + AddChip( + fieldType = FieldType.Tag, + onClick = { onEvent(ViewLoginUiEvent.OnModifyFieldRequest(it)) }, + ) + if (state.note.isBlank()) { AddChip( fieldType = FieldType.Note, @@ -391,41 +421,59 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit) Text(text = stringResource(CoreUiR.string.add)) }, text = { - val schemeTransformation = rememberSchemeStrippingTransformation() - val detectedScheme by schemeTransformation.detectedScheme + when (dialog.fieldType) { + FieldType.Tag -> KeyGoFormSuggestionField( + suggestions = dialog.tagsToSuggest.mapTo(mutableSetOf()) { it.display }, + onSuggestionSelected = { + textFieldInputState.setTextAndPlaceCursorAtEnd( + it + ) + }, + state = textFieldInputState, + label = { + Text(text = dialog.fieldType.addLabel()) + }, + modifier = Modifier.fillMaxWidth(), + ) - val transformation = when { - dialog.fieldType == FieldType.Domain -> - TrimTransformation.then(schemeTransformation) + else -> { + val schemeTransformation = rememberSchemeStrippingTransformation() + val detectedScheme by schemeTransformation.detectedScheme - !dialog.fieldType.isSensitive -> TrimTransformation - else -> null - } + val transformation = when { + dialog.fieldType == FieldType.Domain -> + TrimTransformation.then(schemeTransformation) - KeyGoFormField( - state = textFieldInputState, - label = { - Text(text = dialog.fieldType.addLabel()) - }, - modifier = Modifier.fillMaxWidth(), - prefix = if (dialog.fieldType == FieldType.Domain) { - { Text(text = detectedScheme ?: "https://") } - } else null, - outsideTrailingContent = if (dialog.fieldType == FieldType.Totp) { - { - IconButton( - onClick = { onEvent(ViewLoginUiEvent.OnScanCodeRequest) }, - ) { - Icon( - imageVector = Icons.Default.QrCodeScanner, - contentDescription = null, - ) - } + !dialog.fieldType.isSensitive -> TrimTransformation + else -> null } - } else null, - isSecure = dialog.fieldType.isSensitive, - inputTransformation = transformation, - ) + + KeyGoFormField( + state = textFieldInputState, + label = { + Text(text = dialog.fieldType.addLabel()) + }, + modifier = Modifier.fillMaxWidth(), + prefix = if (dialog.fieldType == FieldType.Domain) { + { Text(text = detectedScheme ?: "https://") } + } else null, + outsideTrailingContent = if (dialog.fieldType == FieldType.Totp) { + { + IconButton( + onClick = { onEvent(ViewLoginUiEvent.OnScanCodeRequest) }, + ) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = null, + ) + } + } + } else null, + isSecure = dialog.fieldType.isSensitive, + inputTransformation = transformation, + ) + } + } }, ) } @@ -465,6 +513,7 @@ private fun FieldType.addLabel(): String { FieldType.Totp -> stringResource(R.string.add_totp) FieldType.Username -> stringResource(R.string.add_username) FieldType.Domain -> stringResource(R.string.add_domain) + FieldType.Tag -> stringResource(R.string.add_tag) FieldType.Note -> stringResource(R.string.add_note) } } @@ -477,6 +526,7 @@ private fun FieldType.addIcon(): ImageVector { FieldType.Totp -> Icons.Default.MoreTime FieldType.Username -> Icons.Default.PersonAdd FieldType.Domain -> Icons.Default.AddLink + FieldType.Tag -> Icons.Default.Sell FieldType.Note -> Icons.AutoMirrored.Default.NoteAdd } } 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 5a56c58b..3b9126b2 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 @@ -4,12 +4,15 @@ 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.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.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.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 @@ -43,6 +46,7 @@ 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.flowOf import kotlinx.coroutines.flow.flowOn @@ -59,11 +63,13 @@ internal class ViewLoginViewModel( private val vaultRepository: VaultRepository, private val updateLogin: CreateNewOrUpdateLoginUseCase, private val isValidUrl: IsValidUrlUseCase, + private val sort: SortUseCase, private val websiteHandler: WebsiteHandler, private val totpGenerator: TotpGenerator, private val registrableDomainResolver: RegistrableDomainResolver, private val totpService: TotpService, private val observeLoginWithCryptoScope: LoginWithCryptoScopeUseCase, + private val observeAllTags: ObserveAllTagsSortedUseCase, ) : ViewModel() { private val _modificationDialogState = MutableStateFlow(null) @@ -98,6 +104,7 @@ internal class ViewLoginViewModel( passwordStrengthScore = login.passwordCredential?.score, username = login.username.orEmpty(), domains = login.domainInfos, + tags = sort(login.tags) { it.display }.toSet(), note = login.note.orEmpty(), totpState = TotpState.NoTotp, pinned = login.pinned, @@ -185,7 +192,7 @@ internal class ViewLoginViewModel( _modificationDialogState.update { null } } - is ViewLoginUiEvent.OnModifyFieldRequest -> { + is ViewLoginUiEvent.OnModifyFieldRequest -> viewModelScope.launch { val fieldType = event.fieldType val state = state.value val initialValue = when (fieldType) { @@ -194,13 +201,19 @@ internal class ViewLoginViewModel( FieldType.Totp -> "" // TOTP is not editable in this context FieldType.Username -> state.username FieldType.Domain -> "" // Only allow adding new domains, not editing existing ones + FieldType.Tag -> "" // Only allow adding new tags, not editing existing ones FieldType.Note -> state.note } + val tagsToSuggest = + if (fieldType == FieldType.Tag) observeAllTags().first().toSet() - state.tags + else emptySet() + _modificationDialogState.update { ModificationDialog( fieldType = fieldType, initialValue = initialValue, + tagsToSuggest = tagsToSuggest, ) } } @@ -271,6 +284,15 @@ 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), + ) + } + } ?: return@launch + FieldType.Note -> UpsertLogin.update( itemId = id, note = newText, diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ModificationDialog.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ModificationDialog.kt index afbdc0a4..e53dbe83 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ModificationDialog.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ModificationDialog.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.item.view.login.model +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.core.presentation.model.InputFieldError @@ -7,4 +8,5 @@ data class ModificationDialog( val fieldType: FieldType, 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/login/model/ViewLoginState.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt index b5c4cd51..1435de40 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/model/ViewLoginState.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.item.view.login.model import androidx.compose.runtime.Immutable 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.core.item.domain.model.VaultMetadata @Immutable @@ -15,6 +16,7 @@ data class ViewLoginState( val totpState: TotpState = TotpState.NoTotp, val username: String = "", val domains: Set = emptySet(), + val tags: Set = emptySet(), val note: String = "", val modificationDialog: ModificationDialog? = null, val scanning: Boolean = false, diff --git a/feature/item/view/src/main/res/values/strings.xml b/feature/item/view/src/main/res/values/strings.xml index 7a101be5..feaf5139 100644 --- a/feature/item/view/src/main/res/values/strings.xml +++ b/feature/item/view/src/main/res/values/strings.xml @@ -3,5 +3,6 @@ Add TOTP Secret Add Username Add Domain + Add Tag Add Note \ No newline at end of file diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt index 8b6e91e0..11bd7e97 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt @@ -1,13 +1,14 @@ package de.davis.keygo.feature.list_screen.domain.model import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType data class FilterState( val sortDirection: SortDirection = SortDirection.Ascending, val selectedScores: Set = emptySet(), val selectedItemTypes: Set = emptySet(), - val selectedLabels: Set = emptySet(), + val selectedTags: Set = emptySet(), val onlyPinned: Boolean = false ) { diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index af9f5a59..0e244dbd 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -4,27 +4,27 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.model.SortDirection import org.koin.core.annotation.Single -import java.text.Collator @Single -class FilterUseCase { - - private val collator = Collator.getInstance().apply { - strength = Collator.PRIMARY // case-insensitive - } +class FilterUseCase( + private val sortUseCase: SortUseCase, +) { operator fun invoke( filterState: FilterState, items: List, passwordScores: Map, + tagMatchingIds: Set? = null, ): List { val filtered = items.filter { item -> matchesItemType(filterState, item) && matchesScore(filterState, item, passwordScores) && - matchesPinnedState(filterState, item) + matchesPinnedState(filterState, item) && + matchesTags(tagMatchingIds, item) } val (pinned, unpinned) = filtered.partition { it.pinned } @@ -35,6 +35,9 @@ class FilterUseCase { private fun matchesPinnedState(filterState: FilterState, item: LiteItem): Boolean = !filterState.onlyPinned || item.pinned + private fun matchesTags(tagMatchingIds: Set?, item: LiteItem): Boolean = + tagMatchingIds == null || item.id in tagMatchingIds + private fun matchesItemType(filterState: FilterState, item: LiteItem): Boolean = filterState.selectedItemTypes.isEmpty() || item.itemType in filterState.selectedItemTypes @@ -50,57 +53,6 @@ class FilterUseCase { return score in filterState.selectedScores } - private fun sort(direction: SortDirection, items: List): List { - val prepared = items.map { item -> - item to item.name.split(CHUNK_REGEX) - } - - val comp = compareByAlphanumeric>(direction) { it.second } - return prepared.sortedWith(comp).map { it.first } - } - - private fun compareByAlphanumeric( - direction: SortDirection, - selector: (T) -> Parts - ): Comparator = Comparator { a, b -> - val cmp = compareParts(selector(a), selector(b)) - if (direction == SortDirection.Descending) -cmp else cmp - } - - private fun compareParts(parts1: Parts, parts2: Parts): Int { - val len = minOf(parts1.size, parts2.size) - - for (i in 0 until len) { - val p1 = parts1[i] - val p2 = parts2[i] - - // Guard against empty chunks produced by splitting blank/empty names - if (p1.isEmpty() || p2.isEmpty()) return p1.length - p2.length - - val cmp = when { - // split regex guarantees entire chunk is digits if first char is - p1[0].isDigit() && p2[0].isDigit() -> compareNumeric(p1, p2) - else -> collator.compare(p1, p2) // locale-aware: handles ä, ö, ü, &, / etc. - } - - if (cmp != 0) return cmp - } - - return parts1.size.compareTo(parts2.size) - } - - private fun compareNumeric(a: String, b: String): Int { - // Fast path: different lengths means different magnitudes — no parsing needed - if (a.length != b.length) return a.length - b.length - - // Same length: lexicographic order == numeric order for digit-only strings - return a.compareTo(b) - } - - companion object { - - private typealias Parts = List - - private val CHUNK_REGEX = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)") - } -} \ No newline at end of file + private fun sort(direction: SortDirection, items: List): List = + sortUseCase(items, ascending = direction == SortDirection.Ascending) { it.name } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt index 374b1cbe..dc7fdebc 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt @@ -11,6 +11,7 @@ import de.davis.keygo.core.item.domain.model.getIdOrNull import de.davis.keygo.core.item.domain.model.lite.LiteItem 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.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.domain.snackbar.SnackbarManager import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow @@ -56,6 +58,7 @@ internal class ItemListViewModel( private val snackbarManager: SnackbarManager, private val itemRepository: ItemRepository, private val filterUseCase: FilterUseCase, + observeAllTags: ObserveAllTagsSortedUseCase, observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, loginRepository: LoginRepository, ) : ViewModel() { @@ -83,12 +86,23 @@ internal class ItemListViewModel( private val passwordScores = loginRepository.observePasswordScores() private val filterState = MutableStateFlow(FilterState.Default) + + @OptIn(ExperimentalCoroutinesApi::class) + private val tagFilteredItemIds: Flow?> = filterState + .map { it.selectedTags } + .distinctUntilChanged() + .flatMapLatest { tags -> + if (tags.isEmpty()) flowOf(null) + else itemRepository.observeItemIdsForTags(tags) + } + private val filteredItems = combine( nonDeletedItems, filterState, passwordScores, - ) { items, filter, scores -> - filterUseCase(filter, items, scores) + tagFilteredItemIds, + ) { items, filter, scores, tagIds -> + filterUseCase(filter, items, scores, tagIds) }.distinctUntilChanged() private val searchResults = MutableStateFlow(listOf()) @@ -127,8 +141,10 @@ internal class ItemListViewModel( private val availableFilterOptions = combine( nonDeletedItems, passwordScores, - ) { items, scores -> - items.toAvailableFilterOptions(scores) + itemRepository.observeTagsByItem(), + observeAllTags(), + ) { items, scores, tagsByItem, allTags -> + items.toAvailableFilterOptions(scores, tagsByItem, allTags) }.distinctUntilChanged() val filterBottomSheetState = combine( @@ -157,8 +173,8 @@ internal class ItemListViewModel( it.copy(selectedItemTypes = it.selectedItemTypes.toggle(action.itemType)) } - is FilterAction.LabelToggled -> filterState.update { - it.copy(selectedLabels = it.selectedLabels.toggle(action.label)) + is FilterAction.TagToggled -> filterState.update { + it.copy(selectedTags = it.selectedTags.toggle(action.tag)) } is FilterAction.ScoreToggled -> filterState.update { diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index dc523ef7..badb2b12 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag 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.components.KeyGoCard @@ -200,20 +201,20 @@ private fun ItemSection( } } - if (state.labelChips.isNotEmpty()) { + if (state.tagChips.isNotEmpty()) { KeyGoCard( title = { - Text(text = stringResource(R.string.labels)) + Text(text = stringResource(R.string.tags)) }, properties = KeyGoCardProperties.outlined(), ) { FlowRow(horizontalArrangement = DefaultHorizontalArrangement) { - state.labelChips.forEach { chip -> + state.tagChips.forEach { chip -> FilterChip( selected = chip.selected, - onClick = { onAction(FilterAction.LabelToggled(chip.value)) }, + onClick = { onAction(FilterAction.TagToggled(chip.value)) }, label = { - Text(text = chip.value) + Text(text = chip.value.display) }, ) } @@ -363,9 +364,9 @@ private fun FilterBottomSheetContentPreview() { itemTypeChips = VaultItemType.entries.map { type -> FilterChipState(value = type, selected = false) }, - labelChips = listOf( - FilterChipState(value = "Label1", selected = false), - FilterChipState(value = "Label2", selected = true), + tagChips = listOf( + FilterChipState(value = Tag.of("Label1")!!, selected = false), + FilterChipState(value = Tag.of("Label2")!!, selected = true), ), ), passwordSection = PasswordSectionState( diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt index ac8519fb..575b3770 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt @@ -234,6 +234,7 @@ private fun ItemListContentPreview() { pinned = false, matchedName = true, matchedNote = false, + matchedTag = false, ) } @@ -254,7 +255,7 @@ private fun ItemListContentPreview() { showPinnedSwitch = false, onlyPinnedChecked = false, itemTypeChips = emptyList(), - labelChips = emptyList() + tagChips = emptyList() ), passwordSection = null, isDefault = true diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index 40fef41f..84683646 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.list_screen.presentation.mapper import androidx.compose.ui.util.fastMapTo import de.davis.keygo.core.item.domain.alias.ItemId 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.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -18,7 +19,7 @@ internal fun FilterState.toBottomSheetState( ): FilterBottomSheetState { val showItemTypeChips = restrictedItemType == null && available.itemTypes.size > 1 val showItemSection = - showItemTypeChips || available.labels.isNotEmpty() || available.hasPinnedItems + showItemTypeChips || available.tags.isNotEmpty() || available.hasPinnedItems val effectiveItemTypes = restrictedItemType?.let { setOf(it) } ?: selectedItemTypes val showLoginSection = available.hasPasswordItems && @@ -32,8 +33,8 @@ internal fun FilterState.toBottomSheetState( itemTypeChips = if (showItemTypeChips) available.itemTypes.map { type -> FilterChipState(value = type, selected = type in selectedItemTypes) } else emptyList(), - labelChips = available.labels.map { label -> - FilterChipState(value = label, selected = label in selectedLabels) + tagChips = available.tags.map { tag -> + FilterChipState(value = tag, selected = tag in selectedTags) }, ) else null, passwordSection = if (showLoginSection) PasswordSectionState( @@ -49,6 +50,8 @@ internal fun FilterState.toBottomSheetState( internal fun List.toAvailableFilterOptions( passwordScores: Map, + tagsByItem: Map>, + allTags: List, ): AvailableFilterOptions { val itemTypes = fastMapTo(mutableSetOf()) { it.itemType } val hasLoginItems = VaultItemType.Login in itemTypes @@ -60,11 +63,17 @@ internal fun List.toAvailableFilterOptions( passwordScores.filterKeys { it in itemIds }.values.toSet() else emptySet() + val visibleTags = buildSet { + this@toAvailableFilterOptions.forEach { item -> + tagsByItem[item.id]?.let(::addAll) + } + } + return AvailableFilterOptions( itemTypes = itemTypes, hasPasswordItems = hasLoginItems, passwordScores = scores, - labels = emptySet(), + tags = allTags.filter { it in visibleTags }.toSet(), hasPinnedItems = any { it.pinned }, ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt index 33854d59..5246e80d 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt @@ -2,13 +2,14 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Immutable import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType @Immutable internal data class AvailableFilterOptions( val passwordScores: Set = emptySet(), val itemTypes: Set = emptySet(), - val labels: Set = emptySet(), + val tags: Set = emptySet(), val hasPasswordItems: Boolean = false, val hasPinnedItems: Boolean = false ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt index 9d9d839e..fa457278 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt @@ -1,13 +1,14 @@ package de.davis.keygo.feature.list_screen.presentation.model import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.SortDirection internal sealed interface FilterAction { data class SortDirectionChanged(val direction: SortDirection) : FilterAction data class ItemTypeToggled(val itemType: VaultItemType) : FilterAction - data class LabelToggled(val label: String) : FilterAction + data class TagToggled(val tag: Tag) : FilterAction data class ScoreToggled(val passwordScore: PasswordScore) : FilterAction data object ShowOnlyPinnedToggled : FilterAction data object ClearFilters : FilterAction diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt index 476a33e0..12e5aad3 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Immutable import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.SortDirection @@ -18,7 +19,7 @@ internal data class ItemSectionState( val showPinnedSwitch: Boolean, val onlyPinnedChecked: Boolean, val itemTypeChips: List>, - val labelChips: List>, + val tagChips: List>, ) @Immutable diff --git a/feature/list_screen/src/main/res/values/strings.xml b/feature/list_screen/src/main/res/values/strings.xml index 0d92291b..ab1c3a9a 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -20,7 +20,7 @@ Item Item Type - Labels + Tags Only pinned Items diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt index c0a788e4..4ac943b8 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.domain.model import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType import kotlin.test.Test import kotlin.test.assertFalse @@ -56,9 +57,9 @@ class FilterStateTest { } @Test - fun `non-empty selected labels is not default`() { + fun `non-empty selected tags is not default`() { val state = FilterState( - selectedLabels = setOf("work"), + selectedTags = setOf(Tag.of("work")!!), ) assertFalse(state.isDefault) } @@ -69,7 +70,7 @@ class FilterStateTest { sortDirection = SortDirection.Descending, selectedScores = setOf(PasswordScore.Weak), selectedItemTypes = setOf(VaultItemType.Login), - selectedLabels = setOf("personal"), + selectedTags = setOf(Tag.of("personal")!!), ) assertFalse(state.isDefault) } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index 48d4314a..739a1631 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -5,6 +5,7 @@ import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.model.SortDirection import java.util.UUID @@ -14,7 +15,7 @@ import kotlin.test.assertTrue class FilterUseCaseTest { - private val useCase: FilterUseCase = FilterUseCase() + private val useCase: FilterUseCase = FilterUseCase(SortUseCase()) private val filterStateAsc = FilterState(sortDirection = SortDirection.Ascending) private val filterStateDesc = FilterState(sortDirection = SortDirection.Descending) @@ -413,4 +414,43 @@ class FilterUseCaseTest { assertEquals(listOf("Alpha", "Mike", "Zulu"), result.map { it.name }) } + + // Filter by tag (precomputed matching ids) + private val tagItems = listOf( + TestLiteItem(name = "Bank A", id = newItemId()), + TestLiteItem(name = "Bank B", id = newItemId()), + TestLiteItem(name = "Work C", id = newItemId()), + ) + + @Test + fun `null tagMatchingIds returns all items`() { + val result = useCase(FilterState(), tagItems, noScores, null) + assertEquals(tagItems.size, result.size) + assertTrue(result.containsAll(tagItems)) + } + + @Test + fun `tagMatchingIds keeps only items whose id is in the set`() { + val matching = setOf(tagItems[0].id, tagItems[1].id) + val result = useCase(FilterState(), tagItems, noScores, matching) + + assertEquals(2, result.size) + assertTrue(result.all { it.id in matching }) + } + + @Test + fun `empty tagMatchingIds returns empty list`() { + val result = useCase(FilterState(), tagItems, noScores, emptySet()) + assertTrue(result.isEmpty()) + } + + @Test + fun `tag filter combines with item type filter`() { + val state = FilterState(selectedItemTypes = setOf(VaultItemType.Login)) + val matching = setOf(tagItems[0].id) + val result = useCase(state, tagItems, noScores, matching) + + assertEquals(1, result.size) + assertEquals(tagItems[0].id, result.single().id) + } } \ No newline at end of file diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt new file mode 100644 index 00000000..92d7a94e --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt @@ -0,0 +1,92 @@ +package de.davis.keygo.feature.list_screen.presentation.mapper + +import de.davis.keygo.core.item.domain.alias.ItemId +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.lite.LiteItem +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FilterState +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FilterMapperTest { + + private data class TestLiteItem( + override val name: String, + override val id: ItemId = UUID.nameUUIDFromBytes(name.toByteArray()), + override val itemType: VaultItemType = VaultItemType.Login, + override val pinned: Boolean = false, + ) : LiteItem + + private val noScores: Map = emptyMap() + private val noTags: Map> = emptyMap() + + private fun tag(value: String): Tag = Tag.of(value)!! + + @Test + fun `tags include only tags carried by a visible item, in allTags order`() { + val a = TestLiteItem("A") + val b = TestLiteItem("B") + + val options = listOf(a, b).toAvailableFilterOptions( + passwordScores = noScores, + tagsByItem = mapOf( + a.id to setOf(tag("Bank")), + b.id to setOf(tag("Work")), + ), + // "Personal" belongs to no visible item -> must be excluded. + allTags = listOf(tag("Bank"), tag("Personal"), tag("Work")), + ) + + // Order follows the (already sorted) allTags list. + assertEquals(listOf(tag("Bank"), tag("Work")), options.tags.toList()) + } + + @Test + fun `tags belonging only to items absent from the list are excluded`() { + val a = TestLiteItem("A") + val hidden = TestLiteItem("Hidden") + + val options = listOf(a).toAvailableFilterOptions( + passwordScores = noScores, + tagsByItem = mapOf( + a.id to setOf(tag("Bank")), + hidden.id to setOf(tag("Secret")), + ), + allTags = listOf(tag("Bank"), tag("Secret")), + ) + + assertEquals(setOf(tag("Bank")), options.tags) + } + + @Test + fun `no tags when no visible item has tags`() { + val options = listOf(TestLiteItem("A")).toAvailableFilterOptions( + passwordScores = noScores, + tagsByItem = noTags, + allTags = listOf(tag("Bank")), + ) + + assertTrue(options.tags.isEmpty()) + } + + @Test + fun `selected tags are reflected as selected chips`() { + val a = TestLiteItem("A") + val available = listOf(a).toAvailableFilterOptions( + passwordScores = noScores, + tagsByItem = mapOf(a.id to setOf(tag("Bank"), tag("Work"))), + allTags = listOf(tag("Bank"), tag("Work")), + ) + + val sheet = FilterState(selectedTags = setOf(tag("Bank"))) + .toBottomSheetState(available, restrictedItemType = null) + + val chips = sheet.itemSection?.tagChips.orEmpty() + assertEquals(setOf(tag("Bank"), tag("Work")), chips.map { it.value }.toSet()) + assertTrue(chips.single { it.value == tag("Bank") }.selected) + assertTrue(!chips.single { it.value == tag("Work") }.selected) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt index 9954fe30..2e660578 100644 --- a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.vault.domain.usecase import de.davis.keygo.core.item.domain.repository.VaultContextRepository import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.vault.domain.model.VaultsAndSelection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -11,11 +12,15 @@ import org.koin.core.annotation.Single class ObserveVaultsAndSelectionUseCase( private val vaultRepository: VaultRepository, private val vaultContextRepository: VaultContextRepository, + private val sortUseCase: SortUseCase, ) { operator fun invoke(): Flow = combine( vaultRepository.observeAllVaultMetadata(), vaultContextRepository.observeVaultContext(), ) { vaults, context -> - VaultsAndSelection(vaults = vaults.sortedBy { it.name }, selection = context) + VaultsAndSelection( + vaults = sortUseCase(vaults) { it.name }, + selection = context, + ) } } diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt index e1f2f41f..2e1d21c7 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt @@ -7,6 +7,7 @@ import de.davis.keygo.core.item.domain.alias.newVaultId 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.model.VaultContext +import de.davis.keygo.core.util.domain.usecase.SortUseCase import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -23,6 +24,7 @@ class ObserveVaultsAndSelectionUseCaseTest { private val useCase = ObserveVaultsAndSelectionUseCase( vaultRepository = vaultRepository, vaultContextRepository = vaultContextRepository, + sortUseCase = SortUseCase(), ) @Test @@ -77,6 +79,15 @@ class ObserveVaultsAndSelectionUseCaseTest { assertEquals(listOf("Alpha", "Mango", "Zebra"), names) } + @Test + fun `sorts vaults with natural numeric order`() = runTest { + vaultRepository.seed(testVault("vault10"), testVault("vault2"), testVault("vault1")) + + val names = useCase().first().vaults.map { it.name } + + assertEquals(listOf("vault1", "vault2", "vault10"), names) + } + private fun testVault( name: String, id: VaultId = newVaultId(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e7e82c0..904424da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ semver = "0.2.5" koinBOM = "4.2.1" koin-plugin = "1.0.0-RC1" room = "2.8.4" +sqlite = "2.6.2" ksp = "2.3.6" datastore = "1.2.1" protobuf = "0.10.0" @@ -87,6 +88,7 @@ koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compos androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled-jvm", version.ref = "sqlite" } androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }