refactor: search history moves to commons

The cap, the most-recent-first ordering, the de-duplication and the
encoding were all written into a desktop `object` bolted to
`java.util.prefs`. None of it is desktop-specific — it is a capped list
and a labelled list — and that placement is the entire reason Android has
never had a search history and the entire reason none of this was tested.

`SearchHistory` in commons owns the behaviour; `SearchHistoryStorage` is
the two-method seam for where the bytes land. `SearchHistoryStore` keeps
its API and becomes the `Preferences` half, so Desktop is unchanged and
an existing stored history keeps loading.

Two bugs the tests found on arrival:

A saved search whose label contained a tab lost the query it named — the
label is the one free-text field and the tab is the field separator, so
everything after it shifted by one. Labels are now escaped, and only for
the two separators and the backslash, so an already-stored label that
contains none of them encodes to exactly itself.

Re-running a query typed in a different token order made a second history
entry. The comparison is on the serialized form now, so two queries that
mean the same thing are one entry however they were typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
This commit is contained in:
Claude
2026-09-10 15:19:55 +00:00
parent c0aeda8cd3
commit a4ccaba4d3
3 changed files with 457 additions and 99 deletions
@@ -0,0 +1,211 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Where a platform keeps two strings. Two reads at startup, one write per change.
*
* Deliberately this small: the whole of what a search history *is* — the cap, the
* most-recent-first ordering, the de-duplication, the encoding — is the same everywhere, and the
* only genuine difference between a desktop and a phone here is which file the bytes land in.
*/
interface SearchHistoryStorage {
suspend fun read(key: String): String?
suspend fun write(
key: String,
value: String?,
)
}
/**
* The searches a reader has run, and the ones they kept.
*
* Desktop has had both for a while; Android had neither, because the code was written into a
* desktop `object` sitting on `java.util.prefs`. Nothing about the behaviour was desktop-specific
* — it is a capped list and a labelled list — so it moves here whole and Android gains it by
* being handed a different [SearchHistoryStorage].
*
* Queries are stored as their serialized text, which is the same text the box holds. That is what
* makes a history entry re-runnable by simply putting it back in the field, and it means a stored
* search survives a change to [SearchQuery]'s shape as long as the token language still parses.
*/
class SearchHistory(
private val storage: SearchHistoryStorage,
private val scope: CoroutineScope,
) {
private val _recent = MutableStateFlow<List<SearchQuery>>(emptyList())
/** Most recent first, capped at [MAX_RECENT]. */
val recent: StateFlow<List<SearchQuery>> = _recent.asStateFlow()
private val _saved = MutableStateFlow<List<SavedSearch>>(emptyList())
val saved: StateFlow<List<SavedSearch>> = _saved.asStateFlow()
init {
// Fire and forget, as the drawer's collapse state is: the lists read as empty until disk
// answers, and the worst case is a recent-searches row that appears a frame late.
scope.launch {
_recent.value = decodeQueries(readOrNull(KEY_RECENT))
_saved.value = decodeSaved(readOrNull(KEY_SAVED))
}
}
/**
* Records a search the reader actually ran.
*
* Re-running something already in the list moves it to the top rather than adding it twice —
* compared on the serialized form, so two queries that mean the same thing count as one
* however they were typed.
*/
fun remember(query: SearchQuery) {
if (query.isEmpty) return
val serialized = QuerySerializer.serialize(query)
val next =
(listOf(query) + _recent.value.filterNot { QuerySerializer.serialize(it) == serialized })
.take(MAX_RECENT)
_recent.value = next
persist(KEY_RECENT, encodeQueries(next))
}
fun clearRecent() {
_recent.value = emptyList()
persist(KEY_RECENT, null)
}
/** Keeps [query] under a name the reader chose. */
fun save(
query: SearchQuery,
label: String,
) {
if (query.isEmpty) return
val now = TimeUtils.now()
val next = _saved.value + SavedSearch(id = "$now-${_saved.value.size}", label = label, query = query, createdAt = now)
_saved.value = next
persist(KEY_SAVED, encodeSaved(next))
}
fun forget(id: String) {
val next = _saved.value.filter { it.id != id }
_saved.value = next
persist(KEY_SAVED, encodeSaved(next))
}
private suspend fun readOrNull(key: String): String? =
try {
storage.read(key)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("SearchHistory") { "Could not read $key: ${e.message}" }
null
}
private fun persist(
key: String,
value: String?,
) {
scope.launch {
try {
storage.write(key, value)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("SearchHistory") { "Could not write $key: ${e.message}" }
}
}
}
companion object {
const val KEY_RECENT = "search_history"
const val KEY_SAVED = "saved_searches"
/** Long enough to find what you ran this morning, short enough to scan. */
const val MAX_RECENT = 20
private const val RECORD = "\n"
private const val FIELD = "\t"
fun encodeQueries(queries: List<SearchQuery>) = queries.joinToString(RECORD) { QuerySerializer.serialize(it) }
fun decodeQueries(raw: String?): List<SearchQuery> =
raw
?.split(RECORD)
?.mapNotNull { line -> QueryParser.parse(line).takeUnless { it.isEmpty } }
.orEmpty()
fun encodeSaved(searches: List<SavedSearch>) =
searches.joinToString(RECORD) {
listOf(escape(it.id), escape(it.label), it.createdAt.toString(), QuerySerializer.serialize(it.query)).joinToString(FIELD)
}
/**
* Keeps a reader's label out of the separators.
*
* A label is the one free-text field here, and a tab in it shifted every field after it —
* so naming a saved search "mine\tyours" lost the query it named. Only the two separators
* and the escape character itself are touched, so a label that contains none of them
* encodes to exactly itself and an already-stored history reads back unchanged.
*/
private fun escape(value: String) = value.replace("\\", "\\\\").replace(FIELD, "\\t").replace(RECORD, "\\n")
/** Anything else after a backslash is left alone, so a label stored before [escape] existed survives. */
private fun unescape(value: String): String {
if ('\\' !in value) return value
val out = StringBuilder(value.length)
var i = 0
while (i < value.length) {
val c = value[i]
if (c != '\\' || i == value.lastIndex) {
out.append(c)
i++
continue
}
when (val next = value[i + 1]) {
't' -> out.append('\t')
'n' -> out.append('\n')
'\\' -> out.append('\\')
else -> out.append(c).append(next)
}
i += 2
}
return out.toString()
}
fun decodeSaved(raw: String?): List<SavedSearch> =
raw
?.split(RECORD)
?.mapNotNull { line ->
val parts = line.split(FIELD)
if (parts.size < 4) return@mapNotNull null
val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
val query = QueryParser.parse(parts[3])
if (query.isEmpty) null else SavedSearch(id = unescape(parts[0]), label = unescape(parts[1]), query = query, createdAt = createdAt)
}.orEmpty()
}
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The history, over the storage it is now independent of.
*
* All of this used to live in a desktop `object` bolted to `java.util.prefs`, which is the only
* reason Android never had it and the only reason none of it was ever tested.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SearchHistoryTest {
private class InMemory(
vararg seed: Pair<String, String>,
) : SearchHistoryStorage {
val values = seed.toMap().toMutableMap()
override suspend fun read(key: String) = values[key]
override suspend fun write(
key: String,
value: String?,
) {
if (value == null) values.remove(key) else values[key] = value
}
}
private fun q(text: String) = QueryParser.parse(text)
@Test
fun whatWasStoredComesBack() =
runTest {
val storage = InMemory(SearchHistory.KEY_RECENT to "kind:article bitcoin\n#nostr")
val history = SearchHistory(storage, backgroundScope)
runCurrent()
assertEquals(
listOf(30023),
history.recent.value
.first()
.kinds,
)
assertEquals(listOf("nostr"), history.recent.value[1].hashtags)
}
@Test
fun aRepeatedSearchMovesUpRatherThanAppearingTwice() =
runTest {
val history = SearchHistory(InMemory(), backgroundScope)
runCurrent()
history.remember(q("bitcoin"))
history.remember(q("nostr"))
history.remember(q("bitcoin"))
runCurrent()
assertEquals(2, history.recent.value.size)
assertEquals(
"bitcoin",
history.recent.value
.first()
.text,
)
}
@Test
fun twoQueriesThatMeanTheSameThingCountAsOne() =
runTest {
// Compared on the serialized form, so the order the tokens were typed in is not a
// second entry.
val history = SearchHistory(InMemory(), backgroundScope)
runCurrent()
history.remember(q("kind:article bitcoin"))
history.remember(q("bitcoin kind:article"))
runCurrent()
assertEquals(1, history.recent.value.size)
}
@Test
fun anEmptyQueryIsNotASearchWorthRemembering() =
runTest {
val history = SearchHistory(InMemory(), backgroundScope)
runCurrent()
history.remember(SearchQuery.EMPTY)
history.remember(q(" "))
runCurrent()
assertTrue(history.recent.value.isEmpty())
}
@Test
fun theListStopsAtItsCapKeepingTheNewest() =
runTest {
val history = SearchHistory(InMemory(), backgroundScope)
runCurrent()
repeat(SearchHistory.MAX_RECENT + 5) { history.remember(q("term$it")) }
runCurrent()
assertEquals(SearchHistory.MAX_RECENT, history.recent.value.size)
assertEquals(
"term${SearchHistory.MAX_RECENT + 4}",
history.recent.value
.first()
.text,
)
}
@Test
fun everyChangeReachesTheStorage() =
runTest {
val storage = InMemory()
val history = SearchHistory(storage, backgroundScope)
runCurrent()
history.remember(q("bitcoin"))
runCurrent()
assertEquals("bitcoin", storage.values[SearchHistory.KEY_RECENT])
history.clearRecent()
runCurrent()
assertTrue(SearchHistory.KEY_RECENT !in storage.values, "a cleared history is removed, not stored empty")
}
@Test
fun aSavedSearchKeepsItsNameAcrossTheRoundTrip() =
runTest {
val storage = InMemory()
val history = SearchHistory(storage, backgroundScope)
runCurrent()
history.save(q("kind:article bitcoin"), "Bitcoin writing")
runCurrent()
val reloaded = SearchHistory(InMemory(SearchHistory.KEY_SAVED to storage.values.getValue(SearchHistory.KEY_SAVED)), backgroundScope)
runCurrent()
assertEquals(
"Bitcoin writing",
reloaded.saved.value
.single()
.label,
)
assertEquals(
listOf(30023),
reloaded.saved.value
.single()
.query.kinds,
)
}
@Test
fun aLabelWithATabInItDoesNotEatTheQueryBesideIt() =
runTest {
// The record separator is a tab, so a label carrying one would shift every field
// after it. Whatever the encoding does with it, the query must survive.
val storage = InMemory()
val history = SearchHistory(storage, backgroundScope)
runCurrent()
history.save(q("bitcoin"), "mine\tyours")
runCurrent()
val reloaded = SearchHistory(InMemory(SearchHistory.KEY_SAVED to storage.values.getValue(SearchHistory.KEY_SAVED)), backgroundScope)
runCurrent()
assertEquals(
"bitcoin",
reloaded.saved.value
.singleOrNull()
?.query
?.text,
)
assertEquals(
"mine\tyours",
reloaded.saved.value
.single()
.label,
)
}
@Test
fun forgettingRemovesOnlyTheOneNamed() =
runTest {
val history = SearchHistory(InMemory(), backgroundScope)
runCurrent()
history.save(q("bitcoin"), "one")
history.save(q("nostr"), "two")
runCurrent()
history.forget(
history.saved.value
.first()
.id,
)
runCurrent()
assertEquals(listOf("two"), history.saved.value.map { it.label })
}
}
@@ -20,118 +20,52 @@
*/
package com.vitorpamplona.amethyst.desktop
import com.vitorpamplona.amethyst.commons.search.QueryParser
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SavedSearch
import com.vitorpamplona.amethyst.commons.search.SearchHistory
import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage
import com.vitorpamplona.amethyst.commons.search.SearchQuery
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.prefs.Preferences
/**
* Desktop's half of the search history: a `Preferences` node, and nothing else.
*
* Everything the history *does* — the cap, the ordering, the de-duplication, the encoding — moved
* to [SearchHistory] in commons, where Android can have it too. What is left here is the one
* genuinely desktop thing, which is where the two strings are kept. The stored format is
* unchanged, so an existing history keeps loading.
*/
object SearchHistoryStore {
private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java)
private const val KEY_HISTORY = "search_history"
private const val KEY_SAVED = "saved_searches"
private const val SEPARATOR = "\n"
private const val SAVED_SEPARATOR = "\t"
private const val MAX_HISTORY = 20
private val storage =
object : SearchHistoryStorage {
override suspend fun read(key: String): String? = prefs.get(key, "").takeIf { it.isNotBlank() }
private val _history = MutableStateFlow<List<SearchQuery>>(emptyList())
val history: StateFlow<List<SearchQuery>> = _history.asStateFlow()
private val _savedSearches = MutableStateFlow<List<SavedSearch>>(emptyList())
val savedSearches: StateFlow<List<SavedSearch>> = _savedSearches.asStateFlow()
init {
_history.value = loadHistory()
_savedSearches.value = loadSaved()
}
fun addToHistory(query: SearchQuery) {
if (query.isEmpty) return
val serialized = QuerySerializer.serialize(query)
val current = _history.value.toMutableList()
current.removeAll { QuerySerializer.serialize(it) == serialized }
current.add(0, query)
if (current.size > MAX_HISTORY) {
current.subList(MAX_HISTORY, current.size).clear()
override suspend fun write(
key: String,
value: String?,
) {
if (value.isNullOrBlank()) prefs.remove(key) else prefs.put(key, value)
}
}
_history.value = current.toList()
persistHistory(current)
}
fun clearHistory() {
_history.value = emptyList()
prefs.remove(KEY_HISTORY)
}
private val store = SearchHistory(storage, CoroutineScope(Dispatchers.Default + SupervisorJob()))
val history: StateFlow<List<SearchQuery>> get() = store.recent
val savedSearches get() = store.saved
fun addToHistory(query: SearchQuery) = store.remember(query)
fun clearHistory() = store.clearRecent()
fun saveSearch(
query: SearchQuery,
label: String,
) {
if (query.isEmpty) return
val saved =
SavedSearch(
id = System.currentTimeMillis().toString(),
label = label,
query = query,
createdAt = System.currentTimeMillis() / 1000,
)
val current = _savedSearches.value + saved
_savedSearches.value = current
persistSaved(current)
}
) = store.save(query, label)
fun deleteSavedSearch(id: String) {
val current = _savedSearches.value.filter { it.id != id }
_savedSearches.value = current
persistSaved(current)
}
private fun loadHistory(): List<SearchQuery> {
val raw = prefs.get(KEY_HISTORY, "")
if (raw.isBlank()) return emptyList()
return raw
.split(SEPARATOR)
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parsed = QueryParser.parse(line)
if (parsed.isEmpty) null else parsed
}
}
private fun persistHistory(queries: List<SearchQuery>) {
val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) }
prefs.put(KEY_HISTORY, raw)
}
private fun loadSaved(): List<SavedSearch> {
val raw = prefs.get(KEY_SAVED, "")
if (raw.isBlank()) return emptyList()
return raw
.split(SEPARATOR)
.filter { it.isNotBlank() }
.mapNotNull { line ->
val parts = line.split(SAVED_SEPARATOR)
if (parts.size < 4) return@mapNotNull null
val id = parts[0]
val label = parts[1]
val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
val queryText = parts[3]
val query = QueryParser.parse(queryText)
if (query.isEmpty) return@mapNotNull null
SavedSearch(id = id, label = label, query = query, createdAt = createdAt)
}
}
private fun persistSaved(searches: List<SavedSearch>) {
val raw =
searches.joinToString(SEPARATOR) { s ->
listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query))
.joinToString(SAVED_SEPARATOR)
}
prefs.put(KEY_SAVED, raw)
}
fun deleteSavedSearch(id: String) = store.forget(id)
}