mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 20:01:55 +00:00
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:
committed by
GitHub
parent
51fe66cd88
commit
8b1b5560d9
@@ -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)
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
|
||||
+47
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -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?,
|
||||
|
||||
+121
-57
@@ -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
|
||||
)
|
||||
|
||||
+204
-78
@@ -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())
|
||||
}
|
||||
|
||||
+47
@@ -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()
|
||||
}
|
||||
}
|
||||
+96
@@ -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") }
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+42
@@ -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>
|
||||
|
||||
+95
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+275
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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 } }
|
||||
)
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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()
|
||||
}
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.io.FileOutputStream
|
||||
*
|
||||
* 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 file: File
|
||||
) {
|
||||
|
||||
+5
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
|
||||
*/
|
||||
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.
|
||||
* 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.
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core
|
||||
import io.element.android.libraries.androidutils.metadata.isInDebug
|
||||
import java.io.Serializable
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@JvmInline
|
||||
value class RoomId(val value: String) : Serializable {
|
||||
init {
|
||||
|
||||
+8
@@ -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>> {
|
||||
return mxCallbackFlow {
|
||||
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
|
||||
|
||||
+4
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import java.io.File
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class FakeMatrixClient(
|
||||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
@@ -173,6 +174,7 @@ class FakeMatrixClient(
|
||||
var knockRoomLambda: (RoomIdOrAlias, String, List<String>) -> Result<RoomInfo?> = { _, _, _ ->
|
||||
Result.success(null)
|
||||
}
|
||||
var getRoomInfoLambda = { _: RoomId -> Result.success<RoomInfo?>(null) }
|
||||
var getRoomInfoFlowLambda = { _: RoomId ->
|
||||
flowOf<Optional<RoomInfo>>(Optional.empty())
|
||||
}
|
||||
@@ -379,6 +381,8 @@ class FakeMatrixClient(
|
||||
return Result.success(visitedRoomsId)
|
||||
}
|
||||
|
||||
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = getRoomInfoLambda(roomId)
|
||||
|
||||
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
|
||||
|
||||
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user