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 { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
id("kotlin-parcelize") id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@@ -31,6 +32,8 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.encryptedDb)
implementation(libs.serialization.json)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
@@ -7,9 +7,27 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface GlobalSearchEvent { sealed interface GlobalSearchEvent {
/** The user cleared the search query. */
object ClearQuery : GlobalSearchEvent object ClearQuery : GlobalSearchEvent
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
object ToggleSearchVisibility : GlobalSearchEvent object ToggleSearchVisibility : GlobalSearchEvent
/** The visible range for the displayed search results changed. */
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent 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 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.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject 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.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.api.timeline.item.event.isMediaContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -54,10 +57,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.jvm.optionals.getOrElse
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Inject @Inject
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val searchHistoryStore: SearchHistoryStore,
) : Presenter<GlobalSearchState> { ) : Presenter<GlobalSearchState> {
@Composable @Composable
override fun present(): GlobalSearchState { override fun present(): GlobalSearchState {
@@ -85,6 +87,21 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } 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) { LaunchedEffect(queryState.text) {
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive // Add a delay to avoid performing too many searches in a short period of time, which can be expensive
delay(200.milliseconds) 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()`. // 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 -> val mappedResults = results.map { result ->
async(coroutineDispatchers.computation) { 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( val formattedTimestamp = dateFormatter.format(
timestamp = result.timestamp, timestamp = result.timestamp,
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
fun handleEvent(event: GlobalSearchEvent) { fun handleEvent(event: GlobalSearchEvent) {
when (event) { when (event) {
GlobalSearchEvent.ClearQuery -> { GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
searchResults = AsyncData.Uninitialized
queryState.clearText() queryState.clearText()
} }
GlobalSearchEvent.ToggleSearchVisibility -> { GlobalSearchEvent.ToggleSearchVisibility -> {
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
} }
} }
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target 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, queryState = queryState,
currentTarget = currentTarget, currentTarget = currentTarget,
results = searchResults, results = searchResults,
history = searchHistory,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -28,6 +28,7 @@ data class GlobalSearchState(
val queryState: TextFieldState, val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget, val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>, val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
val eventSink: (GlobalSearchEvent) -> Unit 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( data class MediaSearchResultContent(
val filename: String, val filename: String,
val extension: String?, val extension: String?,
@@ -36,14 +36,23 @@ 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.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList 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> { class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState> {
override val values: Sequence<GlobalSearchState> override val values: Sequence<GlobalSearchState>
get() = sequenceOf( get() {
return sequenceOf(
// Not enabled, no preview // Not enabled, no preview
aGlobalSearchState(isEnabled = false), aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")), aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
@@ -98,15 +107,68 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
queryState = TextFieldState("Query"), queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())), 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,
),
),
),
)
)
),
)
}
} }
fun aRoomInfo( fun aRoomInfo(
id: RoomId = RoomId("!roomId:server.org"), id: RoomId = roomId1,
name: String? = "A room", name: String? = "A room",
rawName: String? = "A room raw name", rawName: String? = "A room raw name",
topic: String? = "A room topic", topic: String? = "A room topic",
avatarUrl: String? = "https://example.com/avatar.png", avatarUrl: String? = AN_AVATAR_URL,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false, isDirect: Boolean = false,
isEncrypted: Boolean = false, isEncrypted: Boolean = false,
@@ -217,7 +279,7 @@ fun aMessageContent(
) )
internal fun aMessageSearchResult( internal fun aMessageSearchResult(
roomId: RoomId = RoomId("!roomId:server.org"), roomId: RoomId = roomId1,
eventId: EventId = EventId("\$eventId:server.org"), eventId: EventId = EventId("\$eventId:server.org"),
senderId: UserId = UserId("@user:server.org"), senderId: UserId = UserId("@user:server.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(), senderProfile: ProfileDetails = aProfileDetailsReady(),
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(), queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized, results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {}, eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState( ) = GlobalSearchState(
isEnabled = isEnabled, isEnabled = isEnabled,
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
queryState = queryState, queryState = queryState,
currentTarget = currentTarget, currentTarget = currentTarget,
results = results, results = results,
history = history,
eventSink = eventSink eventSink = eventSink
) )
@@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx 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.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.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator 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.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
@@ -103,7 +110,10 @@ fun GlobalSearchView(
onBackButtonClick = { onBackButtonClick = {
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
}, },
onSelectSearchResult = onSelectSearchResult, onSelectSearchResult = { roomId, eventId ->
onSelectSearchResult(roomId, eventId)
state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId))
},
) )
} }
} }
@@ -115,23 +125,65 @@ private fun GlobalSearchContent(
onBackButtonClick: () -> Unit, onBackButtonClick: () -> Unit,
onSelectSearchResult: (RoomId, EventId?) -> 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
navigationIcon = { BackButton(onClick = onBackButtonClick) }, navigationIcon = { BackButton(onClick = onBackButtonClick) },
title = { title = { SearchTopAppBarContent(state = state) }
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { padding ->
Column(
modifier = Modifier.padding(padding),
) {
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
SearchTargetSelector(state = state)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = lazyColumnContentPadding,
state = lazyListState,
) {
val results = state.results.dataOrNull()
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when {
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,
onRoomClick = { roomId -> onSelectSearchResult(roomId, null) },
)
results is GlobalSearchResults.MessageSearchResults -> messageSearchResults(
results = results.results,
onSearchResultSelected = onSelectSearchResult,
)
}
}
}
}
}
@Composable
private fun SearchTopAppBarContent(state: GlobalSearchState) {
// The stateSaver will keep the selection state when returning to this UI // The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search) val searchLabel = stringResource(CommonStrings.action_search)
@@ -142,6 +194,11 @@ private fun GlobalSearchContent(
.semantics { contentDescription = searchLabel }, .semantics { contentDescription = searchLabel },
state = state.queryState, state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActionHandler { performDefault ->
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
performDefault()
},
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@@ -153,10 +210,18 @@ private fun GlobalSearchContent(
), ),
trailingIcon = if (state.queryState.text.isNotEmpty()) { trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable { @Composable {
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) { 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( Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field) contentDescription = stringResource(CommonStrings.a11y_clear_search_field),
) )
} }
} }
@@ -171,18 +236,16 @@ private fun GlobalSearchContent(
} }
focusRequester.saveFocusedChild() focusRequester.saveFocusedChild()
} }
} }
)
}, @Composable
contentWindowInsets = scaffoldScrollableContentInsets, private fun SearchTargetSelector(state: GlobalSearchState) {
) { padding -> if (state.queryState.text.isEmpty()) return
Column(
modifier = Modifier.padding(padding),
) {
SingleChoiceSegmentedButtonRow( SingleChoiceSegmentedButtonRow(
modifier = drawBottomLineModifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) { ) {
SegmentedButton( SegmentedButton(
index = 0, index = 0,
@@ -200,34 +263,6 @@ private fun GlobalSearchContent(
text = stringResource(R.string.search_section_messages), text = stringResource(R.string.search_section_messages),
) )
} }
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = lazyColumnContentPadding,
state = lazyListState,
) {
val results = state.results.dataOrNull()
when {
state.results.isUninitialized() -> startSearching()
state.results.isLoading() -> loading()
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
results is GlobalSearchResults.RoomListResults -> roomListResults(
results = results.results,
onRoomClick = { roomId -> onSelectSearchResult(roomId, null) },
)
results is GlobalSearchResults.MessageSearchResults -> messageSearchResults(
results = results.results,
onSearchResultSelected = onSelectSearchResult,
)
}
}
}
}
} }
private fun LazyListScope.startSearching() { private fun LazyListScope.startSearching() {
@@ -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 { item {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) 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_section_messages">Messages</string>
<string name="search_no_results_title">No results</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="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> </resources>
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory 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.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData 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.RoomListFilter.NormalizedMatchRoomName
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomSummary 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.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import java.util.Optional
class GlobalSearchPresenterTest { class GlobalSearchPresenterTest {
@Test @Test
@@ -60,6 +64,8 @@ class GlobalSearchPresenterTest {
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
assertThat(state.results).isEqualTo(AsyncData.Uninitialized) assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
} }
cancelAndConsumeRemainingEvents()
} }
} }
@@ -85,6 +91,9 @@ class GlobalSearchPresenterTest {
fun `present - toggle search visibility`() = runTest { fun `present - toggle search visibility`() = runTest {
val presenter = createGlobalSearchPresenter() val presenter = createGlobalSearchPresenter()
presenter.test { presenter.test {
// Skip the initial state
skipItems(1)
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse() assertThat(state.isSearchActive).isFalse()
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility) state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
@@ -103,6 +112,9 @@ class GlobalSearchPresenterTest {
fun `present - update target`() = runTest { fun `present - update target`() = runTest {
val presenter = createGlobalSearchPresenter() val presenter = createGlobalSearchPresenter()
presenter.test { presenter.test {
// Skip initial item
skipItems(1)
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS) assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) 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 { fun `present - message search results are mapped when the message search emits`() = runTest {
val messageSearch = FakeMessageSearch() val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } getRoomInfoLambda = { Result.success(aRoomInfo()) }
} }
val presenter = createGlobalSearchPresenter( val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch), messageSearchService = FakeMessageSearchService(messageSearch),
@@ -221,7 +233,7 @@ class GlobalSearchPresenterTest {
fun `present - message search displays the uninitialized state after removing the query`() = runTest { fun `present - message search displays the uninitialized state after removing the query`() = runTest {
val messageSearch = FakeMessageSearch() val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } getRoomInfoLambda = { Result.success(aRoomInfo()) }
} }
val presenter = createGlobalSearchPresenter( val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch), messageSearchService = FakeMessageSearchService(messageSearch),
@@ -256,7 +268,7 @@ class GlobalSearchPresenterTest {
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
val messageSearch = FakeMessageSearch() val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) } getRoomInfoLambda = { Result.success(aRoomInfo()) }
} }
val presenter = createGlobalSearchPresenter( val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch), messageSearchService = FakeMessageSearchService(messageSearch),
@@ -279,6 +291,82 @@ class GlobalSearchPresenterTest {
cancelAndIgnoreRemainingEvents() 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( private fun TestScope.createGlobalSearchPresenter(
@@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter(
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
): GlobalSearchPresenter { ): GlobalSearchPresenter {
return GlobalSearchPresenter( return GlobalSearchPresenter(
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory { roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
@@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter(
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
matrixClient = matrixClient, 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()
}
}
@@ -25,7 +25,7 @@ import java.io.FileOutputStream
* *
* It uses hardcoded constants that are used in that library, for backwards compatibility reasons. * It uses hardcoded constants that are used in that library, for backwards compatibility reasons.
*/ */
internal class EncryptedFile( class EncryptedFile(
private val context: Context, private val context: Context,
private val file: File private val file: File
) { ) {
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
*/ */
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
/**
* Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown.
*/
suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?>
/** /**
* Get a room info flow for a given room ID. * Get a room info flow for a given room ID.
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found. * The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.androidutils.metadata.isInDebug import io.element.android.libraries.androidutils.metadata.isInDebug
import java.io.Serializable import java.io.Serializable
@kotlinx.serialization.Serializable
@JvmInline @JvmInline
value class RoomId(val value: String) : Serializable { value class RoomId(val value: String) : Serializable {
init { init {
@@ -779,6 +779,14 @@ class RustMatrixClient(
} }
} }
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerRoomListService.roomOrNull(roomId.value)?.use { room ->
roomInfoMapper.map(room.roomInfo())
}
}
}
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>> { override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>> {
return mxCallbackFlow { return mxCallbackFlow {
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null } val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import java.io.File import java.io.File
import java.util.Optional import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
@@ -173,6 +174,7 @@ class FakeMatrixClient(
var knockRoomLambda: (RoomIdOrAlias, String, List<String>) -> Result<RoomInfo?> = { _, _, _ -> var knockRoomLambda: (RoomIdOrAlias, String, List<String>) -> Result<RoomInfo?> = { _, _, _ ->
Result.success(null) Result.success(null)
} }
var getRoomInfoLambda = { _: RoomId -> Result.success<RoomInfo?>(null) }
var getRoomInfoFlowLambda = { _: RoomId -> var getRoomInfoFlowLambda = { _: RoomId ->
flowOf<Optional<RoomInfo>>(Optional.empty()) flowOf<Optional<RoomInfo>>(Optional.empty())
} }
@@ -379,6 +381,8 @@ class FakeMatrixClient(
return Result.success(visitedRoomsId) return Result.success(visitedRoomsId)
} }
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = getRoomInfoLambda(roomId)
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId) override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean -> var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->

Some files were not shown because too many files have changed in this diff Show More