First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.usersearch.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
interface UserListDataSource {
// TODO should probably have a flow
suspend fun search(query: String, count: Long): List<MatrixUser>
suspend fun getProfile(userId: UserId): MatrixUser?
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.api
import kotlinx.coroutines.flow.Flow
interface UserRepository {
fun search(query: String): Flow<UserSearchResultState>
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.api
import io.element.android.libraries.matrix.api.user.MatrixUser
data class UserSearchResult(
val matrixUser: MatrixUser,
val isUnresolved: Boolean = false,
)
data class UserSearchResultState(
val results: List<UserSearchResult>,
val isSearching: Boolean,
)
@@ -0,0 +1,34 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.usersearch.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.di)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
implementation(libs.kotlinx.collections.immutable)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test)
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
@ContributesBinding(SessionScope::class)
class MatrixUserListDataSource(
private val client: MatrixClient
) : UserListDataSource {
override suspend fun search(query: String, count: Long): List<MatrixUser> {
val res = client.searchUsers(query, count)
return res.getOrNull()?.results.orEmpty()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return client.getProfile(userId).getOrNull()
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.api.UserSearchResultState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ContributesBinding(SessionScope::class)
class MatrixUserRepository(
private val client: MatrixClient,
private val dataSource: UserListDataSource
) : UserRepository {
override fun search(query: String): Flow<UserSearchResultState> = flow {
val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query))
val shouldFetchSearchResults = query.length >= MINIMUM_SEARCH_LENGTH
// If the search term is a MXID that's not ours, we'll show a 'fake' result for that user, then update it when we get search results.
val fakeSearchResult = if (shouldQueryProfile) {
UserSearchResult(MatrixUser(UserId(query)))
} else {
null
}
if (shouldQueryProfile || shouldFetchSearchResults) {
emit(UserSearchResultState(isSearching = shouldFetchSearchResults, results = listOfNotNull(fakeSearchResult)))
}
if (shouldFetchSearchResults) {
val results = fetchSearchResults(query, shouldQueryProfile)
emit(results)
}
}
private suspend fun fetchSearchResults(query: String, shouldQueryProfile: Boolean): UserSearchResultState {
// Debounce
delay(DEBOUNCE_TIME_MILLIS)
val results = dataSource
.search(query, MAXIMUM_SEARCH_RESULTS)
.filter { !client.isMe(it.userId) }
.map { UserSearchResult(it) }
.toMutableList()
// If the query is another user's MXID and the result doesn't contain that user ID, query the profile information explicitly
if (shouldQueryProfile && results.none { it.matrixUser.userId.value == query }) {
results.add(
0,
dataSource.getProfile(UserId(query))
?.let { UserSearchResult(it) }
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true)
)
}
return UserSearchResultState(results = results, isSearching = false)
}
companion object {
private const val DEBOUNCE_TIME_MILLIS = 250L
private const val MINIMUM_SEARCH_LENGTH = 3
private const val MAXIMUM_SEARCH_RESULTS = 10L
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class MatrixUserListDataSourceTest {
@Test
fun `search - returns users on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.success(
MatrixSearchUserResults(
results = persistentListOf(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
),
limited = false
)
)
)
val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test", 2)
assertThat(results).containsExactly(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
)
}
@Test
fun `search - returns empty list on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.failure(RuntimeException("Ruhroh"))
)
val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test", 2)
assertThat(results).isEmpty()
}
@Test
fun `get profile - returns user on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.success(aMatrixUserProfile())
)
val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
assertThat(result).isEqualTo(aMatrixUserProfile())
}
@Test
fun `get profile - returns null on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.failure(RuntimeException("Ruhroh"))
)
val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
assertThat(result).isNull()
}
private fun aMatrixUserProfile(
userId: UserId = A_USER_ID,
displayName: String = A_USER_NAME,
avatarUrl: String = AN_AVATAR_URL
) = MatrixUser(userId, displayName, avatarUrl)
}
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserListDataSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
private val SESSION_ID = SessionId("@current-user:example.com")
internal class MatrixUserRepositoryTest {
@Test
fun `search - emits nothing if the search query is too short`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search("x")
result.test {
awaitComplete()
}
}
@Test
fun `search - returns empty list if no results are found`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search("some query")
result.test {
awaitItem().also {
assertThat(it.isSearching).isTrue()
assertThat(it.results).isEmpty()
}
awaitItem().also {
assertThat(it.isSearching).isFalse()
assertThat(it.results).isEmpty()
}
awaitComplete()
}
}
@Test
fun `search - returns users if results are found`() = runTest {
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(aMatrixUserList())
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search("some query")
result.test {
awaitItem().also {
assertThat(it.isSearching).isTrue()
assertThat(it.results).isEmpty()
}
awaitItem().also {
assertThat(it.isSearching).isFalse()
assertThat(it.results).isEqualTo(aMatrixUserList().toUserSearchResults())
}
awaitComplete()
}
}
@Test
fun `search - immediately returns placeholder if search is mxid`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
awaitItem().also {
assertThat(it.isSearching).isTrue()
assertThat(it.results).isEqualTo(listOf(placeholderResult()))
}
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `search - doesn't return placeholder if search is the local user's mxid`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search(SESSION_ID.value)
result.test {
awaitItem().also {
assertThat(it.isSearching).isTrue()
assertThat(it.results).isEmpty()
}
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `search - filters out results with the local user's mxid`() = runTest {
val searchResults = aMatrixUserList() + MatrixUser(userId = SESSION_ID, displayName = A_USER_NAME)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search("some text")
result.test {
skipItems(1)
assertThat(awaitItem().results).isEqualTo(aMatrixUserList().toUserSearchResults())
awaitComplete()
}
}
@Test
fun `search - does not change results if they contain searched mxid`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID) + MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem().results).isEqualTo(searchResults.toUserSearchResults())
awaitComplete()
}
}
@Test
fun `search - gets profile results if searched mxid not in results`() = runTest {
val userProfile = MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(userProfile)
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem().results).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults())
awaitComplete()
}
}
@Test
fun `search - doesn't add profile results if searched mxid is local user and not in results`() = runTest {
val userProfile = MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val searchResults = aMatrixUserListWithoutUserId(SESSION_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(userProfile)
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search(SESSION_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem().results).isEqualTo(searchResults.toUserSearchResults())
awaitComplete()
}
}
@Test
fun `search - returns unresolved user if profile can't be loaded`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(null)
val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem().results).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults())
awaitComplete()
}
}
private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId }
private fun List<MatrixUser>.toUserSearchResults() = map { UserSearchResult(it) }
private fun placeholderResult(id: UserId = A_USER_ID, isUnresolved: Boolean = false) = UserSearchResult(MatrixUser(id), isUnresolved = isUnresolved)
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.usersearch"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.test
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
class FakeUserListDataSource : UserListDataSource {
private var searchResult: List<MatrixUser> = emptyList()
private var profile: MatrixUser? = null
override suspend fun search(query: String, count: Long): List<MatrixUser> = searchResult.take(count.toInt())
override suspend fun getProfile(userId: UserId): MatrixUser? = profile
fun givenSearchResult(users: List<MatrixUser>) {
this.searchResult = users
}
fun givenUserProfile(matrixUser: MatrixUser?) {
this.profile = matrixUser
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.usersearch.test
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResultState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeUserRepository : UserRepository {
var providedQuery: String? = null
private set
private val flow = MutableSharedFlow<UserSearchResultState>()
override fun search(query: String): Flow<UserSearchResultState> {
providedQuery = query
return flow
}
suspend fun emitState(state: UserSearchResultState) {
flow.emit(state)
}
}