From 91578c91a2f46a26e41af128a957bd1ec543731f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Fri, 15 Nov 2024 14:48:59 +0100 Subject: [PATCH 01/17] feat: conversation favorites [WPB-11637] (#3634) --- .../di/accountScoped/ConversationModule.kt | 10 +++ .../GetConversationsFromSearchUseCase.kt | 58 +++++++++++++----- .../ConversationListViewModel.kt | 21 ++----- .../GetConversationsFromSearchUseCaseTest.kt | 61 ++++++++++++++++++- .../ConversationListViewModelTest.kt | 3 +- kalium | 2 +- 6 files changed, 121 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index 5c6c05deed..baf45e7b76 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -317,4 +317,14 @@ class ConversationModule { @Provides fun provideGetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase(conversationScope: ConversationScope) = conversationScope.getPaginatedFlowOfConversationDetailsWithEventsBySearchQuery + + @ViewModelScoped + @Provides + fun provideObserveConversationsFromFolderUseCase(conversationScope: ConversationScope) = + conversationScope.observeConversationsFromFolder + + @ViewModelScoped + @Provides + fun provideGetFavoriteFolderUseCase(conversationScope: ConversationScope) = + conversationScope.getFavoriteFolder } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index 71bf72b447..c9663f89cf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -18,6 +18,8 @@ package com.wire.android.ui.home.conversations.usecase +import androidx.paging.LoadState +import androidx.paging.LoadStates import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map @@ -29,13 +31,18 @@ import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationQueryConfig import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase +import com.wire.kalium.logic.feature.conversation.folder.GetFavoriteFolderUseCase +import com.wire.kalium.logic.feature.conversation.folder.ObserveConversationsFromFolderUseCase import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import javax.inject.Inject class GetConversationsFromSearchUseCase @Inject constructor( private val useCase: GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase, + private val getFavoriteFolderUseCase: GetFavoriteFolderUseCase, + private val observeConversationsFromFromFolder: ObserveConversationsFromFolderUseCase, private val wireSessionImageLoader: WireSessionImageLoader, private val userTypeMapper: UserTypeMapper, private val dispatchers: DispatcherProvider, @@ -53,21 +60,44 @@ class GetConversationsFromSearchUseCase @Inject constructor( initialLoadSize = INITIAL_LOAD_SIZE, enablePlaceholders = true, ) - return useCase( - queryConfig = ConversationQueryConfig( - searchQuery = searchQuery, - fromArchive = fromArchive, - newActivitiesOnTop = newActivitiesOnTop, - onlyInteractionEnabled = onlyInteractionEnabled, - conversationFilter = conversationFilter, - ), - pagingConfig = pagingConfig, - startingOffset = 0L, - ).map { pagingData -> - pagingData.map { - it.toConversationItem(wireSessionImageLoader, userTypeMapper, searchQuery) + return when (conversationFilter) { + ConversationFilter.ALL, + ConversationFilter.GROUPS, + ConversationFilter.ONE_ON_ONE -> useCase( + queryConfig = ConversationQueryConfig( + searchQuery = searchQuery, + fromArchive = fromArchive, + newActivitiesOnTop = newActivitiesOnTop, + onlyInteractionEnabled = onlyInteractionEnabled, + conversationFilter = conversationFilter, + ), + pagingConfig = pagingConfig, + startingOffset = 0L, + ) + + ConversationFilter.FAVORITES -> { + when (val result = getFavoriteFolderUseCase.invoke()) { + GetFavoriteFolderUseCase.Result.Failure -> flowOf(emptyList()) + is GetFavoriteFolderUseCase.Result.Success -> + observeConversationsFromFromFolder(result.folder.id) + } + .map { + PagingData.from( + it, + sourceLoadStates = LoadStates( + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + refresh = LoadState.NotLoading(true), + ) + ) + } } - }.flowOn(dispatchers.io()) + } + .map { pagingData -> + pagingData.map { + it.toConversationItem(wireSessionImageLoader, userTypeMapper, searchQuery) + } + }.flowOn(dispatchers.io()) } private companion object { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 0702f0b024..cbea3a7fcd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -218,7 +218,8 @@ class ConversationListViewModelImpl @AssistedInject constructor( .distinctUntilChanged() .flatMapLatest { searchQuery: String -> observeConversationListDetailsWithEvents( - fromArchive = conversationsSource == ConversationsSource.ARCHIVE + fromArchive = conversationsSource == ConversationsSource.ARCHIVE, + conversationFilter = conversationsSource.toFilter() ).map { it.map { conversationDetails -> conversationDetails.toConversationItem( @@ -230,15 +231,11 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } .map { (conversationItems, searchQuery) -> - val filteredConversationItems = filterConversation( - conversationDetails = conversationItems, - filter = conversationsSource.toFilter() - ) if (searchQuery.isEmpty()) { - filteredConversationItems.withFolders(source = conversationsSource).toImmutableMap() + conversationItems.withFolders(source = conversationsSource).toImmutableMap() } else { searchConversation( - conversationDetails = filteredConversationItems, + conversationDetails = conversationItems, searchQuery = searchQuery ).withFolders(source = conversationsSource).toImmutableMap() } @@ -505,13 +502,3 @@ private fun searchConversation(conversationDetails: List, sear is ConversationItem.PrivateConversation -> details.conversationInfo.name.contains(searchQuery, true) } } - -private fun filterConversation(conversationDetails: List, filter: ConversationFilter): List = - conversationDetails.filter { details -> - when (filter) { - ConversationFilter.ALL -> true - ConversationFilter.FAVORITES -> false - ConversationFilter.GROUPS -> details is ConversationItem.GroupConversation - ConversationFilter.ONE_ON_ONE -> details is ConversationItem.PrivateConversation - } - } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt index 2d7dd96a6e..e18ded05df 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt @@ -26,8 +26,13 @@ import com.wire.android.mapper.UserTypeMapper import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents +import com.wire.kalium.logic.data.conversation.ConversationFilter +import com.wire.kalium.logic.data.conversation.ConversationFolder import com.wire.kalium.logic.data.conversation.ConversationQueryConfig +import com.wire.kalium.logic.data.conversation.FolderType import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase +import com.wire.kalium.logic.feature.conversation.folder.GetFavoriteFolderUseCase +import com.wire.kalium.logic.feature.conversation.folder.ObserveConversationsFromFolderUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -80,11 +85,48 @@ class GetConversationsFromSearchUseCaseTest { } } + @Test + fun givenFavoritesFilter_whenGettingConversations_thenObserveConversationsFromFolder() = runTest(dispatcherProvider.main()) { + // Given + val favoriteFolderId = "folder_id" + val folderResult = GetFavoriteFolderUseCase.Result.Success( + folder = ConversationFolder(id = favoriteFolderId, name = "", FolderType.FAVORITE) + ) + val conversationsList = listOf( + ConversationDetailsWithEvents(TestConversationDetails.CONVERSATION_ONE_ONE) + ) + + val (arrangement, useCase) = Arrangement() + .withFavoriteFolderResult(folderResult) + .withFolderConversationsResult(conversationsList) + .arrange() + + // When + useCase( + searchQuery = "", + fromArchive = false, + newActivitiesOnTop = false, + onlyInteractionEnabled = false, + conversationFilter = ConversationFilter.FAVORITES + ).asSnapshot() + + // Then + coVerify(exactly = 1) { arrangement.getFavoriteFolderUseCase.invoke() } + coVerify(exactly = 1) { arrangement.observeConversationsFromFolderUseCase.invoke(favoriteFolderId) } + coVerify(exactly = 0) { arrangement.useCase(any(), any(), any()) } + } + inner class Arrangement { @MockK lateinit var useCase: GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase + @MockK + lateinit var getFavoriteFolderUseCase: GetFavoriteFolderUseCase + + @MockK + lateinit var observeConversationsFromFolderUseCase: ObserveConversationsFromFolderUseCase + @MockK lateinit var wireSessionImageLoader: WireSessionImageLoader @@ -110,6 +152,23 @@ class GetConversationsFromSearchUseCaseTest { } returns flowOf(PagingData.from(conversations)) } - fun arrange() = this to GetConversationsFromSearchUseCase(useCase, wireSessionImageLoader, userTypeMapper, dispatcherProvider) + fun withFavoriteFolderResult(result: GetFavoriteFolderUseCase.Result) = apply { + coEvery { getFavoriteFolderUseCase.invoke() } returns result + } + + fun withFolderConversationsResult(conversations: List) = apply { + coEvery { + observeConversationsFromFolderUseCase.invoke(any()) + } returns flowOf(conversations) + } + + fun arrange() = this to GetConversationsFromSearchUseCase( + useCase, + getFavoriteFolderUseCase, + observeConversationsFromFolderUseCase, + wireSessionImageLoader, + userTypeMapper, + dispatcherProvider + ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index b1806e66a5..d9e1cdb253 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -32,6 +32,7 @@ import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearch import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents +import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -216,7 +217,7 @@ class ConversationListViewModelTest { } returns flowOf( PagingData.from(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) ) - coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false) } returns flowOf( + coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.ALL) } returns flowOf( listOf( TestConversationDetails.CONNECTION, TestConversationDetails.CONVERSATION_ONE_ONE, diff --git a/kalium b/kalium index f69841cbc9..d140d791a3 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit f69841cbc9faee3dc4fd110e4fc75a5404e3b21c +Subproject commit d140d791a308632e568b0ee3f8685644bad90424 From 7a7a40fe50d2cd7001c51f57a3f39e72cee69bf4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 11:00:20 +0000 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20lateinit=20property=20selfUserId?= =?UTF-8?q?=20has=20not=20been=20initialized=20in=20ConversationInfoViewMo?= =?UTF-8?q?del=20[WPB-14317]=20=F0=9F=8D=92=20(#3635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mohamad Jaara Co-authored-by: Yamil Medina --- .../info/ConversationInfoViewModel.kt | 14 ++------------ .../info/ConversationInfoViewModelArrangement.kt | 12 ++---------- .../info/ConversationInfoViewModelTest.kt | 14 -------------- 3 files changed, 4 insertions(+), 36 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt index 3495deb766..5e36411a49 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.CurrentAccount import com.wire.android.model.ImageAsset import com.wire.android.navigation.SavedStateViewModel import com.wire.android.ui.home.conversations.ConversationNavArgs @@ -40,9 +41,7 @@ import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.e2ei.usecase.FetchConversationMLSVerificationStatusUseCase -import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -52,9 +51,9 @@ class ConversationInfoViewModel @Inject constructor( private val qualifiedIdMapper: QualifiedIdMapper, override val savedStateHandle: SavedStateHandle, private val observeConversationDetails: ObserveConversationDetailsUseCase, - private val observerSelfUser: GetSelfUserUseCase, private val fetchConversationMLSVerificationStatus: FetchConversationMLSVerificationStatusUseCase, private val wireSessionImageLoader: WireSessionImageLoader, + @CurrentAccount private val selfUserId: UserId, ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -62,10 +61,7 @@ class ConversationInfoViewModel @Inject constructor( var conversationInfoViewState by mutableStateOf(ConversationInfoViewState(conversationId)) - private lateinit var selfUserId: UserId - init { - getSelfUserId() fetchMLSVerificationStatus() } @@ -75,12 +71,6 @@ class ConversationInfoViewModel @Inject constructor( } } - private fun getSelfUserId() { - viewModelScope.launch { - selfUserId = observerSelfUser().first().id - } - } - /* If this would be collected in the scope of this ViewModel (in `init` for instance) then there would be a race condition. [MessageComposerViewModel] handles the navigating back after removing a group and here it would navigate to home if the group diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt index 397c3e3107..931b5f6280 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt @@ -32,7 +32,6 @@ import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.e2ei.usecase.FetchConversationMLSVerificationStatusUseCase -import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every @@ -57,9 +56,6 @@ class ConversationInfoViewModelArrangement { @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase - @MockK - lateinit var observerSelfUser: GetSelfUserUseCase - @MockK lateinit var fetchConversationMLSVerificationStatus: FetchConversationMLSVerificationStatusUseCase @@ -74,9 +70,9 @@ class ConversationInfoViewModelArrangement { qualifiedIdMapper, savedStateHandle, observeConversationDetails, - observerSelfUser, fetchConversationMLSVerificationStatus, - wireSessionImageLoader + wireSessionImageLoader, + selfUserId = TestUser.SELF_USER_ID, ) } @@ -105,10 +101,6 @@ class ConversationInfoViewModelArrangement { coEvery { observeConversationDetails(any()) } returns flowOf(ObserveConversationDetailsUseCase.Result.Failure(failure)) } - suspend fun withSelfUser() = apply { - coEvery { observerSelfUser() } returns flowOf(TestUser.SELF_USER) - } - fun withMentionedUserId(id: UserId) = apply { every { qualifiedIdMapper.fromStringToQualifiedID(id.toString()) } returns id } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt index d4243d490e..76132e209b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt @@ -47,7 +47,6 @@ class ConversationInfoViewModelTest { val groupConversationDetails = mockConversationDetailsGroup("Conversation Name Goes Here") val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = groupConversationDetails) - .withSelfUser() .withMentionedUserId(TestUser.SELF_USER.id) .arrange() // When @@ -62,7 +61,6 @@ class ConversationInfoViewModelTest { val groupConversationDetails = mockConversationDetailsGroup("Conversation Name Goes Here") val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = groupConversationDetails) - .withSelfUser() .withMentionedUserId(TestUser.OTHER_USER.id) .arrange() // When @@ -79,7 +77,6 @@ class ConversationInfoViewModelTest { .withConversationDetailUpdate( conversationDetails = oneToOneConversationDetails ) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails {} }.run { advanceUntilIdle() @@ -101,7 +98,6 @@ class ConversationInfoViewModelTest { .withConversationDetailUpdate( conversationDetails = oneToOneConversationDetails ) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails {} }.run { advanceUntilIdle() @@ -117,7 +113,6 @@ class ConversationInfoViewModelTest { val groupConversationDetails = mockConversationDetailsGroup("Conversation Name Goes Here") val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = groupConversationDetails) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails {} }.run { advanceUntilIdle() @@ -140,7 +135,6 @@ class ConversationInfoViewModelTest { .withConversationDetailUpdate( conversationDetails = firstConversationDetails ) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails {} }.run { advanceUntilIdle() @@ -169,7 +163,6 @@ class ConversationInfoViewModelTest { runTest { // Given val (_, viewModel) = ConversationInfoViewModelArrangement() - .withSelfUser() .arrange() // When - Then @@ -185,7 +178,6 @@ class ConversationInfoViewModelTest { .withConversationDetailUpdate( conversationDetails = oneToOneConversationDetails ) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails {} }.run { advanceUntilIdle() @@ -202,7 +194,6 @@ class ConversationInfoViewModelTest { val otherUserAvatar = conversationDetails.otherUser.previewPicture val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = conversationDetails) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails {} }.run { advanceUntilIdle() @@ -220,7 +211,6 @@ class ConversationInfoViewModelTest { val groupConversationDetails = mockConversationDetailsGroup("Conversation Name Goes Here") val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = groupConversationDetails) - .withSelfUser() .arrange() // then @@ -244,7 +234,6 @@ class ConversationInfoViewModelTest { val groupConversationDetails = mockConversationDetailsGroup("Conversation Name Goes Here") val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = groupConversationDetails) - .withSelfUser() .arrange() // then @@ -268,7 +257,6 @@ class ConversationInfoViewModelTest { val groupConversationDetails = mockConversationDetailsGroup("Conversation Name Goes Here") val (_, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailUpdate(conversationDetails = groupConversationDetails) - .withSelfUser() .arrange() // then @@ -290,7 +278,6 @@ class ConversationInfoViewModelTest { fun `given Failure while getting an MLS conversation's verification status, then mlsVerificationStatus is null`() = runTest { // Given val (_, viewModel) = ConversationInfoViewModelArrangement() - .withSelfUser() .arrange() // then @@ -309,7 +296,6 @@ class ConversationInfoViewModelTest { // Given val (arrangement, viewModel) = ConversationInfoViewModelArrangement() .withConversationDetailFailure(StorageFailure.DataNotFound) - .withSelfUser() .arrange() launch { viewModel.observeConversationDetails(arrangement.onNotFound) }.run { advanceUntilIdle() From 29ba931d8def3790e619be495f2a2a5da786caec Mon Sep 17 00:00:00 2001 From: Oussama Hassine Date: Mon, 18 Nov 2024 12:06:53 +0100 Subject: [PATCH 03/17] fix: Push notification on top of calling UI shown (WPB-11632) (#3592) Co-authored-by: Yamil Medina --- app/src/main/AndroidManifest.xml | 5 +- .../notification/CallNotificationManager.kt | 60 +++++++++---------- .../notification/NotificationActions.kt | 25 -------- .../notification/NotificationConstants.kt | 2 +- .../android/notification/PendingIntents.kt | 51 ++++++++++++---- .../CallNotificationDismissedReceiver.kt | 57 ------------------ ...eiver.kt => IncomingCallActionReceiver.kt} | 29 +++++++-- app/src/main/res/values-de/strings.xml | 5 -- app/src/main/res/values-es/strings.xml | 5 -- app/src/main/res/values-et/strings.xml | 5 -- app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hr/strings.xml | 4 -- app/src/main/res/values-hu/strings.xml | 5 -- app/src/main/res/values-it/strings.xml | 5 -- app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 5 -- app/src/main/res/values-pt/strings.xml | 5 -- app/src/main/res/values-ru/strings.xml | 5 -- app/src/main/res/values-si/strings.xml | 5 -- app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values/strings.xml | 5 -- .../CallNotificationManagerTest.kt | 25 -------- 23 files changed, 95 insertions(+), 217 deletions(-) delete mode 100644 app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt rename app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/{DeclineIncomingCallReceiver.kt => IncomingCallActionReceiver.kt} (73%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e74e1c19d..f74f053aab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -308,14 +308,11 @@ android:name=".notification.broadcastreceivers.NotificationReplyReceiver" android:exported="false" /> - >(mapOf()) - private val reloadCallNotification = MutableSharedFlow() init { scope.launch { @@ -81,16 +78,6 @@ class CallNotificationManager @Inject constructor( currentCalls to (currentCalls - previousCalls.toSet()) } .distinctUntilChanged() - .flatMapLatest { (allCurrentCalls, newCalls) -> - reloadCallNotification - .map { (userIdString, conversationIdString) -> - allCurrentCalls to allCurrentCalls.filter { // emit call that needs to be reloaded as newOrUpdated - it.userId.toString() == userIdString && it.conversationId.toString() == conversationIdString - } - } - .filter { (_, newCalls) -> newCalls.isNotEmpty() } // only emit if there is something to reload - .onStart { emit(allCurrentCalls to newCalls) } - } .collectLatest { (allCurrentCalls, newCalls) -> // remove outdated incoming call notifications hideOutdatedIncomingCallNotifications(allCurrentCalls) @@ -111,10 +98,6 @@ class CallNotificationManager @Inject constructor( hideIncomingCallNotifications { _, id -> !currentIncomingCallNotificationIds.contains(id) } } - fun reloadCallNotifications(reloadCallNotificationIds: CallNotificationIds) = scope.launch { - reloadCallNotification.emit(reloadCallNotificationIds) - } - fun handleIncomingCalls(calls: List, userId: UserId, userName: String) { if (calls.isEmpty()) { incomingCallsForUsers.update { @@ -167,8 +150,6 @@ class CallNotificationManager @Inject constructor( notificationManager.notify(tag, id, notification) } - // Notifications - companion object { private const val TAG = "CallNotificationManager" private const val CANCEL_CALL_NOTIFICATION_DELAY = 300L @@ -187,9 +168,11 @@ class CallNotificationBuilder @Inject constructor( val userIdString = data.userId.toString() val conversationIdString = data.conversationId.toString() val channelId = NotificationConstants.getOutgoingChannelId(data.userId) + val person = Person.Builder().setName(data.conversationName).build() - return NotificationCompat.Builder(context, channelId) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) + val notificationBuilder = NotificationCompat.Builder(context, channelId) + return notificationBuilder + .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_CALL) .setSmallIcon(R.drawable.notification_icon_small) .setContentTitle(data.conversationName) @@ -198,11 +181,16 @@ class CallNotificationBuilder @Inject constructor( .setAutoCancel(false) .setOngoing(true) .setSilent(true) + .setStyle( + CallStyle.forOngoingCall( + person, + endOngoingCallPendingIntent(context, conversationIdString, userIdString) + ) + ) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .addAction(getHangUpCallAction(context, conversationIdString, userIdString)) .setFullScreenIntent(outgoingCallPendingIntent(context, conversationIdString), true) .setContentIntent(outgoingCallPendingIntent(context, conversationIdString)) - .setDeleteIntent(callNotificationDismissedPendingIntent(context, userIdString, conversationIdString)) .build() } @@ -212,6 +200,7 @@ class CallNotificationBuilder @Inject constructor( val title = getNotificationTitle(data) val content = getNotificationBody(data) val channelId = NotificationConstants.getIncomingChannelId(data.userId) + val person = Person.Builder().setName(title).build() val notification = NotificationCompat.Builder(context, channelId) .setPriority(NotificationCompat.PRIORITY_MAX) @@ -222,13 +211,17 @@ class CallNotificationBuilder @Inject constructor( .setSubText(data.userName) .setAutoCancel(false) .setOngoing(true) + .setStyle( + CallStyle.forIncomingCall( + person, + declineCallPendingIntent(context, conversationIdString, userIdString), + answerCallPendingIntent(context, conversationIdString, userIdString) + ) + ) .setVibrate(VIBRATE_PATTERN) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .addAction(getDeclineCallAction(context, conversationIdString, userIdString)) - .addAction(getOpenIncomingCallAction(context, conversationIdString, userIdString)) .setFullScreenIntent(fullScreenIncomingCallPendingIntent(context, conversationIdString, userIdString), true) .setContentIntent(fullScreenIncomingCallPendingIntent(context, conversationIdString, userIdString)) - .setDeleteIntent(callNotificationDismissedPendingIntent(context, userIdString, conversationIdString)) .build() // Added FLAG_INSISTENT so the ringing sound repeats itself until an action is done. @@ -242,6 +235,7 @@ class CallNotificationBuilder @Inject constructor( val conversationIdString = data.conversationId.toString() val userIdString = data.userId.toString() val title = getNotificationTitle(data) + val person = Person.Builder().setName(title).build() return NotificationCompat.Builder(context, channelId) .setContentTitle(title) @@ -254,11 +248,15 @@ class CallNotificationBuilder @Inject constructor( .setAutoCancel(true) .setOngoing(true) .setUsesChronometer(true) - .addAction(getHangUpCallAction(context, conversationIdString, userIdString)) - .addAction(getOpenOngoingCallAction(context, conversationIdString)) + .setStyle( + CallStyle.forOngoingCall( + person, + endOngoingCallPendingIntent(context, conversationIdString, userIdString) + ) + ) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setFullScreenIntent(openOngoingCallPendingIntent(context, conversationIdString), true) .setContentIntent(openOngoingCallPendingIntent(context, conversationIdString)) - .setDeleteIntent(callNotificationDismissedPendingIntent(context, userIdString, conversationIdString)) .build() } diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt index c187e94eea..f9ad2e2c24 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationActions.kt @@ -19,7 +19,6 @@ package com.wire.android.notification import android.app.Notification -import android.app.PendingIntent import android.content.Context import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput @@ -48,27 +47,3 @@ fun getActionReply( .build() } } - -fun getOpenIncomingCallAction(context: Context, conversationId: String, userId: String) = getAction( - context.getString(R.string.notification_action_open_call), - fullScreenIncomingCallPendingIntent(context, conversationId, userId) -) - -fun getDeclineCallAction(context: Context, conversationId: String, userId: String) = getAction( - context.getString(R.string.notification_action_decline_call), - declineCallPendingIntent(context, conversationId, userId) -) - -fun getOpenOngoingCallAction(context: Context, conversationId: String) = getAction( - context.getString(R.string.notification_action_open_call), - openOngoingCallPendingIntent(context, conversationId) -) - -fun getHangUpCallAction(context: Context, conversationId: String, userId: String) = getAction( - context.getString(R.string.notification_action_hang_up_call), - endOngoingCallPendingIntent(context, conversationId, userId) -) - -private fun getAction(title: String, intent: PendingIntent) = NotificationCompat.Action - .Builder(null, title, intent) - .build() diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt index fdbe058e37..b67a4b702a 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt @@ -23,7 +23,7 @@ import com.wire.kalium.logic.data.user.UserId // TODO: The names need to be localisable object NotificationConstants { - private const val INCOMING_CALL_CHANNEL_ID = "com.wire.android.notification_incoming_call_channel" + const val INCOMING_CALL_CHANNEL_ID = "com.wire.android.notification_incoming_call_channel" private const val OUTGOING_CALL_CHANNEL_ID = "com.wire.android.notification_outgoing_call_channel" const val INCOMING_CALL_CHANNEL_NAME = "Incoming calls" const val OUTGOING_CALL_CHANNEL_NAME = "Outgoing call" diff --git a/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt b/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt index a097b96b61..363dadd2f0 100644 --- a/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt +++ b/app/src/main/kotlin/com/wire/android/notification/PendingIntents.kt @@ -20,13 +20,16 @@ package com.wire.android.notification +import android.Manifest import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri -import com.wire.android.notification.broadcastreceivers.CallNotificationDismissedReceiver -import com.wire.android.notification.broadcastreceivers.DeclineIncomingCallReceiver +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import com.wire.android.notification.broadcastreceivers.EndOngoingCallReceiver +import com.wire.android.notification.broadcastreceivers.IncomingCallActionReceiver import com.wire.android.notification.broadcastreceivers.NotificationReplyReceiver import com.wire.android.ui.WireActivity import com.wire.android.ui.calling.CallActivity.Companion.EXTRA_CONVERSATION_ID @@ -111,7 +114,12 @@ fun endOngoingCallPendingIntent(context: Context, conversationId: String, userId } fun declineCallPendingIntent(context: Context, conversationId: String, userId: String): PendingIntent { - val intent = DeclineIncomingCallReceiver.newIntent(context, conversationId, userId) + val intent = IncomingCallActionReceiver.newIntent( + context = context, + conversationId = conversationId, + userId = userId, + action = IncomingCallActionReceiver.ACTION_DECLINE_CALL + ) return PendingIntent.getBroadcast( context.applicationContext, @@ -121,6 +129,33 @@ fun declineCallPendingIntent(context: Context, conversationId: String, userId: S ) } +fun answerCallPendingIntent(context: Context, conversationId: String, userId: String): PendingIntent { + val notificationManager = NotificationManagerCompat.from(context) + val notification = notificationManager.activeNotifications + val isAlreadyHavingACall = notification.find { + it.notification.channelId.contains(NotificationConstants.INCOMING_CALL_CHANNEL_ID) || + it.notification.channelId.contains(NotificationConstants.ONGOING_CALL_CHANNEL_ID) + } != null + val shouldAnswerCallFromNotificationButton = !isAlreadyHavingACall && + (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) + if (shouldAnswerCallFromNotificationButton) { + val intent = IncomingCallActionReceiver.newIntent( + context = context, + conversationId = conversationId, + userId = userId, + action = IncomingCallActionReceiver.ACTION_ANSWER_CALL + ) + return PendingIntent.getBroadcast( + context.applicationContext, + getRequestCode(conversationId, ANSWER_CALL_REQUEST_CODE), + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } else { + return fullScreenIncomingCallPendingIntent(context, conversationId, userId) + } +} + fun outgoingCallPendingIntent(context: Context, conversationId: String): PendingIntent { val intent = openOutgoingCallIntent(context, conversationId) @@ -154,14 +189,6 @@ private fun openOngoingCallIntent(context: Context, conversationId: String) = putExtra(EXTRA_CONVERSATION_ID, conversationId) } -fun callNotificationDismissedPendingIntent(context: Context, userId: String, conversationId: String): PendingIntent = - PendingIntent.getBroadcast( - context, - getRequestCode(conversationId, CALL_NOTIFICATION_DISMISSED_REQUEST_CODE), - CallNotificationDismissedReceiver.newIntent(context, conversationId, userId), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - private fun openMigrationLoginIntent(context: Context, userHandle: String) = Intent(context.applicationContext, WireActivity::class.java).apply { data = Uri.Builder() @@ -195,12 +222,12 @@ fun openAppPendingIntent(context: Context): PendingIntent { private const val MESSAGE_NOTIFICATIONS_SUMMARY_REQUEST_CODE = 0 private const val DECLINE_CALL_REQUEST_CODE = "decline_call_" +private const val ANSWER_CALL_REQUEST_CODE = "answer_call_" private const val FULL_SCREEN_REQUEST_CODE = "incoming_call_" private const val OPEN_ONGOING_CALL_REQUEST_CODE = 4 private const val OPEN_MIGRATION_LOGIN_REQUEST_CODE = 5 private const val OUTGOING_CALL_REQUEST_CODE = 6 private const val END_ONGOING_CALL_REQUEST_CODE = "hang_up_call_" -private const val CALL_NOTIFICATION_DISMISSED_REQUEST_CODE = "call_notification_dismissed_" private const val OPEN_MESSAGE_REQUEST_CODE_PREFIX = "open_message_" private const val OPEN_OTHER_USER_PROFILE_CODE_PREFIX = "open_other_user_profile_" private const val REPLY_MESSAGE_REQUEST_CODE_PREFIX = "reply_" diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt deleted file mode 100644 index ba618d33ab..0000000000 --- a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ - -package com.wire.android.notification.broadcastreceivers - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import com.wire.android.appLogger -import com.wire.android.notification.CallNotificationIds -import com.wire.android.notification.CallNotificationManager -import com.wire.kalium.logger.obfuscateId -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class CallNotificationDismissedReceiver : BroadcastReceiver() { // requires zero argument constructor - - @Inject - lateinit var callNotificationManager: CallNotificationManager - - override fun onReceive(context: Context, intent: Intent) { - val conversationIdString: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: return - val userIdString: String = intent.getStringExtra(EXTRA_USER_ID) ?: return - appLogger.i( - "CallNotificationDismissedReceiver: onReceive for user ${userIdString.obfuscateId()}" + - " and conversation ${conversationIdString.obfuscateId()}" - ) - callNotificationManager.reloadCallNotifications(CallNotificationIds(userIdString, conversationIdString)) - } - - companion object { - private const val EXTRA_CONVERSATION_ID = "conversation_id_extra" - private const val EXTRA_USER_ID = "user_id_extra" - - fun newIntent(context: Context, conversationIdString: String?, userIdString: String?): Intent = - Intent(context, CallNotificationDismissedReceiver::class.java).apply { - putExtra(EXTRA_CONVERSATION_ID, conversationIdString) - putExtra(EXTRA_USER_ID, userIdString) - } - } -} diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/IncomingCallActionReceiver.kt similarity index 73% rename from app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt rename to app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/IncomingCallActionReceiver.kt index 2601508659..66936e76f1 100644 --- a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/IncomingCallActionReceiver.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class DeclineIncomingCallReceiver : BroadcastReceiver() { // requires zero argument constructor +class IncomingCallActionReceiver : BroadcastReceiver() { @Inject @KaliumCoreLogic @@ -59,6 +59,7 @@ class DeclineIncomingCallReceiver : BroadcastReceiver() { // requires zero argum @Inject lateinit var callNotificationManager: CallNotificationManager + @Suppress("ReturnCount") override fun onReceive(context: Context, intent: Intent) { val conversationIdString: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: run { appLogger.e("CallNotificationDismissReceiver: onReceive, conversation ID is missing") @@ -69,8 +70,18 @@ class DeclineIncomingCallReceiver : BroadcastReceiver() { // requires zero argum appLogger.e("CallNotificationDismissReceiver: onReceive, user ID is missing") return } + val action: String = intent.getStringExtra(EXTRA_ACTION) ?: run { + appLogger.e("CallNotificationDismissReceiver: onReceive, action is missing") + return + } + coroutineScope.launch(Dispatchers.Default) { - coreLogic.getSessionScope(userId).calls.rejectCall(conversationIdString.toQualifiedID(qualifiedIdMapper)) + with(coreLogic.getSessionScope(userId)) { + when (action) { + ACTION_DECLINE_CALL -> calls.rejectCall(qualifiedIdMapper.fromStringToQualifiedID(conversationIdString)) + ACTION_ANSWER_CALL -> calls.answerCall(qualifiedIdMapper.fromStringToQualifiedID(conversationIdString)) + } + } callNotificationManager.hideIncomingCallNotification(userId.toString(), conversationIdString) } } @@ -78,11 +89,21 @@ class DeclineIncomingCallReceiver : BroadcastReceiver() { // requires zero argum companion object { private const val EXTRA_CONVERSATION_ID = "conversation_id_extra" private const val EXTRA_RECEIVER_USER_ID = "user_id_extra" + private const val EXTRA_ACTION = "action_extra" + + const val ACTION_DECLINE_CALL = "action_decline_call" + const val ACTION_ANSWER_CALL = "action_answer_call" - fun newIntent(context: Context, conversationId: String, userId: String): Intent = - Intent(context, DeclineIncomingCallReceiver::class.java).apply { + fun newIntent( + context: Context, + conversationId: String, + userId: String, + action: String + ): Intent = + Intent(context, IncomingCallActionReceiver::class.java).apply { putExtra(EXTRA_CONVERSATION_ID, conversationId) putExtra(EXTRA_RECEIVER_USER_ID, userId) + putExtra(EXTRA_ACTION, action) } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7f9987af90..af3f5924d7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -120,7 +120,6 @@ Timer für selbstlöschende Nachrichten festlegen Auflegen Anruf annehmen - Anruf ablehnen Frontkamera verwenden Kamera auf der Rückseite verwenden Anruf stummschalten @@ -826,9 +825,6 @@ Antworten Sie Eine Nachricht schreiben - Anruf öffnen - Ablehnen - Auflegen Ruft an… Aktiver Anruf… %s ruft an... @@ -845,7 +841,6 @@ Beitreten Auflegen Anruf annehmen - Anruf ablehnen Klingeln… Ruft an… ruft an… diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 29fde4a80a..616b3a2583 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -125,7 +125,6 @@ Mensaje de archivo Colgar llamada Aceptar llamada - Rechazar llamada Cambiar a cámara frontal Cambiar a cámara trasera Participante silenciado @@ -662,9 +661,6 @@ Hasta 500 personas pueden unirse a una conversación en grupo. Responder Escribe un mensaje - Abrir llamada - Rechazar - Colgar Llamando... Llamada en curso... %s llama... @@ -681,7 +677,6 @@ Hasta 500 personas pueden unirse a una conversación en grupo. Unirse Colgar llamada Aceptar llamada - Rechazar llamada Sonando... Llamando... Llamada entrante diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 117386e90b..9b4aee1f83 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -82,7 +82,6 @@ Pingi Lõpeta kõne Võta kõne vastu - Keeldu kõnest Vali eesmine kaamera Vali tagumine kaamera Osaleja vaigistatud @@ -300,9 +299,6 @@ Vasta Sina Sisesta sõnum - Ava kõne - Keeldu - Lõpeta kõne Helistab… Käimasolev kõne… %s helistab... @@ -319,7 +315,6 @@ Liitu Lõpeta kõne Võta kõne vastu - Keeldu kõnest Heliseb… Helistab… Sissetulev kõne diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4a15a463ec..c84b999886 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -135,7 +135,6 @@ Définir une minuterie pour les messages auto-supprimés Raccrocher l\'appel Prendre l\'appel  - Refuser l\'appel Utiliser la caméra avant Utiliser sur la caméra arrière Participant en sourdine diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index fd5938df3b..616ae24f09 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -132,7 +132,6 @@ Postavite mjerač za poruke koje se same brišu Prekini poziv Prihvati poziv - Odbij poziv Prebaci na prednju kameru Prebaci na stražnju kameru Sudionik utišan @@ -502,8 +501,6 @@ Odgovor Vi Napiši poruku - Odbij - Poklopi Poziv u tijeku… %s zove... Netko @@ -516,7 +513,6 @@ Pridruži se Prekini poziv Prihvati poziv - Odbij poziv Zvoni… Svejedno se pridruži Značajka nije dostupna diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e2e8ad2818..ca2b898a5b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -143,7 +143,6 @@ Időzítő beállítása az önmegsemmisítő üzenetekhez Hívás befejezése Hívás fogadása - Hívás elutasítása Szelfikamerára váltás Hátlapi kamerára váltás A résztvevő némítva @@ -894,9 +893,6 @@ Válasz Ön Írjon üzenetet - Nyitott hívás - Elutasítás - Hívás befejezése Hívás… Folyamatban lévő hívás… %s hívja... @@ -914,7 +910,6 @@ Csatlakozás Hívás befejezése Hívás fogadása - Hívás elutasítása Kicseng… Hívás… hívja… diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 51dfa3dde7..020238e306 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -137,7 +137,6 @@ Imposta il timer per l\'eliminazione automatica dei messaggi Chiudi la chiamata Accetta la chiamata - Rifiuta la chiamata Passa alla fotocamera anteriore Passa alla fotocamera posteriore Partecipante mutato @@ -766,9 +765,6 @@ Fino a 500 persone possono unirsi a una conversazione di gruppo. Rispondi Tu Scrivi un messaggio - Apri la chiamata - Rifiuta - Riaggancia Chiamata in arrivo... Chiamata in corso... %s sta chiamando... @@ -785,7 +781,6 @@ Fino a 500 persone possono unirsi a una conversazione di gruppo. Unisciti Riaggancia la chiamata Accetta la chiamata - Rifiuta la chiamata In chiamata... Chiamata in arrivo... Chiamata in entrata diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fbce67a6e1..b6b29e04ca 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -137,7 +137,6 @@ 自動削除メッセージのタイマーを設定します 通話を切る 通話に出る - 通話拒否 フロントカメラに切り替える 背面カメラに切り替える ミュートした参加者 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e800141d53..8cf561849f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -107,7 +107,6 @@ Zaczep Zakończ rozmowę Odbierz połączenie - Odrzuć połączenie Przełącz na przednią kamerę Przełącz na tylną kamerę Uczestnik wyciszony @@ -638,9 +637,6 @@ Do grupy może dołączyć maksymalnie 500 osób. Odpowiedz Ty Napisz wiadomość - Otwórz połączenie - Odrzuć - Zakończ Dzwoni... Trwa połączenie... %s dzwoni... @@ -656,7 +652,6 @@ Do grupy może dołączyć maksymalnie 500 osób. Dołącz Zakończ połączenie Odbierz połączenie - Odrzuć połączenie Dzwoni... Przychodzące połączenie... Przychodzące połączenie diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a38b2fe068..baec64fcbf 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -128,7 +128,6 @@ Pingar todos Desligar a chamada Aceitar a chamada - Recusar a chamada Mudar para a câmera frontal Mudar para a câmera traseira Participante mudo @@ -702,9 +701,6 @@ Até 500 pessoas podem participar de uma conversa em grupo. Responder Você Digite uma mensagem - Abrir Chamada - Recusar - Desligar Chamando… Chamada em andamento… %s está chamando… @@ -721,7 +717,6 @@ Até 500 pessoas podem participar de uma conversa em grupo. Participar Desligar chamada Aceitar chamada - Recusar chamada Chamando… Chamada recebida Chamada Recebida diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 350b4fd841..d1979030d7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -145,7 +145,6 @@ Установите таймер для самоудаления сообщений Завершить вызов Принять вызов - Отклонить вызов Переключиться на фронтальную камеру Переключиться на основную камеру Звук участника отключен @@ -929,9 +928,6 @@ Ответить Вы Введите сообщение - Открыть вызов - Отклонить - Завершить вызов Вызов… Исходящий вызов… %s вызывает... @@ -949,7 +945,6 @@ Присоединиться Завершить вызов Принять вызов - Отклонить вызов Вызываем… Вызов… звонит… diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 597203f376..40a0a32a4c 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -139,7 +139,6 @@ පණිවිඩ ඉබේ මැකීමට කාලය සකසන්න ඇමතුම තබන්න ඇමතුම පිළිගන්න - ඇමතුම නවතන්න ඉදිරිපස රූගතයට පෙරළන්න පසුපස රූගතයට පෙරළන්න සහභාගියා නිහඬ කළා @@ -772,9 +771,6 @@ පිළිතුර ඔබ පණිවිඩයක් ලියන්න - ඇමතුම අරින්න - ප්‍රතික්‍ෂේප - තබන්න අමතමින්… පවතින ඇමතුම… %s අමතමින්... @@ -792,7 +788,6 @@ එක්වන්න ඇමතුම තබන්න ඇමතුම පිළිගන්න - ඇමතුම නවතන්න නාද වෙමින්… අමතමින්… ලැබෙන ඇමතුම diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index dcee6cc500..c5a259e037 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -114,7 +114,6 @@ Pinga Lägg på samtalet Acceptera samtal - Neka samtal Byt till den främre kameran Byt till den bakre kameran Deltagaren tystades ner diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 682135e80d..2b34d6d646 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -140,7 +140,6 @@ Kendi kendini silen mesajlar için zamanlayıcı ayarlama Aramayı kapat Aramayı kabul et - Aramayı reddet Ön kameraya geç Arka kameraya geç Katılımcı sessiz diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c81cee56db..506a4495b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,7 +147,6 @@ Set timer for self-deleting messages Hang up call Accept call - Decline call Flip to front camera Flip to back camera Participant muted @@ -927,9 +926,6 @@ Reply You Type a message - Open Call - Decline - Hang Up Calling… Ongoing call… %s calling... @@ -947,7 +943,6 @@ Join Hang up call Accept call - Decline call Ringing… Calling… is calling… diff --git a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt index c98e8ca99a..fc105378ac 100644 --- a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt @@ -65,31 +65,6 @@ class CallNotificationManagerTest { verify(exactly = 1) { arrangement.notificationManager.cancel(tag, id) } } - @Test - fun `given incoming call, when call notification needs to be reloaded, then show that notification again`() = - runTest(dispatcherProvider.main()) { - // given - val notification = mockk() - val userName = "user name" - val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) - val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) - val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) - val reloadCallIds = CallNotificationIds(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) - val (arrangement, callNotificationManager) = Arrangement() - .withIncomingNotificationForUserAndCall(notification, callNotificationData) - .arrange() - callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) - arrangement.withActiveNotifications(listOf(mockStatusBarNotification(id, tag))) - advanceUntilIdle() - arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call - // when - callNotificationManager.reloadCallNotifications(reloadCallIds) - advanceUntilIdle() - // then - verify(exactly = 1) { arrangement.notificationManager.notify(tag, id, notification) } // should be shown again - verify(exactly = 0) { arrangement.notificationManager.cancel(tag, id) } - } - @Test fun `given an incoming call for one user, then show notification for that call`() = runTest(dispatcherProvider.main()) { From 6f753e9359d9a5b44853e766d4bed9a68a0f7a06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:41:15 +0100 Subject: [PATCH 04/17] chore(deps): [WPB-9777] bump androidx.activity:activity-compose from 1.9.2 to 1.9.3 (#3636) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b56824912e..f18f8253b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ compose-qr = "1.0.1" # Compose composeBom = "2024.10.00" -compose-activity = "1.9.2" +compose-activity = "1.9.3" compose-compiler = "1.5.13" compose-constraint = "1.0.1" compose-navigation = "2.7.7" # adjusted to work with compose-destinations "1.9.54" From c72114e74a63b9e5daded15d0e2451f10ea4535b Mon Sep 17 00:00:00 2001 From: Klejvi Kapaj <40796367+kl3jvi@users.noreply.github.com> Date: Mon, 18 Nov 2024 17:09:52 +0100 Subject: [PATCH 05/17] refactor: AudioMediaRecorder to improve readability and resource management (#3613) Co-authored-by: Mohamad Jaara --- .../recordaudio/AudioMediaRecorder.kt | 329 +++++++++--------- 1 file changed, 158 insertions(+), 171 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt index 292b63b77c..71e2a9fc5c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt @@ -48,6 +48,7 @@ import okio.buffer import java.io.File import java.io.FileInputStream import java.io.IOException +import java.io.RandomAccessFile import java.nio.ByteBuffer import java.nio.ByteOrder import javax.inject.Inject @@ -121,19 +122,21 @@ class AudioMediaRecorder @Inject constructor( val blockAlign = channels * (bitsPerSample / BITS_PER_BYTE) // We use buffer() to correctly write the string values. - bufferedSink.writeUtf8(CHUNK_ID_RIFF) // Chunk ID - bufferedSink.writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Chunk Size (will be updated later) - bufferedSink.writeUtf8(FORMAT_WAVE) // Format - bufferedSink.writeUtf8(SUBCHUNK1_ID_FMT) // Subchunk1 ID - bufferedSink.writeIntLe(SUBCHUNK1_SIZE_PCM) // Subchunk1 Size (PCM) - bufferedSink.writeShortLe(AUDIO_FORMAT_PCM) // Audio Format (PCM) - bufferedSink.writeShortLe(channels) // Number of Channels - bufferedSink.writeIntLe(sampleRate) // Sample Rate - bufferedSink.writeIntLe(byteRate) // Byte Rate - bufferedSink.writeShortLe(blockAlign) // Block Align - bufferedSink.writeShortLe(bitsPerSample) // Bits Per Sample - bufferedSink.writeUtf8(SUBCHUNK2_ID_DATA) // Subchunk2 ID - bufferedSink.writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Subchunk2 Size (will be updated later) + with(bufferedSink) { + writeUtf8(CHUNK_ID_RIFF) // Chunk ID + writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Chunk Size (will be updated later) + writeUtf8(FORMAT_WAVE) // Format + writeUtf8(SUBCHUNK1_ID_FMT) // Subchunk1 ID + writeIntLe(SUBCHUNK1_SIZE_PCM) // Subchunk1 Size (PCM) + writeShortLe(AUDIO_FORMAT_PCM) // Audio Format (PCM) + writeShortLe(channels) // Number of Channels + writeIntLe(sampleRate) // Sample Rate + writeIntLe(byteRate) // Byte Rate + writeShortLe(blockAlign) // Block Align + writeShortLe(bitsPerSample) // Bits Per Sample + writeUtf8(SUBCHUNK2_ID_DATA) // Subchunk2 ID + writeIntLe(PLACEHOLDER_SIZE) // Placeholder for Subchunk2 Size (will be updated later) + } } private fun updateWavHeader(filePath: Path) { @@ -149,17 +152,14 @@ class AudioMediaRecorder @Inject constructor( dataSizeBuffer.order(ByteOrder.LITTLE_ENDIAN) dataSizeBuffer.putInt(dataSize) - val randomAccessFile = java.io.RandomAccessFile(file, "rw") - - // Update Chunk Size - randomAccessFile.seek(CHUNK_SIZE_OFFSET.toLong()) - randomAccessFile.write(chunkSizeBuffer.array()) - - // Update Subchunk2 Size - randomAccessFile.seek(SUBCHUNK2_SIZE_OFFSET.toLong()) - randomAccessFile.write(dataSizeBuffer.array()) - - randomAccessFile.close() + RandomAccessFile(file, "rw").use { randomAccessFile -> + // Update Chunk Size + randomAccessFile.seek(CHUNK_SIZE_OFFSET.toLong()) + randomAccessFile.write(chunkSizeBuffer.array()) + // Update Subchunk2 Size + randomAccessFile.seek(SUBCHUNK2_SIZE_OFFSET.toLong()) + randomAccessFile.write(dataSizeBuffer.array()) + } appLogger.i("Updated WAV Header: Chunk Size = ${fileSize - CHUNK_ID_SIZE}, Data Size = $dataSize") } @@ -175,48 +175,41 @@ class AudioMediaRecorder @Inject constructor( audioRecorder = null } + @Suppress("NestedBlockDepth") private fun writeAudioDataToFile() { val data = ByteArray(BUFFER_SIZE) - var sink: okio.Sink? = null try { - sink = kaliumFileSystem.sink(originalOutputPath!!) - val bufferedSink = sink.buffer() - - // Write WAV header - writeWavHeader(bufferedSink, SAMPLING_RATE, AUDIO_CHANNELS, BITS_PER_SAMPLE) - - while (isRecording) { - val read = audioRecorder?.read(data, 0, BUFFER_SIZE) ?: 0 - if (read > 0) { - bufferedSink.write(data, 0, read) - } + kaliumFileSystem.sink(originalOutputPath!!).use { sink -> + sink.buffer() + .use { + writeWavHeader(it, SAMPLING_RATE, AUDIO_CHANNELS, BITS_PER_SAMPLE) + while (isRecording) { + val read = audioRecorder?.read(data, 0, BUFFER_SIZE) ?: 0 + if (read > 0) { + it.write(data, 0, read) + } - // Check if the file size exceeds the limit - val currentSize = originalOutputPath!!.toFile().length() - if (currentSize > (assetLimitInMB * SIZE_OF_1MB)) { - isRecording = false - scope.launch { - _maxFileSizeReached.emit( - RecordAudioDialogState.MaxFileSizeReached( - maxSize = assetLimitInMB / SIZE_OF_1MB - ) - ) + // Check if the file size exceeds the limit + val currentSize = originalOutputPath!!.toFile().length() + if (currentSize > (assetLimitInMB * SIZE_OF_1MB)) { + isRecording = false + scope.launch { + _maxFileSizeReached.emit( + RecordAudioDialogState.MaxFileSizeReached( + maxSize = assetLimitInMB / SIZE_OF_1MB + ) + ) + } + break + } + } + updateWavHeader(originalOutputPath!!) } - break - } } - - // Close buffer to ensure all data is written - bufferedSink.close() - - // Update WAV header with final file size - updateWavHeader(originalOutputPath!!) } catch (e: IOException) { e.printStackTrace() appLogger.e("[RecordAudio] writeAudioDataToFile: IOException - ${e.message}") - } finally { - sink?.close() } } @@ -224,145 +217,139 @@ class AudioMediaRecorder @Inject constructor( suspend fun convertWavToMp4(inputFilePath: String): Boolean = withContext(Dispatchers.IO) { var codec: MediaCodec? = null var muxer: MediaMuxer? = null - var fileInputStream: FileInputStream? = null - var parcelFileDescriptor: ParcelFileDescriptor? = null var success = true try { - val inputFile = File(inputFilePath) - fileInputStream = FileInputStream(inputFile) - - val outputFile = mp4OutputPath?.toFile() - parcelFileDescriptor = ParcelFileDescriptor.open( - outputFile, - ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE - ) - - val mediaExtractor = MediaExtractor() - mediaExtractor.setDataSource(inputFilePath) - - val format = MediaFormat.createAudioFormat( - MediaFormat.MIMETYPE_AUDIO_AAC, - SAMPLING_RATE, - AUDIO_CHANNELS - ) - format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) - format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) - - codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) - codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - codec.start() - - val bufferInfo = MediaCodec.BufferInfo() - muxer = MediaMuxer(parcelFileDescriptor.fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - var trackIndex = -1 - var sawInputEOS = false - var sawOutputEOS = false - - var retryCount = 0 - var presentationTimeUs = 0L - val bytesPerSample = (BITS_PER_SAMPLE / BITS_PER_BYTE) * AUDIO_CHANNELS - - while (!sawOutputEOS && retryCount < MAX_RETRY_COUNT) { - if (!sawInputEOS) { - val inputBufferIndex = codec.dequeueInputBuffer(TIMEOUT_US) - if (inputBufferIndex >= 0) { - val inputBuffer = codec.getInputBuffer(inputBufferIndex) - inputBuffer?.clear() - - val sampleSize = fileInputStream.channel.read(inputBuffer!!) - if (sampleSize < 0) { - codec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - sawInputEOS = true - } else { - val numSamples = sampleSize / bytesPerSample - val bufferDurationUs = (numSamples * MICROSECONDS_PER_SECOND) / SAMPLING_RATE - codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0) - - presentationTimeUs += bufferDurationUs - } - } - } - - val outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) - - when { - outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - val newFormat = codec.outputFormat - trackIndex = muxer.addTrack(newFormat) - muxer.start() - retryCount = 0 - } - - outputBufferIndex >= 0 -> { - val outputBuffer = codec.getOutputBuffer(outputBufferIndex) - - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { - bufferInfo.size = 0 - } - - if (bufferInfo.size != 0 && outputBuffer != null) { - outputBuffer.position(bufferInfo.offset) - outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + FileInputStream(File(inputFilePath)).use { fileInputStream -> + mp4OutputPath?.toFile()?.let { outputFile -> + ParcelFileDescriptor.open( + outputFile, + ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE + ).use { parcelFileDescriptor -> + + val mediaExtractor = MediaExtractor() + mediaExtractor.setDataSource(inputFilePath) + + val format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_AAC, + SAMPLING_RATE, + AUDIO_CHANNELS + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) + format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + + codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) + val mediaCodec = codec!! + mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + mediaCodec.start() + + val bufferInfo = MediaCodec.BufferInfo() + muxer = MediaMuxer(parcelFileDescriptor.fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + val mediaMuxer = muxer!! + + var trackIndex = -1 + var sawInputEOS = false + var sawOutputEOS = false + + var retryCount = 0 + var presentationTimeUs = 0L + val bytesPerSample = (BITS_PER_SAMPLE / BITS_PER_BYTE) * AUDIO_CHANNELS + + while (!sawOutputEOS && retryCount < MAX_RETRY_COUNT) { + if (!sawInputEOS) { + val inputBufferIndex = mediaCodec.dequeueInputBuffer(TIMEOUT_US) + if (inputBufferIndex >= 0) { + val inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex) + inputBuffer?.clear() + + val sampleSize = fileInputStream.channel.read(inputBuffer!!) + if (sampleSize < 0) { + mediaCodec.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + sawInputEOS = true + } else { + val numSamples = sampleSize / bytesPerSample + val bufferDurationUs = (numSamples * MICROSECONDS_PER_SECOND) / SAMPLING_RATE + mediaCodec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTimeUs, 0) + + presentationTimeUs += bufferDurationUs + } + } + } - if (trackIndex >= 0) { - muxer.writeSampleData(trackIndex, outputBuffer, bufferInfo) - } else { - appLogger.e("Track index is not set. Skipping writeSampleData.") + val outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + + when { + outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + val newFormat = mediaCodec.outputFormat + trackIndex = mediaMuxer.addTrack(newFormat) + mediaMuxer.start() + retryCount = 0 + } + + outputBufferIndex >= 0 -> { + val outputBuffer = mediaCodec.getOutputBuffer(outputBufferIndex) + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + bufferInfo.size = 0 + } + + if (bufferInfo.size != 0 && outputBuffer != null) { + outputBuffer.position(bufferInfo.offset) + outputBuffer.limit(bufferInfo.offset + bufferInfo.size) + + if (trackIndex >= 0) { + mediaMuxer.writeSampleData(trackIndex, outputBuffer, bufferInfo) + } else { + appLogger.e("Track index is not set. Skipping writeSampleData.") + } + } + + mediaCodec.releaseOutputBuffer(outputBufferIndex, false) + retryCount = 0 + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + sawOutputEOS = true + } + } + + outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + retryCount++ + delay(RETRY_DELAY_IN_MILLIS) + } } } - - codec.releaseOutputBuffer(outputBufferIndex, false) - retryCount = 0 - - if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - sawOutputEOS = true + if (retryCount >= MAX_RETRY_COUNT) { + appLogger.e("Reached maximum retries without receiving output from codec.") + success = false } } - - outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { - retryCount++ - delay(RETRY_DELAY_IN_MILLIS) - } + } ?: run { + appLogger.e("[RecordAudio] convertWavToMp4: mp4OutputPath is null") + success = false } } - if (retryCount >= MAX_RETRY_COUNT) { - appLogger.e("Reached maximum retries without receiving output from codec.") - success = false - } } catch (e: Exception) { appLogger.e("Could not convert wav to mp4: ${e.message}", throwable = e) - success = false } finally { try { - fileInputStream?.close() - } catch (e: Exception) { - appLogger.e("Could not close FileInputStream: ${e.message}", throwable = e) - success = false - } - - try { - muxer?.stop() - muxer?.release() + muxer?.let { safeMuxer -> + safeMuxer.stop() + safeMuxer.release() + } } catch (e: Exception) { appLogger.e("Could not stop or release MediaMuxer: ${e.message}", throwable = e) success = false } try { - codec?.stop() - codec?.release() + codec?.let { safeCodec -> + safeCodec.stop() + safeCodec.release() + } } catch (e: Exception) { appLogger.e("Could not stop or release MediaCodec: ${e.message}", throwable = e) success = false } - - try { - parcelFileDescriptor?.close() - } catch (e: Exception) { - appLogger.e("Could not close ParcelFileDescriptor: ${e.message}", throwable = e) - success = false - } } success } From 85cd574ae39d3a26a995935a9f01eb7cd66053c4 Mon Sep 17 00:00:00 2001 From: AndroidBob Date: Mon, 18 Nov 2024 19:04:59 +0100 Subject: [PATCH 06/17] chore(l10n): update localization strings via Crowdin (WPB-9776) (#3631) Co-authored-by: yamilmedina --- app/src/main/res/values-de/strings.xml | 10 ++++++++++ app/src/main/res/values-ru/strings.xml | 2 ++ 2 files changed, 12 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index af3f5924d7..b2c39f82ff 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -100,6 +100,7 @@ Audioanruf starten Wire Jemanden erwähnen + Zurück zur Unterhaltungsliste Unterhaltungsdetails öffnen Nach Personen suchen oder eine neue Gruppe erstellen Senden @@ -117,6 +118,7 @@ Nachricht bearbeiten Weitere Optionen Kontakt hinzufügen + Ping Timer für selbstlöschende Nachrichten festlegen Auflegen Anruf annehmen @@ -142,6 +144,7 @@ Teilnehmer Unterhaltungsdetails schließen Gästezugang anpassen + Timer anpassen Unterhaltungsoptionen öffnen Zurück zu Unterhaltungsdetails Rolle bearbeiten @@ -157,6 +160,7 @@ Öffnen Anruf annehmen Teilen + ausklappen bearbeiten auswählen ausgewählt @@ -167,7 +171,9 @@ Unterhaltung öffnen Link öffnen Benachrichtigungseinstellungen öffnen + Zurück zur Ansicht neue Unterhaltung Unterhaltungsoptionen + Zurück zur neuen Gruppenerstellung Ausstehende Genehmigung der Kontaktanfrage Ihr Profil schließen Ihr Profil @@ -563,6 +569,8 @@ Gruppe verlassen Gruppe löschen + Gruppen + 1:1 Unterhaltungen Alles Sie erhalten alle Benachrichtigungen für diese Unterhaltung, einschließlich Audio- und Videoanrufe @@ -888,6 +896,8 @@ Ignorieren Verschaffen Sie sich Gewissheit über die Identität von %s, bevor Sie den Kontakt hinzufügen. Bitte überprüfen Sie die Identität der Person, bevor Sie die Kontaktanfrage annehmen. + Wire kann diese Person nicht finden + Entweder fehlt die Berechtigung für dieses Benutzerkonto oder die Person nutzt Wire nicht. Unterhaltung kann nicht beginnen Sie können die Unterhaltung mit %1$s im Moment nicht beginnen. %1$s muss Wire zuerst öffnen oder sich neu anmelden. Bitte versuchen Sie es später noch einmal. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1979030d7..2474c1e892 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -992,6 +992,8 @@ Игнорировать Перед добавлением убедитесь в личности %s. Прежде чем принять запрос на добавление, верифицируйте личность собеседника. + Wire не может найти этого человека + Возможно, у вас нет разрешения на использование этой учетной записи, либо этот человек отсутствует в Wire. Невозможно начать беседу Вы не можете начать беседу с %1$s прямо сейчас. %1$s следует сначала открыть Wire или авторизоваться. Пожалуйста, повторите попытку позже. From b932c427cd532cc80533cdb06d68c952993e2edb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:33:32 +0000 Subject: [PATCH 07/17] =?UTF-8?q?chore:=20enable=20conversation=20list=20p?= =?UTF-8?q?agination=20for=20internal=20app=20[WPB-14295]=20=F0=9F=8D=92?= =?UTF-8?q?=20(#3628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mohamad Jaara Co-authored-by: Yamil Medina --- .github/workflows/build-edge-env.yml | 3 ++- default.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-edge-env.yml b/.github/workflows/build-edge-env.yml index 6d1de71747..3b09132143 100644 --- a/.github/workflows/build-edge-env.yml +++ b/.github/workflows/build-edge-env.yml @@ -95,4 +95,5 @@ jobs: serviceAccountJson: service_account.json packageName: com.wire.internal releaseFiles: app/build/outputs/bundle/internalCompat/*.aab - track: alpha + track: production + status: completed diff --git a/default.json b/default.json index a1a9eb220c..bdd844d43a 100644 --- a/default.json +++ b/default.json @@ -78,7 +78,8 @@ "analytics_enabled": true, "picture_in_picture_enabled": true, "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", - "analytics_server_url": "https://countly.wire.com/" + "analytics_server_url": "https://countly.wire.com/", + "paginated_conversation_list_enabled": true }, "fdroid": { "application_id": "com.wire", From 6954aa6b32a04b925f5f93d2435f1fe367663d64 Mon Sep 17 00:00:00 2001 From: Yamil Medina Date: Mon, 18 Nov 2024 15:38:18 -0300 Subject: [PATCH 08/17] docs: add contributing file and readme link (WPB-12192) (#3629) Co-authored-by: Oussama Hassine --- CONTRIBUTING.md | 41 +++++++++++++++++++++++++++++++++++++++++ README.md | 14 ++++---------- 2 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..18bba2730f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +## I want to contribute to Wire + +You can contribute to Wire in several ways: + +## Finding bugs + +If you find a bug in how Wire apps work, please submit a ticket to [our support](https://support.wire.com) and we will keep you informed about the progress. +Alternatively, you can submit your finding on [wire issues](https://github.com/wireapp/wire/issues). Please make sure to provide as much information as possible to help us reproduce the issue. + +## Contributing to the code + +If you wish to contribute source code to one of our repositories you have to sign +our [Contributor Agreement](https://github.com/wireapp/wire/raw/master/assets/Wire%20Contributor%20Agreement.pdf) +first. + +When you submit your first pull request, you can sign the agreement electronically by filling in the +required information. You will not have to sign it again for subsequent pull requests from the same +GitHub account. + +When opening a pull request, please make sure to follow the next guidelines: + +- Make sure to fill in the pull request template to the fullest extent possible, this will help us + understand faster the changes proposed. +- Make sure to run the tests and linters before submitting the pull request. +- Add the necessary tests for the changes you are proposing, this will help us ensure that the + changes are working as expected. + +> [!NOTE] +> We accept only bug fixes and code improvements. We cannot accept new features, UI or UX changes – these are decided by Wire and built by the Wire development team. + +## I want to help translate Wire + +If you want to help Wire to speak more languages, please refer to our [site](https://support.wire.com/hc/en-us/articles/202856874-Language-support), to see the official list of supported languages and those who are open to contribute. + +To do so, you will find instructions there, but you can do the following: + +1. Create a [Crowdin account](https://crowdin.com/). +2. Request access to add translations in our [project](https://crowdin.com/project/wire-android-reloaded). +3. Translate away. diff --git a/README.md b/README.md index 6bf98626d1..503d525dfe 100644 --- a/README.md +++ b/README.md @@ -61,16 +61,6 @@ It might be that after cloning the Android project, some build issues appear on - There is a valid SDK path on your `local.properties` AND `kalium/local.properties` files pointing to the Android SDK folder. In Mac, that folder can be usually found under `sdk.dir=/Users/YOUR_USER_FOLDER/Library/Android/sdk`. The IDE **will not** create `kalium/local.properties` automatically, so you might want to copy/paste the one in the project root - When you've already started working on the project adding some commits, it might occur that your local build breaks, if that is the case, make sure you've updated the `kalium` submodule reference by running: `git submodule update --remote --merge` -## Contributing - -If you want to help Wire to speak more languages, please refer to our [site](https://support.wire.com/hc/en-us/articles/202856874-Language-support), to see the official list of supported languages and those who are open to contribute. - -To do so, you will find instructions there, but you can do the following: - -1. Create a [Crowdin account](https://crowdin.com/). -2. Request access to add translations in our [project](https://crowdin.com/project/wire-android-reloaded). -3. Translate away. - # App flavours We have a few different app flavours with different intended usages. Each app flavour has a different icon background colour to enable easier distinction. @@ -99,3 +89,7 @@ To see how they are customised in details, check [the flavour configuration file ## Build Types The apps can be built for release or debugging. Debug versions might have extra debugging tools, are not minified, and can be profiled if needed. In general, debug builds _run slower_ due to the lack of minimisation. + +## Contributing + +If you want to contribute to Wire for Android, please refer to the [CONTRIBUTING.md](./CONTRIBUTING.md) file for more information. From 4348c491448a94cabe2c269e4be901e0d8d958f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 10:21:17 +0100 Subject: [PATCH 09/17] chore(deps): [WPB-9777] bump JamesIves/github-pages-deploy-action from 4.6.8 to 4.6.9 (#3646) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-adr-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-adr-docs.yml b/.github/workflows/deploy-adr-docs.yml index c854eda6b6..f5c1bd2d14 100644 --- a/.github/workflows/deploy-adr-docs.yml +++ b/.github/workflows/deploy-adr-docs.yml @@ -33,7 +33,7 @@ jobs: - name: Deploy docs 🚀 if: github.event_name == 'push' && github.ref_name == 'develop' - uses: JamesIves/github-pages-deploy-action@v4.6.8 + uses: JamesIves/github-pages-deploy-action@v4.6.9 with: branch: gh-pages clean: false From c1fc0034898f4ff611d3ecded1d58771e78a5dad Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Tue, 19 Nov 2024 13:42:05 +0100 Subject: [PATCH 10/17] chore: kalium ref --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index d140d791a3..e617c90fb7 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d140d791a308632e568b0ee3f8685644bad90424 +Subproject commit e617c90fb7cd79e554946aa865e46a9ed9a78b67 From 9d6d30d3cec8bdf86a9d32ce2bd6158db9e4a386 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Wed, 20 Nov 2024 10:10:44 +0100 Subject: [PATCH 11/17] feat: enable countly crash reporting [WPB-12186] (#3641) --- .../AnonymousAnalyticsRecorderImpl.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 78ef8edb4e..46e9d2ce23 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -40,12 +40,18 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { context, analyticsSettings.countlyAppKey, analyticsSettings.countlyServerUrl - ) - .enableTemporaryDeviceIdMode() // Nothing is sent until a proper ID is placed - .setLoggingEnabled(analyticsSettings.enableDebugLogging) - countlyConfig.apm.enableAppStartTimeTracking() - countlyConfig.apm.enableForegroundBackgroundTracking() - countlyConfig.setApplication(context.applicationContext as Application) + ).apply { + setApplication(context.applicationContext as Application) + enableTemporaryDeviceIdMode() // Nothing is sent until a proper ID is placed + setLoggingEnabled(analyticsSettings.enableDebugLogging) + crashes.apply { + enableCrashReporting() + } + apm.apply { + enableAppStartTimeTracking() + enableForegroundBackgroundTracking() + } + } Countly.sharedInstance().init(countlyConfig) Countly.sharedInstance().consent().giveConsent(arrayOf("apm")) From d40175ef8645046ed661a1fb604fe48bcc7aea50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Wed, 20 Nov 2024 10:13:30 +0100 Subject: [PATCH 12/17] fix: hide LH indicators on conversations when self user is under LH [WPB-6391] (#3637) --- .../wire/android/mapper/ConversationMapper.kt | 4 +- .../com/wire/android/mapper/MessageMapper.kt | 2 +- .../conversation/ConversationSheetState.kt | 6 +- .../messages/item/RegularMessageItem.kt | 2 +- .../ui/home/conversations/mock/Mock.kt | 22 +- .../ui/home/conversations/model/UIMessage.kt | 2 +- .../ConversationListViewModel.kt | 40 +++- .../common/ConversationItemFactory.kt | 2 +- .../common/ConversationTitle.kt | 6 +- .../conversationslist/common/UserLabel.kt | 4 +- .../model/ConversationItem.kt | 12 +- .../com/wire/android/framework/TestMessage.kt | 2 +- .../MessageComposerViewModelArrangement.kt | 2 +- .../ConversationListViewModelTest.kt | 210 ++++++++++++------ 14 files changed, 212 insertions(+), 104 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index fb7ca3929a..951ca60cee 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -49,7 +49,7 @@ fun ConversationDetailsWithEvents.toConversationItem( groupName = conversationDetails.conversation.name.orEmpty(), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - isLegalHold = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), lastMessageContent = lastMessage.toUIPreview(unreadEventCount), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, @@ -83,7 +83,7 @@ fun ConversationDetailsWithEvents.toConversationItem( ), conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, - isLegalHold = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), + showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), lastMessageContent = lastMessage.toUIPreview(unreadEventCount), badgeEventType = parsePrivateConversationEventType( conversationDetails.otherUser.connectionStatus, 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 24bb43d7d2..cb0680e6e3 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -148,7 +148,7 @@ class MessageMapper @Inject constructor( is SelfUser, null -> Membership.None }, connectionState = getConnectionState(sender), - isLegalHold = sender?.isUnderLegalHold == true, + showLegalHoldIndicator = sender?.isUnderLegalHold == true, messageTime = MessageTime(message.date), messageStatus = getMessageStatus(message), messageId = message.id, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt index 0e48a9ac9a..0279e37367 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt @@ -78,7 +78,7 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator ) } } @@ -102,7 +102,7 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator ) } } @@ -122,7 +122,7 @@ fun rememberConversationSheetState( protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isUnderLegalHold = isLegalHold + isUnderLegalHold = showLegalHoldIndicator ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index 42bbe66926..98a29bffee 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -513,7 +513,7 @@ private fun MessageAuthorRow(messageHeader: MessageHeader) { startPadding = dimensions().spacing6x, isDeleted = isSenderDeleted ) - if (isLegalHold) { + if (showLegalHoldIndicator) { LegalHoldIndicator(modifier = Modifier.padding(start = dimensions().spacing6x)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt index 7b0461a57b..b995681aed 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/mock/Mock.kt @@ -65,7 +65,7 @@ val mockMessageTime = MessageTime(Instant.fromEpochSeconds(MOCK_TIME_IN_SECONDS) val mockHeader = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -307,7 +307,7 @@ fun mockAssetMessage(assetId: String = "asset1", messageId: String = "msg1") = U header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -337,7 +337,7 @@ fun mockAssetAudioMessage(assetId: String = "asset1", messageId: String = "msg1" header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -396,7 +396,7 @@ fun mockedImageUIMessage( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = messageStatus, messageId = messageId, @@ -417,7 +417,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -447,7 +447,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Delivered, isDeleted = true, @@ -468,7 +468,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -490,7 +490,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -512,7 +512,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Delivered, @@ -543,7 +543,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, @@ -565,7 +565,7 @@ fun getMockedMessages(): List = listOf( header = MessageHeader( username = UIText.DynamicString("John Doe"), membership = Membership.External, - isLegalHold = false, + showLegalHoldIndicator = false, messageTime = mockMessageTime, messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index c3f70e439d..bd507cdf38 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -127,7 +127,7 @@ sealed interface UIMessage { data class MessageHeader( val username: UIText, val membership: Membership, - val isLegalHold: Boolean, + val showLegalHoldIndicator: Boolean, val messageTime: MessageTime, val messageStatus: MessageStatus, val messageId: String, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index cbea3a7fcd..2b5c4b6674 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.insertSeparators +import androidx.paging.map import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.di.CurrentAccount @@ -64,6 +65,8 @@ import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMet import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser +import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase import com.wire.kalium.logic.feature.team.Result @@ -78,6 +81,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -121,6 +125,7 @@ class ConversationListViewModelPreview( @HiltViewModel(assistedFactory = ConversationListViewModelImpl.Factory::class) class ConversationListViewModelImpl @AssistedInject constructor( @Assisted val conversationsSource: ConversationsSource, + @Assisted private val usePagination: Boolean = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, dispatcher: DispatcherProvider, private val updateConversationMutedStatus: UpdateConversationMutedStatusUseCase, private val getConversationsPaginated: GetConversationsFromSearchUseCase, @@ -133,6 +138,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase, private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, + private val observeLegalHoldStateForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, @CurrentAccount val currentAccount: UserId, private val wireSessionImageLoader: WireSessionImageLoader, private val userTypeMapper: UserTypeMapper, @@ -140,7 +146,10 @@ class ConversationListViewModelImpl @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(conversationsSource: ConversationsSource): ConversationListViewModelImpl + fun create( + conversationsSource: ConversationsSource, + usePagination: Boolean = BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED, + ): ConversationListViewModelImpl } private val _infoMessage = MutableSharedFlow() @@ -173,7 +182,11 @@ class ConversationListViewModelImpl @AssistedInject constructor( conversationFilter = conversationsSource.toFilter(), onlyInteractionEnabled = false, newActivitiesOnTop = containsNewActivitiesSection, - ).map { + ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + conversations.map { + it.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) + } + }.map { it.insertSeparators { before, after -> when { // do not add separators if the list shouldn't show conversations grouped into different folders @@ -200,7 +213,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) override val conversationListState: ConversationListState - get() = if (BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) { + get() = if (usePagination) { ConversationListState.Paginated( conversations = conversationsPaginatedFlow, domain = currentAccount.domain @@ -210,7 +223,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( } init { - if (!BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) { + if (!usePagination) { viewModelScope.launch { searchQueryFlow .debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE } @@ -220,13 +233,13 @@ class ConversationListViewModelImpl @AssistedInject constructor( observeConversationListDetailsWithEvents( fromArchive = conversationsSource == ConversationsSource.ARCHIVE, conversationFilter = conversationsSource.toFilter() - ).map { - it.map { conversationDetails -> + ).combine(observeLegalHoldStateForSelfUser()) { conversations, selfUserLegalHoldStatus -> + conversations.map { conversationDetails -> conversationDetails.toConversationItem( wireSessionImageLoader = wireSessionImageLoader, userTypeMapper = userTypeMapper, searchQuery = searchQuery, - ) + ).hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus) } to searchQuery } } @@ -438,6 +451,19 @@ private fun ConversationsSource.toFilter(): ConversationFilter = when (this) { ConversationsSource.ONE_ON_ONE -> ConversationFilter.ONE_ON_ONE } +private fun ConversationItem.hideIndicatorForSelfUserUnderLegalHold(selfUserLegalHoldStatus: LegalHoldStateForSelfUser) = + // if self user is under legal hold then we shouldn't show legal hold indicator next to every conversation + // the indication is shown in the header of the conversation list for self user in that case and it's enough + when (selfUserLegalHoldStatus) { + is LegalHoldStateForSelfUser.Enabled -> when (this) { + is ConversationItem.ConnectionConversation -> this.copy(showLegalHoldIndicator = false) + is ConversationItem.GroupConversation -> this.copy(showLegalHoldIndicator = false) + is ConversationItem.PrivateConversation -> this.copy(showLegalHoldIndicator = false) + } + + else -> this + } + @Suppress("ComplexMethod") private fun List.withFolders(source: ConversationsSource): Map> { return when (source) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 44ac89e3f7..9a8f216b91 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -159,7 +159,7 @@ private fun GeneralConversationItem( title = { ConversationTitle( name = groupName.ifEmpty { stringResource(id = R.string.member_name_deleted_label) }, - isLegalHold = conversation.isLegalHold, + showLegalHoldIndicator = conversation.showLegalHoldIndicator, searchQuery = searchQuery ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt index 0382e16960..1bbf736286 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationTitle.kt @@ -38,7 +38,7 @@ fun ConversationTitle( name: String, searchQuery: String, modifier: Modifier = Modifier, - isLegalHold: Boolean = false, + showLegalHoldIndicator: Boolean = false, badges: @Composable () -> Unit = {} ) { Row( @@ -57,7 +57,7 @@ fun ConversationTitle( HighlightName(name = name, searchQuery = searchQuery) } badges() - if (isLegalHold) { + if (showLegalHoldIndicator) { Spacer(modifier = Modifier.width(6.dp)) LegalHoldIndicator() } @@ -67,5 +67,5 @@ fun ConversationTitle( @Preview(widthDp = 200) @Composable fun PreviewConversationTitle() { - ConversationTitle("very very loooooooooooong name", searchQuery = "test", isLegalHold = true) + ConversationTitle("very very loooooooooooong name", searchQuery = "test", showLegalHoldIndicator = true) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt index 0daf7203d9..c7ec40a8d7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt @@ -39,7 +39,7 @@ fun UserLabel( with(userInfoLabel) { ConversationTitle( name = if (unavailable) stringResource(id = R.string.username_unavailable_label) else labelName, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, modifier = modifier, badges = { if (membership.hasLabel()) { @@ -54,7 +54,7 @@ fun UserLabel( data class UserInfoLabel( val labelName: String, - val isLegalHold: Boolean, + val showLegalHoldIndicator: Boolean, val membership: Membership, val unavailable: Boolean = false, val proteusVerificationStatus: Conversation.VerificationStatus? = null, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index 467805827d..dbc5f36305 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -33,7 +33,7 @@ import com.wire.kalium.logic.data.user.type.isTeammate sealed class ConversationItem : ConversationFolderItem { abstract val conversationId: ConversationId abstract val mutedStatus: MutedConversationStatus - abstract val isLegalHold: Boolean + abstract val showLegalHoldIndicator: Boolean abstract val lastMessageContent: UILastMessageContent? abstract val badgeEventType: BadgeEventType abstract val teamId: TeamId? @@ -53,7 +53,7 @@ sealed class ConversationItem : ConversationFolderItem { val isSelfUserMember: Boolean = true, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, @@ -71,7 +71,7 @@ sealed class ConversationItem : ConversationFolderItem { val blockingState: BlockingState, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, @@ -87,7 +87,7 @@ sealed class ConversationItem : ConversationFolderItem { val conversationInfo: ConversationInfo, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, - override val isLegalHold: Boolean = false, + override val showLegalHoldIndicator: Boolean = false, override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val isArchived: Boolean = false, @@ -123,7 +123,7 @@ val OtherUser.BlockState: BlockingState fun ConversationItem.PrivateConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, membership = conversationInfo.membership, unavailable = conversationInfo.isSenderUnavailable, mlsVerificationStatus = mlsVerificationStatus, @@ -133,7 +133,7 @@ fun ConversationItem.PrivateConversation.toUserInfoLabel() = fun ConversationItem.ConnectionConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, - isLegalHold = isLegalHold, + showLegalHoldIndicator = showLegalHoldIndicator, membership = conversationInfo.membership, unavailable = conversationInfo.isSenderUnavailable ) diff --git a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt index f5dbab4b6e..17cac8553c 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt @@ -145,7 +145,7 @@ object TestMessage { val UI_MESSAGE_HEADER = MessageHeader( username = UIText.DynamicString("username"), membership = Membership.Guest, - isLegalHold = true, + showLegalHoldIndicator = true, messageTime = MessageTime(Instant.parse("2022-03-30T15:36:00.000Z")), messageStatus = MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index a0413084b0..4387a91ec3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -225,7 +225,7 @@ internal fun mockUITextMessage(id: String = "someId", userName: String = "mockUs every { it.header } returns mockk().also { every { it.messageId } returns id every { it.username } returns UIText.DynamicString(userName) - every { it.isLegalHold } returns false + every { it.showLegalHoldIndicator } returns false every { it.messageTime } returns MessageTime(Instant.DISTANT_PAST) every { it.messageStatus } returns MessageStatus( flowStatus = MessageFlowStatus.Sent, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index d9e1cdb253..5f6ea2533d 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -19,7 +19,11 @@ package com.wire.android.ui.home.conversationslist +import androidx.paging.LoadState +import androidx.paging.LoadStates import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -29,6 +33,7 @@ import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper import com.wire.android.ui.common.dialogs.BlockUserDialogState import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase +import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents @@ -47,6 +52,8 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetails import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser +import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.team.DeleteTeamConversationUseCase import io.mockk.MockKAnnotations @@ -55,7 +62,9 @@ import io.mockk.coVerify 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 import org.junit.jupiter.api.extension.ExtendWith @@ -66,64 +75,116 @@ class ConversationListViewModelTest { private val dispatcherProvider = TestDispatcherProvider() - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated("", false, true, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } - - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val searchQueryText = "search" -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// conversationListViewModel.searchQueryChanged(searchQueryText) -// advanceUntilIdle() -// -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated(searchQueryText, false, true, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } - - // TODO: reenable this test once pagination is implemented -// @Test -// fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = -// runTest(dispatcherProvider.main()) { -// // Given -// val searchQueryText = "search" -// val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() -// -// // When -// conversationListViewModel.conversationListState.foldersWithConversations.test { -// conversationListViewModel.searchQueryChanged(searchQueryText) -// advanceUntilIdle() -// -// // Then -// coVerify(exactly = 1) { -// arrangement.getConversationsPaginated(searchQueryText, true, false, false) -// } -// cancelAndIgnoreRemainingEvents() -// } -// } + @Test + fun `given initial empty search query, when collecting conversations, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated("", false, true, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given updated non-empty search query, when collecting conversations, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, false, true, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given updated non-empty search query, when collecting archived, then call use case with proper params`() = + runTest(dispatcherProvider.main()) { + // Given + val searchQueryText = "search" + val (arrangement, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.ARCHIVE).arrange() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.test { + conversationListViewModel.searchQueryChanged(searchQueryText) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { + arrangement.getConversationsPaginated(searchQueryText, true, false, false) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given self user is under legal hold, when collecting conversations, then hide LH indicators`() = + runTest(dispatcherProvider.main()) { + // Given + val conversations = listOf( + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), + ).associateBy { it.conversationId } + val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withConversationsPaginated(conversations.values.toList()) + .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Enabled) + .arrange() + advanceUntilIdle() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() + .filterIsInstance() + .forEach { + // Then + assertEquals(false, it.showLegalHoldIndicator) // self user is under LH so hide LH indicators next to conversations + } + } + + @Test + fun `given self user is not under legal hold, when collecting conversations, then show LH indicator when conversation is under LH`() = + runTest(dispatcherProvider.main()) { + // Given + val conversations = listOf( + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_1", ""), showLegalHoldIndicator = true), + TestConversationItem.CONNECTION.copy(conversationId = ConversationId("conn_2", ""), showLegalHoldIndicator = false), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_1", ""), showLegalHoldIndicator = true), + TestConversationItem.PRIVATE.copy(conversationId = ConversationId("private_2", ""), showLegalHoldIndicator = false), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_1", ""), showLegalHoldIndicator = true), + TestConversationItem.GROUP.copy(conversationId = ConversationId("group_2", ""), showLegalHoldIndicator = false), + ).associateBy { it.conversationId } + val (_, conversationListViewModel) = Arrangement(conversationsSource = ConversationsSource.MAIN) + .withConversationsPaginated(conversations.values.toList()) + .withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) + .arrange() + advanceUntilIdle() + + // When + (conversationListViewModel.conversationListState as ConversationListState.Paginated).conversations.asSnapshot() + .filterIsInstance() + .forEach { + // Then + val expected = conversations[it.conversationId]!!.showLegalHoldIndicator // show indicator when conversation is under LH + assertEquals(expected, it.showLegalHoldIndicator) + } + } @Test fun `given a valid conversation muting state, when calling muteConversation, then should call with call the UseCase`() = @@ -207,16 +268,16 @@ class ConversationListViewModelTest { private lateinit var observeConversationListDetailsWithEventsUseCase: ObserveConversationListDetailsWithEventsUseCase + @MockK + private lateinit var observeLegalHoldStateForSelfUserUseCase: ObserveLegalHoldStateForSelfUserUseCase + @MockK private lateinit var wireSessionImageLoader: WireSessionImageLoader init { MockKAnnotations.init(this, relaxUnitFun = true) - coEvery { - getConversationsPaginated.invoke(any(), any(), any(), any()) - } returns flowOf( - PagingData.from(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) - ) + withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) + withSelfUserLegalHoldState(LegalHoldStateForSelfUser.Disabled) coEvery { observeConversationListDetailsWithEventsUseCase.invoke(false, ConversationFilter.ALL) } returns flowOf( listOf( TestConversationDetails.CONNECTION, @@ -245,6 +306,25 @@ class ConversationListViewModelTest { coEvery { unblockUser(any()) } returns UnblockUserResult.Success } + fun withConversationsPaginated(items: List) = apply { + coEvery { + getConversationsPaginated.invoke(any(), any(), any(), any()) + } returns flowOf( + PagingData.from( + data = items, + sourceLoadStates = LoadStates( + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + refresh = LoadState.NotLoading(true), + ), + ) + ) + } + + fun withSelfUserLegalHoldState(LegalHoldStateForSelfUser: LegalHoldStateForSelfUser) = apply { + coEvery { observeLegalHoldStateForSelfUserUseCase() } returns flowOf(LegalHoldStateForSelfUser) + } + fun arrange() = this to ConversationListViewModelImpl( conversationsSource = conversationsSource, dispatcher = dispatcherProvider, @@ -260,8 +340,10 @@ class ConversationListViewModelTest { updateConversationArchivedStatus = updateConversationArchivedStatus, currentAccount = TestUser.SELF_USER_ID, observeConversationListDetailsWithEvents = observeConversationListDetailsWithEventsUseCase, + observeLegalHoldStateForSelfUser = observeLegalHoldStateForSelfUserUseCase, userTypeMapper = UserTypeMapper(), - wireSessionImageLoader = wireSessionImageLoader + wireSessionImageLoader = wireSessionImageLoader, + usePagination = true, ) } From 7ea737a9042dfe26d01df192a68b1dfbbaa9e6e3 Mon Sep 17 00:00:00 2001 From: Yamil Medina Date: Wed, 20 Nov 2024 06:16:27 -0300 Subject: [PATCH 13/17] fix: qr code link to user as text is incorrect, was wrongly cherrypicked (WPB-14416) (#3647) --- .../com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt index 880408327a..b4e763d20c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/qr/SelfQRCodeScreen.kt @@ -203,7 +203,7 @@ private fun SelfQRCodeContent( color = colorsScheme().secondaryText ) Spacer(modifier = Modifier.weight(1f)) - ShareLinkButton(state.userProfileLink, trackAnalyticsEvent) + ShareLinkButton(state.userAccountProfileLink, trackAnalyticsEvent) VerticalSpace.x8() ShareQRCodeButton { trackAnalyticsEvent(AnalyticsEvent.QrCode.Modal.ShareQrCode) From 4caa4ff7a89afe231e1c46ecbdea5266305ba58f Mon Sep 17 00:00:00 2001 From: Oussama Hassine Date: Wed, 20 Nov 2024 10:43:09 +0100 Subject: [PATCH 14/17] feat: Highlight mentions in TextInputs (WPB-1895) (#3642) --- .../ui/common/textfield/CodeTextField.kt | 2 +- .../common/textfield/CodeTextFieldLayout.kt | 1 + .../textfield/MentionDeletionHandler.kt | 50 ++++++++ .../textfield/MentionVisualTransformation.kt | 60 +++++++++ .../common/textfield/WirePasswordTextField.kt | 2 +- .../ui/common/textfield/WireTextField.kt | 119 +++++++++++++++++- .../common/textfield/WireTextFieldLayout.kt | 21 +++- .../home/conversations/ConversationScreen.kt | 2 +- .../messages/item/RegularMessageItem.kt | 1 - .../conversations/search/HighLightName.kt | 13 ++ .../messagecomposer/EnabledMessageComposer.kt | 16 ++- .../messagecomposer/MembersMentionList.kt | 35 ++++++ .../home/messagecomposer/MessageComposer.kt | 47 ++++--- .../messagecomposer/MessageComposerInput.kt | 36 ++++-- .../state/MessageComposerStateHolder.kt | 49 ++++---- .../state/MessageCompositionHolder.kt | 74 +++++------ .../MessageCompositionInputStateHolder.kt | 14 ++- .../textfield/MentionDeletionHandlerTest.kt | 97 ++++++++++++++ .../MessageComposerStateHolderTest.kt | 57 +++++---- .../state/MessageCompositionHolderTest.kt | 56 ++++----- .../MessageCompositionInputStateHolderTest.kt | 14 ++- 21 files changed, 597 insertions(+), 169 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt index af7d5553dd..1f19379769 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt @@ -69,7 +69,7 @@ fun CodeTextField( maxHorizontalSpacing = maxHorizontalSpacing, horizontalAlignment = horizontalAlignment, modifier = modifier, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicTextField( state = textState, textStyle = textStyle, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt index d05c9acf38..60bcadf6e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt @@ -105,6 +105,7 @@ internal fun CodeTextFieldLayout( } }, textFieldModifier = Modifier, + decorationBox = {} ) val bottomText = when { state is WireTextFieldState.Error && state.errorText != null -> state.errorText diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt new file mode 100644 index 0000000000..9f34070fc6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt @@ -0,0 +1,50 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield + +import androidx.compose.ui.text.TextRange + +object MentionDeletionHandler { + @Suppress("ReturnCount") + fun handle( + oldText: String, + newText: String, + oldSelection: TextRange, + mentions: List + ): String { + if (oldText == newText) { + // No change in text, only cursor movement, return as is + return oldText + } + for (mention in mentions) { + // Find the start position of the mention in the text + val mentionStart = oldText.indexOf(mention) + + if (mentionStart == -1) continue + + val mentionEnd = mentionStart + mention.length + + // Check if the selection (i.e., user's cursor position) is inside the mention's range + if (oldSelection.start in mentionStart + 1..mentionEnd || oldSelection.end in mentionStart + 1..mentionEnd) { + // If the user is deleting inside the mention, remove the entire mention + return oldText.removeRange(mentionStart, mentionEnd) + } + } + return newText + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt new file mode 100644 index 0000000000..ebd399766e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionVisualTransformation.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import com.wire.android.ui.home.conversations.model.UIMention + +class MentionVisualTransformation( + val color: Color, + val mentions: List +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val styledText = buildAnnotatedString { + var lastIndex = 0 + text.takeIf { it.isNotEmpty() }?.let { + mentions.forEach { mention -> + // Append the text before the mention + append(text.subSequence(lastIndex, mention.start)) + // Apply the style to the mention + withStyle(style = SpanStyle(color = color, fontWeight = FontWeight.Bold)) { + append(text.subSequence(mention.start, mention.start + mention.length)) + } + lastIndex = mention.start + mention.length + } + } + // Append the remaining text after the last mention + append(text.subSequence(lastIndex, text.length)) + } + return TransformedText( + text = styledText, + offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + override fun transformedToOriginal(offset: Int): Int = offset + } + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt index 87626e8f3a..c87cf1fdb2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt @@ -110,7 +110,7 @@ fun WirePasswordTextField( modifier = modifier.then(autoFillModifier(autoFillType, textState::setTextAndPlaceCursorAtEnd)), testTag = testTag, onTap = onTap, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicSecureTextField( state = textState, textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 0bf843388d..4ee35be59b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.KeyboardActionHandler @@ -42,6 +43,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,10 +56,13 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -134,7 +140,7 @@ internal fun WireTextField( ), onTap = onTap, testTag = testTag, - innerBasicTextField = { decorator, textFieldModifier -> + innerBasicTextField = { decorator, textFieldModifier, _ -> BasicTextField( state = textState, textStyle = textStyle.copy( @@ -163,6 +169,107 @@ internal fun WireTextField( ) } +@Composable +internal fun WireTextField( + textFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + mentions: List = emptyList(), + placeholderText: String? = null, + labelText: String? = null, + labelMandatoryIcon: Boolean = false, + descriptionText: String? = null, + semanticDescription: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + state: WireTextFieldState = WireTextFieldState.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + textStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), + colors: WireTextFieldColors = wireTextFieldColors(), + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onTap: ((Offset) -> Unit)? = null, + testTag: String = String.EMPTY +) { + WireTextFieldLayout( + modifier = modifier, + shouldShowPlaceholder = textFieldValue.value.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + semanticDescription = semanticDescription, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + onTap = onTap, + testTag = testTag, + innerBasicTextField = { _, textFieldModifier, decoratorBox -> + BasicTextField( + value = textFieldValue.value, + onValueChange = { newText -> + val mentionsByName = mentions.map { it.handler } + val updatedText = + MentionDeletionHandler.handle( + textFieldValue.value.text, + newText.text, + textFieldValue.value.selection, + mentionsByName + ) + onValueChange(TextFieldValue(updatedText, newText.selection)) + }, + textStyle = textStyle.copy( + color = colors.textColor(state = state).value, + textDirection = TextDirection.ContentOrLtr + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + readOnly = state is WireTextFieldState.ReadOnly, + enabled = state !is WireTextFieldState.Disabled, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier, + decorationBox = decoratorBox, + onTextLayout = onTextLayout( + textFieldValue, + onSelectedLineIndexChanged, + onLineBottomYCoordinateChanged + ), + visualTransformation = MentionVisualTransformation(colorsScheme().primary, mentions), + ) + } + ) +} + +private fun onTextLayout( + textFieldValue: State, + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, +): (TextLayoutResult) -> Unit = { + val lineOfText = it.getLineForOffset(textFieldValue.value.selection.end) + val bottomYCoordinate = it.getLineBottom(lineOfText) + onSelectedLineIndexChanged(lineOfText) + onLineBottomYCoordinateChanged(bottomYCoordinate) +} + private fun onTextLayout( state: TextFieldState, onSelectedLineIndexChanged: (Int) -> Unit = { }, @@ -203,6 +310,16 @@ private fun KeyboardOptions.Companion.defaultEmail(imeAction: ImeAction): Keyboa ) } +@PreviewMultipleThemes +@Composable +fun PreviewWireTextFieldWithTextFieldValue() = WireTheme { + WireTextField( + modifier = Modifier.padding(16.dp), + textFieldValue = remember { mutableStateOf(TextFieldValue("text")) }, + onValueChange = {} + ) +} + @PreviewMultipleThemes @Composable fun PreviewWireTextField() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt index b99c98af95..d93d1e7330 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -111,6 +111,21 @@ internal fun WireTextFieldLayout( onTap = onTap, ) }, + decorationBox = { innerTextField -> + InnerTextLayout( + innerTextField = innerTextField, + shouldShowPlaceholder = shouldShowPlaceholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + placeholderText = placeholderText, + style = state, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + colors = colors, + onTap = onTap, + ) + }, textFieldModifier = Modifier .fillMaxWidth() .background(color = colors.backgroundColor(state).value, shape = shape) @@ -218,5 +233,9 @@ private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, sp fun interface InnerBasicTextFieldBuilder { @Composable - fun Build(decorator: TextFieldDecorator, textFieldModifier: Modifier) + fun Build( + decorator: TextFieldDecorator, + textFieldModifier: Modifier, + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 4677a8fae1..0087bc2f6e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -295,7 +295,7 @@ fun ConversationScreen( LaunchedEffect(messageDraftViewModel.state.value.quotedMessageId) { val compositionState = messageDraftViewModel.state.value if (compositionState.quotedMessage != null) { - messageComposerStateHolder.messageCompositionHolder.updateQuote(compositionState.quotedMessage) + messageComposerStateHolder.messageCompositionHolder.value.updateQuote(compositionState.quotedMessage) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index 98a29bffee..d548d2a7b0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -111,7 +111,6 @@ import kotlin.math.absoluteValue import kotlin.math.min // TODO: a definite candidate for a refactor and cleanup -@OptIn(ExperimentalFoundationApi::class) @Suppress("ComplexMethod") @Composable fun RegularMessageItem( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt index 7c7d830bd9..ebb17ba5ea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt @@ -28,10 +28,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import com.wire.android.R +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY import com.wire.android.util.QueryMatchExtractor +import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun HighlightName( @@ -93,3 +95,14 @@ fun HighlightName( @Composable private fun String.isUnknownUser() = this == stringResource(id = R.string.username_unavailable_label) + +@PreviewMultipleThemes +@Composable +fun PreviewHighlightName() { + WireTheme { + HighlightName( + name = "John Doe", + searchQuery = "John" + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index 6cc8a51f69..39fc2d15e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -172,7 +172,7 @@ fun EnabledMessageComposer( membersToMention = messageComposerViewState.value.mentionSearchResult, searchQuery = messageComposerViewState.value.mentionSearchQuery, onMentionPicked = { pickedMention -> - messageCompositionHolder.addMention(pickedMention) + messageCompositionHolder.value.addMention(pickedMention) onClearMentionSearchResult() }, modifier = Modifier.align(Alignment.BottomCenter) @@ -205,14 +205,18 @@ fun EnabledMessageComposer( ActiveMessageComposerInput( conversationId = conversationId, messageComposition = messageComposition.value, - messageTextState = inputStateHolder.messageTextState, + messageTextFieldValue = inputStateHolder.messageTextFieldValue, + onValueChange = { + inputStateHolder.messageTextFieldValue.value = it + }, + mentions = (messageComposition.value.selectedMentions), isTextExpanded = inputStateHolder.isTextExpanded, inputType = messageCompositionInputStateHolder.inputType, focusRequester = messageCompositionInputStateHolder.focusRequester, onFocused = ::onInputFocused, onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, onTextCollapse = messageCompositionInputStateHolder::collapseText, - onCancelReply = messageCompositionHolder::clearReply, + onCancelReply = messageCompositionHolder.value::clearReply, onCancelEdit = ::cancelEdit, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, onSendButtonClicked = onSendButtonClicked, @@ -274,7 +278,7 @@ fun EnabledMessageComposer( membersToMention = mentionSearchResult, searchQuery = messageComposerViewState.value.mentionSearchQuery, onMentionPicked = { - messageCompositionHolder.addMention(it) + messageCompositionHolder.value.addMention(it) onClearMentionSearchResult() } ) @@ -289,9 +293,9 @@ fun EnabledMessageComposer( attachmentsVisible = inputStateHolder.optionsVisible, isEditing = messageCompositionInputStateHolder.inputType is InputType.Editing, isMentionActive = messageComposerViewState.value.mentionSearchResult.isNotEmpty(), - onMentionButtonClicked = messageCompositionHolder::startMention, + onMentionButtonClicked = messageCompositionHolder.value::startMention, onOnSelfDeletingOptionClicked = onChangeSelfDeletionClicked, - onRichOptionButtonClicked = messageCompositionHolder::addOrRemoveMessageMarkdown, + onRichOptionButtonClicked = messageCompositionHolder.value::addOrRemoveMessageMarkdown, onPingOptionClicked = onPingOptionClicked, onAdditionalOptionsMenuClicked = { if (!hideRipple) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt index d122ba6c58..d5336197ae 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MembersMentionList.kt @@ -26,10 +26,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.wire.android.model.Clickable import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.home.conversations.mention.MemberItemToMention import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.newconversation.model.Contact +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.kalium.logic.data.user.ConnectionState @Composable fun MembersMentionList( @@ -66,3 +69,35 @@ fun MembersMentionList( } } } + +@MultipleThemePreviews +@Composable +fun MembersMentionListPreview() { + WireTheme { + MembersMentionList( + membersToMention = listOf( + Contact( + id = "1", + domain = "domain", + name = "Marko Alonso", + handle = "john.doe", + label = "label", + membership = Membership.Admin, + connectionState = ConnectionState.ACCEPTED + ), + Contact( + id = "2", + domain = "domain", + name = "John Doe", + handle = "john.doe", + label = "label", + membership = Membership.Admin, + connectionState = ConnectionState.ACCEPTED + ) + ), + searchQuery = "John", + onMentionPicked = {}, + modifier = Modifier + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index 92abe5a72e..e16bda6fe6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -48,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.R import com.wire.android.ui.common.TextWithLearnMore import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation @@ -134,10 +134,10 @@ fun MessageComposer( messageComposerStateHolder = messageComposerStateHolder, messageListContent = messageListContent, onSendButtonClicked = { - onSendMessageBundle(messageCompositionHolder.toMessageBundle(conversationId)) + onSendMessageBundle(messageCompositionHolder.value.toMessageBundle(conversationId)) onClearMentionSearchResult() clearMessage() - messageCompositionHolder.onClearDraft() + messageCompositionHolder.value.onClearDraft() }, onPingOptionClicked = onPingOptionClicked, onImagesPicked = onImagesPicked, @@ -249,29 +249,36 @@ private fun BaseComposerPreview( ) ) } - val messageTextState = rememberTextFieldState() + + val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } + val messageComposition = remember { mutableStateOf(MessageComposition(ConversationId("value", "domain"))) } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } + val messageCompositionHolder = remember { + mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = {}, + onSaveDraft = {}, + onSearchMentionQueryChanged = {}, + onClearMentionSearchResult = {}, + onTypingEvent = {} + ) + ) + } MessageComposer( conversationId = ConversationId("value", "domain"), bottomSheetVisible = false, messageComposerStateHolder = MessageComposerStateHolder( messageComposerViewState = messageComposerViewState, messageCompositionInputStateHolder = MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ), - messageCompositionHolder = MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = {}, - onSaveDraft = {}, - onSearchMentionQueryChanged = {}, - onClearMentionSearchResult = {}, - onTypingEvent = {} - ), + messageCompositionHolder = messageCompositionHolder, additionalOptionStateHolder = AdditionalOptionStateHolder(), ), onPingOptionClicked = { }, @@ -288,6 +295,12 @@ private fun BaseComposerPreview( ) } +@PreviewMultipleThemes +@Composable +private fun PreviewMessageComposerEnabled() = WireTheme { + BaseComposerPreview(interactionAvailability = InteractionAvailability.ENABLED) +} + @PreviewMultipleThemes @Composable private fun PreviewMessageComposerDeletedUser() = WireTheme { @@ -311,9 +324,3 @@ private fun PreviewMessageComposerUnsupportedProtocol() = WireTheme { private fun PreviewMessageComposerLegalHold() = WireTheme { BaseComposerPreview(interactionAvailability = InteractionAvailability.LEGAL_HOLD) } - -@PreviewMultipleThemes -@Composable -private fun PreviewMessageComposerEnabled() = WireTheme { - BaseComposerPreview(interactionAvailability = InteractionAvailability.ENABLED) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index 48e30853d6..e9c2b9012b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -29,16 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -53,6 +52,7 @@ import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension @@ -68,6 +68,7 @@ import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.messages.QuotedMessagePreview +import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionArgs import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModel import com.wire.android.ui.home.messagecomposer.actions.SelfDeletingMessageActionViewModelImpl @@ -85,7 +86,9 @@ import com.wire.kalium.logic.data.message.SelfDeletionTimer fun ActiveMessageComposerInput( conversationId: ConversationId, messageComposition: MessageComposition, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + mentions: List, isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, @@ -128,7 +131,9 @@ fun ActiveMessageComposerInput( InputContent( conversationId = conversationId, - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = focusRequester, @@ -165,7 +170,9 @@ fun ActiveMessageComposerInput( @Composable private fun InputContent( conversationId: ConversationId, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, + mentions: List, isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, @@ -208,7 +215,9 @@ private fun InputContent( isTextExpanded = isTextExpanded, focusRequester = focusRequester, colors = inputType.inputTextColor(isSelfDeleting = viewModel.state().duration != null), - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, placeHolderText = viewModel.state().duration?.let { stringResource(id = R.string.self_deleting_message_label) } ?: inputType.labelText(), onFocused = onFocused, @@ -265,10 +274,12 @@ private fun InputContent( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun MessageComposerTextInput( + mentions: List, isTextExpanded: Boolean, focusRequester: FocusRequester, colors: WireTextFieldColors, - messageTextState: TextFieldState, + messageTextFieldValue: State, + onValueChange: (TextFieldValue) -> Unit, placeHolderText: String, onTextCollapse: () -> Unit, onFocused: () -> Unit, @@ -286,9 +297,10 @@ private fun MessageComposerTextInput( } WireTextField( - textState = messageTextState, + textFieldValue = messageTextFieldValue, + onValueChange = onValueChange, + mentions = mentions, colors = colors, - lineLimits = TextFieldLineLimits.MultiLine(), textStyle = MaterialTheme.wireTypography.body01, // Add an extra space so that the cursor is placed one space before "Type a message" placeholderText = " $placeHolderText", @@ -354,7 +366,9 @@ private fun PreviewActiveMessageComposerInput(inputType: InputType, isTextExpand ActiveMessageComposerInput( conversationId = ConversationId("conversationId", "domain"), messageComposition = MessageComposition(ConversationId("conversationId", "domain")), - messageTextState = rememberTextFieldState("abc"), + mentions = emptyList(), + messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) }, + onValueChange = {}, isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = FocusRequester(), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index 771e8e7713..0ea6534dc7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -29,6 +27,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -52,26 +52,33 @@ fun rememberMessageComposerStateHolder( val messageComposition = remember(draftMessageComposition) { mutableStateOf(draftMessageComposition) } - val messageTextState = rememberTextFieldState() + + val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } + LaunchedEffect(draftMessageComposition.draftText) { if (draftMessageComposition.draftText.isNotBlank()) { - messageTextState.setTextAndPlaceCursorAtEnd(draftMessageComposition.draftText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = draftMessageComposition.draftText, + selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text + ) } } val messageCompositionHolder = remember { - MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = onClearDraft, - onSaveDraft = onSaveDraft, - onSearchMentionQueryChanged = onSearchMentionQueryChanged, - onClearMentionSearchResult = onClearMentionSearchResult, - onTypingEvent = onTypingEvent, + mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = onClearDraft, + onSaveDraft = onSaveDraft, + onSearchMentionQueryChanged = onSearchMentionQueryChanged, + onClearMentionSearchResult = onClearMentionSearchResult, + onTypingEvent = onTypingEvent, + ) ) } LaunchedEffect(Unit) { - messageCompositionHolder.handleMessageTextUpdates() + messageCompositionHolder.value.handleMessageTextUpdates() } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { @@ -80,14 +87,14 @@ fun rememberMessageComposerStateHolder( val messageCompositionInputStateHolder = rememberSaveable( saver = MessageCompositionInputStateHolder.saver( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester, density = density ) ) { MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ) @@ -116,18 +123,18 @@ fun rememberMessageComposerStateHolder( class MessageComposerStateHolder( val messageComposerViewState: State, val messageCompositionInputStateHolder: MessageCompositionInputStateHolder, - val messageCompositionHolder: MessageCompositionHolder, + val messageCompositionHolder: State, val additionalOptionStateHolder: AdditionalOptionStateHolder, ) { - val messageComposition = messageCompositionHolder.messageComposition + val messageComposition = messageCompositionHolder.value.messageComposition fun toEdit(messageId: String, editMessageText: String, mentions: List) { - messageCompositionHolder.setEditText(messageId, editMessageText, mentions) + messageCompositionHolder.value.setEditText(messageId, editMessageText, mentions) messageCompositionInputStateHolder.toEdit(editMessageText) } fun toReply(message: UIMessage.Regular) { - messageCompositionHolder.setReply(message) + messageCompositionHolder.value.setReply(message) messageCompositionInputStateHolder.toComposing() } @@ -146,7 +153,7 @@ class MessageComposerStateHolder( fun cancelEdit() { messageCompositionInputStateHolder.toComposing() - messageCompositionHolder.clearMessage() + messageCompositionHolder.value.clearMessage() } fun showAttachments(showOptions: Boolean) { @@ -154,6 +161,6 @@ class MessageComposerStateHolder( } fun clearMessage() { - messageCompositionHolder.clearMessage() + messageCompositionHolder.value.clearMessage() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index 3d70862f77..18059202bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -17,12 +17,10 @@ */ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.substring import com.wire.android.ui.home.conversations.model.UIMention import com.wire.android.ui.home.conversations.model.UIMessage @@ -53,7 +51,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged @Suppress("TooManyFunctions") class MessageCompositionHolder( val messageComposition: MutableState, - val messageTextState: TextFieldState, + var messageTextFieldValue: MutableState, val onClearDraft: () -> Unit, private val onSaveDraft: (MessageDraft) -> Unit, private val onSearchMentionQueryChanged: (String) -> Unit, @@ -72,7 +70,7 @@ class MessageCompositionHolder( editMessageId = null ) } - onSaveDraft(messageComposition.value.toDraft(messageTextState.text.toString())) + onSaveDraft(messageComposition.value.toDraft(messageTextFieldValue.value.text)) } fun setReply(message: UIMessage.Regular) { @@ -96,7 +94,7 @@ class MessageCompositionHolder( ) } } - onSaveDraft(messageComposition.value.toDraft(messageTextState.text.toString())) + onSaveDraft(messageComposition.value.toDraft(messageTextFieldValue.value.text)) } fun clearReply() { @@ -110,13 +108,13 @@ class MessageCompositionHolder( } suspend fun handleMessageTextUpdates() { - snapshotFlow { messageTextState.text to messageTextState.selection } + snapshotFlow { messageTextFieldValue.value.text to messageTextFieldValue.value.selection } .distinctUntilChanged() .collectLatest { (messageText, selection) -> - updateTypingEvent(messageText.toString()) - updateMentionsIfNeeded(messageText.toString()) - requestMentionSuggestionIfNeeded(messageText.toString(), selection) - onSaveDraft(messageComposition.value.toDraft(messageText.toString())) + updateTypingEvent(messageText) + updateMentionsIfNeeded(messageText) + requestMentionSuggestionIfNeeded(messageText, selection) + onSaveDraft(messageComposition.value.toDraft(messageText)) } } @@ -162,8 +160,8 @@ class MessageCompositionHolder( } fun startMention() { - val beforeSelection = messageTextState.text - .subSequence(0, messageTextState.selection.min) + val beforeSelection = messageTextFieldValue.value.text + .subSequence(0, messageTextFieldValue.value.selection.min) .run { if (endsWith(String.WHITE_SPACE) || endsWith(String.NEW_LINE_SYMBOL) || this == String.EMPTY) { this.toString() @@ -174,10 +172,10 @@ class MessageCompositionHolder( } } - val afterSelection = messageTextState.text + val afterSelection = messageTextFieldValue.value.text .subSequence( - messageTextState.selection.max, - messageTextState.text.length + messageTextFieldValue.value.selection.max, + messageTextFieldValue.value.text.length ) val resultText = StringBuilder(beforeSelection) @@ -186,16 +184,16 @@ class MessageCompositionHolder( .toString() val newSelection = TextRange(beforeSelection.length + 1) - messageTextState.edit { - replace(0, messageTextState.text.length, resultText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, resultText), selection = newSelection - } + ) requestMentionSuggestionIfNeeded(resultText, newSelection) } fun addMention(contact: Contact) { val mention = UIMention( - start = currentMentionStartIndex(messageTextState.text.toString(), messageTextState.selection), + start = currentMentionStartIndex(messageTextFieldValue.value.text, messageTextFieldValue.value.selection), length = contact.name.length + 1, // +1 cause there is an "@" before it userId = UserId(contact.id, contact.domain), handler = String.MENTION_SYMBOL + contact.name @@ -209,12 +207,12 @@ class MessageCompositionHolder( } private fun insertMentionIntoText(mention: UIMention) { - val beforeMentionText = messageTextState.text + val beforeMentionText = messageTextFieldValue.value.text .subSequence(0, mention.start) - val afterMentionText = messageTextState.text + val afterMentionText = messageTextFieldValue.value.text .subSequence( - messageTextState.selection.max, - messageTextState.text.length + messageTextFieldValue.value.selection.max, + messageTextFieldValue.value.text.length ) val resultText = StringBuilder() .append(beforeMentionText) @@ -227,16 +225,18 @@ class MessageCompositionHolder( // + 1 cause we add space after mention and move selector there val newSelection = TextRange(beforeMentionText.length + mention.handler.length + 1) - - messageTextState.edit { - replace(0, messageTextState.text.length, resultText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, resultText), selection = newSelection - } + ) onSaveDraft(messageComposition.value.toDraft(resultText)) } fun setEditText(messageId: String, editMessageText: String, mentions: List) { - messageTextState.setTextAndPlaceCursorAtEnd(editMessageText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = editMessageText, + selection = TextRange(editMessageText.length) // Place cursor at the end of the new text + ) messageComposition.update { it.copy( selectedMentions = mentions.mapNotNull { it.toUiMention(editMessageText) }, @@ -250,9 +250,9 @@ class MessageCompositionHolder( markdown: RichTextMarkdown, ) { val isHeader = markdown == RichTextMarkdown.Header - val range = messageTextState.selection - val selectedText = messageTextState.text.substring(messageTextState.selection) - val stringBuilder = StringBuilder(messageTextState.text.toString()) + val range = messageTextFieldValue.value.selection + val selectedText = messageTextFieldValue.value.text.substring(messageTextFieldValue.value.selection) + val stringBuilder = StringBuilder(messageTextFieldValue.value.text) val markdownLength = markdown.value.length val markdownLengthComplete = if (isHeader) markdownLength else (markdownLength * RICH_TEXT_MARKDOWN_MULTIPLIER) @@ -285,15 +285,15 @@ class MessageCompositionHolder( } val newMessageText = stringBuilder.toString() - messageTextState.edit { - replace(0, messageTextState.text.length, newMessageText) + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text.replaceRange(0, messageTextFieldValue.value.text.length, newMessageText), selection = TextRange(selectionStart, selectionEnd) - } + ) onSaveDraft(messageComposition.value.toDraft(newMessageText)) } fun clearMessage() { - messageTextState.clearText() + messageTextFieldValue.value = TextFieldValue(String.EMPTY) messageComposition.update { it.copy( quotedMessageId = null, @@ -305,7 +305,7 @@ class MessageCompositionHolder( } fun toMessageBundle(conversationId: ConversationId) = - messageComposition.value.toMessageBundle(conversationId, messageTextState.text.toString()) + messageComposition.value.toMessageBundle(conversationId, messageTextFieldValue.value.text) private fun currentMentionStartIndex(messageText: String, selection: TextRange): Int { val lastIndexOfAt = messageText.lastIndexOf(String.MENTION_SYMBOL, selection.min - 1) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt index 8725f4bbf7..320655a133 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt @@ -18,8 +18,8 @@ package com.wire.android.ui.home.messagecomposer.state import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -42,7 +43,7 @@ import com.wire.android.util.isNotMarkdownBlank @Stable class MessageCompositionInputStateHolder( - val messageTextState: TextFieldState, + val messageTextFieldValue: MutableState, private val keyboardController: SoftwareKeyboardController?, val focusRequester: FocusRequester ) { @@ -64,11 +65,12 @@ class MessageCompositionInputStateHolder( val inputType: InputType by derivedStateOf { when (val state = compositionState) { is CompositionState.Composing -> InputType.Composing( - isSendButtonEnabled = messageTextState.text.isNotMarkdownBlank() + isSendButtonEnabled = messageTextFieldValue.value.text.isNotMarkdownBlank() ) is CompositionState.Editing -> InputType.Editing( - isEditButtonEnabled = messageTextState.text != state.originalMessageText && messageTextState.text.isNotMarkdownBlank() + isEditButtonEnabled = messageTextFieldValue.value.text != state.originalMessageText && + messageTextFieldValue.value.text.isNotMarkdownBlank() ) } } @@ -170,7 +172,7 @@ class MessageCompositionInputStateHolder( val composeTextHeight = 128.dp fun saver( - messageTextState: TextFieldState, + messageTextFieldValue: MutableState, keyboardController: SoftwareKeyboardController?, focusRequester: FocusRequester, density: Density @@ -188,7 +190,7 @@ class MessageCompositionInputStateHolder( restore = { savedState -> with(density) { MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = keyboardController, focusRequester = focusRequester ).apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt new file mode 100644 index 0000000000..f28ad9b2b0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield + +import androidx.compose.ui.text.TextRange +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MentionDeletionHandlerTest { + + @Test + fun `given mention in text when deleting inside mention then mention is removed`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello , how are you?" + val oldSelection = TextRange(6, 17) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals("Hello , how are you?", result) + } + + @Test + fun `given mention with last character deleted when deleting last character then mention is removed`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Do, how are you?" + val oldSelection = TextRange(3, 13) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals("Hello , how are you?", result) + } + + @Test + fun `given cursor at beginning of mention when no deletion then text remains unchanged`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Doe, how are you?" + val oldSelection = TextRange(6, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(oldText, result) + } + + @Test + fun `given text with mention when deleting outside of mention then text remains unchanged`() { + val oldText = "Hello @John Doe, how are you?" + val newText = "Hello @John Doehow are you?" + val oldSelection = TextRange(5, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } + + @Test + fun `given multiple mentions in text when deleting inside mentions then all mentions are removed`() { + val oldText = "Hello @John Doe and @Jane Doe, how are you?" + val newText = "Hello , how are you?" + val oldSelection = TextRange(6, 17) + val mentions = listOf("@John Doe", "@Jane Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } + + @Test + fun `given text without mentions when no mentions to delete then text remains unchanged`() { + val oldText = "Hello there, how are you?" + val newText = "Hello, how are you?" + val oldSelection = TextRange(6, 6) + val mentions = listOf("@John Doe") + + val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) + + assertEquals(newText, result) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt index 03fe454d77..8a55dfb5bd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt @@ -19,11 +19,12 @@ package com.wire.android.ui.home.messagecomposer import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation @@ -61,10 +62,10 @@ class MessageComposerStateHolderTest { private lateinit var messageComposerViewState: MutableState private lateinit var messageComposition: MutableState private lateinit var messageCompositionInputStateHolder: MessageCompositionInputStateHolder - private lateinit var messageCompositionHolder: MessageCompositionHolder + private lateinit var messageCompositionHolder: State private lateinit var additionalOptionStateHolder: AdditionalOptionStateHolder private lateinit var state: MessageComposerStateHolder - private lateinit var messageTextState: TextFieldState + private lateinit var messageTextFieldValue: MutableState @BeforeEach fun before() { @@ -73,20 +74,22 @@ class MessageComposerStateHolderTest { every { focusRequester.captureFocus() } returns true messageComposerViewState = mutableStateOf(MessageComposerViewState()) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) - messageTextState = TextFieldState() + messageTextFieldValue = mutableStateOf(TextFieldValue()) messageCompositionInputStateHolder = MessageCompositionInputStateHolder( - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, keyboardController = null, focusRequester = focusRequester ) - messageCompositionHolder = MessageCompositionHolder( - messageComposition = messageComposition, - messageTextState = messageTextState, - onClearDraft = {}, - onSaveDraft = {}, - onSearchMentionQueryChanged = {}, - onClearMentionSearchResult = {}, - onTypingEvent = {}, + messageCompositionHolder = mutableStateOf( + MessageCompositionHolder( + messageComposition = messageComposition, + messageTextFieldValue = messageTextFieldValue, + onClearDraft = {}, + onSaveDraft = {}, + onSearchMentionQueryChanged = {}, + onClearMentionSearchResult = {}, + onTypingEvent = {}, + ) ) additionalOptionStateHolder = AdditionalOptionStateHolder() @@ -131,9 +134,13 @@ class MessageComposerStateHolderTest { editMessageText = "edit_message_text", mentions = listOf() ) - state.messageCompositionHolder.messageTextState.edit { - append("some text") - } + + state.messageCompositionHolder.value.messageTextFieldValue.value = + messageTextFieldValue.value.copy( + text = messageTextFieldValue.value.text + "some text", + selection = TextRange(messageTextFieldValue.value.text.length + "some text".length) + ) + assertInstanceOf(InputType.Editing::class.java, messageCompositionInputStateHolder.inputType).also { assertEquals(true, it.isEditButtonEnabled) } @@ -147,7 +154,7 @@ class MessageComposerStateHolderTest { state.toReply(mockMessageWithText) // then - assertEquals(String.EMPTY, messageCompositionHolder.messageTextState.text.toString()) + assertEquals(String.EMPTY, messageCompositionHolder.value.messageTextFieldValue.value.text) assertInstanceOf(InputType.Composing::class.java, messageCompositionInputStateHolder.inputType) } @@ -155,13 +162,15 @@ class MessageComposerStateHolderTest { fun `given some message was being composed, when setting toReply, then input continues with the current text`() = runTest { // given val currentText = "Potato" - messageCompositionHolder.messageTextState.setTextAndPlaceCursorAtEnd(currentText) - + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = currentText, + selection = TextRange(currentText.length) + ) // when state.toReply(mockMessageWithText) // then - assertEquals(currentText, messageCompositionHolder.messageTextState.text.toString()) + assertEquals(currentText, messageCompositionHolder.value.messageTextFieldValue.value.text) } @Test @@ -188,15 +197,15 @@ class MessageComposerStateHolderTest { // then assertEquals( String.EMPTY, - messageCompositionHolder.messageTextState.text.toString() + messageCompositionHolder.value.messageTextFieldValue.value.text ) assertEquals( null, - messageCompositionHolder.messageComposition.value.quotedMessage + messageCompositionHolder.value.messageComposition.value.quotedMessage ) assertEquals( null, - messageCompositionHolder.messageComposition.value.quotedMessageId + messageCompositionHolder.value.messageComposition.value.quotedMessageId ) } } 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 91fee977b5..e0cab8fb69 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 @@ -18,10 +18,10 @@ package com.wire.android.ui.home.messagecomposer.state import android.content.Context -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -47,7 +47,7 @@ class MessageCompositionHolderTest { private lateinit var state: MessageCompositionHolder private lateinit var messageComposition: MutableState - private lateinit var messageTextState: TextFieldState + private lateinit var messageTextFieldValue: MutableState private val dispatcher = StandardTestDispatcher() @BeforeEach @@ -56,10 +56,10 @@ class MessageCompositionHolderTest { Dispatchers.setMain(dispatcher) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) - messageTextState = TextFieldState() + messageTextFieldValue = mutableStateOf(TextFieldValue()) state = MessageCompositionHolder( messageComposition = messageComposition, - messageTextState = messageTextState, + messageTextFieldValue = messageTextFieldValue, onClearDraft = {}, onSaveDraft = {}, onSearchMentionQueryChanged = {}, @@ -82,7 +82,7 @@ class MessageCompositionHolderTest { // then assertEquals( "# ", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @@ -95,7 +95,7 @@ class MessageCompositionHolderTest { // then assertEquals( "****", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @@ -108,62 +108,52 @@ class MessageCompositionHolderTest { // then assertEquals( "__", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding header markdown on selection, then # is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "header") - selection = TextRange( - start = 0, - end = 6 - ) - } - + val newText = "header" + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = newText, + selection = TextRange(0, 6) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Header) // then assertEquals( "# header", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding bold markdown on selection, then 2x star char is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "bold") - selection = TextRange( - start = 0, - end = 4 - ) - } - + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = "bold", // Replace the entire text with "bold" + selection = TextRange(0, 4) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Bold) // then assertEquals( "**bold**", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } @Test fun `given non empty text, when adding italic markdown on selection, then 2x _ is added to the text`() = runTest { // given - state.messageTextState.edit { - replace(0, length, "italic") - selection = TextRange( - start = 0, - end = 6 - ) - } + state.messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = "italic", // Replace the entire text with "bold" + selection = TextRange(0, 6) + ) // when state.addOrRemoveMessageMarkdown(markdown = RichTextMarkdown.Italic) @@ -171,7 +161,7 @@ class MessageCompositionHolderTest { // then assertEquals( "_italic_", - state.messageTextState.text.toString() + state.messageTextFieldValue.value.text ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 9364fc0fd3..81f3a08a53 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -20,10 +20,11 @@ package com.wire.android.ui.home.messagecomposer.state -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension @@ -233,14 +234,14 @@ class MessageCompositionInputStateHolderTest { class Arrangement { - private val textFieldState = TextFieldState() + private val textFieldValue = mutableStateOf(TextFieldValue()) val softwareKeyboardController = mockk() private val focusRequester = mockk() private val state by lazy { - MessageCompositionInputStateHolder(textFieldState, softwareKeyboardController, focusRequester) + MessageCompositionInputStateHolder(textFieldValue, softwareKeyboardController, focusRequester) } init { @@ -251,7 +252,10 @@ class MessageCompositionInputStateHolderTest { } fun withText(text: String) = apply { - textFieldState.setTextAndPlaceCursorAtEnd(text) + textFieldValue.value = textFieldValue.value.copy( + text = text, + selection = TextRange(text.length) + ) } fun arrange() = state to this From 8a30ee1c44098b3996cad133d2fbf29cd716abcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Wed, 20 Nov 2024 11:20:52 +0100 Subject: [PATCH 15/17] fix: Countly crash [WPB-14415] (#3649) Co-authored-by: Oussama Hassine --- .../feature/analytics/AnonymousAnalyticsRecorderImpl.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 46e9d2ce23..bee5ab3885 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -25,6 +25,7 @@ import com.wire.android.feature.analytics.model.AnalyticsEventConstants import com.wire.android.feature.analytics.model.AnalyticsSettings import ly.count.android.sdk.Countly import ly.count.android.sdk.CountlyConfig +import ly.count.android.sdk.UtilsInternalLimits class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { @@ -73,8 +74,13 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { Countly.sharedInstance().onStop() } + /** + * We need to change our segmentation map to [MutableMap] because + * Countly is doing additional operations on it. + * See [UtilsInternalLimits.removeUnsupportedDataTypes] + */ override fun sendEvent(event: AnalyticsEvent) { - Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation()) + Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation().toMutableMap()) } override fun halt() { From 7cd4bd6e8e07a9f7ba9ad9a1c738969fb962ea6c Mon Sep 17 00:00:00 2001 From: Yamil Medina Date: Wed, 20 Nov 2024 09:52:14 -0300 Subject: [PATCH 16/17] feat: conference simulcast support (WPB-11480) (#3638) --- .../ui/calling/ongoing/OngoingCallScreen.kt | 12 +++- .../calling/ongoing/OngoingCallViewModel.kt | 26 ++++++- .../ongoing/fullscreen/FullScreenTile.kt | 6 ++ .../ongoing/fullscreen/SelectedParticipant.kt | 8 ++- .../ui/calling/OngoingCallViewModelTest.kt | 69 +++++++++++++++++++ gradle/libs.versions.toml | 2 +- kalium | 2 +- 7 files changed, 118 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index f9877a1fa6..ec577f2c22 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -177,6 +177,8 @@ fun OngoingCallScreen( clearVideoPreview = sharedCallingViewModel::clearVideoPreview, onCollapse = onCollapse, requestVideoStreams = ongoingCallViewModel::requestVideoStreams, + onSelectedParticipant = ongoingCallViewModel::onSelectedParticipant, + selectedParticipantForFullScreen = ongoingCallViewModel.selectedParticipant, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, participants = sharedCallingViewModel.participantsState, @@ -289,6 +291,8 @@ private fun OngoingCallContent( hideDoubleTapToast: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit, requestVideoStreams: (participants: List) -> Unit, + onSelectedParticipant: (selectedParticipant: SelectedParticipant) -> Unit, + selectedParticipantForFullScreen: SelectedParticipant, participants: PersistentList, inPictureInPictureMode: Boolean, currentUserId: UserId, @@ -303,7 +307,6 @@ private fun OngoingCallContent( ) var shouldOpenFullScreen by remember { mutableStateOf(false) } - var selectedParticipantForFullScreen by remember { mutableStateOf(SelectedParticipant()) } WireBottomSheetScaffold( sheetDragHandle = null, @@ -391,11 +394,14 @@ private fun OngoingCallContent( selectedParticipant = selectedParticipantForFullScreen, height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, closeFullScreen = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, onBackButtonClicked = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, + requestVideoStreams = requestVideoStreams, setVideoPreview = setVideoPreview, clearVideoPreview = clearVideoPreview, participants = participants @@ -412,7 +418,7 @@ private fun OngoingCallContent( requestVideoStreams = requestVideoStreams, currentUserId = currentUserId, onDoubleTap = { selectedParticipant -> - selectedParticipantForFullScreen = selectedParticipant + onSelectedParticipant(selectedParticipant) shouldOpenFullScreen = !shouldOpenFullScreen }, ) @@ -580,6 +586,8 @@ fun PreviewOngoingCallContent(participants: PersistentList) { participants = participants, inPictureInPictureMode = false, currentUserId = UserId("userId", "domain"), + onSelectedParticipant = {}, + selectedParticipantForFullScreen = SelectedParticipant(), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 8e07e40bc1..84c6e1a57d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -28,8 +28,10 @@ import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -63,6 +65,8 @@ class OngoingCallViewModel @AssistedInject constructor( var state by mutableStateOf(OngoingCallState()) private set + var selectedParticipant by mutableStateOf(SelectedParticipant()) + private set init { viewModelScope.launch { @@ -124,7 +128,11 @@ class OngoingCallViewModel @AssistedInject constructor( .also { if (it.isNotEmpty()) { val clients: List = it.map { uiParticipant -> - CallClient(uiParticipant.id.toString(), uiParticipant.clientId) + CallClient( + userId = uiParticipant.id.toString(), + clientId = uiParticipant.clientId, + quality = mapQualityStream(uiParticipant) + ) } requestVideoStreams(conversationId, clients) } @@ -132,12 +140,20 @@ class OngoingCallViewModel @AssistedInject constructor( } } + private fun mapQualityStream(uiParticipant: UICallParticipant): CallQuality { + return if (uiParticipant.clientId == selectedParticipant.clientId) { + CallQuality.HIGH + } else { + CallQuality.LOW + } + } + private fun startDoubleTapToastDisplayCountDown() { doubleTapIndicatorCountDownTimer?.cancel() doubleTapIndicatorCountDownTimer = object : CountDownTimer(DOUBLE_TAP_TOAST_DISPLAY_TIME, COUNT_DOWN_INTERVAL) { override fun onTick(p0: Long) { - appLogger.i("startDoubleTapToastDisplayCountDown: $p0") + appLogger.d("$TAG - startDoubleTapToastDisplayCountDown: $p0") } override fun onFinish() { @@ -171,10 +187,16 @@ class OngoingCallViewModel @AssistedInject constructor( } } + fun onSelectedParticipant(selectedParticipant: SelectedParticipant) { + appLogger.d("$TAG - Selected participant: ${selectedParticipant.toLogString()}") + this.selectedParticipant = selectedParticipant + } + companion object { const val DOUBLE_TAP_TOAST_DISPLAY_TIME = 7000L const val COUNT_DOWN_INTERVAL = 1000L const val DELAY_TO_SHOW_DOUBLE_TAP_TOAST = 500L + const val TAG = "OngoingCallViewModel" } @AssistedFactory diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index 7edbd0d7ba..cee64f8303 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -60,6 +60,7 @@ fun FullScreenTile( closeFullScreen: (offset: Offset) -> Unit, onBackButtonClicked: () -> Unit, setVideoPreview: (View) -> Unit, + requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, @@ -119,6 +120,10 @@ fun FullScreenTile( } ) } + + LaunchedEffect(selectedParticipant.userId) { + requestVideoStreams(listOf(it)) + } } } @@ -139,6 +144,7 @@ fun PreviewFullScreenTile() = WireTheme { closeFullScreen = {}, onBackButtonClicked = {}, setVideoPreview = {}, + requestVideoStreams = {}, clearVideoPreview = {}, participants = participants, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt index 95238a33c2..af1a1d87f4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt @@ -17,10 +17,16 @@ */ package com.wire.android.ui.calling.ongoing.fullscreen +import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.user.UserId data class SelectedParticipant( val userId: UserId = UserId("", ""), val clientId: String = "", val isSelfUser: Boolean = false -) +) { + + fun toLogString(): String { + return "SelectedParticipant(userId=${userId.toLogString()}, clientId=${clientId.obfuscateId()}, isSelfUser=$isSelfUser)" + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 79e3abce3c..92937700fd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -23,9 +23,11 @@ import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation @@ -170,6 +172,72 @@ class OngoingCallViewModelTest { } } + @Test + fun givenAUserIsSelected_whenRequestedFullScreen_thenSetTheUserAsSelected() = + runTest { + val (_, ongoingCallViewModel) = Arrangement() + .withCall(provideCall().copy(isCameraOn = true)) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + + assertEquals(selectedParticipant3, ongoingCallViewModel.selectedParticipant) + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForFullScreenParticipant_ThenRequestItInHighQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.HIGH) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForAllParticipant_ThenRequestItInLowQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.LOW) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(SelectedParticipant()) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + private class Arrangement { @MockK @@ -268,6 +336,7 @@ class OngoingCallViewModelTest { accentId = -1 ) val participants = listOf(participant1, participant2, participant3) + val selectedParticipant3 = SelectedParticipant(participant3.id, participant3.clientId, false) } private fun provideCall( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f18f8253b2..6f99643033 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ detekt = "1.23.6" google-gms = "4.4.2" gms-location = "21.3.0" android-gradlePlugin = "8.5.2" -desugaring = "2.1.2" +desugaring = "2.1.3" firebaseBOM = "33.4.0" fragment = "1.5.6" resaca = "3.0.0" diff --git a/kalium b/kalium index e617c90fb7..9273703fe3 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit e617c90fb7cd79e554946aa865e46a9ed9a78b67 +Subproject commit 9273703fe301924bf7ae8c17723c57bb443df41f From c91127141ddcef2e9dd9d5eff92dfe3e118eda85 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek <76782439+damian-kaczmarek@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:39:39 +0100 Subject: [PATCH 17/17] feat: connect to upgrade-personal-to-team API #WPB-11992 (#3625) Co-authored-by: ohassine --- .../com/wire/android/di/CoreLogicModule.kt | 8 ++ .../teammigration/TeamMigrationState.kt | 4 +- .../teammigration/TeamMigrationViewModel.kt | 36 ++++++++- .../TeamMigrationConfirmationStepScreen.kt | 28 ++++++- .../TeamMigrationViewModelTest.kt | 73 ++++++++++++++++++- kalium | 2 +- 6 files changed, 144 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt index 0b32e3d76f..3bdf323e37 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -492,4 +492,12 @@ class UseCaseModule { @Provides fun provideSendFCMTokenToAPIUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = coreLogic.getSessionScope(currentAccount).debug.sendFCMTokenToServer + + @ViewModelScoped + @Provides + fun provideMigrateFromPersonalToTeamUseCase( + @KaliumCoreLogic coreLogic: CoreLogic, + @CurrentAccount currentAccount: UserId + ) = + coreLogic.getSessionScope(currentAccount).migrateFromPersonalToTeam } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt index 561ad8f57b..0399b9c079 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt @@ -18,8 +18,10 @@ package com.wire.android.ui.userprofile.teammigration import androidx.compose.foundation.text.input.TextFieldState +import com.wire.kalium.logic.CoreFailure data class TeamMigrationState( val teamNameTextState: TextFieldState = TextFieldState(), - val shouldShowMigrationLeaveDialog: Boolean = false + val shouldShowMigrationLeaveDialog: Boolean = false, + val migrationFailure: CoreFailure? = null, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt index 89494b1f5f..9fd2d31dab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt @@ -17,18 +17,25 @@ */ package com.wire.android.ui.userprofile.teammigration +import androidx.compose.foundation.text.input.setTextAndSelectAll import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamResult +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class TeamMigrationViewModel @Inject constructor( - private val anonymousAnalyticsManager: AnonymousAnalyticsManager + private val anonymousAnalyticsManager: AnonymousAnalyticsManager, + private val migrateFromPersonalToTeam: MigrateFromPersonalToTeamUseCase ) : ViewModel() { var teamMigrationState by mutableStateOf(TeamMigrationState()) @@ -81,4 +88,31 @@ class TeamMigrationViewModel @Inject constructor( ) ) } + + fun migrateFromPersonalToTeamAccount(onSuccess: () -> Unit) { + viewModelScope.launch { + migrateFromPersonalToTeam.invoke( + teamMigrationState.teamNameTextState.text.toString(), + ).let { result -> + when (result) { + is MigrateFromPersonalToTeamResult.Success -> { + teamMigrationState.teamNameTextState.setTextAndSelectAll(result.teamName) + onSuccess() + } + + is MigrateFromPersonalToTeamResult.Error -> { + onMigrationFailure(result.failure) + } + } + } + } + } + + fun failureHandled() { + teamMigrationState = teamMigrationState.copy(migrationFailure = null) + } + + private fun onMigrationFailure(failure: CoreFailure) { + teamMigrationState = teamMigrationState.copy(migrationFailure = failure) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt index 69fc9ca95e..9be96dcb19 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt @@ -47,10 +47,12 @@ import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.WireCheckbox import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.error.CoreFailureErrorDialog import com.wire.android.ui.destinations.TeamMigrationDoneStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph +import com.wire.android.ui.userprofile.teammigration.TeamMigrationState import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons import com.wire.android.ui.userprofile.teammigration.common.BulletList @@ -66,21 +68,43 @@ fun TeamMigrationConfirmationStepScreen( navigator: DestinationsNavigator, teamMigrationViewModel: TeamMigrationViewModel ) { + val state = remember { teamMigrationViewModel.teamMigrationState } TeamMigrationConfirmationStepScreenContent( onContinueButtonClicked = { - // TODO: call the API to migrate the user to the team, if successful navigate to next screen - navigator.navigate(TeamMigrationDoneStepScreenDestination) + teamMigrationViewModel.migrateFromPersonalToTeamAccount( + onSuccess = { + navigator.navigate(TeamMigrationDoneStepScreenDestination) + }, + ) }, onBackPressed = { navigator.popBackStack() } ) + + HandleErrors(state, teamMigrationViewModel::failureHandled) + LaunchedEffect(Unit) { teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(3) } } +@Composable +private fun HandleErrors( + teamMigrationState: TeamMigrationState, + onFailureHandled: () -> Unit +) { + val failure = teamMigrationState.migrationFailure ?: return + // TODO handle error WPB-14281 + CoreFailureErrorDialog( + coreFailure = failure, + onDialogDismiss = { + onFailureHandled() + } + ) +} + @Composable private fun TeamMigrationConfirmationStepScreenContent( modifier: Modifier = Modifier, diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt index 331f4b5c00..4def3b0403 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt @@ -17,15 +17,26 @@ */ package com.wire.android.ui.userprofile.teammigration +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.wire.android.config.CoroutineTestExtension import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamResult +import com.wire.kalium.logic.feature.user.migration.MigrateFromPersonalToTeamUseCase import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +@ExtendWith(CoroutineTestExtension::class) class TeamMigrationViewModelTest { @Test @@ -157,17 +168,75 @@ class TeamMigrationViewModelTest { } } + @Test + fun `given team name, when migrateFromPersonalToTeamAccount return success, then call use case and onSuccess`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withMigrateFromPersonalToTeamSuccess() + .arrange() + + val onSuccess = mockk<() -> Unit>(relaxed = true) + + viewModel.migrateFromPersonalToTeamAccount(onSuccess) + + coVerify(exactly = 1) { + arrangement.migrateFromPersonalToTeam(Arrangement.TEAM_NAME) + } + verify(exactly = 1) { onSuccess() } + } + + @Test + fun `given team name, when migrateFromPersonalToTeamAccount return failure, then call use case and handle the failure`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withMigrateFromPersonalToTeamError() + .arrange() + + val onSuccess = {} + + viewModel.migrateFromPersonalToTeamAccount(onSuccess) + + coVerify(exactly = 1) { + arrangement.migrateFromPersonalToTeam(Arrangement.TEAM_NAME) + } + Assertions.assertNotNull(viewModel.teamMigrationState.migrationFailure) + viewModel.failureHandled() + Assertions.assertNull(viewModel.teamMigrationState.migrationFailure) + } + private class Arrangement { @MockK lateinit var anonymousAnalyticsManager: AnonymousAnalyticsManager + @MockK + lateinit var migrateFromPersonalToTeam: MigrateFromPersonalToTeamUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) } fun arrange() = this to TeamMigrationViewModel( - anonymousAnalyticsManager = anonymousAnalyticsManager - ) + anonymousAnalyticsManager = anonymousAnalyticsManager, + migrateFromPersonalToTeam = migrateFromPersonalToTeam, + ).also { viewModel -> + viewModel.teamMigrationState.teamNameTextState.setTextAndPlaceCursorAtEnd(TEAM_NAME) + } + + fun withMigrateFromPersonalToTeamSuccess() = apply { + coEvery { migrateFromPersonalToTeam(any()) } returns MigrateFromPersonalToTeamResult.Success( + TEAM_NAME + ) + } + + fun withMigrateFromPersonalToTeamError() = apply { + coEvery { migrateFromPersonalToTeam(any()) } returns MigrateFromPersonalToTeamResult.Error( + NetworkFailure.NoNetworkConnection(null) + ) + } + + companion object { + const val TEAM_NAME = "teamName" + } } } diff --git a/kalium b/kalium index 9273703fe3..08c0cffe74 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 9273703fe301924bf7ae8c17723c57bb443df41f +Subproject commit 08c0cffe74b9523c7464e3645eb79f2ca7d59d3f