Skip to content
3 changes: 0 additions & 3 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@
<string name="connectivity">Connectivity</string>
<string name="settings">Settings</string>

<string name="match_name">Matched name</string>
<string name="match_note">Matched note</string>
<string name="match_name_and_note">Matched name and note</string>
<string name="match_not_found">No matches found</string>

<string name="coming_soon">Coming soon</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,17 @@ internal interface ItemDao {
)
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
(i.name LIKE '%' || :query || '%') AS matchedName,
(i.note LIKE '%' || :query || '%') AS matchedNote,
(l.username LIKE '%' || :query || '%') AS matchedUsername,
(tm.item_id IS NOT NULL) AS matchedTag
FROM item i
LEFT JOIN tag_matches tm ON tm.item_id = i.id
LEFT JOIN login l ON l.id = i.id
WHERE (:itemType IS NULL OR i.item_type = :itemType)
AND (i.name LIKE '%' || :query || '%'
OR i.note LIKE '%' || :query || '%'
OR l.username LIKE '%' || :query || '%'
OR tm.item_id IS NOT NULL)
"""
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal data class LightweightItemSearchResult(
val itemType: VaultItemType,
val matchedName: Boolean,
val matchedNote: Boolean,
val matchedUsername: Boolean,
val matchedTag: Boolean,
val pinned: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ internal fun LightweightItemSearchResult.toDomain() = LiteItemSearchResult(
itemType = itemType,
matchedName = matchedName,
matchedNote = matchedNote,
matchedUsername = matchedUsername,
matchedTag = matchedTag,
pinned = pinned,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ data class LiteItemSearchResult(
override val pinned: Boolean,
val matchedName: Boolean,
val matchedNote: Boolean,
val matchedUsername: Boolean,
val matchedTag: Boolean,
) : LiteItem
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.room3.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.LoginEntity
import de.davis.keygo.core.item.data.local.entity.TagEntity
import de.davis.keygo.core.item.data.local.entity.Timestamp
import de.davis.keygo.core.item.data.local.entity.VaultEntity
Expand Down Expand Up @@ -58,6 +59,7 @@ internal class ItemDaoSearchTest {
name: String,
note: String? = null,
tags: Set<String> = emptySet(),
username: String? = null,
): ItemId {
val id = newItemId()
itemDao.upsert(
Expand All @@ -77,6 +79,7 @@ internal class ItemDaoSearchTest {
id,
tags.map { TagEntity(value = it, normalized = it.lowercase()) }.toSet(),
)
db.loginDao().upsert(LoginEntity(id = id, username = username))
return id
}

Expand Down Expand Up @@ -105,12 +108,51 @@ internal class ItemDaoSearchTest {
}

@Test
fun `searchItem does not return items with no name note or tag match`() = runTest {
insertItem(name = "Email", note = "personal", tags = setOf("Mail"))
fun `searchItem does not return items with no name note username or tag match`() = runTest {
insertItem(name = "Email", note = "personal", tags = setOf("Mail"), username = "me@x.io")

assertTrue(itemDao.searchItem(query = "Bank", normalizedQuery = "bank").first().isEmpty())
}

@Test
fun `searchItem matches an item by username only`() = runTest {
val id = insertItem(name = "Chase", username = "treasurer@example.com")

val r = itemDao.searchItem(query = "treasurer", normalizedQuery = "treasurer")
.first()
.single()

assertEquals(id, r.id)
assertTrue(r.matchedUsername)
assertFalse(r.matchedName)
assertFalse(r.matchedNote)
assertFalse(r.matchedTag)
}

/**
* The login join is one-to-one on a shared primary key, so a username match must not duplicate
* the item row it already matched by name.
*/
@Test
fun `searchItem returns a single row when name and username both match`() = runTest {
insertItem(name = "Bank", username = "bank-admin")

val r = itemDao.searchItem(query = "bank", normalizedQuery = "bank").first().single()

assertTrue(r.matchedName)
assertTrue(r.matchedUsername)
}

@Test
fun `searchItem leaves matchedUsername false for an item without a username`() = runTest {
insertItem(name = "Bank of Earth", username = null)

val r = itemDao.searchItem(query = "Bank", normalizedQuery = "bank").first().single()

assertTrue(r.matchedName)
assertFalse(r.matchedUsername)
}

@Test
fun `searchItem tag match still passes an explicit itemType filter`() = runTest {
insertItem(name = "Chase", tags = setOf("Bank"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class ItemMapperTest {
name = "Search result",
itemType = VaultItemType.Login,
matchedName = true,
matchedUsername = true,
matchedNote = false,
matchedTag = true,
pinned = false,
Expand All @@ -125,6 +126,7 @@ class ItemMapperTest {
assertEquals(VaultItemType.Login, result.itemType)
assertTrue(result.matchedName)
assertFalse(result.matchedNote)
assertTrue(result.matchedUsername)
assertTrue(result.matchedTag)
assertFalse(result.pinned)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ class FakeItemRepository(
.mapNotNull { item ->
val matchedName = item.name.contains(query, ignoreCase = true)
val matchedNote = item.note?.contains(query, ignoreCase = true) == true
val matchedUsername = item.username?.contains(query, ignoreCase = true) == true
val matchedTag = item.tags.any { it.display.contains(query, ignoreCase = true) }
if (!matchedName && !matchedNote && !matchedTag) return@mapNotNull null
if (!matchedName && !matchedNote && !matchedUsername && !matchedTag)
return@mapNotNull null

LiteItemSearchResult(
id = item.id,
Expand All @@ -113,6 +115,7 @@ class FakeItemRepository(
pinned = item.pinned,
matchedName = matchedName,
matchedNote = matchedNote,
matchedUsername = matchedUsername,
matchedTag = matchedTag,
)
}
Expand Down
3 changes: 0 additions & 3 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@

<string name="create_new_item">Create new Item</string>

<string name="match_name">Matched name</string>
<string name="match_note">Matched note</string>
<string name="match_name_and_note">Matched name and note</string>
<string name="match_not_found">No matches found</string>

<string name="copy_to_clipboard_content_description">Copy to clipboard</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package de.davis.keygo.feature.list_screen.domain.usecase

import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult
import de.davis.keygo.core.util.domain.usecase.SortUseCase
import org.koin.core.annotation.Single

@Single
class RankSearchResultsUseCase(
private val sortUseCase: SortUseCase,
) {

operator fun invoke(
query: String,
results: List<LiteItemSearchResult>,
): List<LiteItemSearchResult> {
val alphabetical = sortUseCase(results) { it.name }
if (query.isBlank()) return alphabetical

// sortedBy is stable, so the alphabetical order survives within each rank.
return alphabetical.sortedBy { rankOf(query, it) }
}

private fun rankOf(query: String, result: LiteItemSearchResult): Int = when {
result.name.equals(query, ignoreCase = true) -> EXACT_NAME
result.matchedName && result.name.startsWith(query, ignoreCase = true) -> NAME_PREFIX
result.matchedName -> NAME
result.matchedUsername -> USERNAME
result.matchedTag -> TAG
else -> NOTE
}

private companion object {
const val EXACT_NAME = 0
const val NAME_PREFIX = 1
const val NAME = 2
const val USERNAME = 3
const val TAG = 4
const val NOTE = 5
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import de.davis.keygo.core.util.combine
import de.davis.keygo.feature.list_screen.domain.model.FilterState
import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase
import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase
import de.davis.keygo.feature.list_screen.presentation.mapper.toAvailableFilterOptions
import de.davis.keygo.feature.list_screen.presentation.mapper.toBottomSheetState
import de.davis.keygo.feature.list_screen.presentation.model.Event
import de.davis.keygo.feature.list_screen.presentation.model.FilterAction
import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState
import de.davis.keygo.feature.list_screen.presentation.model.ItemSelection
import de.davis.keygo.feature.list_screen.presentation.model.ListItemState
import de.davis.keygo.feature.list_screen.presentation.model.SearchState
import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand Down Expand Up @@ -60,6 +62,7 @@ internal class ItemListViewModel(
@InjectedParam private val restrictedItemType: VaultItemType?,
private val itemRepository: ItemRepository,
private val filterUseCase: FilterUseCase,
private val rankSearchResults: RankSearchResultsUseCase,
observeAllTags: ObserveAllTagsSortedUseCase,
observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase,
loginRepository: LoginRepository,
Expand Down Expand Up @@ -107,31 +110,33 @@ internal class ItemListViewModel(
private val _isDeleteConfirmationVisible = MutableStateFlow(false)

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private val searchResults = snapshotFlow { searchTextFieldState.text }
private val searchState = snapshotFlow { searchTextFieldState.text.toString() }
.debounce(SEARCH_DEBOUNCE)
.flatMapLatest {
queryToItems(it.toString(), forceSearchAllVaults = true)
.distinctUntilChanged()
.flatMapLatest { query ->
itemRepository.searchVaultItem(query, restrictedItemType)
.map { SearchState(query, rankSearchResults(query, it)) }
}
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
// Nobody has searched yet, and combine withholds its first emission until every input has
// emitted: without this the list screen's first render would wait on a full cross-vault
// search for the empty query.
.onStart { emit(emptyList()) }
.onStart { emit(SearchState()) }

val listItemState = combine(
vaultsAndSelection,
filteredItems,
searchResults,
searchState,
selection,
submittedSearchQuery,
highlightedId,
_isVaultFlowVisible,
_isDeleteConfirmationVisible,
) { vaultsAndSel, items, searchResults, selection, submittedSearchQuery, highlightedId, isVaultFlowVisible, isDeleteConfirmationVisible ->
) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, highlightedId, isVaultFlowVisible, isDeleteConfirmationVisible ->
ListItemState(
items = items,
searchResults = searchResults,
searchState = searchState,
hasSearchQuery = submittedSearchQuery.isNotBlank(),
selection = selection,
highlightedId = highlightedId,
Expand Down Expand Up @@ -209,12 +214,9 @@ internal class ItemListViewModel(
private fun <T> Set<T>.toggle(element: T): Set<T> =
if (element in this) this - element else this + element

private fun queryToItems(
query: String,
forceSearchAllVaults: Boolean = false
): Flow<List<LiteItem>> =
(if (!forceSearchAllVaults && query.isBlank()) vaultSpecificItems
else itemRepository.searchVaultItem(query, restrictedItemType))
private fun queryToItems(query: String): Flow<List<LiteItem>> =
if (query.isBlank()) vaultSpecificItems
else itemRepository.searchVaultItem(query, restrictedItemType)

fun onSubmitQuery() {
submittedSearchQuery.update { searchTextFieldState.text.toString() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import de.davis.keygo.feature.list_screen.presentation.model.FilterAction
import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState
import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState
import de.davis.keygo.feature.list_screen.presentation.model.ListItemState
import de.davis.keygo.feature.list_screen.presentation.model.SearchState
import de.davis.keygo.feature.vault.presentation.VaultFlow
import kotlinx.coroutines.launch

Expand Down Expand Up @@ -144,17 +145,13 @@ internal fun ItemListContent(
val searchResultContent: @Composable ColumnScope.() -> Unit = {
val scope = rememberCoroutineScope()
SearchResult(
searchResult = uiState.searchResults,
idOf = { it.id },
nameOf = { it.name },
matchedInName = { true },
matchedInNote = { false },
onClick = { item ->
searchState = uiState.searchState,
onResultClick = { itemId ->
scope.launch { searchBarState.animateToCollapsed() }

// Clicking a search result should not select the item when currently
// other items are selected.
onItemClick(item.id, true)
onItemClick(itemId, true)
},
modifier = Modifier.padding(8.dp)
)
Expand Down Expand Up @@ -267,6 +264,7 @@ private fun ItemListContentPreview() {
itemType = VaultItemType.Login,
pinned = false,
matchedName = true,
matchedUsername = false,
matchedNote = false,
matchedTag = false,
)
Expand All @@ -275,7 +273,10 @@ private fun ItemListContentPreview() {
val uiState = remember {
ListItemState(
items = listOf(sampleItem),
searchResults = listOf(sampleItem),
searchState = SearchState(
results = listOf(sampleItem),
query = "Sam"
),
hasSearchQuery = false,
highlightedId = null,
)
Expand Down
Loading
Loading