Add recent search results to global search (#7742)

* Add global search results history: keep the latest 25 items used in the global search, save them to an encrypted file store.

* Integrate the search history in the global search feature

* Add `MatrixClient.getRoomInfo` and use it in `GlobalSearchPresenter`

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-09-22 17:17:57 +02:00
committed by GitHub
parent 51fe66cd88
commit 8b1b5560d9
153 changed files with 1537 additions and 401 deletions
+3
View File
@@ -12,6 +12,7 @@ import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -31,6 +32,8 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.encryptedDb)
implementation(libs.serialization.json)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
@@ -7,9 +7,27 @@
package io.element.android.features.home.impl.search
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface GlobalSearchEvent {
/** The user cleared the search query. */
object ClearQuery : GlobalSearchEvent
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
object ToggleSearchVisibility : GlobalSearchEvent
/** The visible range for the displayed search results changed. */
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
/** The user changed the search target (e.g. from "Rooms" to "Messages"). */
data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
/** The user triggered the keyboard search action (enter key): save the current query to the search history. */
object SaveQueryToHistory : GlobalSearchEvent
/** The user opened a room from the search results: save it to the search history. */
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
/** The user selected a search result from the search history. */
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent
}
@@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.search.history.SearchHistoryResult
import io.element.android.features.home.impl.search.history.SearchHistoryStore
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -47,6 +49,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
@@ -54,10 +57,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.jvm.optionals.getOrElse
import kotlin.time.Duration.Companion.milliseconds
@Inject
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
private val permalinkParser: PermalinkParser,
private val coroutineDispatchers: CoroutineDispatchers,
private val matrixClient: MatrixClient,
private val searchHistoryStore: SearchHistoryStore,
) : Presenter<GlobalSearchState> {
@Composable
override fun present(): GlobalSearchState {
@@ -85,6 +87,21 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result ->
when (result) {
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
is SearchHistoryResult.Room -> {
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo)
}
}
}
value = AsyncData.Success(mappedHistoryItems.toImmutableList())
}
}
LaunchedEffect(queryState.text) {
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive
delay(200.milliseconds)
@@ -141,7 +158,7 @@ class GlobalSearchPresenter(
// result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
val mappedResults = results.map { result ->
async(coroutineDispatchers.computation) {
val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@async null
val formattedTimestamp = dateFormatter.format(
timestamp = result.timestamp,
mode = DateFormatterMode.TimeOrDate,
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
fun handleEvent(event: GlobalSearchEvent) {
when (event) {
GlobalSearchEvent.ClearQuery -> {
GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
searchResults = AsyncData.Uninitialized
queryState.clearText()
}
GlobalSearchEvent.ToggleSearchVisibility -> {
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
}
}
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
GlobalSearchEvent.SaveQueryToHistory -> {
val term = queryState.text.toString()
if (term.isNotBlank()) {
coroutineScope.launch {
searchHistoryStore.add(SearchHistoryResult.Query(term))
}
}
}
is GlobalSearchEvent.SaveRoomToHistory -> coroutineScope.launch {
searchHistoryStore.add(SearchHistoryResult.Room(event.roomId))
}
is GlobalSearchEvent.SearchHistoryResultSelected -> {
when (event.resultItem) {
is SearchHistoryResultItem.Query -> coroutineScope.launch {
delay(300.milliseconds)
queryState.edit {
replace(0, length, event.resultItem.term)
}
searchResults = AsyncData.Loading()
currentTarget = GlobalSearchTarget.ROOMS
}
is SearchHistoryResultItem.Room -> Unit
}
}
}
}
@@ -214,6 +256,7 @@ class GlobalSearchPresenter(
queryState = queryState,
currentTarget = currentTarget,
results = searchResults,
history = searchHistory,
eventSink = ::handleEvent,
)
}
@@ -28,6 +28,7 @@ data class GlobalSearchState(
val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
val eventSink: (GlobalSearchEvent) -> Unit
)
@@ -81,6 +82,20 @@ sealed interface MessageSearchResultItem {
}
}
sealed interface SearchHistoryResultItem {
val id: String
data class Query(val term: String) : SearchHistoryResultItem {
override val id: String = "query:$term"
}
data class Room(
val roomId: RoomId,
val roomInfo: RoomInfo,
) : SearchHistoryResultItem {
override val id: String = "room:${roomId.value}"
}
}
data class MediaSearchResultContent(
val filename: String,
val extension: String?,
@@ -36,77 +36,139 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
private val tombstonedRoomId = RoomId("!tombstone:server.org")
private val roomId1 = RoomId("!roomId:server.org")
private val roomId2 = RoomId("!roomId2:server.org")
private val dmRoomId = RoomId("!dm:server.org")
private const val AN_AVATAR_URL = "https://example.com/avatar.png"
private val aCanonicalAlias = RoomAlias("#alias:server.org")
class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState> {
override val values: Sequence<GlobalSearchState>
get() = sequenceOf(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
get() {
return sequenceOf(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(
persistentListOf(
SearchHistoryResultItem.Query("Query 1"),
SearchHistoryResultItem.Query("Query 2"),
SearchHistoryResultItem.Room(
roomId = roomId1,
roomInfo = aRoomInfo(
id = roomId1,
name = "A room with alias",
avatarUrl = AN_AVATAR_URL,
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
roomId = roomId2,
roomInfo = aRoomInfo(
id = roomId2,
name = "A room",
avatarUrl = AN_AVATAR_URL,
)
),
SearchHistoryResultItem.Room(
roomId = dmRoomId,
roomInfo = aRoomInfo(
id = dmRoomId,
name = "A DM",
isDm = true,
heroes = listOf(MatrixUser(UserId("@user:server.org"), "User", AN_AVATAR_URL)),
avatarUrl = AN_AVATAR_URL,
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
roomId = tombstonedRoomId,
roomInfo = aRoomInfo(
id = tombstonedRoomId,
name = "A tombstoned room",
avatarUrl = AN_AVATAR_URL,
successorRoom = SuccessorRoom(
roomId = RoomId("!successorRoom:server.org"),
reason = null,
),
),
),
)
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
)
)
}
}
fun aRoomInfo(
id: RoomId = RoomId("!roomId:server.org"),
id: RoomId = roomId1,
name: String? = "A room",
rawName: String? = "A room raw name",
topic: String? = "A room topic",
avatarUrl: String? = "https://example.com/avatar.png",
avatarUrl: String? = AN_AVATAR_URL,
isPublic: Boolean = true,
isDirect: Boolean = false,
isEncrypted: Boolean = false,
@@ -217,7 +279,7 @@ fun aMessageContent(
)
internal fun aMessageSearchResult(
roomId: RoomId = RoomId("!roomId:server.org"),
roomId: RoomId = roomId1,
eventId: EventId = EventId("\$eventId:server.org"),
senderId: UserId = UserId("@user:server.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState(
isEnabled = isEnabled,
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
queryState = queryState,
currentTarget = currentTarget,
results = results,
history = history,
eventSink = eventSink
)
@@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
@@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -63,6 +68,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
@@ -80,6 +86,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -103,7 +110,10 @@ fun GlobalSearchView(
onBackButtonClick = {
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
},
onSelectSearchResult = onSelectSearchResult,
onSelectSearchResult = { roomId, eventId ->
onSelectSearchResult(roomId, eventId)
state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId))
},
)
}
}
@@ -115,63 +125,11 @@ private fun GlobalSearchContent(
onBackButtonClick: () -> Unit,
onSelectSearchResult: (RoomId, EventId?) -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
val drawBottomLineModifier = Modifier.drawBehind {
drawLine(
color = borderColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.value
)
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackButtonClick) },
title = {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
title = { SearchTopAppBarContent(state = state) }
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
@@ -179,27 +137,9 @@ private fun GlobalSearchContent(
Column(
modifier = Modifier.padding(padding),
) {
SingleChoiceSegmentedButtonRow(
modifier = drawBottomLineModifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
SearchTargetSelector(state = state)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
@@ -212,9 +152,21 @@ private fun GlobalSearchContent(
state = lazyListState,
) {
val results = state.results.dataOrNull()
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when {
state.results.isUninitialized() -> startSearching()
state.results.isLoading() -> loading()
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
searchHistory(historyResults) { result ->
when (result) {
is SearchHistoryResultItem.Query -> Unit
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
}
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
}
} else {
startSearching()
}
state.results.isLoading() -> loadingResults()
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
results is GlobalSearchResults.RoomListResults -> roomListResults(
results = results.results,
@@ -230,6 +182,89 @@ private fun GlobalSearchContent(
}
}
@Composable
private fun SearchTopAppBarContent(state: GlobalSearchState) {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActionHandler { performDefault ->
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
performDefault()
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
IconButton(
modifier = Modifier.clip(CircleShape).size(20.dp),
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.borderInteractivePrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary
)
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field),
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
@Composable
private fun SearchTargetSelector(state: GlobalSearchState) {
if (state.queryState.text.isEmpty()) return
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
}
private fun LazyListScope.startSearching() {
item {
IconTitleSubtitleMolecule(
@@ -243,7 +278,98 @@ private fun LazyListScope.startSearching() {
}
}
private fun LazyListScope.loading() {
private fun LazyListScope.searchHistory(
historyResults: ImmutableList<SearchHistoryResultItem>,
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit,
) {
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
text = stringResource(R.string.screen_search_recent_searches_section_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
}
items(
items = historyResults,
key = { it.id },
) { result ->
Row(
modifier = Modifier
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (result) {
is SearchHistoryResultItem.Query -> {
Icon(
modifier = Modifier.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(6.dp),
imageVector = CompoundIcons.History(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
Text(
text = result.term,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
is SearchHistoryResultItem.Room -> {
val heroAvatars = remember(result.roomInfo.heroes) {
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
}
Avatar(
avatarData = result.roomInfo.getAvatarData(AvatarSize.SearchRoomListItem),
avatarType = AvatarType.Room(
heroes = heroAvatars,
isTombstoned = result.roomInfo.successorRoom != null,
),
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = result.roomInfo.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
if (result.roomInfo.isDm) {
val dmUserId = result.roomInfo.heroes.firstOrNull()?.userId
dmUserId?.let { userId ->
Text(
text = userId.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else if (result.roomInfo.canonicalAlias != null) {
Text(
text = result.roomInfo.canonicalAlias?.value!!,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
}
}
private fun LazyListScope.loadingResults() {
item {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File
private const val SEARCH_HISTORY_FILE_NAME = "search_history"
/**
* Persists the search history in an encrypted file inside the current session file directory, using
* [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore).
*/
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryFileStore(
@ApplicationContext private val context: Context,
matrixClient: MatrixClient,
) : SearchHistoryFileStore {
private val file: File by lazy {
File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME)
}
override fun read(): ByteArray? {
if (!file.exists()) return null
return EncryptedFile(context, file).openFileInput().use { it.readBytes() }
}
override fun write(bytes: ByteArray) {
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
// Opening a FileOutputStream truncates any previous content, so this overwrites the file.
EncryptedFile(context, file).openFileOutput().use { it.write(bytes) }
}
override fun delete(): Boolean {
return file.delete()
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryStore(
private val fileStore: SearchHistoryFileStore,
private val dispatchers: CoroutineDispatchers,
private val jsonProvider: JsonProvider,
) : SearchHistoryStore {
private val json by lazy { jsonProvider() }
// Guards [fileStore] access and the lazy initialization of [cache].
private val mutex = Mutex()
private val cache = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
private var isLoaded = false
override val history: Flow<List<SearchHistoryResult>> = flow {
ensureLoaded()
emitAll(cache)
}
override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
val updated = buildList {
add(result)
// Deduplicate: an equal entry is moved to the front rather than being duplicated.
addAll(cache.value.filterNot { it == result })
}
.distinct()
.take(MAX_SEARCH_HISTORY_SIZE)
cache.value = updated
persist(updated)
}
}
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
mutex.withLock {
isLoaded = true
cache.value = emptyList()
runCatchingExceptions { fileStore.delete() }
.onFailure { Timber.e(it, "Failed to delete search history") }
}
}
private suspend fun ensureLoaded() = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
}
}
/**
* Must be called while holding [mutex].
*/
private fun ensureLoadedLocked() {
if (isLoaded) return
cache.value = readFromDisk()
isLoaded = true
}
private fun readFromDisk(): List<SearchHistoryResult> {
return runCatchingExceptions {
val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList()
json.decodeFromString<List<SearchHistoryResult>>(bytes.decodeToString())
}.getOrElse {
Timber.e(it, "Failed to read search history, resetting it")
emptyList()
}
}
private fun persist(items: List<SearchHistoryResult>) {
runCatchingExceptions {
fileStore.write(json.encodeToString(items).encodeToByteArray())
}.onFailure { Timber.e(it, "Failed to persist search history") }
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
/**
* Low level, raw byte read/write of the persisted search history.
*
* This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on
* the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the
* bytes in an encrypted file.
*/
interface SearchHistoryFileStore {
/**
* Return the persisted bytes, or `null` if nothing has been persisted yet.
*/
fun read(): ByteArray?
/**
* Persist [bytes], replacing any previously persisted content.
*/
fun write(bytes: ByteArray)
/**
* Delete any persisted content.
*
* @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete.
*/
fun delete(): Boolean
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.serialization.Serializable
/**
* A single entry in the global search history.
*
* The history stores either the term used to perform a search (when the keyboard search action is
* triggered) or the id of a room opened from the search results.
*/
@Serializable
sealed interface SearchHistoryResult {
@Serializable
data class Query(val term: String) : SearchHistoryResult
@Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import kotlinx.coroutines.flow.Flow
/**
* Stores the global search history for the current session.
*
* The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries.
* The most recent entry is always at the front of the list; when the cap is reached the oldest
* entries are dropped.
*/
interface SearchHistoryStore {
/**
* Emits the current search history, most recent entry first, and re-emits whenever it changes.
*/
val history: Flow<List<SearchHistoryResult>>
/**
* Add a new [result] at the front of the history.
*
* If an equal entry already exists it is moved to the front instead of being duplicated. When
* the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped.
*/
suspend fun add(result: SearchHistoryResult)
/**
* Remove every entry from the history.
*/
suspend fun clear()
}
/**
* Maximum number of entries kept in the search history.
*/
const val MAX_SEARCH_HISTORY_SIZE = 25
@@ -14,4 +14,5 @@
<string name="search_section_messages">Messages</string>
<string name="search_no_results_title">No results</string>
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
<string name="screen_search_recent_searches_section_title">Recent searches</string>
</resources>
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.home.impl.search.history.FakeSearchHistoryStore
import io.element.android.features.home.impl.search.history.SearchHistoryResult
import io.element.android.features.home.impl.search.history.SearchHistoryStore
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
@@ -28,6 +31,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Joined
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomSummary
@@ -41,12 +45,12 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.util.Optional
class GlobalSearchPresenterTest {
@Test
@@ -60,6 +64,8 @@ class GlobalSearchPresenterTest {
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
}
cancelAndConsumeRemainingEvents()
}
}
@@ -85,6 +91,9 @@ class GlobalSearchPresenterTest {
fun `present - toggle search visibility`() = runTest {
val presenter = createGlobalSearchPresenter()
presenter.test {
// Skip the initial state
skipItems(1)
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
@@ -103,6 +112,9 @@ class GlobalSearchPresenterTest {
fun `present - update target`() = runTest {
val presenter = createGlobalSearchPresenter()
presenter.test {
// Skip initial item
skipItems(1)
awaitItem().let { state ->
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
@@ -194,7 +206,7 @@ class GlobalSearchPresenterTest {
fun `present - message search results are mapped when the message search emits`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch),
@@ -221,7 +233,7 @@ class GlobalSearchPresenterTest {
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch),
@@ -256,7 +268,7 @@ class GlobalSearchPresenterTest {
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch),
@@ -279,6 +291,82 @@ class GlobalSearchPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveQueryToHistory stores the current query`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.queryState.edit { append("A query") }
testScheduler.advanceUntilIdle()
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("A query"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveQueryToHistory ignores a blank query`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveRoomToHistory stores the room`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.SaveRoomToHistory(A_ROOM_ID))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Room(A_ROOM_ID))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle()
ensureAllEventsConsumed()
}
}
@Test
fun `present - SearchHistoryResultSelected changes the search query when a previous search is selected`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
// Skip loading search results state
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test")))
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
assertThat(awaitItem().results.isLoading()).isTrue()
}
}
}
private fun TestScope.createGlobalSearchPresenter(
@@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter(
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
permalinkParser: PermalinkParser = FakePermalinkParser(),
matrixClient: MatrixClient = FakeMatrixClient(),
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
): GlobalSearchPresenter {
return GlobalSearchPresenter(
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
@@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter(
permalinkParser = permalinkParser,
coroutineDispatchers = testCoroutineDispatchers(),
matrixClient = matrixClient,
searchHistoryStore = searchHistoryStore,
)
}
@@ -0,0 +1,275 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.search
import androidx.activity.ComponentActivity
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class GlobalSearchViewTest : RobolectricTest() {
@Test
fun `back button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBack()
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `back key - with search active toggles the search`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBackKey()
// Advance time to let the event be processed, as the search toggle might have some delay (e.g. for the animation)
mainClock.advanceTimeBy(1)
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `clear query button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
val clearSearch = activity!!.getString(CommonStrings.a11y_clear_search_field)
onNodeWithContentDescription(clearSearch).performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.ClearQuery)
}
@Test
fun `search target selector - clicking on 'Chats' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_chats)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS))
}
@Test
fun `search target selector - clicking on 'Messages' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_messages)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
}
@Test
fun `clicking on a room result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aRoom:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
persistentListOf(
aRoomListRoomSummary(id = roomId.value, name = "A room result"),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A room result").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a message result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aMessageRoom:server.org")
val eventId = EventId("\$anEvent:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, eventId)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults(
persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
formattedTimestamp = "12:00",
),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A message room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryResultItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(queryResult)),
eventSink = eventsRecorder,
),
// A query history result must not trigger the search result callback
onSelectSearchResult = EnsureNeverCalledWithTwoParams(),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("Recent term").performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
}
@Test
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room(
roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
)
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(roomResult)),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A history room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertList(
listOf(
GlobalSearchEvent.SaveRoomToHistory(roomId),
GlobalSearchEvent.SearchHistoryResultSelected(roomResult),
)
)
}
@Test
fun `with a query and uninitialized results - displays the start searching placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
results = AsyncData.Uninitialized,
),
)
val startSearching = activity!!.getString(R.string.start_searching_title)
onNodeWithText(startSearching).assertIsDisplayed()
}
@Test
fun `with empty results - displays the no results placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.RoomListResults(persistentListOf())),
),
)
val noResults = activity!!.getString(R.string.search_no_results_title)
onNodeWithText(noResults).assertIsDisplayed()
}
private fun AndroidComposeUiTest<ComponentActivity>.setGlobalSearchView(
state: GlobalSearchState,
onSelectSearchResult: (RoomId, EventId?) -> Unit = EnsureNeverCalledWithTwoParams(),
) {
setContent {
GlobalSearchView(
state = state,
onSelectSearchResult = onSelectSearchResult,
)
}
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
class DefaultSearchHistoryStoreTest {
@Test
fun `history is empty when nothing has been persisted`() = runTest {
val store = createStore()
assertThat(store.history.first()).isEmpty()
}
@Test
fun `add prepends new entries so the most recent is first`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("first"))
store.add(SearchHistoryResult.Query("second"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("second"),
SearchHistoryResult.Query("first"),
).inOrder()
}
@Test
fun `add supports both queries and rooms`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("term"))
store.add(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("term"),
).inOrder()
}
@Test
fun `adding an existing entry moves it to the front without duplicating`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("a"))
store.add(SearchHistoryResult.Query("b"))
store.add(SearchHistoryResult.Query("a"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("a"),
SearchHistoryResult.Query("b"),
).inOrder()
}
@Test
fun `history is capped to the maximum size, dropping the oldest entries`() = runTest {
val store = createStore()
repeat(MAX_SEARCH_HISTORY_SIZE + 10) { index ->
store.add(SearchHistoryResult.Query("query$index"))
}
val history = store.history.first()
assertThat(history).hasSize(MAX_SEARCH_HISTORY_SIZE)
// Newest entry is first...
assertThat(history.first()).isEqualTo(SearchHistoryResult.Query("query${MAX_SEARCH_HISTORY_SIZE + 9}"))
// ...and the oldest ones have been dropped.
assertThat(history).doesNotContain(SearchHistoryResult.Query("query0"))
assertThat(history.last()).isEqualTo(SearchHistoryResult.Query("query10"))
}
@Test
fun `persisted history is deserialized back into SearchHistoryResult items`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
createStore(fileStore).apply {
add(SearchHistoryResult.Query("persisted term"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
// A brand new store backed by the same file reads the same content back.
val reloaded = createStore(fileStore)
assertThat(reloaded.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("persisted term"),
).inOrder()
}
@Test
fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
val store = createStore(fileStore)
store.add(SearchHistoryResult.Query("term"))
store.clear()
assertThat(store.history.first()).isEmpty()
assertThat(fileStore.content).isNull()
}
@Test
fun `corrupted content is ignored and results in an empty history`() = runTest {
val fileStore = FakeSearchHistoryFileStore(initialContent = "not valid json".toByteArray())
val store = createStore(fileStore)
assertThat(store.history.first()).isEmpty()
// The store recovers and can still add new entries.
store.add(SearchHistoryResult.Room(RoomId("!recovered:domain")))
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Room(RoomId("!recovered:domain")))
}
private fun TestScope.createStore(
fileStore: SearchHistoryFileStore = FakeSearchHistoryFileStore(),
) = DefaultSearchHistoryStore(
fileStore = fileStore,
dispatchers = testCoroutineDispatchers(),
jsonProvider = { Json { ignoreUnknownKeys = true } }
)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
/**
* In-memory [SearchHistoryFileStore] used to test [DefaultSearchHistoryStore] without the Android Keystore.
*/
class FakeSearchHistoryFileStore(
initialContent: ByteArray? = null,
) : SearchHistoryFileStore {
var content: ByteArray? = initialContent
private set
var writeCount = 0
private set
override fun read(): ByteArray? = content
override fun write(bytes: ByteArray) {
content = bytes
writeCount++
}
override fun delete(): Boolean {
content = null
return true
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeSearchHistoryStore : SearchHistoryStore {
private val state = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
override val history: Flow<List<SearchHistoryResult>> = state
override suspend fun add(result: SearchHistoryResult) {
state.value = buildList {
add(result)
addAll(state.value.filterNot { it == result })
}.take(MAX_SEARCH_HISTORY_SIZE)
}
override suspend fun clear() {
state.value = emptyList()
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import kotlinx.serialization.json.Json
import org.junit.Test
class SearchHistoryResultSerializationTest {
private val json = Json
@Test
fun `Query round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Query("hello world")
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `Room round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Room(A_ROOM_ID)
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `RoomId is serialized as its raw string value`() {
val encoded = json.encodeToString<SearchHistoryResult>(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(encoded).contains(A_ROOM_ID.value)
}
@Test
fun `a mixed list round-trips preserving order`() {
val original = listOf(
SearchHistoryResult.Query("first"),
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("second"),
SearchHistoryResult.Room(A_ROOM_ID_2),
)
val decoded = json.decodeFromString<List<SearchHistoryResult>>(json.encodeToString(original))
assertThat(decoded).containsExactlyElementsIn(original).inOrder()
}
}