Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f048be1
feat(database): Add tag entity
OffRange May 15, 2026
27b126d
feat(presentation): Add create tag support
OffRange May 16, 2026
41df99f
feat(presentation): Add view tags support
OffRange May 17, 2026
6a692ca
feat(presentation): Add filter by tag support
OffRange May 17, 2026
35f649d
feat(presentation): Add search by tag support
OffRange May 17, 2026
6ef2241
feat(core-util): add shared SortUseCase for natural locale-aware sort
OffRange May 17, 2026
ae36e0a
refactor(list-screen): delegate FilterUseCase sort to shared SortUseCase
OffRange May 17, 2026
713fd10
refactor(core-item): observeAllTags returns ordered List<Tag>
OffRange May 17, 2026
4f5c499
feat(item-create): sort tag suggestions via ObserveTagSuggestionsUseCase
OffRange May 17, 2026
084a6c7
refactor(vault): delegate vault sort to shared SortUseCase
OffRange May 17, 2026
831b599
feat(list-screen): sort filter-chip tag labels via SortUseCase
OffRange May 18, 2026
a55c1e9
feat(sort): Add convenient string sort function
OffRange May 18, 2026
719a9e8
fix(item-view): sort tags
OffRange May 18, 2026
10a3a6d
refactor(item-core): rename and move ObserveTagSuggestionsUseCase to …
OffRange May 18, 2026
71f64a6
refactor(core-item): Extract KeyGoFormSuggestionField
OffRange May 18, 2026
d00613d
feat(view-login): Add tag suggestions to modification dialog
OffRange May 18, 2026
606953c
feat(list_screen): Filter tag options by visible items
OffRange May 18, 2026
ab7a845
refactor(tags): rename labels to tags
OffRange May 20, 2026
5c25ddc
refactor(tag): Move ObserveAllTagsSortedUseCase to :core:item
OffRange May 20, 2026
eb20b2f
refactor(item): introduce Tag value class and case-insensitive matching
OffRange May 20, 2026
b27ec74
build: remove isReturnDefaultValues and isIncludeAndroidResources uni…
OffRange May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/item/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ad09e26be7e4b8a0df65d9ba9e325f37",
"identityHash": "40462f38d8b4873c28fe3aec0e192229",
"entities": [
{
"tableName": "vault",
Expand Down Expand Up @@ -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')"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LightweightItemSearchResult>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<TagEntity>>

@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<String>): Flow<List<ItemId>>

@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<List<ItemTagProjection>>

@Query("SELECT tag_id FROM tag_cross_ref WHERE item_id = :itemId")
abstract suspend fun tagIdsForItem(itemId: ItemId): List<Long>

@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<Long>)

@Transaction
open suspend fun syncTags(itemId: ItemId, tags: Set<TagEntity>) {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +33,8 @@ import org.koin.core.annotation.Single
PasswordEntity::class,
DomainInfoEntity::class,
PasskeyEntity::class,
TagEntity::class,
TagCrossRef::class,
],
version = 1,
)
Expand All @@ -48,6 +53,8 @@ internal abstract class ItemDatabase : RoomDatabase() {
abstract fun domainInfoDao(): DomainInfoDao

abstract fun passkeyDao(): PasskeyDao

abstract fun tagDao(): TagDao
}

@Module
Expand Down Expand Up @@ -81,4 +88,7 @@ internal class DatabaseModule {

@Single
fun providePasskeyDao(db: ItemDatabase) = db.passkeyDao()

@Single
fun provideTagDao(db: ItemDatabase) = db.tagDao()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package de.davis.keygo.core.item.data.local.entity

import androidx.room.ColumnInfo
import androidx.room.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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<TagEntity> = emptySet(),
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ internal data class LightweightItemSearchResult(
val itemType: VaultItemType,
val matchedName: Boolean,
val matchedNote: Boolean,
val matchedTag: Boolean,
val pinned: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DomainInfoEntity> =
domainInfos.map { it.toData(loginId) }.toSet()
internal fun Login.toDomainInfoEntities(): Set<DomainInfoEntity> =
domainInfos.map { it.toData(this.id) }.toSet()
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal fun LightweightItemSearchResult.toDomain() = LiteItemSearchResult(
itemType = itemType,
matchedName = matchedName,
matchedNote = matchedNote,
matchedTag = matchedTag,
pinned = pinned,
)

Expand Down
Loading
Loading