From 5aa2802c954644040d8a7d9eb403f18c199cd47c Mon Sep 17 00:00:00 2001 From: Alexandre Ferris Date: Fri, 1 Dec 2023 01:39:22 +0100 Subject: [PATCH] feat: Add pagination in searched messages result screen (WPB-5498) (#2489) Co-authored-by: MohamadJaara --- app/build.gradle.kts | 1 + .../android/di/accountScoped/MessageModule.kt | 9 +- .../com/wire/android/mapper/MessageMapper.kt | 1 + ...SearchConversationMessagesResultsScreen.kt | 30 ++- .../SearchConversationMessagesScreen.kt | 19 +- .../SearchConversationMessagesState.kt | 8 +- .../SearchConversationMessagesViewModel.kt | 49 ++--- ...etConversationMessagesFromSearchUseCase.kt | 65 ++++--- ...SearchConversationMessagesViewModelTest.kt | 184 ++++++++---------- ...nversationMessagesFromSearchUseCaseTest.kt | 46 +++-- .../state/MessageCompositionHolderTest.kt | 4 +- gradle/libs.versions.toml | 1 + kalium | 2 +- 13 files changed, 228 insertions(+), 191 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c201ce6b6db..9e8839d5ac8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -167,6 +167,7 @@ dependencies { testImplementation(libs.turbine) testImplementation(libs.okio.fakeFileSystem) testRuntimeOnly(libs.junit5.engine) + testImplementation(libs.androidx.paging.testing) // Acceptance/Functional tests dependencies androidTestImplementation(libs.androidx.test.runner) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index c7b5a79ff4c..5a2a9f28a30 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -28,10 +28,10 @@ import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.asset.UpdateAssetMessageDownloadStatusUseCase import com.wire.kalium.logic.feature.message.DeleteMessageUseCase -import com.wire.kalium.logic.feature.message.GetConversationMessagesFromSearchQueryUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetNotificationsUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesByConversationUseCase +import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase import com.wire.kalium.logic.feature.message.MarkMessagesAsNotifiedUseCase import com.wire.kalium.logic.feature.message.MessageScope @@ -45,6 +45,7 @@ import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesByConversation +import com.wire.kalium.logic.feature.message.getPaginatedFlowOfMessagesBySearchQueryAndConversation import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped @@ -152,8 +153,10 @@ class MessageModule { @ViewModelScoped @Provides - fun provideGetConversationMessagesFromSearchQueryUseCase(messageScope: MessageScope): GetConversationMessagesFromSearchQueryUseCase = - messageScope.getConversationMessagesFromSearchQuery + fun provideGetPaginatedFlowOfMessagesBySearchQueryAndConversation( + messageScope: MessageScope + ): GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase = + messageScope.getPaginatedFlowOfMessagesBySearchQueryAndConversation @ViewModelScoped @Provides diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index 7746c634276..8bed0aea1f7 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -52,6 +52,7 @@ class MessageMapper @Inject constructor( private val userTypeMapper: UserTypeMapper, private val messageContentMapper: MessageContentMapper, private val isoFormatter: ISOFormatter, + // TODO(qol): a message mapper should not depend on a UI related component private val wireSessionImageLoader: WireSessionImageLoader ) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt index 91af91e261f..6b5b1acf9a6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt @@ -18,8 +18,12 @@ package com.wire.android.ui.home.conversations.search.messages import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.home.conversations.MessageItem import com.wire.android.ui.home.conversations.info.ConversationDetailsData @@ -27,15 +31,23 @@ import com.wire.android.ui.home.conversations.mock.mockMessageWithText import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.coroutines.flow.flowOf @Composable fun SearchConversationMessagesResultsScreen( - searchResult: List, + lazyPagingMessages: LazyPagingItems, searchQuery: String = "", onMessageClick: (messageId: String) -> Unit ) { LazyColumn { - items(searchResult) { message -> + items( + count = lazyPagingMessages.itemCount, + key = lazyPagingMessages.itemKey { it.header.messageId }, + contentType = lazyPagingMessages.itemContentType { it } + ) { index -> + val message: UIMessage = lazyPagingMessages[index] + ?: return@items // We can draw a placeholder here, as we fetch the next page of messages + when (message) { is UIMessage.Regular -> { MessageItem( @@ -70,10 +82,14 @@ fun SearchConversationMessagesResultsScreen( fun previewSearchConversationMessagesResultsScreen() { WireTheme { SearchConversationMessagesResultsScreen( - searchResult = listOf( - mockMessageWithText, - mockMessageWithText, - ), + lazyPagingMessages = flowOf( + PagingData.from( + listOf( + mockMessageWithText.copy(header = mockMessageWithText.header.copy(messageId = "1")), + mockMessageWithText.copy(header = mockMessageWithText.header.copy(messageId = "2")), + ) + ) + ).collectAsLazyPagingItems(), onMessageClick = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt index a179aee86a9..b651a1a2953 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R @@ -35,6 +37,7 @@ import com.wire.android.ui.common.topappbar.search.SearchTopBar import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.UIMessage +import kotlinx.coroutines.flow.Flow @RootNavGraph @Destination( @@ -63,9 +66,7 @@ fun SearchConversationMessagesScreen( content = { SearchConversationMessagesResultContent( searchQuery = searchQuery.text, - noneSearchSucceed = isEmptyResult, searchResult = searchResult, - isLoading = isLoading, onMessageClick = { messageId -> navigator.navigate( NavigationCommand( @@ -91,22 +92,22 @@ fun SearchConversationMessagesScreen( @Composable fun SearchConversationMessagesResultContent( searchQuery: String, - noneSearchSucceed: Boolean, - searchResult: List, - isLoading: Boolean, + searchResult: Flow>, onMessageClick: (messageId: String) -> Unit ) { + val lazyPagingMessages = searchResult.collectAsLazyPagingItems() + if (searchQuery.isEmpty()) { SearchConversationMessagesEmptyScreen() } else { - if (noneSearchSucceed && !isLoading) { - SearchConversationMessagesNoResultsScreen() - } else { + if (lazyPagingMessages.itemCount > 0) { SearchConversationMessagesResultsScreen( - searchResult = searchResult, + lazyPagingMessages = lazyPagingMessages, searchQuery = searchQuery, onMessageClick = onMessageClick ) + } else { + SearchConversationMessagesNoResultsScreen() } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt index ccbb4be0be2..c6143778cd1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt @@ -18,15 +18,15 @@ package com.wire.android.ui.home.conversations.search.messages import androidx.compose.ui.text.input.TextFieldValue +import androidx.paging.PagingData import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.id.ConversationId -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow data class SearchConversationMessagesState( val conversationId: ConversationId, val searchQuery: TextFieldValue = TextFieldValue(""), - val searchResult: ImmutableList = persistentListOf(), - val isEmptyResult: Boolean = false, + val searchResult: Flow> = emptyFlow(), val isLoading: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt index e80a42176b2..b164bc41210 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt @@ -28,19 +28,21 @@ import androidx.lifecycle.viewmodel.compose.saveable import com.wire.android.ui.home.conversations.search.SearchPeopleViewModel import com.wire.android.ui.home.conversations.usecase.GetConversationMessagesFromSearchUseCase import com.wire.android.ui.navArgs +import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.id.QualifiedID -import com.wire.kalium.logic.functional.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SearchConversationMessagesViewModel @Inject constructor( private val getSearchMessagesForConversation: GetConversationMessagesFromSearchUseCase, + private val dispatchers: DispatcherProvider, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -63,32 +65,35 @@ class SearchConversationMessagesViewModel @Inject constructor( private val mutableSearchQueryFlow = MutableStateFlow(searchConversationMessagesState.searchQuery.text) init { - viewModelScope.launch { - mutableSearchQueryFlow - .debounce(SearchPeopleViewModel.DEFAULT_SEARCH_QUERY_DEBOUNCE) - .collectLatest { searchTerm -> + val messagesResultFlow = mutableSearchQueryFlow + .onEach { + searchConversationMessagesState = searchConversationMessagesState.copy( + isLoading = true + ) + } + .debounce(SearchPeopleViewModel.DEFAULT_SEARCH_QUERY_DEBOUNCE) + .flatMapConcat { searchTerm -> + getSearchMessagesForConversation( + searchTerm = searchTerm, + conversationId = conversationId, + lastReadIndex = 0 + ).onEach { searchConversationMessagesState = searchConversationMessagesState.copy( - isLoading = true + isLoading = false ) - - getSearchMessagesForConversation( - searchTerm = searchTerm, - conversationId = conversationId - ).onSuccess { uiMessages -> - searchConversationMessagesState = searchConversationMessagesState.copy( - searchResult = uiMessages.toPersistentList(), - isEmptyResult = uiMessages.isEmpty(), - isLoading = false - ) - } - } - } + }.flowOn(dispatchers.io()) + } + searchConversationMessagesState = searchConversationMessagesState.copy( + searchResult = messagesResultFlow + ) } fun searchQueryChanged(searchQuery: TextFieldValue) { val textQueryChanged = searchConversationMessagesState.searchQuery.text != searchQuery.text // we set the state with a searchQuery, immediately to update the UI first - searchConversationMessagesState = searchConversationMessagesState.copy(searchQuery = searchQuery) + searchConversationMessagesState = searchConversationMessagesState.copy( + searchQuery = searchQuery + ) if (textQueryChanged && searchQuery.text.isNotBlank()) { viewModelScope.launch { mutableSearchQueryFlow.emit(searchQuery.text.trim()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt index 8f5640a136d..3dd771258c1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt @@ -17,57 +17,72 @@ */ package com.wire.android.ui.home.conversations.usecase +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.flatMap import com.wire.android.mapper.MessageMapper import com.wire.android.ui.home.conversations.model.UIMessage -import com.wire.kalium.logic.CoreFailure +import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase -import com.wire.kalium.logic.feature.message.GetConversationMessagesFromSearchQueryUseCase -import com.wire.kalium.logic.functional.Either -import com.wire.kalium.logic.functional.map +import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import javax.inject.Inject +import kotlin.math.max class GetConversationMessagesFromSearchUseCase @Inject constructor( - private val getConversationMessagesFromSearch: GetConversationMessagesFromSearchQueryUseCase, + private val getMessagesSearch: GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase, private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, - private val messageMapper: MessageMapper + private val messageMapper: MessageMapper, + private val dispatchers: DispatcherProvider ) { /** * This operation combines messages searched from a conversation and its respective user to UI - * @param searchQuery The search term used to define which messages will be returned. + * @param searchTerm The search term used to define which messages will be returned. * @param conversationId The conversation ID that it will look for messages in. * @return A [Either>] indicating the success of the operation. */ suspend operator fun invoke( searchTerm: String, - conversationId: ConversationId - ): Either> = + conversationId: ConversationId, + lastReadIndex: Int + ): Flow> { + val pagingConfig = PagingConfig( + pageSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + initialLoadSize = INITIAL_LOAD_SIZE + ) + if (searchTerm.length >= MINIMUM_CHARACTERS_TO_SEARCH) { - getConversationMessagesFromSearch( + return getMessagesSearch( searchQuery = searchTerm, - conversationId = conversationId - ).map { foundMessages -> - foundMessages.flatMap { messageItem -> - observeMemberDetailsByIds( - userIdList = messageMapper.memberIdList( - messages = foundMessages - ) - ).map { usersList -> - messageMapper.toUIMessage( - userList = usersList, - message = messageItem - )?.let { listOf(it) } ?: emptyList() - }.first() + conversationId = conversationId, + pagingConfig = pagingConfig, + startingOffset = max(0, lastReadIndex - PREFETCH_DISTANCE) + ).map { pagingData -> + pagingData.flatMap { messageItem -> + observeMemberDetailsByIds(messageMapper.memberIdList(listOf(messageItem))) + .mapLatest { usersList -> + messageMapper.toUIMessage(usersList, messageItem)?.let { listOf(it) } + ?: emptyList() + }.first() } - } + }.flowOn(dispatchers.io()) } else { - Either.Right(value = listOf()) + return flowOf(PagingData.empty()).flowOn(dispatchers.io()) } + } private companion object { const val MINIMUM_CHARACTERS_TO_SEARCH = 1 + const val PAGE_SIZE = 20 + const val INITIAL_LOAD_SIZE = 20 + const val PREFETCH_DISTANCE = 30 } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt index f3f6b45866c..a7cd767a9be 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt @@ -21,8 +21,12 @@ import android.os.Bundle import androidx.compose.ui.text.input.TextFieldValue import androidx.core.os.bundleOf import androidx.lifecycle.SavedStateHandle +import androidx.paging.PagingData +import androidx.paging.map +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension +import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.ui.home.conversations.mock.mockMessageWithText import com.wire.android.ui.home.conversations.model.MessageBody @@ -34,16 +38,18 @@ import com.wire.android.ui.home.conversations.usecase.GetConversationMessagesFro import com.wire.android.ui.navArgs import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -53,131 +59,98 @@ import org.junit.jupiter.api.extension.ExtendWith class SearchConversationMessagesViewModelTest { @Test - fun `given search term, when searching for messages, then specific messages are returned`() = - runTest() { - // given - val searchTerm = "message" - val messages = listOf( - mockMessageWithText.copy( - messageContent = UIMessageContent.TextMessage( - messageBody = MessageBody( - UIText.DynamicString("message1") - ) - ) - ), - mockMessageWithText.copy( - messageContent = UIMessageContent.TextMessage( - messageBody = MessageBody( - UIText.DynamicString("message2") - ) - ) + fun `given search term, when searching for messages, then specific messages are returned`() = runTest { + // given + val message1 = mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString("message1") ) ) + ) - val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() - .withSuccessSearch(searchTerm, messages) - .arrange() + val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() + .arrange() - // when - viewModel.searchQueryChanged(TextFieldValue(searchTerm)) - advanceUntilIdle() + // when + arrangement.withSuccessSearch(pagingDataFlow = PagingData.from(listOf(message1))) - // then - assertEquals( - TextFieldValue(searchTerm), - viewModel.searchConversationMessagesState.searchQuery - ) - coVerify(exactly = 1) { - arrangement.getSearchMessagesForConversation( - searchTerm, - arrangement.conversationId - ) + // then + viewModel.searchConversationMessagesState.searchResult.test { + awaitItem().map { + it shouldBeEqualTo message1 } } + } @Test - fun `given search term with empty space at start and at end, when searching for messages, then specific messages are returned`() = - runTest() { - // given - val searchTerm = "message" - val messages = listOf( - mockMessageWithText.copy( - messageContent = UIMessageContent.TextMessage( - messageBody = MessageBody( - UIText.DynamicString(" message1 ") - ) + fun `given blank search term, when searching for messages, then search is not triggered`() = runTest { + // given + val searchTerm = " " + val messages = listOf( + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message1") ) - ), - mockMessageWithText.copy( - messageContent = UIMessageContent.TextMessage( - messageBody = MessageBody( - UIText.DynamicString(" message2 ") - ) + ) + ), + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message2") ) ) ) + ) - val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() - .withSuccessSearch(searchTerm, messages) - .arrange() + val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() + .withSuccessSearch(PagingData.from(messages)) + .arrange() - // when - viewModel.searchQueryChanged(TextFieldValue(searchTerm)) - advanceUntilIdle() + // when + viewModel.searchQueryChanged(TextFieldValue(searchTerm)) + advanceUntilIdle() - // then - assertEquals( - TextFieldValue(searchTerm.trim()), - viewModel.searchConversationMessagesState.searchQuery + // then + assertEquals( + TextFieldValue(searchTerm), + viewModel.searchConversationMessagesState.searchQuery + ) + coVerify(exactly = 0) { + arrangement.getSearchMessagesForConversation( + searchTerm, + arrangement.conversationId, + any() ) - coVerify(exactly = 1) { - arrangement.getSearchMessagesForConversation( - searchTerm, - arrangement.conversationId - ) - } } + } @Test - fun `given blank search term, when searching for messages, then search is not triggered`() = - runTest() { + fun `given search term with empty space at start and at end, when searching for messages, then specific messages are returned`() = + runTest { // given - val searchTerm = " " - val messages = listOf( - mockMessageWithText.copy( - messageContent = UIMessageContent.TextMessage( - messageBody = MessageBody( - UIText.DynamicString(" message1") - ) - ) - ), - mockMessageWithText.copy( - messageContent = UIMessageContent.TextMessage( - messageBody = MessageBody( - UIText.DynamicString(" message2") - ) + val searchTerm = "message" + val message1 = mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString(" message1 ") ) ) ) val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() - .withSuccessSearch(searchTerm.trim(), messages) .arrange() // when viewModel.searchQueryChanged(TextFieldValue(searchTerm)) - advanceUntilIdle() // then - assertEquals( - TextFieldValue(searchTerm), - viewModel.searchConversationMessagesState.searchQuery - ) - coVerify(exactly = 0) { - arrangement.getSearchMessagesForConversation( - searchTerm, - arrangement.conversationId - ) + viewModel.searchConversationMessagesState.searchResult.test { + arrangement.withSuccessSearch(pagingDataFlow = PagingData.from(listOf(message1))) + awaitItem().map { + it shouldBeEqualTo message1 + } } } @@ -187,6 +160,8 @@ class SearchConversationMessagesViewModelTest { domain = "some-dummy-domain" ) + private val messagesChannel = Channel>(capacity = Channel.UNLIMITED) + @MockK private lateinit var savedStateHandle: SavedStateHandle @@ -196,6 +171,7 @@ class SearchConversationMessagesViewModelTest { private val viewModel: SearchConversationMessagesViewModel by lazy { SearchConversationMessagesViewModel( getSearchMessagesForConversation = getSearchMessagesForConversation, + dispatchers = TestDispatcherProvider(), savedStateHandle = savedStateHandle ) } @@ -207,16 +183,22 @@ class SearchConversationMessagesViewModelTest { every { savedStateHandle.navArgs() } returns SearchConversationMessagesNavArgs( conversationId = conversationId ) - every { savedStateHandle.get("searchConversationMessagesState") } returns bundleOf("value" to "") + every { savedStateHandle.get("searchConversationMessagesState") } returns bundleOf( + "value" to "" + ) + coEvery { + getSearchMessagesForConversation( + any(), + any(), + any() + ) + } returns messagesChannel.consumeAsFlow() } suspend fun withSuccessSearch( - searchTerm: String, - messages: List + pagingDataFlow: PagingData ) = apply { - coEvery { - getSearchMessagesForConversation(eq(searchTerm), eq(conversationId)) - } returns Either.Right(messages) + messagesChannel.send(pagingDataFlow) } fun arrange() = this to viewModel diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt index f5226eeee99..7cb2124fba0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt @@ -17,7 +17,10 @@ */ package com.wire.android.ui.home.conversations.usecase +import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestMessage import com.wire.android.framework.TestUser import com.wire.android.mapper.MessageMapper @@ -34,14 +37,14 @@ import com.wire.kalium.logic.data.user.User import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase -import com.wire.kalium.logic.feature.message.GetConversationMessagesFromSearchQueryUseCase -import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test @@ -60,14 +63,11 @@ class GetConversationMessagesFromSearchUseCaseTest { .arrange() // when - val result = useCase("", arrangement.conversationId) + val result = useCase("", arrangement.conversationId, 100).asSnapshot() + advanceUntilIdle() // then - assert(result is Either.Right>) - assertEquals( - Either.Right(listOf()), - result - ) + assert(result.isEmpty()) } @Test @@ -88,13 +88,13 @@ class GetConversationMessagesFromSearchUseCaseTest { .arrange() // when - val result = useCase(arrangement.searchTerm, arrangement.conversationId) + val result = useCase(arrangement.searchTerm, arrangement.conversationId, 100).asSnapshot() + advanceUntilIdle() // then - assert(result is Either.Right>) assertEquals( Arrangement.messages.size, - (result as Either.Right).value.size + result.size ) } @@ -106,7 +106,7 @@ class GetConversationMessagesFromSearchUseCaseTest { ) @MockK - lateinit var getConversationMessagesFromSearch: GetConversationMessagesFromSearchQueryUseCase + lateinit var getMessagesSearch: GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase @MockK lateinit var observeMemberDetailsByIds: ObserveUserListByIdUseCase @@ -116,9 +116,10 @@ class GetConversationMessagesFromSearchUseCaseTest { private val useCase: GetConversationMessagesFromSearchUseCase by lazy { GetConversationMessagesFromSearchUseCase( - getConversationMessagesFromSearch, + getMessagesSearch, observeMemberDetailsByIds, - messageMapper + messageMapper, + dispatchers = TestDispatcherProvider(), ) } @@ -128,8 +129,12 @@ class GetConversationMessagesFromSearchUseCaseTest { suspend fun withSearchSuccess() = apply { coEvery { - getConversationMessagesFromSearch(searchTerm, conversationId) - } returns Either.Right(messages) + getMessagesSearch(searchTerm, conversationId, any(), any()) + } returns flowOf( + PagingData.from( + messages + ) + ) } fun withMemberIdList() = apply { @@ -137,6 +142,13 @@ class GetConversationMessagesFromSearchUseCaseTest { message1.senderUserId, message2.senderUserId ) + every { messageMapper.memberIdList(listOf(message1)) } returns listOf( + message1.senderUserId, + ) + + every { messageMapper.memberIdList(listOf(message2)) } returns listOf( + message2.senderUserId, + ) } suspend fun withMemberDetails() = apply { @@ -146,7 +158,7 @@ class GetConversationMessagesFromSearchUseCaseTest { } fun withMappedMessage(user: User, message: Message.Standalone) = apply { - every { messageMapper.toUIMessage(users, message) } returns UIMessage.Regular( + every { messageMapper.toUIMessage(any(), message) } returns UIMessage.Regular( userAvatarData = UserAvatarData( asset = null, availabilityStatus = UserAvailabilityStatus.NONE diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt index 702063ca293..44c47d82fd3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt @@ -55,7 +55,7 @@ class MessageCompositionHolderTest { } @Test - fun `given empty text, when adding bold markdown, then 2x ** is added to the text`() = runTest { + fun `given empty text, when adding bold markdown, then 2x star char is added to the text`() = runTest { // given // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Bold) @@ -106,7 +106,7 @@ class MessageCompositionHolderTest { } @Test - fun `given non empty text, when adding bold markdown on selection, then 2x ** is added to the text`() = runTest { + fun `given non empty text, when adding bold markdown on selection, then 2x star char is added to the text`() = runTest { // given state.messageComposition.update { it.copy( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84d15b81518..70d2fd06322 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -149,6 +149,7 @@ androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-cor androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-workManager" } androidx-paging3 = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging3" } androidx-paging3Compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging3Compose" } +androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "androidx-paging3" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } androidx-dataStore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-dataStore" } androidx-exifInterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exif" } diff --git a/kalium b/kalium index 71fd11e12c0..2c6d0566c19 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 71fd11e12c061061078ba0cbb5f787344718db26 +Subproject commit 2c6d0566c19213b8f6dc75a4a059cc0369357e6e