From 06d0b4d238f9312ff42c9942ac8a4dd8b8c616ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Tue, 31 Dec 2024 13:38:15 +0100 Subject: [PATCH] feat: Delete group conversation locally [#WPB-11556] (#3774) --- .../conversation/ConversationSheetContent.kt | 7 +- .../conversation/ConversationSheetState.kt | 14 +- .../conversation/HomeSheetContent.kt | 23 +++ .../android/ui/home/HomeSnackBarMessage.kt | 6 + .../details/GroupConversationDetailsScreen.kt | 6 +- .../GroupConversationDetailsViewModel.kt | 3 +- .../DeleteConversationGroupLocallyDialog.kt | 61 +++++++ .../ConversationListViewModel.kt | 40 ++++- .../ConversationsDialogsState.kt | 3 + .../ConversationsScreenContent.kt | 17 +- .../other/OtherUserProfileScreenViewModel.kt | 3 +- .../OtherUserProfileBottomSheet.kt | 3 +- .../android/workmanager/WireWorkerFactory.kt | 4 + .../worker/DeleteConversationLocallyWorker.kt | 150 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + .../ConversationSheetContentTest.kt | 49 +++++- .../GroupConversationDetailsViewModelTest.kt | 3 +- .../ConversationListViewModelTest.kt | 7 +- 18 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/DeleteConversationGroupLocallyDialog.kt create mode 100644 app/src/main/kotlin/com/wire/android/workmanager/worker/DeleteConversationLocallyWorker.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt index 36ca87da01e..98706114d96 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt @@ -45,6 +45,7 @@ fun ConversationSheetContent( unblockUser: (UnblockUserDialogState) -> Unit, leaveGroup: (GroupDialogState) -> Unit, deleteGroup: (GroupDialogState) -> Unit, + deleteGroupLocally: (GroupDialogState) -> Unit, isBottomSheetVisible: () -> Boolean = { true } ) { // it may be null as initial state @@ -63,6 +64,7 @@ fun ConversationSheetContent( unblockUserClick = unblockUser, leaveGroup = leaveGroup, deleteGroup = deleteGroup, + deleteGroupLocally = deleteGroupLocally, navigateToNotification = conversationSheetState::toMutingNotificationOption ) } @@ -125,7 +127,8 @@ data class ConversationSheetContent( val mlsVerificationStatus: Conversation.VerificationStatus, val proteusVerificationStatus: Conversation.VerificationStatus, val isUnderLegalHold: Boolean, - val isFavorite: Boolean? + val isFavorite: Boolean?, + val isDeletingConversationLocallyRunning: Boolean ) { private val isSelfUserMember: Boolean get() = selfRole != null @@ -144,6 +147,8 @@ data class ConversationSheetContent( fun canLeaveTheGroup(): Boolean = conversationTypeDetail is ConversationTypeDetail.Group && isSelfUserMember + fun canDeleteGroupLocally(): Boolean = !isSelfUserMember && !isDeletingConversationLocallyRunning + fun canBlockUser(): Boolean { return conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState == BlockingState.NOT_BLOCKED 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 250e5c7439b..e0a8ec20bc6 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 @@ -59,7 +59,8 @@ class ConversationSheetState( @Composable fun rememberConversationSheetState( conversationItem: ConversationItem, - conversationOptionNavigation: ConversationOptionNavigation + conversationOptionNavigation: ConversationOptionNavigation, + isConversationDeletionLocallyRunning: Boolean ): ConversationSheetState { val conversationSheetContent: ConversationSheetContent = when (conversationItem) { is ConversationItem.GroupConversation -> { @@ -79,7 +80,8 @@ fun rememberConversationSheetState( mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, isUnderLegalHold = showLegalHoldIndicator, - isFavorite = isFavorite + isFavorite = isFavorite, + isDeletingConversationLocallyRunning = isConversationDeletionLocallyRunning ) } } @@ -105,7 +107,8 @@ fun rememberConversationSheetState( mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, isUnderLegalHold = showLegalHoldIndicator, - isFavorite = isFavorite + isFavorite = isFavorite, + isDeletingConversationLocallyRunning = false ) } } @@ -126,13 +129,14 @@ fun rememberConversationSheetState( mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, isUnderLegalHold = showLegalHoldIndicator, - isFavorite = null + isFavorite = null, + isDeletingConversationLocallyRunning = false ) } } } - return remember(conversationItem, conversationOptionNavigation) { + return remember(conversationItem, conversationOptionNavigation, isConversationDeletionLocallyRunning) { ConversationSheetState( conversationSheetContent = conversationSheetContent, conversationOptionNavigation = conversationOptionNavigation diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt index c06603826aa..fe26a89cd43 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt @@ -67,6 +67,7 @@ internal fun ConversationMainSheetContent( unblockUserClick: (UnblockUserDialogState) -> Unit, leaveGroup: (GroupDialogState) -> Unit, deleteGroup: (GroupDialogState) -> Unit, + deleteGroupLocally: (GroupDialogState) -> Unit, navigateToNotification: () -> Unit ) { WireMenuModalSheetContent( @@ -270,6 +271,28 @@ internal fun ConversationMainSheetContent( ) } } + if (conversationSheetContent.canDeleteGroupLocally()) { + add { + MenuBottomSheetItem( + leading = { + MenuItemIcon( + id = R.drawable.ic_close, + contentDescription = null + ) + }, + title = stringResource(R.string.label_delete_group_locally), + itemProvidedColor = MaterialTheme.colorScheme.error, + onItemClick = { + deleteGroupLocally( + GroupDialogState( + conversationSheetContent.conversationId, + conversationSheetContent.title + ) + ) + } + ) + } + } if (conversationSheetContent.canDeleteGroup()) { add { MenuBottomSheetItem( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt index 0a8bb081457..544c3cac50d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeSnackBarMessage.kt @@ -59,6 +59,12 @@ sealed class HomeSnackBarMessage(override val uiText: UIText) : SnackBarMessage groupName ) ) + data class DeleteConversationGroupLocallySuccess(val groupName: String) : HomeSnackBarMessage( + UIText.StringResource( + R.string.conversation_group_removed_locally_success, + groupName + ) + ) data object DeleteConversationGroupError : HomeSnackBarMessage(UIText.StringResource(R.string.delete_group_conversation_error)) data object LeftConversationSuccess : HomeSnackBarMessage(UIText.StringResource(R.string.left_conversation_group_success)) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index 9b9cfcdaf99..4e9564c8b4d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -489,7 +489,8 @@ private fun GroupConversationDetailsContent( blockUser = {}, unblockUser = {}, leaveGroup = leaveGroupDialogState::show, - deleteGroup = deleteGroupDialogState::show + deleteGroup = deleteGroupDialogState::show, + deleteGroupLocally = {} ) } ) @@ -609,7 +610,8 @@ fun PreviewGroupConversationDetails() { mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, isUnderLegalHold = false, proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, - isFavorite = false + isFavorite = false, + isDeletingConversationLocallyRunning = false ), bottomSheetEventsHandler = GroupConversationDetailsBottomSheetEventsHandler.PREVIEW, onBackPressed = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt index 6878238a949..64dc6769a4b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt @@ -162,7 +162,8 @@ class GroupConversationDetailsViewModel @Inject constructor( mlsVerificationStatus = groupDetails.conversation.mlsVerificationStatus, proteusVerificationStatus = groupDetails.conversation.proteusVerificationStatus, isUnderLegalHold = groupDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), - isFavorite = groupDetails.isFavorite + isFavorite = groupDetails.isFavorite, + isDeletingConversationLocallyRunning = false ) updateState( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/DeleteConversationGroupLocallyDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/DeleteConversationGroupLocallyDialog.kt new file mode 100644 index 00000000000..abc834dd1cc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/DeleteConversationGroupLocallyDialog.kt @@ -0,0 +1,61 @@ +/* + * 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.home.conversations.details.menu + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.VisibilityState +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.visbility.VisibilityState +import com.wire.android.ui.home.conversationslist.model.GroupDialogState + +@Composable +internal fun DeleteConversationGroupLocallyDialog( + dialogState: VisibilityState, + isLoading: Boolean, + onDeleteGroupLocally: (GroupDialogState) -> Unit, +) { + VisibilityState(dialogState) { + WireDialog( + title = stringResource(id = R.string.delete_group_locally_conversation_dialog_title, it.conversationName), + text = stringResource(id = R.string.delete_group_locally_conversation_dialog_description), + buttonsHorizontalAlignment = true, + onDismiss = dialogState::dismiss, + dismissButtonProperties = WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_cancel), + state = WireButtonState.Default + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = { onDeleteGroupLocally(it) }, + text = stringResource(id = R.string.delete_group_locally_delete_for_me_label), + type = WireDialogButtonType.Primary, + state = if (isLoading) { + WireButtonState.Disabled + } else { + WireButtonState.Error + }, + loading = isLoading + ) + ) + } +} 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 070abed799e..334063536d1 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 @@ -27,6 +27,7 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.insertSeparators import androidx.paging.map +import androidx.work.WorkManager import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.di.CurrentAccount @@ -47,6 +48,9 @@ import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.ui.home.conversationslist.model.DialogState import com.wire.android.ui.home.conversationslist.model.GroupDialogState import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.workmanager.worker.ConversationDeletionLocallyStatus +import com.wire.android.workmanager.worker.enqueueConversationDeletionLocally +import com.wire.android.workmanager.worker.observeConversationDeletionStatusLocally import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.MutedConversationStatus @@ -88,6 +92,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -109,6 +114,8 @@ interface ConversationListViewModel { fun blockUser(blockUserState: BlockUserDialogState) {} fun unblockUser(userId: UserId) {} fun deleteGroup(groupDialogState: GroupDialogState) {} + fun deleteGroupLocally(groupDialogState: GroupDialogState) {} + fun observeIsDeletingConversationLocally(conversationId: ConversationId): Flow fun leaveGroup(leaveGroupState: GroupDialogState) {} fun clearConversationContent(dialogState: DialogState) {} fun muteConversation(conversationId: ConversationId?, mutedConversationStatus: MutedConversationStatus) {} @@ -120,6 +127,7 @@ class ConversationListViewModelPreview( foldersWithConversations: Flow> = previewConversationFoldersFlow(), ) : ConversationListViewModel { override val conversationListState = ConversationListState.Paginated(foldersWithConversations) + override fun observeIsDeletingConversationLocally(conversationId: ConversationId): Flow = flowOf(false) } @Suppress("MagicNumber", "TooManyFunctions", "LongParameterList") @@ -142,7 +150,8 @@ class ConversationListViewModelImpl @AssistedInject constructor( private val observeLegalHoldStateForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, @CurrentAccount val currentAccount: UserId, private val userTypeMapper: UserTypeMapper, - private val observeSelfUser: GetSelfUserUseCase + private val observeSelfUser: GetSelfUserUseCase, + private val workManager: WorkManager ) : ConversationListViewModel, ViewModel() { @AssistedFactory @@ -367,6 +376,35 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } + override fun deleteGroupLocally(groupDialogState: GroupDialogState) { + viewModelScope.launch { + closeBottomSheet.emit(Unit) + workManager.enqueueConversationDeletionLocally(groupDialogState.conversationId) + .collect { status -> + when (status) { + ConversationDeletionLocallyStatus.SUCCEEDED -> { + _infoMessage.emit(HomeSnackBarMessage.DeleteConversationGroupLocallySuccess(groupDialogState.conversationName)) + } + + ConversationDeletionLocallyStatus.FAILED -> { + _infoMessage.emit(HomeSnackBarMessage.DeleteConversationGroupError) + } + + ConversationDeletionLocallyStatus.RUNNING, + ConversationDeletionLocallyStatus.IDLE -> { + // nop + } + } + } + } + } + + override fun observeIsDeletingConversationLocally(conversationId: ConversationId): Flow { + return workManager.observeConversationDeletionStatusLocally(conversationId) + .map { status -> status == ConversationDeletionLocallyStatus.RUNNING } + .distinctUntilChanged() + } + // TODO: needs to be implemented @Suppress("EmptyFunctionBlock") override fun moveConversationToFolder() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsDialogsState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsDialogsState.kt index f8c0ca7e3b0..cb1384c935f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsDialogsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsDialogsState.kt @@ -34,6 +34,7 @@ import com.wire.android.ui.home.conversationslist.model.GroupDialogState class ConversationsDialogsState( val leaveGroupDialogState: VisibilityState, val deleteGroupDialogState: VisibilityState, + val deleteGroupLocallyDialogState: VisibilityState, val blockUserDialogState: VisibilityState, val unblockUserDialogState: VisibilityState, val clearContentDialogState: VisibilityState, @@ -48,6 +49,7 @@ fun rememberConversationsDialogsState(requestInProgress: Boolean): Conversations val leaveGroupDialogState = rememberVisibilityState() val deleteGroupDialogState = rememberVisibilityState() + val deleteGroupLocallyDialogState = rememberVisibilityState() val blockUserDialogState = rememberVisibilityState() val unblockUserDialogState = rememberVisibilityState() val clearContentDialogState = rememberVisibilityState() @@ -57,6 +59,7 @@ fun rememberConversationsDialogsState(requestInProgress: Boolean): Conversations ConversationsDialogsState( leaveGroupDialogState, deleteGroupDialogState, + deleteGroupLocallyDialogState, blockUserDialogState, unblockUserDialogState, clearContentDialogState, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 3a180efd9a7..438d949008d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.wire.android.R @@ -63,6 +64,7 @@ import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.details.dialog.ClearConversationContentDialog import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupDialog +import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupLocallyDialog import com.wire.android.ui.home.conversations.details.menu.LeaveConversationGroupDialog import com.wire.android.ui.home.conversationslist.common.ConversationList import com.wire.android.ui.home.conversationslist.model.ConversationItem @@ -273,6 +275,15 @@ fun ConversationsScreenContent( onDeleteGroup = conversationListViewModel::deleteGroup ) + DeleteConversationGroupLocallyDialog( + isLoading = requestInProgress, + dialogState = deleteGroupLocallyDialogState, + onDeleteGroupLocally = { state -> + conversationListViewModel.deleteGroupLocally(state) + deleteGroupLocallyDialogState.dismiss() + } + ) + LeaveConversationGroupDialog( dialogState = leaveGroupDialogState, isLoading = requestInProgress, @@ -299,9 +310,12 @@ fun ConversationsScreenContent( WireModalSheetLayout( sheetState = sheetState, sheetContent = { + val conversationDeletionInProgress by conversationListViewModel.observeIsDeletingConversationLocally(it.conversationId) + .collectAsStateWithLifecycle(false) val conversationState = rememberConversationSheetState( conversationItem = it, - conversationOptionNavigation = currentConversationOptionNavigation + conversationOptionNavigation = currentConversationOptionNavigation, + isConversationDeletionLocallyRunning = conversationDeletionInProgress ) ConversationSheetContent( @@ -320,6 +334,7 @@ fun ConversationsScreenContent( unblockUser = unblockUserDialogState::show, leaveGroup = leaveGroupDialogState::show, deleteGroup = deleteGroupDialogState::show, + deleteGroupLocally = deleteGroupLocallyDialogState::show ) }, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index 69effb5e0db..d1828b55a25 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -417,7 +417,8 @@ class OtherUserProfileScreenViewModel @Inject constructor( mlsVerificationStatus = conversation.mlsVerificationStatus, proteusVerificationStatus = conversation.proteusVerificationStatus, isUnderLegalHold = conversation.legalHoldStatus.showLegalHoldIndicator(), - isFavorite = null + isFavorite = null, + isDeletingConversationLocallyRunning = false ) } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt index 1757407daf3..d5b9203eef4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/bottomsheet/OtherUserProfileBottomSheet.kt @@ -65,7 +65,8 @@ fun OtherUserProfileBottomSheetContent( blockUser = blockUser, unblockUser = unblockUser, leaveGroup = { }, - deleteGroup = { } + deleteGroup = { }, + deleteGroupLocally = { } ) } diff --git a/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt b/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt index e763092fcf0..47adebbf400 100644 --- a/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt +++ b/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt @@ -27,6 +27,7 @@ import com.wire.android.feature.StartPersistentWebsocketIfNecessaryUseCase import com.wire.android.migration.MigrationManager import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager +import com.wire.android.workmanager.worker.DeleteConversationLocallyWorker import com.wire.android.workmanager.worker.MigrationWorker import com.wire.android.workmanager.worker.NotificationFetchWorker import com.wire.android.workmanager.worker.PersistentWebsocketCheckWorker @@ -63,6 +64,9 @@ class WireWorkerFactory @Inject constructor( PersistentWebsocketCheckWorker::class.java.canonicalName -> PersistentWebsocketCheckWorker(appContext, workerParameters, startPersistentWebsocketIfNecessary) + DeleteConversationLocallyWorker::class.java.canonicalName -> + DeleteConversationLocallyWorker(appContext, workerParameters, coreLogic) + else -> null } } diff --git a/app/src/main/kotlin/com/wire/android/workmanager/worker/DeleteConversationLocallyWorker.kt b/app/src/main/kotlin/com/wire/android/workmanager/worker/DeleteConversationLocallyWorker.kt new file mode 100644 index 00000000000..dc1fc112e1f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/workmanager/worker/DeleteConversationLocallyWorker.kt @@ -0,0 +1,150 @@ +/* + * 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.workmanager.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedIdMapperImpl +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.functional.fold +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull + +/** + * A worker responsible for performing local deletion of conversations in the background. + * + * This worker is used to delete conversations along with their associated assets. Since the + * deletion process can involve large amounts of data (e.g., clearing files, database entries), + * it is executed as a background task to avoid blocking the main thread or user interactions. + * + * @param appContext The application context, provided by WorkManager. + * @param workerParams Parameters associated with this work request, including input data. + * @param coreLogic A utility object that handles core application logic, such as session and + * conversation management. + */ +@HiltWorker +class DeleteConversationLocallyWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val coreLogic: CoreLogic +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result = coroutineScope { + inputData.getString(CONVERSATION_ID)?.let { id -> + val conversationId = QualifiedIdMapperImpl(null).fromStringToQualifiedID(id) + val currentSession = coreLogic.getGlobalScope().session.currentSession() + if (currentSession is CurrentSessionResult.Success && currentSession.accountInfo.isValid()) { + val deleteConversationLocally = + coreLogic.getSessionScope(currentSession.accountInfo.userId).conversations.deleteConversationLocallyUseCase + deleteConversationLocally(conversationId) + .fold({ Result.failure() }, { Result.success() }) + } else { + Result.failure() + } + } ?: Result.failure() + } + + companion object { + private const val NAME = "delete_conversation_locally_" + const val CONVERSATION_ID = "delete_conversation_locally_conversation_id" + + fun createUniqueWorkName(conversationId: String): String { + return "$NAME$conversationId" + } + } +} + +enum class ConversationDeletionLocallyStatus { + IDLE, RUNNING, SUCCEEDED, FAILED +} + +/** + * Enqueues a background task to delete a conversation and its associated assets locally. + * + * This function uses the WorkManager API to create and enqueue a one-time work request for + * the `DeleteConversationLocallyWorker`. The work request is configured to run either as + * expedited (if quota allows) or as non-expedited work. If a deletion task for the same + * conversation is already running, the existing task will be kept or replaced based on the + * work policy. + * + * Additionally, this function returns a [Flow] that allows observing the status of the + * conversation deletion process. + * + * *Not having a Backoff criteria is intentional.* If there is an error during the process, + * it will be displayed to the users immediately rather than retrying silently in the background. + */ +fun WorkManager.enqueueConversationDeletionLocally(conversationId: ConversationId): Flow { + val workName = DeleteConversationLocallyWorker.createUniqueWorkName(conversationId.toString()) + val request = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(workDataOf(DeleteConversationLocallyWorker.CONVERSATION_ID to conversationId.toString())) + .build() + val isAlreadyRunning = getWorkInfosForUniqueWorkLiveData(workName) + .value + ?.firstOrNull() + ?.state == WorkInfo.State.RUNNING + enqueueUniqueWork( + workName, + if (isAlreadyRunning) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.REPLACE, + request + ) + + return observeConversationDeletionStatusLocally(conversationId) +} + +/** + * Observes the status of a conversation deletion task running locally. + * + * This function returns a [Flow] that emits the current status of the conversation deletion + * process for the given conversation ID. The status is determined by monitoring the + * [WorkInfo] associated with the unique work name of the `DeleteConversationLocallyWorker`. + * + * The returned statuses include: + * - [ConversationDeletionLocallyStatus.RUNNING]: The deletion task is currently in progress. + * - [ConversationDeletionLocallyStatus.SUCCEEDED]: The deletion task completed successfully. + * - [ConversationDeletionLocallyStatus.FAILED]: The deletion task failed, was blocked, or cancelled. + * - [ConversationDeletionLocallyStatus.IDLE]: No active work is found for the given conversation ID. + */ +fun WorkManager.observeConversationDeletionStatusLocally(conversationId: ConversationId): Flow { + return getWorkInfosForUniqueWorkFlow(DeleteConversationLocallyWorker.createUniqueWorkName(conversationId.toString())) + .mapNotNull { workInfos -> + workInfos.lastOrNull()?.let { workInfo -> + when (workInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.RUNNING -> ConversationDeletionLocallyStatus.RUNNING + + WorkInfo.State.SUCCEEDED -> ConversationDeletionLocallyStatus.SUCCEEDED + WorkInfo.State.FAILED, + WorkInfo.State.BLOCKED, + WorkInfo.State.CANCELLED -> ConversationDeletionLocallyStatus.FAILED + } + } ?: ConversationDeletionLocallyStatus.IDLE + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee6757b6701..6f271514800 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -574,6 +574,9 @@ Delete Group… Remove “%s”? The group will be removed from your conversations list on all devices. You will no longer be able to access the group and its content. + Delete “%s” group for me? + You won’t be able to access the group and its content. There is no option to restore it. + Delete for Me Share With Wire Search for conversation @@ -667,6 +670,7 @@ Block Unblock Leave Group + Delete Group For Me Delete Group Filter Conversations @@ -916,6 +920,7 @@ User could not be blocked %s blocked “%s” removed + “%s” deleted for me There was an error while leaving conversation You joined the conversation. You left the conversation. diff --git a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt index 35cd7180406..557b0041251 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt @@ -156,6 +156,43 @@ class ConversationSheetContentTest { assertFalse(canEditNotifications) } + @Test + fun givenGroupConversation_whenMemberOfTheConversation_thenDeleteConversationLocallyIsNotVisible() = runTest { + // given + val conversationSheetContent = createGroupSheetContent("Title") + + // when + val canDeleteGroupLocally = conversationSheetContent.canDeleteGroupLocally() + + // then + assertFalse(canDeleteGroupLocally) + } + + @Test + fun givenGroupConversation_whenNotMemberOfTheConversation_thenDeleteConversationLocallyIsVisible() = runTest { + // given + val conversationSheetContent = createGroupSheetContent(title = "Title", selfRole = null) + + // when + val canDeleteGroupLocally = conversationSheetContent.canDeleteGroupLocally() + + // then + assertTrue(canDeleteGroupLocally) + } + + @Test + fun givenGroupConversation_whenNotMemberOfTheConversationAndDeletionRunning_thenDeleteConversationLocallyIsNotVisible() = runTest { + // given + val conversationSheetContent = + createGroupSheetContent(title = "Title", selfRole = null, conversationDeletionLocallyRunning = true) + + // when + val canDeleteGroupLocally = conversationSheetContent.canDeleteGroupLocally() + + // then + assertFalse(canDeleteGroupLocally) + } + private fun createPrivateSheetContent( blockingState: BlockingState, isUserDeleted: Boolean @@ -178,12 +215,15 @@ class ConversationSheetContentTest { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isUnderLegalHold = false, - isFavorite = false + isFavorite = false, + isDeletingConversationLocallyRunning = false ) } private fun createGroupSheetContent( - title: String + title: String, + selfRole: Conversation.Member.Role? = Conversation.Member.Role.Member, + conversationDeletionLocallyRunning: Boolean = false, ): ConversationSheetContent { val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) @@ -192,14 +232,15 @@ class ConversationSheetContentTest { conversationId = details.conversation.id, mutingConversationState = details.conversation.mutedStatus, conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), - selfRole = Conversation.Member.Role.Member, + selfRole = selfRole, isTeamConversation = details.conversation.isTeamGroup(), isArchived = false, protocol = Conversation.ProtocolInfo.Proteus, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isUnderLegalHold = false, - isFavorite = false + isFavorite = false, + isDeletingConversationLocallyRunning = conversationDeletionLocallyRunning ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt index cd2a5611e20..edf4b90c139 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt @@ -452,7 +452,8 @@ class GroupConversationDetailsViewModelTest { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, isUnderLegalHold = true, - isFavorite = false + isFavorite = false, + isDeletingConversationLocallyRunning = false ) // When - Then assertEquals(expected, viewModel.conversationSheetContent) 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 56bc7e134b5..70dcead1d5b 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 @@ -23,6 +23,7 @@ import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingData import androidx.paging.testing.asSnapshot +import androidx.work.WorkManager import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider @@ -274,6 +275,9 @@ class ConversationListViewModelTest { @MockK private lateinit var observeSelfUser: GetSelfUserUseCase + @MockK + private lateinit var workManager: WorkManager + init { MockKAnnotations.init(this, relaxUnitFun = true) withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) @@ -343,7 +347,8 @@ class ConversationListViewModelTest { observeLegalHoldStateForSelfUser = observeLegalHoldStateForSelfUserUseCase, userTypeMapper = UserTypeMapper(), observeSelfUser = observeSelfUser, - usePagination = true + usePagination = true, + workManager = workManager ) }