feat: Android remembers what you searched for

Desktop has had a search history since the advanced bar was written.
Android had none, for no reason other than where the code sat — and an
empty search box on Android rendered nothing at all, which is the one
place a reader's last searches are worth offering.

The behaviour came over with `SearchHistory`; what is added here is
Android's half. `DataStoreSearchHistoryStorage` is the file the two
strings live in — device-global like the drawer's collapse state, since
what you searched for is a property of this phone and is never published
to a relay. The empty box now lists the recent searches, each one tap
from being re-run: an entry is stored as the same text the box holds, so
putting it back in the field is the whole of loading it.

Recorded on the Enter key rather than on every pause in typing. The field
already had an `onSubmit` slot that nothing on Android was passing;
recording every settled query would fill the list with the prefixes of
the word being 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:34:24 +00:00
parent a4ccaba4d3
commit 707cc60261
4 changed files with 140 additions and 2 deletions
@@ -0,0 +1,72 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage
import kotlinx.coroutines.flow.first
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/**
* Where Android keeps the search history: one `datastore/search_history.preferences_pb` file.
*
* The history itself — what it holds, how much of it, in what order — is
* [com.vitorpamplona.amethyst.commons.search.SearchHistory] in commons, shared with Desktop. This
* is only the two strings and the file they live in.
*
* Device-global rather than per-account, like the drawer's collapse state beside it: what you
* searched for is a property of this phone, and it is never published to a relay.
*/
class DataStoreSearchHistoryStorage(
private val filesDir: File,
) : SearchHistoryStorage {
constructor(context: Context) : this(context.applicationContext.filesDir)
// DataStore v1 throws if two instances are ever active on the same file, and the search screen
// is rebuilt per seeded query, so the store is shared per absolute path across the process.
private val store: DataStore<Preferences> get() = dataStoreFor(File(filesDir, "datastore/search_history.preferences_pb"))
override suspend fun read(key: String): String? = store.data.first()[stringPreferencesKey(key)]
override suspend fun write(
key: String,
value: String?,
) {
store.edit {
if (value.isNullOrBlank()) it.remove(stringPreferencesKey(key)) else it[stringPreferencesKey(key)] = value
}
}
companion object {
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
stores.computeIfAbsent(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
}
}
@@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.search.SearchQueryState
import com.vitorpamplona.amethyst.commons.search.RenderableKinds
import com.vitorpamplona.amethyst.commons.search.SearchHistory
import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage
import com.vitorpamplona.amethyst.commons.search.SearchPipeline
import com.vitorpamplona.amethyst.commons.search.SearchResultKind
import com.vitorpamplona.amethyst.commons.search.SearchScope
@@ -88,6 +90,8 @@ import kotlinx.coroutines.flow.update
class SearchBarViewModel(
val account: Account,
val nip05Client: INip05Client,
/** Where this device keeps what has been searched for; see [history]. */
historyStorage: SearchHistoryStorage,
/**
* The query the calling screen seeded the box with — `from:<npub>` off a profile,
* `kind:article` off the articles feed. It is ordinary field text from here on: the reader
@@ -122,6 +126,18 @@ class SearchBarViewModel(
*/
val pickerOpen = MutableStateFlow(false)
/**
* What has been searched for on this device, and what has been kept.
*
* Desktop has had this since the advanced bar was written; Android never did, because the
* code sat in a desktop object on `java.util.prefs`. Nothing about it was desktop-specific,
* so the move to commons is the whole of the port.
*/
val history = SearchHistory(historyStorage, viewModelScope)
/** Records the query the reader actually ran — the Enter key, not every pause in typing. */
fun remember() = history.remember(state.query)
val queryAsksNothing get() = state.asksNothing
val searchSettled get() = state.settled
val scopePinnedToNotes get() = state.scopePinnedToNotes
@@ -503,9 +519,10 @@ class SearchBarViewModel(
class Factory(
val account: Account,
val nip05: INip05Client,
val historyStorage: SearchHistoryStorage,
val initialQuery: String? = null,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account, nip05, initialQuery) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = SearchBarViewModel(account, nip05, historyStorage, initialQuery) as T
}
}
@@ -70,6 +70,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
@@ -91,6 +92,8 @@ import com.vitorpamplona.amethyst.commons.resources.search_filters_title
import com.vitorpamplona.amethyst.commons.resources.search_follows_only
import com.vitorpamplona.amethyst.commons.resources.search_no_results
import com.vitorpamplona.amethyst.commons.resources.search_no_results_explainer
import com.vitorpamplona.amethyst.commons.resources.search_recent
import com.vitorpamplona.amethyst.commons.resources.search_recent_clear
import com.vitorpamplona.amethyst.commons.resources.search_scope_all
import com.vitorpamplona.amethyst.commons.resources.search_scope_notes
import com.vitorpamplona.amethyst.commons.resources.search_scope_people
@@ -102,6 +105,7 @@ import com.vitorpamplona.amethyst.commons.resources.search_source_local
import com.vitorpamplona.amethyst.commons.resources.search_source_relays
import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin
import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin_explainer
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SearchScope
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.search.SearchSource
@@ -112,6 +116,7 @@ import com.vitorpamplona.amethyst.commons.ui.search.SearchFieldState
import com.vitorpamplona.amethyst.commons.ui.search.TokenizedSearchField
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.preferences.DataStoreSearchHistoryStorage
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow
@@ -156,6 +161,7 @@ fun SearchScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val historyStorage = LocalContext.current.let { context -> remember(context) { DataStoreSearchHistoryStorage(context) } }
val searchBarViewModel: SearchBarViewModel =
viewModel(
// Keyed on the seed: navigating from one screen's search button to another's has to
@@ -165,6 +171,7 @@ fun SearchScreen(
SearchBarViewModel.Factory(
accountViewModel.account,
accountViewModel.nip05ClientBuilder(),
historyStorage,
initialQuery,
),
)
@@ -638,6 +645,9 @@ private fun SearchTextField(
groups = groupCandidates,
onPeopleQuery = { userSuggestions.processCurrentWord(it) },
onGroupQuery = { partial -> groupQuery = partial },
// The Enter key is what says a search was meant, rather than passed through on the way
// to a longer word. Recording every pause in typing would fill the list with prefixes.
onSubmit = { searchBarViewModel.remember() },
peoplePicker = { _, onPick ->
ShowUserSuggestionList(
userSuggestions = userSuggestions,
@@ -698,6 +708,7 @@ private fun DisplaySearchResults(
val notes by searchBarViewModel.searchResultsNotes.collectAsStateWithLifecycle()
val asksNothing by searchBarViewModel.queryAsksNothing.collectAsStateWithLifecycle()
val settled by searchBarViewModel.searchSettled.collectAsStateWithLifecycle()
val recent by searchBarViewModel.history.recent.collectAsStateWithLifecycle()
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -706,7 +717,43 @@ private fun DisplaySearchResults(
) {
item(key = "scaffold-header") { headerContent() }
if (!isRefreshing) return@LazyColumn
if (!isRefreshing) {
// An empty box used to render nothing at all. What a reader searched for before is
// the one thing worth offering there, and it is one tap from being re-run because a
// history entry is stored as the same text the box holds.
if (recent.isNotEmpty()) {
item(key = "recent-header") {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(Res.string.search_recent),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.placeholderText,
)
TextButton(onClick = { searchBarViewModel.history.clearRecent() }) {
Text(stringRes(Res.string.search_recent_clear))
}
}
}
itemsIndexed(recent, key = { _, item -> "recent-${QuerySerializer.serialize(item)}" }) { _, item ->
val asText = remember(item) { QuerySerializer.serialize(item) }
Text(
text = asText,
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { searchBarViewModel.updateSearchValue(asText) }
.padding(horizontal = 16.dp, vertical = 12.dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
return@LazyColumn
}
// Every result list is empty. Which of the two things that means is not cosmetic: a box
// holding only a seeded `kind:` never asked a relay anything, and telling that reader
@@ -2246,6 +2246,8 @@
<string name="search_no_results_explainer">Try fewer filters, or a different word.</string>
<string name="search_type_to_begin">Type something to search</string>
<string name="search_type_to_begin_explainer">The filters above narrow what is searched — they do not search on their own.</string>
<string name="search_recent">Recent searches</string>
<string name="search_recent_clear">Clear</string>
<string name="search_filters_reset">Reset</string>
<string name="search_filters_section_source">Source</string>
<string name="search_filters_section_sort">Sort by</string>