Index messages locally in an encrypted on-device store so encrypted rooms can be searched at all (#7249)

* Add a message search abstraction over the Rust SDK's local index

Wraps the SDK's search service so the rest of the app never sees MatrixRustSDK
types, following the existing convention in libraries/matrix.

- MessageSearch, MessageSearchService, MessageSearchResult and
  MessageSearchPaginationState describe the surface in the api module.
- RustMessageSearch and RustMessageSearchService implement it, with
  MessageSearchResultMapper and MessageSearchResultsProcessor turning SDK results
  into Kotlin data classes.
- TantivyQueryEscaper escapes the query so a user typing ":)" or "AND" searches
  for that literal text rather than tripping the tantivy parser, and joins every
  word as a Must clause.
- Fakes and unit tests cover the mapping, the processor and the escaper, the last
  checked against a real tantivy 0.26.1 parser.

* Attach the encrypted search index behind a disabled feature flag

Gives the client an on-device tantivy index encrypted with the session's client
secret, so encrypted rooms can be searched at all.

- The index store is attached at ClientBuilder time via withSearchIndexStore, in
  a search-index directory alongside the session, keyed on the client secret. A
  session without one gets no index.
- MatrixClient exposes isMessageSearchAvailable so callers gate on the real
  indexing capability rather than on the raw flag, and getCacheSize() counts the
  index directory so it stays visible in the settings figures.
- FeatureFlags.MessageSearch (feature.message_search) is off by default and
  isFinished = false. It is read once at ClientBuilder time, so develop stays
  releasable and nothing reaches a user.

* Simplify search index wiring following review

Tie search availability to the MessageSearch feature flag alone and let
the index be created without a client secret, encrypting it only when
the session has one, matching the SDK's SQLite stores. Make
getBaseClientBuilder take the flag as a required parameter and return
ClientBuilder directly again, dropping the BaseClientBuilder wrapper
and the mid-restore snapshot parameter. Check the flag through
FeatureFlagService in RustMatrixAuthenticationService instead of
caching it in a field, and revert the extra matrix_sdk_search tracing
target, which belongs in the SDK's defaults.
This commit is contained in:
hayyaksi
2026-07-30 12:17:40 +03:00
committed by GitHub
parent af9db20f4f
commit e6d4472c0c
24 changed files with 1353 additions and 8 deletions
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -55,6 +56,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -91,6 +93,8 @@ class FakeMatrixClient(
override val syncService: SyncService = FakeSyncService(),
override val encryptionService: EncryptionService = FakeEncryptionService(),
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
override val isMessageSearchAvailable: Boolean = false,
override val messageSearchService: MessageSearchService = FakeMessageSearchService(),
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
@@ -0,0 +1,98 @@
/*
* 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.libraries.matrix.test.search
import io.element.android.libraries.matrix.api.search.MessageSearch
import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
import io.element.android.libraries.matrix.api.search.MessageSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeMessageSearch(
private val setQueryResult: suspend (String) -> Result<Unit> = { Result.success(Unit) },
private val paginateResult: suspend () -> Result<Unit> = { Result.success(Unit) },
private val controllablePagination: Boolean = false,
) : MessageSearch {
private val mutableResults = MutableStateFlow<ImmutableList<MessageSearchResult>>(persistentListOf())
override val results: StateFlow<ImmutableList<MessageSearchResult>> = mutableResults
private val mutablePaginationState = MutableStateFlow<MessageSearchPaginationState>(
MessageSearchPaginationState.Idle(endReached = false)
)
override val paginationState: StateFlow<MessageSearchPaginationState> = mutablePaginationState
var lastQuery: String? = null
private set
var setQueryCallCount = 0
private set
var paginateCallCount = 0
private set
var completedPaginateCallCount = 0
private set
var cancelledPaginateCallCount = 0
private set
private var pendingPagination = CompletableDeferred<PaginationCompletion>()
override suspend fun setQuery(query: String): Result<Unit> {
lastQuery = query
setQueryCallCount++
return setQueryResult(query)
}
override suspend fun paginate(): Result<Unit> {
paginateCallCount++
if (!controllablePagination) {
return paginateResult()
}
mutablePaginationState.value = MessageSearchPaginationState.Loading
return try {
val completion = pendingPagination.await()
completedPaginateCallCount++
mutablePaginationState.value = MessageSearchPaginationState.Idle(endReached = completion.endReached)
completion.result
} catch (exception: CancellationException) {
cancelledPaginateCallCount++
mutablePaginationState.value = MessageSearchPaginationState.Idle(endReached = false)
throw exception
} finally {
pendingPagination = CompletableDeferred()
}
}
fun emitResults(results: ImmutableList<MessageSearchResult>) {
mutableResults.value = results
}
fun emitPaginationState(state: MessageSearchPaginationState) {
mutablePaginationState.value = state
}
fun completePagination(
result: Result<Unit> = Result.success(Unit),
endReached: Boolean = false,
) {
check(pendingPagination.complete(PaginationCompletion(result, endReached))) {
"There is no pending pagination to complete."
}
}
private data class PaginationCompletion(
val result: Result<Unit>,
val endReached: Boolean,
)
}
@@ -0,0 +1,30 @@
/*
* 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.libraries.matrix.test.search
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.search.MessageSearch
import io.element.android.libraries.matrix.api.search.MessageSearchService
import kotlinx.coroutines.CoroutineScope
class FakeMessageSearchService(
private val messageSearch: MessageSearch = FakeMessageSearch(),
) : MessageSearchService {
/** The room the search was scoped to on the last call — null means all rooms. */
var lastRoomId: RoomId? = null
private set
var createMessageSearchCallCount = 0
private set
override fun createMessageSearch(scope: CoroutineScope, roomId: RoomId?): MessageSearch {
lastRoomId = roomId
createMessageSearchCallCount++
return messageSearch
}
}