From e77eb2ca90092d685ae87e2d887dbfaec888dc3d Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 23 Feb 2026 11:23:02 +0100 Subject: [PATCH 01/15] fix: Improve network manager coroutine scope --- .../main/java/com/infomaniak/mail/utils/NetworkManager.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt b/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt index ff8e201f94..7a4d02a678 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt @@ -21,7 +21,6 @@ import com.infomaniak.core.network.NetworkAvailability import com.infomaniak.core.sentry.SentryLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach @@ -31,8 +30,6 @@ import javax.inject.Singleton @Singleton class NetworkManager @Inject constructor() { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - /** * A StateFlow that emits the current network availability status. * It starts collecting immediately and keeps the latest value in memory. @@ -42,7 +39,7 @@ class NetworkManager @Inject constructor() { SentryLog.d("NetworkManager", if (available) "Online" else "Offline") } .stateIn( - scope = scope, + scope = CoroutineScope(Dispatchers.Default), started = SharingStarted.Eagerly, initialValue = true ) From 49b2305b5c9c8d78bf0ca1e8fec673e68b1cdcb0 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 18 Feb 2026 16:04:25 +0100 Subject: [PATCH 02/15] refactor: Add move, spam and delete use cases --- .../com/infomaniak/mail/ui/MainViewModel.kt | 23 -- .../main/folder/PerformSwipeActionManager.kt | 4 +- .../main/folder/ThreadListMultiSelection.kt | 2 +- .../main/folderPicker/FolderPickerFragment.kt | 22 +- .../mail/ui/main/thread/ThreadFragment.kt | 2 +- .../main/thread/actions/ActionsViewModel.kt | 287 +++++++----------- .../MessageActionsBottomSheetDialog.kt | 4 +- .../actions/MultiSelectBottomSheetDialog.kt | 4 +- .../actions/ThreadActionsBottomSheetDialog.kt | 4 +- .../mail/useCases/MessagesActionsUseCase.kt | 252 +++++++++++++++ .../com/infomaniak/mail/utils/SharedUtils.kt | 12 +- 11 files changed, 391 insertions(+), 225 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index e958c1ef49..bef872182c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -747,29 +747,6 @@ class MainViewModel @Inject constructor( return apiResponses } - /** - * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder - * that were targeting this message will display for a brief moment until we refresh their folders. This is because those - * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be - * displayed. - * - * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by - * the api at the same time anyway. - */ - private suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { - for (messageToMove in messagesToMove) { - if (messageToMove.emojiReactions.isEmpty()) continue - - mailboxContentRealm().write { - messageToMove.emojiReactions.forEach { reaction -> - reaction.authors.forEach { author -> - MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) - } - } - } - } - } - private fun showMoveSnackbar( threads: List, message: Message?, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 42133c5b38..faa85e75e3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -138,7 +138,7 @@ object PerformSwipeActionManager { } SwipeAction.SPAM -> { - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox @@ -206,7 +206,7 @@ object PerformSwipeActionManager { fun onHandleDelete() { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 86cad6bcbe..d4a750b718 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -113,7 +113,7 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = selectedThreads.toList(), currentFolder = currentFolder.value, mailbox = currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index 50c997ef5f..5fb284f50e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt @@ -153,13 +153,21 @@ class FolderPickerFragment : Fragment() { private fun onFolderSelected(folder: Folder?): Unit = with(navigationArgs) { when (action) { FolderPickerAction.MOVE -> folder?.id?.let { - actionsViewModel.moveThreadsOrMessagesTo( - destinationFolderId = it, - threadsUids = threadsUids.toList(), - messagesUids = messagesUids?.toList(), - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!! - ) + if (messagesUids != null) { + actionsViewModel.moveMessagesTo( + destinationFolderId = it, + messagesUids = messagesUids.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) + } else { + actionsViewModel.moveThreadsTo( + destinationFolderId = it, + threadsUids = threadsUids.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) + } } FolderPickerAction.SEARCH -> { searchViewModel.selectAllFoldersFilter(folder == null) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index aed536b8ac..0cbb14fc8b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -864,7 +864,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Delete) val thread = threadViewModel.threadLive.value ?: return@deleteWithConfirmationPopup - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index e8c4d0a85e..a500c7f51d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -46,7 +46,7 @@ import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.di.IoDispatcher import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData -import com.infomaniak.mail.utils.FeatureAvailability +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh @@ -55,7 +55,6 @@ import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear import com.infomaniak.mail.utils.extensions.allFailed import com.infomaniak.mail.utils.extensions.appContext -import com.infomaniak.mail.utils.extensions.atLeastOneFailed import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded import com.infomaniak.mail.utils.extensions.getFirstTranslatedError import com.infomaniak.mail.utils.extensions.getFoldersIds @@ -79,6 +78,7 @@ class ActionsViewModel @Inject constructor( private val mailboxContentRealm: RealmDatabase.MailboxContent, private val mailboxController: MailboxController, private val messageController: MessageController, + private val messagesActionsUseCase: MessagesActionsUseCase, private val sharedUtils: SharedUtils, private val snackbarManager: SnackbarManager, private val threadController: ThreadController, @@ -96,24 +96,46 @@ class ActionsViewModel @Inject constructor( fun moveToSpamFolder(messagesUid: List, currentFolderId: String?, mailbox: Mailbox) { viewModelScope.launch(ioCoroutineContext) { val messages = messageController.getMessages(messagesUid) - toggleMessagesSpamStatus(messages, currentFolderId, mailbox) + handleToggleSpamMessages(messages, currentFolderId, mailbox, displaySnackbar = true) } } - fun toggleThreadsOrMessagesSpamStatus( - messages: List? = null, - threads: Set? = null, + fun toggleThreadsSpamStatus( + threads: Set, currentFolderId: String?, mailbox: Mailbox, displaySnackbar: Boolean = true ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMarkAsSpam = when { - threads != null -> getMessagesFromThreadToSpamOrHam(threads) - messages != null -> messageController.getUnscheduledMessages(messages) - else -> emptyList() - } + val messagesToMarkAsSpam = messagesActionsUseCase.getMessagesFromThreadToSpamOrHam(threads) + handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) + } - toggleMessagesSpamStatus(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) + fun toggleMessagesSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true + ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMarkAsSpam = messageController.getUnscheduledMessages(messages) + handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) + } + + private fun handleToggleSpamMessages( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true + ) = viewModelScope.launch(ioCoroutineContext) { + val result = messagesActionsUseCase.toggleMessagesSpamStatus( + messages = messages, + currentFolderId = currentFolderId, + mailbox = mailbox, + callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + ) + + if (displaySnackbar) { + showMoveSnackbar(result.movedThreads, result.messages, result.apiResponses, result.destinationFolder) + } } fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { @@ -141,32 +163,6 @@ class ActionsViewModel @Inject constructor( } } - private fun toggleMessagesSpamStatus( - messages: List, - currentFolderId: String?, - mailbox: Mailbox, - displaySnackbar: Boolean = true, - ) = viewModelScope.launch(ioCoroutineContext) { - - val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null - val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) - - val destinationFolderRole = if (folderRole == FolderRole.SPAM) { - FolderRole.INBOX - } else { - FolderRole.SPAM - } - val destinationFolder = folderController.getFolder(destinationFolderRole)!! - - val unscheduledMessages = messageController.getUnscheduledMessages(messages) - - moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduledMessages, displaySnackbar) - } - - private suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { - return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } - } - private suspend fun updateBlockedSenders(mailbox: Mailbox, updatedSendersRestrictions: SendersRestrictions) { with(ApiRepository.updateBlockedSenders(mailbox.hostingId, mailbox.mailboxName, updatedSendersRestrictions)) { if (isSuccess()) { @@ -179,98 +175,47 @@ class ActionsViewModel @Inject constructor( //endregion //region Move - fun moveThreadsOrMessagesTo( + fun moveThreadsTo( destinationFolderId: String, - threadsUids: List? = null, - messagesUids: List? = null, + threadsUids: List, currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val destinationFolder = folderController.getFolder(destinationFolderId) ?: return@launch - val threads: List? = threadsUids?.let { threadController.getThreads(threadsUids).toList() } - val messages = messagesUids?.let { messageController.getMessages(it) } - val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) + val threads: List = threadController.getThreads(threadsUids).toList() + val messagesToMove = sharedUtils.getMessagesFromThreadsToMove(threads) - moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) + handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } - private suspend fun moveMessagesTo( - destinationFolder: Folder, + fun moveMessagesTo( + destinationFolderId: String, + messagesUids: List, currentFolderId: String?, mailbox: Mailbox, - messages: List, - shouldDisplaySnackbar: Boolean = true, - ) { - - val movedThreads = moveOutThreadsLocally(messages, destinationFolder) - val featureFlags = mailbox.featureFlags - - val apiResponses = moveMessages( - mailbox = mailbox, - messagesToMove = messages, - destinationFolder = destinationFolder, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlags, localSettings), - ) - - if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { - - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), - destinationFolderId = destinationFolder.id, - currentFolderId = currentFolderId, - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(movedThreads) }), - ) - } - - if (apiResponses.atLeastOneFailed() && movedThreads.isNotEmpty()) { - threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) - } + ) = viewModelScope.launch(ioCoroutineContext) { + val messages = messagesUids.let { messageController.getMessages(it) } + val messagesToMove = sharedUtils.getMessagesToMove(messages, currentFolderId) - if (shouldDisplaySnackbar) showMoveSnackbar(movedThreads, messages, apiResponses, destinationFolder) + handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } - private suspend fun moveMessages( + private fun handleMessagesMove( + destinationFolderId: String, + messages: List, + currentFolderId: String?, mailbox: Mailbox, - messagesToMove: List, - destinationFolder: Folder, - alsoMoveReactionMessages: Boolean, - ): List> { - val apiResponses = ApiRepository.moveMessages( - mailboxUuid = mailbox.uuid, - messagesUids = messagesToMove.getUids(), - destinationId = destinationFolder.id, - alsoMoveReactionMessages = alsoMoveReactionMessages, + ) = viewModelScope.launch(ioCoroutineContext) { + + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return@launch + val result = messagesActionsUseCase.moveMessagesTo( + destinationFolder, + currentFolderId, + mailbox, + messages, + callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), ) - // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the - // same batch or in other batches that are target by emoji reactions did not - if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) deleteEmojiReactionMessagesLocally(messagesToMove) - - return apiResponses - } - - /** - * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder - * that were targeting this message will display for a brief moment until we refresh their folders. This is because those - * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be - * displayed. - * - * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by - * the api at the same time anyway. - */ - private suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { - for (messageToMove in messagesToMove) { - if (messageToMove.emojiReactions.isEmpty()) continue - - mailboxContentRealm().write { - messageToMove.emojiReactions.forEach { reaction -> - reaction.authors.forEach { author -> - MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) - } - } - } - } + showMoveSnackbar(result.movedThreads, result.messages, result.apiResponses, result.destinationFolder) } private fun showMoveSnackbar( @@ -311,74 +256,59 @@ class ActionsViewModel @Inject constructor( //endregion //region Delete - fun deleteThreadsOrMessages( - threads: List? = null, - messages: List? = null, + fun deleteThreads( + threads: List, currentFolder: Folder?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToDelete = getMessagesToDelete(threads, messages) - deleteMessages(messagesToDelete, currentFolder, mailbox) + val messagesToDelete = messagesActionsUseCase.getMessagesFromThreadToDelete(threads) + handleDeleteMessages(messagesToDelete, currentFolder, mailbox) } - private fun deleteMessages( + fun deleteMessages( messages: List, currentFolder: Folder?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val shouldPermanentlyDelete = isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messages, currentFolder)) + val messagesToDelete = messagesActionsUseCase.getMessagesToDelete(messages) + handleDeleteMessages(messagesToDelete, currentFolder, mailbox) + } + + private fun handleDeleteMessages( + messagesToDelete: List, + currentFolder: Folder?, + mailbox: Mailbox, + ) = viewModelScope.launch(ioCoroutineContext) { + val shouldPermanentlyDelete = + isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messagesToDelete, currentFolder)) if (shouldPermanentlyDelete) { - permanentlyDelete(messages, currentFolder, mailbox) + val result = messagesActionsUseCase.permanentlyDelete( + messagesToDelete, + currentFolder, + mailbox, + onApiFinished = { activityDialogLoaderResetTrigger.postValue(Unit) }, // UI logic stays here + refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + ) + + showDeleteSnackbar( + apiResponses = result.apiResponses, + messages = messagesToDelete, + undoResources = result.undoResources, + undoFoldersIds = result.undoFoldersIds, + undoDestinationId = result.undoDestinationId, + numberOfImpactedThreads = messagesToDelete.count() + ) } else { moveMessagesTo( - destinationFolder = folderController.getFolder(FolderRole.TRASH)!!, - messages = messages, + destinationFolderId = folderController.getFolder(FolderRole.TRASH)!!.id, + messagesUids = messagesToDelete.getUids(), currentFolderId = currentFolder?.id, mailbox = mailbox ) } } - private suspend fun permanentlyDelete(messagesToDelete: List, currentFolder: Folder?, mailbox: Mailbox) { - val undoResources = emptyList() - val uids = messagesToDelete.getUids() - - val uidsToMove = moveOutThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) - - val apiResponses = ApiRepository.deleteMessages( - mailboxUuid = mailbox.uuid, - messagesUids = uids, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings) - ) - - activityDialogLoaderResetTrigger.postValue(Unit) - - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messagesToDelete.getFoldersIds(), - currentFolderId = currentFolder?.id, - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(uidsToMove) }), - ) - } - - if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( - threadsUids = uidsToMove, - hasBeenMovedOut = false - ) - - val undoDestinationId = messagesToDelete.first().folderId - val undoFoldersIds = messagesToDelete.getFoldersIds(exception = undoDestinationId) - showDeleteSnackbar( - apiResponses = apiResponses, - messages = messagesToDelete, - undoResources = undoResources, - undoFoldersIds = undoFoldersIds, - undoDestinationId = undoDestinationId, - numberOfImpactedThreads = messagesToDelete.count(), - ) - } private fun showDeleteSnackbar( apiResponses: List>, @@ -406,12 +336,6 @@ class ActionsViewModel @Inject constructor( snackbarManager.postValue(snackbarTitle, undoData) } - private suspend fun getMessagesToDelete(threads: List?, messages: List?) = when { - threads != null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() - } - //endregion //region Archive @@ -497,6 +421,15 @@ class ActionsViewModel @Inject constructor( } } + private fun onDownloadStart() { + isDownloadingChanges.postValue(true) + } + + private fun onDownloadStop(threadsUids: List = emptyList()) = viewModelScope.launch(ioCoroutineContext) { + threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) + isDownloadingChanges.postValue(false) + } + private suspend fun getMessagesToMarkAsUnseen( threads: List?, messages: List?, @@ -594,7 +527,7 @@ class ActionsViewModel @Inject constructor( val snackbarTitle = if (isSuccess()) { if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { - toggleThreadsOrMessagesSpamStatus( + toggleMessagesSpamStatus( messages = messages, currentFolderId = currentFolder?.id, mailbox = mailbox, @@ -780,14 +713,6 @@ class ActionsViewModel @Inject constructor( } //endregion - private fun onDownloadStart() { - isDownloadingChanges.postValue(true) - } - - private fun onDownloadStop(threadsUids: List = emptyList()) = viewModelScope.launch(ioCoroutineContext) { - threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) - isDownloadingChanges.postValue(false) - } private fun refreshFoldersAsync( mailbox: Mailbox, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index c10b2c193b..3af5c80e47 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -160,7 +160,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onDelete() { descriptionDialog.deleteWithConfirmationPopup(message.folder.role, count = 1) { trackBottomSheetMessageActionsEvent(MatomoName.Delete) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteMessages( messages = listOf(message), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -228,7 +228,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onSpam() { trackBottomSheetMessageActionsEvent(MatomoName.Spam, value = isFromSpam) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleMessagesSpamStatus( messages = listOf(message), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index aff85c5de8..bdaa9fedf1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -146,7 +146,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.spam.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.Spam, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleThreadsSpamStatus( threads = threads, currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox, @@ -270,7 +270,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, threadsCount, isFromBottomSheet = true) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = threads.toList(), currentFolder = currentFolder, mailbox = currentMailbox diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index da55e45de6..6af12e052a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -179,7 +179,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onDelete() { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Delete) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -256,7 +256,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onSpam() { trackBottomSheetThreadActionsEvent(MatomoName.Spam, value = isFromSpam) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt new file mode 100644 index 0000000000..9f8c47e164 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -0,0 +1,252 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.mail.useCases + +import com.infomaniak.core.network.models.ApiResponse +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.data.api.ApiRepository +import com.infomaniak.mail.data.cache.RealmDatabase +import com.infomaniak.mail.data.cache.mailboxContent.FolderController +import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders +import com.infomaniak.mail.data.cache.mailboxContent.MessageController +import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks +import com.infomaniak.mail.data.cache.mailboxContent.ThreadController +import com.infomaniak.mail.data.models.Folder +import com.infomaniak.mail.data.models.Folder.FolderRole +import com.infomaniak.mail.data.models.MoveResult +import com.infomaniak.mail.data.models.mailbox.Mailbox +import com.infomaniak.mail.data.models.message.Message +import com.infomaniak.mail.data.models.thread.Thread +import com.infomaniak.mail.utils.FeatureAvailability +import com.infomaniak.mail.utils.FolderRoleUtils +import com.infomaniak.mail.utils.SharedUtils +import com.infomaniak.mail.utils.extensions.atLeastOneFailed +import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded +import com.infomaniak.mail.utils.extensions.getFoldersIds +import com.infomaniak.mail.utils.extensions.getUids +import javax.inject.Inject + +class MessagesActionsUseCase @Inject constructor( + private val folderController: FolderController, + private val folderRoleUtils: FolderRoleUtils, + private val localSettings: LocalSettings, + private val mailboxContentRealm: RealmDatabase.MailboxContent, + private val messageController: MessageController, + private val threadController: ThreadController, + private val sharedUtils: SharedUtils, +) { + + suspend fun toggleMessagesSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + callbacks: RefreshCallbacks? = null, + ): MoveMessagesResult { + val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null + val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) + + val destinationFolderRole = if (folderRole == FolderRole.SPAM) { + FolderRole.INBOX + } else { + FolderRole.SPAM + } + val destinationFolder = folderController.getFolder(destinationFolderRole)!! + + val unscheduleMessages = messageController.getUnscheduledMessages(messages) + + return moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, callbacks) + } + + suspend fun moveMessagesTo( + destinationFolder: Folder, + currentFolderId: String?, + mailbox: Mailbox, + messages: List, + callbacks: RefreshCallbacks? = null, + ): MoveMessagesResult { + + val movedThreads = moveOutThreadsLocally(messages, destinationFolder) + val featureFlags = mailbox.featureFlags + + val apiResponses = moveMessages( + mailbox = mailbox, + messagesToMove = messages, + destinationFolder = destinationFolder, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlags, localSettings), + ) + + if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { + + refreshFoldersAsync( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), + destinationFolderId = destinationFolder.id, + currentFolderId = currentFolderId, + callbacks = callbacks, + ) + } + + if (apiResponses.atLeastOneFailed() && movedThreads.isNotEmpty()) { + threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) + } + + return MoveMessagesResult(movedThreads, messages, apiResponses, destinationFolder) + } + + private suspend fun refreshFoldersAsync( + mailbox: Mailbox, + messagesFoldersIds: ImpactedFolders, + currentFolderId: String? = null, + destinationFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + ) { + sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) + } + + private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { + val uidsToMove = mutableListOf().apply { + messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> + val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } + if (nbMessagesInCurrentFolder == 0) add(thread.uid) + } + } + + if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) + return uidsToMove + } + + + private suspend fun moveMessages( + mailbox: Mailbox, + messagesToMove: List, + destinationFolder: Folder, + alsoMoveReactionMessages: Boolean, + ): List> { + val apiResponses = ApiRepository.moveMessages( + mailboxUuid = mailbox.uuid, + messagesUids = messagesToMove.getUids(), + destinationId = destinationFolder.id, + alsoMoveReactionMessages = alsoMoveReactionMessages, + ) + + // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the + // same batch or in other batches that are target by emoji reactions did not + if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) deleteEmojiReactionMessagesLocally(messagesToMove) + + return apiResponses + } + + /** + * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder + * that were targeting this message will display for a brief moment until we refresh their folders. This is because those + * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be + * displayed. + * + * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by + * the api at the same time anyway. + */ + suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { + for (messageToMove in messagesToMove) { + if (messageToMove.emojiReactions.isEmpty()) continue + + mailboxContentRealm().write { + messageToMove.emojiReactions.forEach { reaction -> + reaction.authors.forEach { author -> + MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) + } + } + } + } + } + + suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { + return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } + } + + + // Delete Region + suspend fun permanentlyDelete( + messagesToDelete: List, + currentFolder: Folder?, + mailbox: Mailbox, + onApiFinished: () -> Unit, // Callback for the loader reset + refreshCallbacks: RefreshCallbacks? + ): DeleteResult { + val undoResources = emptyList() + val uids = messagesToDelete.getUids() + + val uidsToMove = moveOutThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) + + val apiResponses = ApiRepository.deleteMessages( + mailboxUuid = mailbox.uuid, + messagesUids = uids, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings) + ) + + onApiFinished() + + if (apiResponses.atLeastOneSucceeded()) { + refreshFoldersAsync( + mailbox = mailbox, + messagesFoldersIds = messagesToDelete.getFoldersIds(), + currentFolderId = currentFolder?.id, + callbacks = refreshCallbacks, + ) + } + + if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( + threadsUids = uidsToMove, + hasBeenMovedOut = false + ) + + val undoDestinationId = messagesToDelete.first().folderId + val undoFoldersIds = messagesToDelete.getFoldersIds(exception = undoDestinationId) + + return DeleteResult( + apiResponses = apiResponses, + uidsToMove = uidsToMove, + undoResources = undoResources, + undoFoldersIds = undoFoldersIds, + undoDestinationId = undoDestinationId, + ) + } + + suspend fun getMessagesFromThreadToDelete(threads: List): List { + return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } + } + + suspend fun getMessagesToDelete(messages: List) = messageController.getMessagesAndDuplicates(messages) + + // End Region + data class MoveMessagesResult( + val movedThreads: List, + val messages: List, + val apiResponses: List>, + val destinationFolder: Folder + ) + + data class DeleteResult( + val apiResponses: List>, + val uidsToMove: List, + val undoResources: List, + val undoFoldersIds: ImpactedFolders, + val undoDestinationId: String, + ) +} + + diff --git a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt index 476d895dd0..def387118d 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -146,10 +146,14 @@ class SharedUtils @Inject constructor( else -> listOf(message) } - suspend fun getMessagesToMove(threads: List?, messages: List?, currentFolderId: String?) = when { - messages != null -> messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } - threads != null -> threads.flatMap { messageController.getMovableMessages(it) } - else -> emptyList() //this should never happen, we have to send a list of threads or messages. + suspend fun getMessagesFromThreadsToMove(threads: List): List { + return threads.flatMap { + messageController.getMovableMessages(it) + } + } + + fun getMessagesToMove(messages: List, currentFolderId: String?): List { + return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } } suspend fun refreshFolders( From 34925628122f2e67174bef28cd42d7e76a64126d Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 19 Feb 2026 09:37:47 +0100 Subject: [PATCH 03/15] refactor: Add seen actions to messagesActionsUseCase --- .../com/infomaniak/mail/ui/MainViewModel.kt | 15 ++- .../main/folder/PerformSwipeActionManager.kt | 4 +- .../main/folder/ThreadListMultiSelection.kt | 4 +- .../mail/ui/main/thread/ThreadFragment.kt | 2 +- .../main/thread/actions/ActionsViewModel.kt | 115 ++++++------------ .../MessageActionsBottomSheetDialog.kt | 4 +- .../actions/MultiSelectBottomSheetDialog.kt | 4 +- .../actions/ThreadActionsBottomSheetDialog.kt | 4 +- .../mail/useCases/MessagesActionsUseCase.kt | 51 ++++++++ 9 files changed, 108 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index bef872182c..ed0ed7d868 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -70,6 +70,7 @@ import com.infomaniak.mail.di.IoDispatcher import com.infomaniak.mail.di.MailboxInfoRealm import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.ContactUtils.getPhoneContacts import com.infomaniak.mail.utils.ContactUtils.mergeApiContactsIntoPhoneContacts @@ -141,6 +142,7 @@ class MainViewModel @Inject constructor( private val mailboxController: MailboxController, private val mergedContactController: MergedContactController, private val messageController: MessageController, + private val messagesActionsUseCase: MessagesActionsUseCase, private val myKSuiteDataUtils: MyKSuiteDataUtils, private val networkManager: NetworkManager, private val permissionsController: PermissionsController, @@ -686,8 +688,13 @@ class MainViewModel @Inject constructor( ) = viewModelScope.launch(ioCoroutineContext) { val destinationFolder = folderController.getFolder(destinationFolderId)!! val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - val messages = messagesUid?.let { messageController.getMessages(it) } - val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) + var messagesToMove = emptyList() + if (messagesUid != null) { + val messages = messagesUid.let { messageController.getMessages(it) } + messagesToMove = sharedUtils.getMessagesToMove(messages, currentFolderId) + } else { + messagesToMove = sharedUtils.getMessagesFromThreadsToMove(threads) + } moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, null, messagesToMove) } @@ -742,7 +749,9 @@ class MainViewModel @Inject constructor( // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the // same batch or in other batches that are target by emoji reactions did not - if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) deleteEmojiReactionMessagesLocally(messagesToMove) + if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) messagesActionsUseCase.deleteEmojiReactionMessagesLocally( + messagesToMove + ) return apiResponses } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index faa85e75e3..b4080c3985 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -129,7 +129,7 @@ object PerformSwipeActionManager { true } SwipeAction.READ_UNREAD -> { - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(thread.uid), currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox @@ -173,7 +173,7 @@ object PerformSwipeActionManager { } fun onSuccess() { - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index d4a750b718..c105da1c02 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -76,7 +76,7 @@ class ThreadListMultiSelection { when (menuId) { R.id.quickActionUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedThreadsCount) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, currentFolderId = currentFolderId, @@ -90,7 +90,7 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = selectedThreads.toList(), currentFolder = currentFolder.value, mailbox = currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 0cbb14fc8b..091ce61b2f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -853,7 +853,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Archive, isFromArchive) val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index a500c7f51d..6372a7854c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -309,7 +309,6 @@ class ActionsViewModel @Inject constructor( } } - private fun showDeleteSnackbar( apiResponses: List>, messages: List, @@ -339,18 +338,25 @@ class ActionsViewModel @Inject constructor( //endregion //region Archive + fun archiveThreads( + threads: List, + currentFolder: Folder?, + mailbox: Mailbox, + ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMove = sharedUtils.getMessagesFromThreadsToMove(threads) + handleArchiveMessage(messagesToMove, currentFolder, mailbox) + } - fun archiveThreadsOrMessages( - threads: List? = null, - messages: List? = null, + fun archiveMessages( + messages: List, currentFolder: Folder?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolder?.id) - archiveMessages(messagesToMove, currentFolder, mailbox) + val messagesToMove = sharedUtils.getMessagesToMove(messages, currentFolder?.id) + handleArchiveMessage(messagesToMove, currentFolder, mailbox) } - private fun archiveMessages( + private fun handleArchiveMessage( messages: List, currentFolder: Folder?, mailbox: Mailbox @@ -361,86 +367,36 @@ class ActionsViewModel @Inject constructor( val destinationFolderRole = if (isFromArchive) FolderRole.INBOX else FolderRole.ARCHIVE val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return@launch - moveMessagesTo(destinationFolder, currentFolder?.id, mailbox, messages) + moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolder?.id, mailbox) } //region Seen - fun toggleThreadsOrMessagesSeenStatus( - threadsUids: List? = null, - messages: List? = null, + fun toggleThreadsSeenStatus( + threadsUids: List, shouldRead: Boolean = true, currentFolderId: String?, mailbox: Mailbox - ) { - toggleMessagesSeenStatus(threadsUids, messages, shouldRead = shouldRead, currentFolderId, mailbox) + ) = viewModelScope.launch(ioCoroutineContext) { + val threads = threadsUids.let { threadController.getThreads(threadsUids) } + val isSeen = if (threads.count() == 1) threads.single().isSeen else !shouldRead + val messagesToToggleSeen = messagesActionsUseCase.getMessagesFromThreadsToMarkAsUnseen(threads, mailbox) + + messagesActionsUseCase.handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox) } - private fun toggleMessagesSeenStatus( - threadsUids: List? = null, - messages: List? = null, + fun toggleMessagesSeenStatus( + messages: List, shouldRead: Boolean = true, currentFolderId: String?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - val threads = threadsUids?.let { threadController.getThreads(threadsUids) } - val isSeen = when { - messages?.count() == 1 -> messages.single().isSeen - threads?.count() == 1 -> threads.single().isSeen - else -> !shouldRead - } - - val messagesToToggleSeen = getMessagesToMarkAsUnseen(threads, messages, mailbox) - - if (isSeen) { - markAsUnseen(messagesToToggleSeen, mailbox) - } else { - sharedUtils.markMessagesAsSeen( - messages = messagesToToggleSeen, - currentFolderId = currentFolderId, - mailbox = mailbox, - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } - } - - private suspend fun markAsUnseen(messages: List, mailbox: Mailbox) { - val messagesUids = messages.map { it.uid } - - sharedUtils.updateSeenStatus(messagesUids, isSeen = false) + val isSeen = if (messages.count() == 1) messages.single().isSeen else !shouldRead + val messagesToToggleSeen = messagesActionsUseCase.getMessagesToMarkAsUnseen(messages) + val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) - val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) - - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } else { - sharedUtils.updateSeenStatus(messagesUids, isSeen = true) - } - } - - private fun onDownloadStart() { - isDownloadingChanges.postValue(true) - } - - private fun onDownloadStop(threadsUids: List = emptyList()) = viewModelScope.launch(ioCoroutineContext) { - threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) - isDownloadingChanges.postValue(false) + messagesActionsUseCase.handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox, refreshCallbacks) } - private suspend fun getMessagesToMarkAsUnseen( - threads: List?, - messages: List?, - mailbox: Mailbox - ): List = when { - threads != null -> threads.flatMap { thread -> - messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) - } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() //this should never happen, we should always send a list of messages or threads. - } //endregion //region Favorite @@ -724,15 +680,12 @@ class ActionsViewModel @Inject constructor( sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) } - private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { - val uidsToMove = mutableListOf().apply { - messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> - val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } - if (nbMessagesInCurrentFolder == 0) add(thread.uid) - } - } + private fun onDownloadStart() { + isDownloadingChanges.postValue(true) + } - if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) - return uidsToMove + private fun onDownloadStop(threadsUids: List = emptyList()) = viewModelScope.launch(ioCoroutineContext) { + threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) + isDownloadingChanges.postValue(false) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 3af5c80e47..220775e192 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -173,7 +173,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onArchive() { descriptionDialog.archiveWithConfirmationPopup(message.folder.role, count = 1) { trackBottomSheetMessageActionsEvent(MatomoName.Archive, message.folder.role == FolderRole.ARCHIVE) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveMessages( messages = listOf(message), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -183,7 +183,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onReadUnread() { trackBottomSheetMessageActionsEvent(MatomoName.MarkAsSeen, message.isSeen) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleMessagesSeenStatus( messages = listOf(message), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index bdaa9fedf1..8435840327 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -244,7 +244,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { R.id.actionMove -> onMoveClicked(threadsCount, threadsUids, folderRole) R.id.actionReadUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = threadsUids, shouldRead = shouldRead, currentFolderId = currentFolderId, @@ -257,7 +257,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, threadsCount, isFromBottomSheet = true) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = threads.toList(), currentFolder = currentFolder, mailbox = currentMailbox diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 6af12e052a..c207d392d5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -192,7 +192,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onArchive() { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Archive, isFromArchive) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -202,7 +202,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onReadUnread() { trackBottomSheetThreadActionsEvent(MatomoName.MarkAsSeen, value = thread.isSeen) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(navigationArgs.threadUid), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index 9f8c47e164..4f4e36b1e6 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -233,6 +233,57 @@ class MessagesActionsUseCase @Inject constructor( suspend fun getMessagesToDelete(messages: List) = messageController.getMessagesAndDuplicates(messages) // End Region + + // Seen Region + suspend fun getMessagesFromThreadsToMarkAsUnseen(threads: List, mailbox: Mailbox): List { + return threads.flatMap { thread -> + messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) + } + } + + suspend fun getMessagesToMarkAsUnseen(messages: List): List { + return messageController.getMessagesAndDuplicates(messages) + } + + suspend fun markAsUnseen(messages: List, mailbox: Mailbox, callbacks: RefreshCallbacks?) { + val messagesUids = messages.map { it.uid } + + sharedUtils.updateSeenStatus(messagesUids, isSeen = false) + + val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) + + if (apiResponses.atLeastOneSucceeded()) { + refreshFoldersAsync( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + callbacks = callbacks, + ) + } else { + sharedUtils.updateSeenStatus(messagesUids, isSeen = true) + } + } + + suspend fun handleToggleSeenStatus( + messages: List, + isSeen: Boolean, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks? = null + ) { + if (isSeen) { + markAsUnseen(messages, mailbox, refreshCallbacks) + } else { + sharedUtils.markMessagesAsSeen( + messages = messages, + currentFolderId = currentFolderId, + mailbox = mailbox, + callbacks = refreshCallbacks, + ) + } + } + + // End Region + data class MoveMessagesResult( val movedThreads: List, val messages: List, From ffbccae3fefd5f19642ddaf19555a55fe38f5a58 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 19 Feb 2026 10:42:09 +0100 Subject: [PATCH 04/15] refactor: Add toggle favorite to messagesActionsUseCase --- .../main/folder/PerformSwipeActionManager.kt | 2 +- .../main/folder/ThreadListMultiSelection.kt | 2 +- .../mail/ui/main/thread/ThreadFragment.kt | 2 +- .../main/thread/actions/ActionsViewModel.kt | 83 ++--------- .../MessageActionsBottomSheetDialog.kt | 2 +- .../actions/MultiSelectBottomSheetDialog.kt | 2 +- .../actions/ThreadActionsBottomSheetDialog.kt | 2 +- .../mail/useCases/MessagesActionsUseCase.kt | 141 ++++++++++++++++-- 8 files changed, 148 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index b4080c3985..3559c3bef5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -103,7 +103,7 @@ object PerformSwipeActionManager { handleDeleteSwipe(thread, position, folderRole, isPermanentDeleteFolder, currentMailbox) } SwipeAction.FAVORITE -> { - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) + actionsViewModel.toggleThreadsFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) true } SwipeAction.MOVE -> { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index c105da1c02..28ba37c6ae 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -100,7 +100,7 @@ class ThreadListMultiSelection { } R.id.quickActionFavorite -> { trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = selectedThreadsUids, mailbox = currentMailbox.value!!, shouldFavorite = shouldMultiselectFavorite diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 091ce61b2f..ed73e903f9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -825,7 +825,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private fun initUi(threadUid: String, folderRole: FolderRole?) = with(binding) { iconFavorite.setOnClickListener { trackThreadActionsEvent(MatomoName.Favorite, threadViewModel.threadLive.value!!.isFavorite) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = listOf(threadUid), mailbox = mainViewModel.currentMailbox.value!! ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 6372a7854c..9ec679f807 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -377,11 +377,8 @@ class ActionsViewModel @Inject constructor( currentFolderId: String?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - val threads = threadsUids.let { threadController.getThreads(threadsUids) } - val isSeen = if (threads.count() == 1) threads.single().isSeen else !shouldRead - val messagesToToggleSeen = messagesActionsUseCase.getMessagesFromThreadsToMarkAsUnseen(threads, mailbox) - - messagesActionsUseCase.handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox) + val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleThreadSeenStatus(threadsUids, shouldRead, currentFolderId, mailbox, refreshCallbacks) } fun toggleMessagesSeenStatus( @@ -390,81 +387,29 @@ class ActionsViewModel @Inject constructor( currentFolderId: String?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - val isSeen = if (messages.count() == 1) messages.single().isSeen else !shouldRead - val messagesToToggleSeen = messagesActionsUseCase.getMessagesToMarkAsUnseen(messages) val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) - - messagesActionsUseCase.handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox, refreshCallbacks) + messagesActionsUseCase.toggleMessagesSeenStatus(messages, shouldRead, currentFolderId, mailbox, refreshCallbacks) } //endregion //region Favorite - fun toggleThreadsOrMessagesFavoriteStatus( - threadsUids: List? = null, - messages: List? = null, + fun toggleThreadsFavoriteStatus( + threadsUids: List, shouldFavorite: Boolean = true, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - val threads = threadsUids?.let { threadController.getThreads(threadsUids) } - - val isFavorite = when { - messages?.count() == 1 -> messages.single().isFavorite - threads?.count() == 1 -> threads.single().isFavorite - else -> !shouldFavorite - } - - val messages = if (isFavorite) { - getMessagesToUnfavorite(threads, messages) - } else { - getMessagesToFavorite(threads, messages, mailbox) - } - - toggleMessagesFavoriteStatus(messages, isFavorite, mailbox) - } - - private fun toggleMessagesFavoriteStatus(messages: List, isFavorite: Boolean, mailbox: Mailbox) { - viewModelScope.launch(ioCoroutineContext) { - val uids = messages.getUids() - - updateFavoriteStatus(messagesUids = uids, isFavorite = !isFavorite) - - val apiResponses = if (isFavorite) { - ApiRepository.removeFromFavorites(mailbox.uuid, uids) - } else { - ApiRepository.addToFavorites(mailbox.uuid, uids) - } - - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } else { - updateFavoriteStatus(messagesUids = uids, isFavorite = isFavorite) - } - } - } - - private suspend fun getMessagesToFavorite(threads: List?, messages: List?, mailbox: Mailbox) = when { - threads != null -> threads.flatMap { thread -> - messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) - } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() // this should never happen, we should always pass threads or messages - } - - private suspend fun getMessagesToUnfavorite(threads: List?, messages: List?) = when { - threads != null -> threads.flatMap { messageController.getFavoriteMessages(it) } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() + val callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleThreadFavorite(threadsUids, shouldFavorite, mailbox, callbacks) } - private suspend fun updateFavoriteStatus(messagesUids: List, isFavorite: Boolean) { - mailboxContentRealm().write { - MessageController.updateFavoriteStatus(messagesUids, isFavorite, realm = this) - } + fun toggleMessagesFavoriteStatus( + messages: List, + shouldFavorite: Boolean = true, + mailbox: Mailbox + ) = viewModelScope.launch(ioCoroutineContext) { + val callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleMessagesFavorite(messages, shouldFavorite, mailbox, callbacks) } //endregion diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 220775e192..a18f28450d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -220,7 +220,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onFavorite() { trackBottomSheetMessageActionsEvent(MatomoName.Favorite, message.isFavorite) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleMessagesFavoriteStatus( messages = listOf(message), mailbox = mainViewModel.currentMailbox.value!! ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 8435840327..fc9cd58c5c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -203,7 +203,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = threadsUids, mailbox = currentMailbox, shouldFavorite = shouldFavorite diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index c207d392d5..f72383b4f8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -248,7 +248,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onFavorite() { trackBottomSheetThreadActionsEvent(MatomoName.Favorite, thread.isFavorite) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = listOf(navigationArgs.threadUid), mailbox = mainViewModel.currentMailbox.value!! ) diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index 4f4e36b1e6..a2aa21d6ea 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -235,17 +235,63 @@ class MessagesActionsUseCase @Inject constructor( // End Region // Seen Region - suspend fun getMessagesFromThreadsToMarkAsUnseen(threads: List, mailbox: Mailbox): List { + suspend fun toggleThreadSeenStatus( + threadsUids: List, + shouldRead: Boolean = true, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks? = null + ) { + val threads = threadsUids.let { threadController.getThreads(threadsUids) } + val isSeen = if (threads.count() == 1) threads.single().isSeen else !shouldRead + val messagesToToggleSeen = getMessagesFromThreadsToMarkAsUnseen(threads, mailbox) + + handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox, refreshCallbacks) + } + + suspend fun toggleMessagesSeenStatus( + messages: List, + shouldRead: Boolean = true, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks? + ) { + val isSeen = if (messages.count() == 1) messages.single().isSeen else !shouldRead + val messagesToToggleSeen = getMessagesToMarkAsUnseen(messages) + + handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox, refreshCallbacks) + } + + private suspend fun handleToggleSeenStatus( + messages: List, + isSeen: Boolean, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks? = null + ) { + if (isSeen) { + markAsUnseen(messages, mailbox, refreshCallbacks) + } else { + sharedUtils.markMessagesAsSeen( + messages = messages, + currentFolderId = currentFolderId, + mailbox = mailbox, + callbacks = refreshCallbacks, + ) + } + } + + private suspend fun getMessagesFromThreadsToMarkAsUnseen(threads: List, mailbox: Mailbox): List { return threads.flatMap { thread -> messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) } } - suspend fun getMessagesToMarkAsUnseen(messages: List): List { + private suspend fun getMessagesToMarkAsUnseen(messages: List): List { return messageController.getMessagesAndDuplicates(messages) } - suspend fun markAsUnseen(messages: List, mailbox: Mailbox, callbacks: RefreshCallbacks?) { + private suspend fun markAsUnseen(messages: List, mailbox: Mailbox, callbacks: RefreshCallbacks?) { val messagesUids = messages.map { it.uid } sharedUtils.updateSeenStatus(messagesUids, isSeen = false) @@ -263,25 +309,94 @@ class MessagesActionsUseCase @Inject constructor( } } - suspend fun handleToggleSeenStatus( + // End Region + + // Favorites Region + + suspend fun toggleThreadFavorite( + threadsUids: List, + shouldFavorite: Boolean = true, + mailbox: Mailbox, + callbacks: RefreshCallbacks? = null + ) { + val threads = threadsUids.let { threadController.getThreads(threadsUids) } + val isFavorite = if (threads.count() == 1) threads.single().isFavorite else !shouldFavorite + val messages = if (isFavorite) { + getMessagesFromThreadToUnfavorite(threads) + } else { + getMessagesFromThreadToFavorite(threads, mailbox) + } + + toggleMessagesFavoriteStatus(messages, isFavorite, mailbox, callbacks) + } + + suspend fun toggleMessagesFavorite( messages: List, - isSeen: Boolean, - currentFolderId: String?, + shouldFavorite: Boolean = true, mailbox: Mailbox, - refreshCallbacks: RefreshCallbacks? = null + callbacks: RefreshCallbacks? = null ) { - if (isSeen) { - markAsUnseen(messages, mailbox, refreshCallbacks) + val isFavorite = if (messages.count() == 1) messages.single().isFavorite else !shouldFavorite + + val messages = if (isFavorite) { + getMessagesToUnfavorite(messages) } else { - sharedUtils.markMessagesAsSeen( - messages = messages, - currentFolderId = currentFolderId, + getMessagesToFavorite(messages) + } + + toggleMessagesFavoriteStatus(messages, isFavorite, mailbox, callbacks) + } + + private suspend fun toggleMessagesFavoriteStatus( + messages: List, + isFavorite: Boolean, + mailbox: Mailbox, + callbacks: RefreshCallbacks? + ) { + val uids = messages.getUids() + + updateFavoriteStatus(messagesUids = uids, isFavorite = !isFavorite) + + val apiResponses = if (isFavorite) { + ApiRepository.removeFromFavorites(mailbox.uuid, uids) + } else { + ApiRepository.addToFavorites(mailbox.uuid, uids) + } + + if (apiResponses.atLeastOneSucceeded()) { + refreshFoldersAsync( mailbox = mailbox, - callbacks = refreshCallbacks, + messagesFoldersIds = messages.getFoldersIds(), + callbacks = callbacks, ) + } else { + updateFavoriteStatus(messagesUids = uids, isFavorite = isFavorite) } } + private suspend fun getMessagesToFavorite(messages: List): List { + return messageController.getMessagesAndDuplicates(messages) + } + + private suspend fun getMessagesToUnfavorite(messages: List): List { + return messageController.getMessagesAndDuplicates(messages) + } + + private suspend fun getMessagesFromThreadToFavorite(threads: List, mailbox: Mailbox): List { + return threads.flatMap { thread -> + messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) + } + } + + private suspend fun getMessagesFromThreadToUnfavorite(threads: List): List { + return threads.flatMap { messageController.getFavoriteMessages(it) } + } + + private suspend fun updateFavoriteStatus(messagesUids: List, isFavorite: Boolean) { + mailboxContentRealm().write { + MessageController.updateFavoriteStatus(messagesUids, isFavorite, realm = this) + } + } // End Region data class MoveMessagesResult( From 17fab052377e6bb0aac9990747a0420bb1e2da96 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 19 Feb 2026 11:45:05 +0100 Subject: [PATCH 05/15] refactor: Add phishing and block user to messagesActionsUseCase --- .../main/thread/actions/ActionsViewModel.kt | 71 +++---- .../mail/useCases/MessagesActionsUseCase.kt | 190 +++++++++++------- 2 files changed, 153 insertions(+), 108 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 9ec679f807..7c15b9ef28 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -27,7 +27,6 @@ import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.api.ApiRepository -import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.cache.mailboxContent.FolderController import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders import com.infomaniak.mail.data.cache.mailboxContent.MessageController @@ -75,7 +74,6 @@ class ActionsViewModel @Inject constructor( private val folderController: FolderController, private val folderRoleUtils: FolderRoleUtils, private val localSettings: LocalSettings, - private val mailboxContentRealm: RealmDatabase.MailboxContent, private val mailboxController: MailboxController, private val messageController: MessageController, private val messagesActionsUseCase: MessagesActionsUseCase, @@ -139,11 +137,7 @@ class ActionsViewModel @Inject constructor( } fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - ApiRepository.setSpamFilter( - mailboxHostingId = mailbox.hostingId, - mailboxName = mailbox.mailboxName, - activateSpamFilter = true, - ) + messagesActionsUseCase.activateSpamFilter(mailbox) } fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { @@ -416,33 +410,30 @@ class ActionsViewModel @Inject constructor( //region Phishing fun reportPhishing(messages: List, currentFolder: Folder?, mailbox: Mailbox) { viewModelScope.launch(ioCoroutineContext) { - val mailboxUuid = mailbox.uuid - val messagesUids: List = messages.map { it.uid } - - if (messagesUids.isEmpty()) { - snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) - return@launch - } - - with(ApiRepository.reportPhishing(mailboxUuid, messagesUids)) { - val snackbarTitle = if (isSuccess()) { - - if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { - toggleMessagesSpamStatus( - messages = messages, - currentFolderId = currentFolder?.id, - mailbox = mailbox, - displaySnackbar = false - ) - } - - R.string.snackbarReportPhishingConfirmation - } else { - translateError() + val result = messagesActionsUseCase.reportPhishing( + messages = messages, + currentFolder = currentFolder, + mailbox = mailbox, + onReportSuccess = { + // We keep the call to toggleMessagesSpamStatus in the VM + // because it likely involves more UI triggers/refresh logic + toggleMessagesSpamStatus( + messages = messages, + currentFolderId = currentFolder?.id, + mailbox = mailbox, + displaySnackbar = false + ) } + ) - reportPhishingTrigger.postValue(Unit) - snackbarManager.postValue(appContext.getString(snackbarTitle)) + when (result) { + is MessagesActionsUseCase.PhishingResult.Success -> { + reportPhishingTrigger.postValue(Unit) + snackbarManager.postValue(appContext.getString(result.messageRes)) + } + is MessagesActionsUseCase.PhishingResult.Error -> { + snackbarManager.postValue(appContext.getString(result.messageRes)) + } } } } @@ -450,12 +441,16 @@ class ActionsViewModel @Inject constructor( //region BlockUser fun blockUser(folderId: String, shortUid: Int, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - val mailboxUuid = mailbox.uuid - with(ApiRepository.blockUser(mailboxUuid, folderId, shortUid)) { - val snackbarTitle = if (isSuccess()) R.string.snackbarBlockUserConfirmation else translateError() - snackbarManager.postValue(appContext.getString(snackbarTitle)) - - reportPhishingTrigger.postValue(Unit) + val result = messagesActionsUseCase.blockUser(folderId, shortUid, mailbox) + when (result) { + is MessagesActionsUseCase.BlockUserResult.Success -> { + // UI Trigger stays in the ViewModel + reportPhishingTrigger.postValue(Unit) + snackbarManager.postValue(appContext.getString(result.messageRes)) + } + is MessagesActionsUseCase.BlockUserResult.Error -> { + snackbarManager.postValue(appContext.getString(result.messageRes)) + } } } //endregion diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index a2aa21d6ea..86033b2169 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -18,6 +18,8 @@ package com.infomaniak.mail.useCases import com.infomaniak.core.network.models.ApiResponse +import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError +import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.cache.RealmDatabase @@ -40,6 +42,7 @@ import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded import com.infomaniak.mail.utils.extensions.getFoldersIds import com.infomaniak.mail.utils.extensions.getUids import javax.inject.Inject +import com.infomaniak.core.legacy.R as RCore class MessagesActionsUseCase @Inject constructor( private val folderController: FolderController, @@ -51,27 +54,7 @@ class MessagesActionsUseCase @Inject constructor( private val sharedUtils: SharedUtils, ) { - suspend fun toggleMessagesSpamStatus( - messages: List, - currentFolderId: String?, - mailbox: Mailbox, - callbacks: RefreshCallbacks? = null, - ): MoveMessagesResult { - val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null - val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) - - val destinationFolderRole = if (folderRole == FolderRole.SPAM) { - FolderRole.INBOX - } else { - FolderRole.SPAM - } - val destinationFolder = folderController.getFolder(destinationFolderRole)!! - - val unscheduleMessages = messageController.getUnscheduledMessages(messages) - - return moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, callbacks) - } - + // Move Region suspend fun moveMessagesTo( destinationFolder: Folder, currentFolderId: String?, @@ -91,8 +74,7 @@ class MessagesActionsUseCase @Inject constructor( ) if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { - - refreshFoldersAsync( + sharedUtils.refreshFolders( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), destinationFolderId = destinationFolder.id, @@ -108,29 +90,6 @@ class MessagesActionsUseCase @Inject constructor( return MoveMessagesResult(movedThreads, messages, apiResponses, destinationFolder) } - private suspend fun refreshFoldersAsync( - mailbox: Mailbox, - messagesFoldersIds: ImpactedFolders, - currentFolderId: String? = null, - destinationFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - ) { - sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) - } - - private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { - val uidsToMove = mutableListOf().apply { - messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> - val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } - if (nbMessagesInCurrentFolder == 0) add(thread.uid) - } - } - - if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) - return uidsToMove - } - - private suspend fun moveMessages( mailbox: Mailbox, messagesToMove: List, @@ -151,33 +110,53 @@ class MessagesActionsUseCase @Inject constructor( return apiResponses } - /** - * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder - * that were targeting this message will display for a brief moment until we refresh their folders. This is because those - * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be - * displayed. - * - * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by - * the api at the same time anyway. - */ - suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { - for (messageToMove in messagesToMove) { - if (messageToMove.emojiReactions.isEmpty()) continue - - mailboxContentRealm().write { - messageToMove.emojiReactions.forEach { reaction -> - reaction.authors.forEach { author -> - MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) - } - } + private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { + val uidsToMove = mutableListOf().apply { + messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> + val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } + if (nbMessagesInCurrentFolder == 0) add(thread.uid) } } + + if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) + return uidsToMove + } + // End Region + + // Spam Region + suspend fun toggleMessagesSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + callbacks: RefreshCallbacks? = null, + ): MoveMessagesResult { + val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null + val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) + + val destinationFolderRole = if (folderRole == FolderRole.SPAM) { + FolderRole.INBOX + } else { + FolderRole.SPAM + } + val destinationFolder = folderController.getFolder(destinationFolderRole)!! + + val unscheduleMessages = messageController.getUnscheduledMessages(messages) + + return moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, callbacks) } suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } } + suspend fun activateSpamFilter(mailbox: Mailbox) { + ApiRepository.setSpamFilter( + mailboxHostingId = mailbox.hostingId, + mailboxName = mailbox.mailboxName, + activateSpamFilter = true, + ) + } + // End Region // Delete Region suspend fun permanentlyDelete( @@ -201,7 +180,7 @@ class MessagesActionsUseCase @Inject constructor( onApiFinished() if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( + sharedUtils.refreshFolders( mailbox = mailbox, messagesFoldersIds = messagesToDelete.getFoldersIds(), currentFolderId = currentFolder?.id, @@ -299,7 +278,7 @@ class MessagesActionsUseCase @Inject constructor( val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( + sharedUtils.refreshFolders( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(), callbacks = callbacks, @@ -308,11 +287,9 @@ class MessagesActionsUseCase @Inject constructor( sharedUtils.updateSeenStatus(messagesUids, isSeen = true) } } - // End Region // Favorites Region - suspend fun toggleThreadFavorite( threadsUids: List, shouldFavorite: Boolean = true, @@ -364,7 +341,7 @@ class MessagesActionsUseCase @Inject constructor( } if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( + sharedUtils.refreshFolders( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(), callbacks = callbacks, @@ -399,6 +376,79 @@ class MessagesActionsUseCase @Inject constructor( } // End Region + // Phishing Region + suspend fun reportPhishing( + messages: List, + currentFolder: Folder?, + mailbox: Mailbox, + onReportSuccess: suspend () -> Unit + ): PhishingResult { + val messagesUids = messages.map { it.uid } + if (messagesUids.isEmpty()) return PhishingResult.Error(RCore.string.anErrorHasOccurred) + + val response = ApiRepository.reportPhishing(mailbox.uuid, messagesUids) + + return if (response.isSuccess()) { + if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { + onReportSuccess() + } + PhishingResult.Success(R.string.snackbarReportPhishingConfirmation) + } else { + PhishingResult.Error(response.translateError()) + } + } + + sealed class PhishingResult { + data class Success(val messageRes: Int) : PhishingResult() + data class Error(val messageRes: Int) : PhishingResult() + } + // End Region + + // Block user Region + suspend fun blockUser( + folderId: String, + shortUid: Int, + mailbox: Mailbox + ): BlockUserResult { + val response = ApiRepository.blockUser(mailbox.uuid, folderId, shortUid) + + return if (response.isSuccess()) { + BlockUserResult.Success(R.string.snackbarBlockUserConfirmation) + } else { + BlockUserResult.Error(response.translateError()) + } + } + + sealed class BlockUserResult { + data class Success(val messageRes: Int) : BlockUserResult() + data class Error(val messageRes: Int) : BlockUserResult() + } + // End Region + + + /** + * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder + * that were targeting this message will display for a brief moment until we refresh their folders. This is because those + * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be + * displayed. + * + * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by + * the api at the same time anyway. + */ + suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { + for (messageToMove in messagesToMove) { + if (messageToMove.emojiReactions.isEmpty()) continue + + mailboxContentRealm().write { + messageToMove.emojiReactions.forEach { reaction -> + reaction.authors.forEach { author -> + MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) + } + } + } + } + } + data class MoveMessagesResult( val movedThreads: List, val messages: List, From 49d0dfc858646b01a82f9c4c26f3d701fd2bc53c Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 19 Feb 2026 15:37:40 +0100 Subject: [PATCH 06/15] refactor: Add snooze and undo actions to messageActionsUseCase --- .../main/thread/actions/ActionsViewModel.kt | 205 +++++------------- .../actions/MultiSelectBottomSheetDialog.kt | 2 +- .../mail/useCases/MessagesActionsUseCase.kt | 157 ++++++++++++-- 3 files changed, 196 insertions(+), 168 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 7c15b9ef28..8701e57adb 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -26,19 +26,15 @@ import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings -import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.cache.mailboxContent.FolderController import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.ThreadController -import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.MoveResult -import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox -import com.infomaniak.mail.data.models.mailbox.SendersRestrictions import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread @@ -48,20 +44,17 @@ import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils -import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear import com.infomaniak.mail.utils.extensions.allFailed import com.infomaniak.mail.utils.extensions.appContext import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded -import com.infomaniak.mail.utils.extensions.getFirstTranslatedError import com.infomaniak.mail.utils.extensions.getFoldersIds import com.infomaniak.mail.utils.extensions.getUids import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.invoke import kotlinx.coroutines.launch import java.util.Date import javax.inject.Inject @@ -74,7 +67,6 @@ class ActionsViewModel @Inject constructor( private val folderController: FolderController, private val folderRoleUtils: FolderRoleUtils, private val localSettings: LocalSettings, - private val mailboxController: MailboxController, private val messageController: MessageController, private val messagesActionsUseCase: MessagesActionsUseCase, private val sharedUtils: SharedUtils, @@ -142,28 +134,9 @@ class ActionsViewModel @Inject constructor( fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { if (mailbox == null) return@launch - - with(ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName)) { - if (isSuccess()) { - val restrictions = data - if (restrictions == null) { - snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) - return@launch - } - restrictions.blockedSenders.removeIf { it.email == email } - - updateBlockedSenders(mailbox, restrictions) - } - } - } - - private suspend fun updateBlockedSenders(mailbox: Mailbox, updatedSendersRestrictions: SendersRestrictions) { - with(ApiRepository.updateBlockedSenders(mailbox.hostingId, mailbox.mailboxName, updatedSendersRestrictions)) { - if (isSuccess()) { - mailboxController.updateMailbox(mailbox.objectId) { - it.sendersRestrictions = updatedSendersRestrictions - } - } + val result = messagesActionsUseCase.unblockMail(email, mailbox) + if (result is MessagesActionsUseCase.ApiCallResult.Error) { + snackbarManager.postValue(appContext.getString(result.messageRes)) } } //endregion @@ -427,11 +400,11 @@ class ActionsViewModel @Inject constructor( ) when (result) { - is MessagesActionsUseCase.PhishingResult.Success -> { + is MessagesActionsUseCase.ApiCallResult.Success -> { reportPhishingTrigger.postValue(Unit) snackbarManager.postValue(appContext.getString(result.messageRes)) } - is MessagesActionsUseCase.PhishingResult.Error -> { + is MessagesActionsUseCase.ApiCallResult.Error -> { snackbarManager.postValue(appContext.getString(result.messageRes)) } } @@ -443,12 +416,12 @@ class ActionsViewModel @Inject constructor( fun blockUser(folderId: String, shortUid: Int, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { val result = messagesActionsUseCase.blockUser(folderId, shortUid, mailbox) when (result) { - is MessagesActionsUseCase.BlockUserResult.Success -> { + is MessagesActionsUseCase.ApiCallResult.Success -> { // UI Trigger stays in the ViewModel reportPhishingTrigger.postValue(Unit) snackbarManager.postValue(appContext.getString(result.messageRes)) } - is MessagesActionsUseCase.BlockUserResult.Error -> { + is MessagesActionsUseCase.ApiCallResult.Error -> { snackbarManager.postValue(appContext.getString(result.messageRes)) } } @@ -458,106 +431,62 @@ class ActionsViewModel @Inject constructor( //region Snooze // For now we only do snooze for Threads. suspend fun snoozeThreads(date: Date, threadUids: List, currentFolderId: String?, mailbox: Mailbox?): Boolean { - var isSuccess = false - - viewModelScope.launch { - mailbox?.let { currentMailbox -> - val threads = threadUids.mapNotNull { threadController.getThread(it) } - - val messageUids = threads.mapNotNull { thread -> - thread.getDisplayedMessages(currentMailbox.featureFlags, localSettings) - .lastOrNull { it.folderId == currentFolderId }?.uid - } + if (mailbox == null) return false - val responses = ioDispatcher { ApiRepository.snoozeMessages(currentMailbox.uuid, messageUids, date) } - - isSuccess = responses.atLeastOneSucceeded() - val userFeedbackMessage = if (isSuccess) { - // Snoozing threads requires to refresh the snooze folder. - // It's the only folder that will update the snooze state of any message. - refreshFoldersAsync(currentMailbox, ImpactedFolders(mutableSetOf(FolderRole.SNOOZED))) - - val formattedDate = appContext.dayOfWeekDateWithoutYear(date) - appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, threads.count(), formattedDate) - } else { - val errorMessageRes = responses.getFirstTranslatedError() ?: RCore.string.anErrorHasOccurred - appContext.getString(errorMessageRes) - } + val result = messagesActionsUseCase.snoozeThreads( + date = date, + threadUids = threadUids, + currentFolderId = currentFolderId, + mailbox = mailbox, + ) - snackbarManager.postValue(userFeedbackMessage) + val feedback = when (result) { + is MessagesActionsUseCase.SnoozeResult.Success -> { + val formattedDate = appContext.dayOfWeekDateWithoutYear(result.date) + appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, result.threadCount, formattedDate) } - }.join() + is MessagesActionsUseCase.SnoozeResult.Error -> appContext.getString(result.messageRes) + } - return isSuccess + snackbarManager.postValue(feedback) + return result is MessagesActionsUseCase.SnoozeResult.Success } suspend fun rescheduleSnoozedThreads(date: Date, threadUids: List, mailbox: Mailbox): BatchSnoozeResult { - var rescheduleResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown - - viewModelScope.launch(ioCoroutineContext) { - val snoozedThreadUuids = threadUids.mapNotNull { threadUid -> - val thread = threadController.getThread(threadUid) ?: return@mapNotNull null - thread.snoozeUuid.takeIf { thread.isSnoozed() } - } - if (snoozedThreadUuids.isEmpty()) return@launch - - val result = rescheduleSnoozedThreads(mailbox, snoozedThreadUuids, date) - - val userFeedbackMessage = when (result) { - is BatchSnoozeResult.Success -> { - refreshFoldersAsync(mailbox, result.impactedFolders) + val result = messagesActionsUseCase.rescheduleSnoozedThreads( + date = date, + threadUids = threadUids, + mailbox = mailbox, + ) - val formattedDate = appContext.dayOfWeekDateWithoutYear(date) - appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, threadUids.count(), formattedDate) - } - is BatchSnoozeResult.Error -> getRescheduleSnoozedErrorMessage(result) + val feedback = when (result) { + is BatchSnoozeResult.Success -> { + val formattedDate = appContext.dayOfWeekDateWithoutYear(date) + appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, threadUids.count(), formattedDate) } + is BatchSnoozeResult.Error -> getRescheduleSnoozedErrorMessage(result) + } - snackbarManager.postValue(userFeedbackMessage) - - rescheduleResult = result - }.join() - - return rescheduleResult + snackbarManager.postValue(feedback) + return result } - suspend fun unsnoozeThreads(threads: Collection, mailbox: Mailbox?): BatchSnoozeResult { - var unsnoozeResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown + fun unsnoozeThreads(threads: List, mailbox: Mailbox?) { + if (mailbox == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return + } viewModelScope.launch(ioCoroutineContext) { - unsnoozeResult = if (mailbox == null) { - BatchSnoozeResult.Error.Unknown - } else { - ioDispatcher { unsnoozeThreadsWithoutRefresh(scope = null, mailbox, threads) } - } - - unsnoozeResult.let { - val userFeedbackMessage = when (it) { - is BatchSnoozeResult.Success -> { - sharedUtils.refreshFolders(mailbox = mailbox!!, messagesFoldersIds = it.impactedFolders) - appContext.resources.getQuantityString(R.plurals.snackbarUnsnoozeSuccess, threads.count()) - } - is BatchSnoozeResult.Error -> getUnsnoozeErrorMessage(it) - } - - snackbarManager.postValue(userFeedbackMessage) + val result = messagesActionsUseCase.unsnoozeThreads(threads, mailbox) + val message = when (result) { + is BatchSnoozeResult.Success -> appContext.resources.getQuantityString( + R.plurals.snackbarUnsnoozeSuccess, threads.count() + ) + is BatchSnoozeResult.Error -> getUnsnoozeErrorMessage(result) } - }.join() - - return unsnoozeResult - } - - private suspend fun rescheduleSnoozedThreads( - currentMailbox: Mailbox, - snoozeUuids: List, - date: Date, - ): BatchSnoozeResult { - return SharedUtils.rescheduleSnoozedThreads( - mailboxUuid = currentMailbox.uuid, - snoozeUuids = snoozeUuids, - newDate = date, - impactedFolders = ImpactedFolders(mutableSetOf(FolderRole.SNOOZED)), - ) + snackbarManager.postValue(message) + } } private fun getRescheduleSnoozedErrorMessage(errorResult: BatchSnoozeResult.Error): String { @@ -582,44 +511,16 @@ class ActionsViewModel @Inject constructor( //region Undo action fun undoAction(undoData: UndoData, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - - fun List>.getFailedCall() = firstOrNull { it.data != true } - - val (resources, foldersIds, destinationFolderId) = undoData - - val apiResponses = resources.map { ApiRepository.undoAction(it) } - - if (apiResponses.atLeastOneSucceeded()) { - // Don't use `refreshFoldersAsync` here, it will make the Snackbars blink. - sharedUtils.refreshFolders( - mailbox = mailbox, - messagesFoldersIds = foldersIds, - destinationFolderId = destinationFolderId, - ) - } - - val failedCall = apiResponses.getFailedCall() - - val snackbarTitle = when { - failedCall == null -> R.string.snackbarMoveCancelled - else -> failedCall.translateError() + val result = messagesActionsUseCase.undoAction(undoData, mailbox) + val message = when (result) { + is MessagesActionsUseCase.ApiCallResult.Success -> appContext.getString(result.messageRes) + is MessagesActionsUseCase.ApiCallResult.Error -> appContext.getString(result.messageRes) } - snackbarManager.postValue(appContext.getString(snackbarTitle)) + snackbarManager.postValue(message) } //endregion - - private fun refreshFoldersAsync( - mailbox: Mailbox, - messagesFoldersIds: ImpactedFolders, - currentFolderId: String? = null, - destinationFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - ) = viewModelScope.launch(ioCoroutineContext) { - sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) - } - private fun onDownloadStart() { isDownloadingChanges.postValue(true) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index fc9cd58c5c..11e50c3503 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -140,7 +140,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.cancelSnooze.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.CancelSnooze, threadsCount, isFromBottomSheet = true) - lifecycleScope.launch { actionsViewModel.unsnoozeThreads(threads, mainViewModel.currentMailbox.value) } + lifecycleScope.launch { actionsViewModel.unsnoozeThreads(threads.toList(), mainViewModel.currentMailbox.value) } isMultiSelectOn = false } diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index 86033b2169..871cbbd245 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.mail.useCases +import androidx.annotation.StringRes import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.mail.R @@ -28,19 +29,27 @@ import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.ThreadController +import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.MoveResult +import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox +import com.infomaniak.mail.data.models.mailbox.SendersRestrictions import com.infomaniak.mail.data.models.message.Message +import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread +import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.atLeastOneFailed import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded +import com.infomaniak.mail.utils.extensions.getFirstTranslatedError import com.infomaniak.mail.utils.extensions.getFoldersIds import com.infomaniak.mail.utils.extensions.getUids +import kotlinx.coroutines.coroutineScope +import java.util.Date import javax.inject.Inject import com.infomaniak.core.legacy.R as RCore @@ -49,6 +58,7 @@ class MessagesActionsUseCase @Inject constructor( private val folderRoleUtils: FolderRoleUtils, private val localSettings: LocalSettings, private val mailboxContentRealm: RealmDatabase.MailboxContent, + private val mailboxController: MailboxController, private val messageController: MessageController, private val threadController: ThreadController, private val sharedUtils: SharedUtils, @@ -382,9 +392,9 @@ class MessagesActionsUseCase @Inject constructor( currentFolder: Folder?, mailbox: Mailbox, onReportSuccess: suspend () -> Unit - ): PhishingResult { + ): ApiCallResult { val messagesUids = messages.map { it.uid } - if (messagesUids.isEmpty()) return PhishingResult.Error(RCore.string.anErrorHasOccurred) + if (messagesUids.isEmpty()) return ApiCallResult.Error(RCore.string.anErrorHasOccurred) val response = ApiRepository.reportPhishing(mailbox.uuid, messagesUids) @@ -392,39 +402,151 @@ class MessagesActionsUseCase @Inject constructor( if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { onReportSuccess() } - PhishingResult.Success(R.string.snackbarReportPhishingConfirmation) + ApiCallResult.Success(R.string.snackbarReportPhishingConfirmation) } else { - PhishingResult.Error(response.translateError()) + ApiCallResult.Error(response.translateError()) } } - sealed class PhishingResult { - data class Success(val messageRes: Int) : PhishingResult() - data class Error(val messageRes: Int) : PhishingResult() - } + // End Region - // Block user Region + // Block Region suspend fun blockUser( folderId: String, shortUid: Int, mailbox: Mailbox - ): BlockUserResult { + ): ApiCallResult { val response = ApiRepository.blockUser(mailbox.uuid, folderId, shortUid) return if (response.isSuccess()) { - BlockUserResult.Success(R.string.snackbarBlockUserConfirmation) + ApiCallResult.Success(R.string.snackbarBlockUserConfirmation) } else { - BlockUserResult.Error(response.translateError()) + ApiCallResult.Error(response.translateError()) } } - sealed class BlockUserResult { - data class Success(val messageRes: Int) : BlockUserResult() - data class Error(val messageRes: Int) : BlockUserResult() + suspend fun unblockMail(email: String, mailbox: Mailbox): ApiCallResult? { + val response = ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName) + return if (response.isSuccess()) { + val restrictions = response.data ?: return ApiCallResult.Error(RCore.string.anErrorHasOccurred) + restrictions.apply { + blockedSenders.removeIf { it.email == email } + } + updateBlockedSenders(mailbox, restrictions) + null + } else { + ApiCallResult.Error(response.translateError()) + } + } + + private suspend fun updateBlockedSenders(mailbox: Mailbox, updatedSendersRestrictions: SendersRestrictions) { + with(ApiRepository.updateBlockedSenders(mailbox.hostingId, mailbox.mailboxName, updatedSendersRestrictions)) { + if (isSuccess()) { + mailboxController.updateMailbox(mailbox.objectId) { + it.sendersRestrictions = updatedSendersRestrictions + } + } + } } // End Region + // Snooze Region + suspend fun snoozeThreads( + date: Date, + threadUids: List, + currentFolderId: String?, + mailbox: Mailbox, + ): SnoozeResult { + val threads = threadUids.mapNotNull { threadController.getThread(it) } + val messageUids = threads.mapNotNull { thread -> + thread.getDisplayedMessages(mailbox.featureFlags, localSettings) + .lastOrNull { it.folderId == currentFolderId }?.uid + } + + val responses = ApiRepository.snoozeMessages(mailbox.uuid, messageUids, date) + + return if (responses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders(mailbox, ImpactedFolders(mutableSetOf(FolderRole.SNOOZED))) + SnoozeResult.Success(threads.count(), date) + } else { + val errorRes = responses.getFirstTranslatedError() ?: RCore.string.anErrorHasOccurred + SnoozeResult.Error(errorRes) + } + } + + suspend fun rescheduleSnoozedThreads( + date: Date, + threadUids: List, + mailbox: Mailbox, + ): BatchSnoozeResult { + val snoozedThreadUuids = threadUids.mapNotNull { threadUid -> + val thread = threadController.getThread(threadUid) ?: return@mapNotNull null + thread.snoozeUuid.takeIf { thread.isSnoozed() } + } + + if (snoozedThreadUuids.isEmpty()) return BatchSnoozeResult.Error.Unknown + + val result = SharedUtils.rescheduleSnoozedThreads( + mailboxUuid = mailbox.uuid, + snoozeUuids = snoozedThreadUuids, + newDate = date, + impactedFolders = ImpactedFolders(mutableSetOf(FolderRole.SNOOZED)), + ) + + if (result is BatchSnoozeResult.Success) { + sharedUtils.refreshFolders(mailbox, result.impactedFolders) + } + + return result + } + + suspend fun unsnoozeThreads( + threads: Collection, + mailbox: Mailbox, + ): BatchSnoozeResult = coroutineScope { + + val result = SharedUtils.unsnoozeThreadsWithoutRefresh( + mailbox = mailbox, + threads = threads, + scope = this + ) + + if (result is BatchSnoozeResult.Success) { + sharedUtils.refreshFolders(mailbox, result.impactedFolders) + } + + result + } + + sealed class SnoozeResult { + data class Success(val threadCount: Int, val date: Date) : SnoozeResult() + data class Error(@StringRes val messageRes: Int) : SnoozeResult() + } + // End Region + + // Undo Region + suspend fun undoAction(undoData: SnackbarManager.UndoData, mailbox: Mailbox): ApiCallResult { + val (resources, foldersIds, destinationFolderId) = undoData // 1. Execute the API calls for each resource + val apiResponses = resources.map { ApiRepository.undoAction(it) } + + if (apiResponses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = foldersIds, + destinationFolderId = destinationFolderId, + ) + } + + val failedCall = apiResponses.firstOrNull { it.data != true } + + return if (failedCall == null) { + ApiCallResult.Success(R.string.snackbarMoveCancelled) + } else { + ApiCallResult.Error(failedCall.translateError()) + } + } + // End Region /** * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder @@ -449,6 +571,11 @@ class MessagesActionsUseCase @Inject constructor( } } + sealed class ApiCallResult { + data class Success(val messageRes: Int) : ApiCallResult() + data class Error(val messageRes: Int) : ApiCallResult() + } + data class MoveMessagesResult( val movedThreads: List, val messages: List, From 009626af664ab5c1c430cd2297b26b10a082775e Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 20 Feb 2026 08:38:02 +0100 Subject: [PATCH 07/15] refactor: Remove messages related functions from SharedUtils --- .../receivers/NotificationActionsReceiver.kt | 6 +- .../com/infomaniak/mail/ui/MainViewModel.kt | 4 +- .../mail/ui/main/thread/ThreadViewModel.kt | 22 ++-- .../main/thread/actions/ActionsViewModel.kt | 12 +- .../mail/ui/newMessage/NewMessageViewModel.kt | 6 +- .../mail/useCases/MessagesActionsUseCase.kt | 104 +++++++++++++++++- .../com/infomaniak/mail/utils/SharedUtils.kt | 102 ----------------- 7 files changed, 125 insertions(+), 131 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt b/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt index ae59a6fe77..4cd99225bc 100644 --- a/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt +++ b/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt @@ -39,13 +39,13 @@ import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.di.IoDispatcher +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.NotificationPayload import com.infomaniak.mail.utils.NotificationPayload.NotificationBehavior import com.infomaniak.mail.utils.NotificationPayload.NotificationBehavior.NotificationType import com.infomaniak.mail.utils.NotificationUtils -import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded import com.infomaniak.mail.utils.extensions.getApiException import com.infomaniak.mail.utils.extensions.getUids @@ -86,7 +86,7 @@ class NotificationActionsReceiver : BroadcastReceiver() { lateinit var refreshController: RefreshController @Inject - lateinit var sharedUtils: SharedUtils + lateinit var messagesActionsUseCase: MessagesActionsUseCase @Inject @IoDispatcher @@ -171,7 +171,7 @@ class NotificationActionsReceiver : BroadcastReceiver() { val threads = message.threads.filter { it.folderId == message.folderId } val mailbox = mailboxController.getMailbox(userId, mailboxId) ?: return@launch - val messages = sharedUtils.getMessagesToMove(threads, message) + val messages = messagesActionsUseCase.getMessagesToMove(threads, message) val destinationFolder = folderController.getFolder(folderRole) ?: return@launch val okHttpClient = AccountUtils.getHttpClient(userId) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index ed0ed7d868..7de1efe85e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -691,9 +691,9 @@ class MainViewModel @Inject constructor( var messagesToMove = emptyList() if (messagesUid != null) { val messages = messagesUid.let { messageController.getMessages(it) } - messagesToMove = sharedUtils.getMessagesToMove(messages, currentFolderId) + messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolderId) } else { - messagesToMove = sharedUtils.getMessagesFromThreadsToMove(threads) + messagesToMove = messagesActionsUseCase.getMessagesFromThreadsToMove(threads) } moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, null, messagesToMove) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt index 478ca23d90..012fbf227d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt @@ -59,6 +59,7 @@ import com.infomaniak.mail.ui.main.thread.models.EmojiReactionAuthorUi import com.infomaniak.mail.ui.main.thread.models.EmojiReactionStateUi import com.infomaniak.mail.ui.main.thread.models.MessageUi import com.infomaniak.mail.ui.main.thread.models.MessageUi.UnsubscribeState +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.FeatureAvailability.isSnoozeAvailable @@ -122,6 +123,7 @@ class ThreadViewModel @Inject constructor( private val mailboxContentRealm: RealmDatabase.MailboxContent, private val mailboxController: MailboxController, private val messageController: MessageController, + private val messagesActionsUseCase: MessagesActionsUseCase, private val refreshController: RefreshController, private val sharedUtils: SharedUtils, private val snackbarManager: SnackbarManager, @@ -174,15 +176,15 @@ class ThreadViewModel @Inject constructor( private val fakeReactions = MutableStateFlow>>(emptyMap()) /** - * Flow tracking the unsubscribe status of each message, keyed by its UID. - * - * Whenever a message is unsubscribed, its state (e.g., [UnsubscribeState.InProgress], [UnsubscribeState.Completed]) - * is updated in this map, and the entire map is re-emitted to trigger a refresh of the [MessageUi]. - * - * This flow is collected inside a [combine] to ensure that unsubscribe statuses are preserved - * even when other changes (e.g., sorting, filtering, content updates) trigger a full rebuild - * of the [MessageUi] list. - */ + * Flow tracking the unsubscribe status of each message, keyed by its UID. + * + * Whenever a message is unsubscribed, its state (e.g., [UnsubscribeState.InProgress], [UnsubscribeState.Completed]) + * is updated in this map, and the entire map is re-emitted to trigger a refresh of the [MessageUi]. + * + * This flow is collected inside a [combine] to ensure that unsubscribe statuses are preserved + * even when other changes (e.g., sorting, filtering, content updates) trigger a full rebuild + * of the [MessageUi] list. + */ private val unsubscribeStateByMessageUid = MutableStateFlow>(emptyMap()) @OptIn(ExperimentalCoroutinesApi::class) @@ -415,7 +417,7 @@ class ThreadViewModel @Inject constructor( } private fun markThreadAsSeen(thread: Thread) = viewModelScope.launch(ioCoroutineContext) { - sharedUtils.markAsSeen(mailbox(), listOf(thread)) + messagesActionsUseCase.markAsSeen(mailbox(), listOf(thread)) } private fun sendMatomoAboutThreadMessagesCount(thread: Thread, featureFlags: Mailbox.FeatureFlagSet) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 8701e57adb..fc5796fac6 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -25,7 +25,6 @@ import com.infomaniak.core.legacy.utils.SingleLiveEvent import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.mail.R -import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.cache.mailboxContent.FolderController import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders import com.infomaniak.mail.data.cache.mailboxContent.MessageController @@ -43,7 +42,6 @@ import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.FolderRoleUtils -import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear @@ -66,10 +64,8 @@ class ActionsViewModel @Inject constructor( application: Application, private val folderController: FolderController, private val folderRoleUtils: FolderRoleUtils, - private val localSettings: LocalSettings, private val messageController: MessageController, private val messagesActionsUseCase: MessagesActionsUseCase, - private val sharedUtils: SharedUtils, private val snackbarManager: SnackbarManager, private val threadController: ThreadController, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -149,7 +145,7 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val threads: List = threadController.getThreads(threadsUids).toList() - val messagesToMove = sharedUtils.getMessagesFromThreadsToMove(threads) + val messagesToMove = messagesActionsUseCase.getMessagesFromThreadsToMove(threads) handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } @@ -161,7 +157,7 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val messages = messagesUids.let { messageController.getMessages(it) } - val messagesToMove = sharedUtils.getMessagesToMove(messages, currentFolderId) + val messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolderId) handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } @@ -310,7 +306,7 @@ class ActionsViewModel @Inject constructor( currentFolder: Folder?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMove = sharedUtils.getMessagesFromThreadsToMove(threads) + val messagesToMove = messagesActionsUseCase.getMessagesFromThreadsToMove(threads) handleArchiveMessage(messagesToMove, currentFolder, mailbox) } @@ -319,7 +315,7 @@ class ActionsViewModel @Inject constructor( currentFolder: Folder?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMove = sharedUtils.getMessagesToMove(messages, currentFolder?.id) + val messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolder?.id) handleArchiveMessage(messagesToMove, currentFolder, mailbox) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt index cb695c3f12..594a3bda22 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt @@ -80,6 +80,7 @@ import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.newMessage.NewMessageActivity.DraftSaveConfiguration import com.infomaniak.mail.ui.newMessage.NewMessageEditorManager.EditorAction import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldType +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.ContactUtils.arrangeMergedContacts import com.infomaniak.mail.utils.DraftInitManager @@ -87,7 +88,6 @@ import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.LocalStorageUtils import com.infomaniak.mail.utils.MessageBodyUtils import com.infomaniak.mail.utils.SentryDebug -import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.SignatureUtils import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.coroutineContext @@ -150,7 +150,7 @@ class NewMessageViewModel @Inject constructor( private val contactGroupController: ContactGroupController, private val notificationManagerCompat: NotificationManagerCompat, private val draftInitManager: DraftInitManager, - private val sharedUtils: SharedUtils, + private val messagesActionsUseCase: MessagesActionsUseCase, private val signatureUtils: SignatureUtils, private val snackbarManager: SnackbarManager, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -508,7 +508,7 @@ class NewMessageViewModel @Inject constructor( val message = previousMessageUid?.let { MessageController.getMessage(it, realm) } ?: return if (message.isSeen) return - sharedUtils.markAsSeen( + messagesActionsUseCase.markAsSeen( mailbox = mailbox, threads = message.threads.filter { it.folderId == message.folderId }, message = message, diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index 871cbbd245..fd914c1f52 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -100,6 +100,22 @@ class MessagesActionsUseCase @Inject constructor( return MoveMessagesResult(movedThreads, messages, apiResponses, destinationFolder) } + + suspend fun getMessagesToMove(threads: List, message: Message?) = when (message) { + null -> threads.flatMap { messageController.getMovableMessages(it) } + else -> listOf(message) + } + + suspend fun getMessagesFromThreadsToMove(threads: List): List { + return threads.flatMap { + messageController.getMovableMessages(it) + } + } + + fun getMessagesToMove(messages: List, currentFolderId: String?): List { + return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } + } + private suspend fun moveMessages( mailbox: Mailbox, messagesToMove: List, @@ -261,7 +277,7 @@ class MessagesActionsUseCase @Inject constructor( if (isSeen) { markAsUnseen(messages, mailbox, refreshCallbacks) } else { - sharedUtils.markMessagesAsSeen( + markMessagesAsSeen( messages = messages, currentFolderId = currentFolderId, mailbox = mailbox, @@ -270,6 +286,88 @@ class MessagesActionsUseCase @Inject constructor( } } + /** + * Mark a Message or some Threads as read + * @param mailbox The Mailbox where the Threads & Messages are located + * @param threads The Threads to mark as read + * @param message The Message to mark as read + * @param callbacks The callbacks for when the refresh of Threads begins/ends + * @param shouldRefreshThreads Sometimes, we don't want to refresh Threads after doing this action. For example, when replying + * to a Message. + */ + suspend fun markAsSeen( + mailbox: Mailbox, + threads: List, + message: Message? = null, + currentFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + shouldRefreshThreads: Boolean = true, + ) { + + val messages = when (message) { + null -> threads.flatMap { messageController.getUnseenMessages(it) } + else -> messageController.getMessageAndDuplicates(threads.first(), message) + } + + val threadsUids = threads.map { it.uid } + val messagesUids = messages.map { it.uid } + + updateSeenStatus(threadsUids, messagesUids, isSeen = true) + + val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) + + if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + currentFolderId = currentFolderId, + callbacks = callbacks, + ) + } + + if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(threadsUids, messagesUids, isSeen = false) + } + + suspend fun markMessagesAsSeen( + mailbox: Mailbox, + messages: List, + currentFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + shouldRefreshThreads: Boolean = true, + ) { + + val messagesUids = messages.map { it.uid } + + updateSeenStatus(messagesUids, isSeen = true) + + val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) + + if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + currentFolderId = currentFolderId, + callbacks = callbacks, + ) + } + + if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(messagesUids, isSeen = false) + } + + + private suspend fun updateSeenStatus(threadsUids: List, messagesUids: List, isSeen: Boolean) { + mailboxContentRealm().write { + MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) + ThreadController.updateSeenStatus(threadsUids, isSeen, realm = this) + } + } + + suspend fun updateSeenStatus(messagesUids: List, isSeen: Boolean) { + mailboxContentRealm().write { + MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) + } + } + private suspend fun getMessagesFromThreadsToMarkAsUnseen(threads: List, mailbox: Mailbox): List { return threads.flatMap { thread -> messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) @@ -283,7 +381,7 @@ class MessagesActionsUseCase @Inject constructor( private suspend fun markAsUnseen(messages: List, mailbox: Mailbox, callbacks: RefreshCallbacks?) { val messagesUids = messages.map { it.uid } - sharedUtils.updateSeenStatus(messagesUids, isSeen = false) + updateSeenStatus(messagesUids, isSeen = false) val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) @@ -294,7 +392,7 @@ class MessagesActionsUseCase @Inject constructor( callbacks = callbacks, ) } else { - sharedUtils.updateSeenStatus(messagesUids, isSeen = true) + updateSeenStatus(messagesUids, isSeen = true) } } // End Region diff --git a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt index def387118d..45bb3bad3b 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -24,11 +24,9 @@ import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders -import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshMode -import com.infomaniak.mail.data.cache.mailboxContent.ThreadController import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.isSnoozed @@ -40,10 +38,7 @@ import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh -import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded import com.infomaniak.mail.utils.extensions.getApiException -import com.infomaniak.mail.utils.extensions.getFoldersIds -import com.infomaniak.mail.utils.extensions.getUids import io.realm.kotlin.Realm import io.realm.kotlin.ext.toRealmList import io.sentry.Sentry @@ -56,105 +51,8 @@ import javax.inject.Inject class SharedUtils @Inject constructor( private val mailboxContentRealm: RealmDatabase.MailboxContent, private val refreshController: RefreshController, - private val messageController: MessageController, private val mailboxController: MailboxController, ) { - /** - * Mark a Message or some Threads as read - * @param mailbox The Mailbox where the Threads & Messages are located - * @param threads The Threads to mark as read - * @param message The Message to mark as read - * @param callbacks The callbacks for when the refresh of Threads begins/ends - * @param shouldRefreshThreads Sometimes, we don't want to refresh Threads after doing this action. For example, when replying - * to a Message. - */ - suspend fun markAsSeen( - mailbox: Mailbox, - threads: List, - message: Message? = null, - currentFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - shouldRefreshThreads: Boolean = true, - ) { - - val messages = when (message) { - null -> threads.flatMap { messageController.getUnseenMessages(it) } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } - - val threadsUids = threads.map { it.uid } - val messagesUids = messages.map { it.uid } - - updateSeenStatus(threadsUids, messagesUids, isSeen = true) - - val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) - - if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { - refreshFolders( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - currentFolderId = currentFolderId, - callbacks = callbacks, - ) - } - - if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(threadsUids, messagesUids, isSeen = false) - } - - suspend fun markMessagesAsSeen( - mailbox: Mailbox, - messages: List, - currentFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - shouldRefreshThreads: Boolean = true, - ) { - - val messagesUids = messages.map { it.uid } - - updateSeenStatus(messagesUids, isSeen = true) - - val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) - - if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { - refreshFolders( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - currentFolderId = currentFolderId, - callbacks = callbacks, - ) - } - - if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(messagesUids, isSeen = false) - } - - - private suspend fun updateSeenStatus(threadsUids: List, messagesUids: List, isSeen: Boolean) { - mailboxContentRealm().write { - MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) - ThreadController.updateSeenStatus(threadsUids, isSeen, realm = this) - } - } - - suspend fun updateSeenStatus(messagesUids: List, isSeen: Boolean) { - mailboxContentRealm().write { - MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) - } - } - - suspend fun getMessagesToMove(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getMovableMessages(it) } - else -> listOf(message) - } - - suspend fun getMessagesFromThreadsToMove(threads: List): List { - return threads.flatMap { - messageController.getMovableMessages(it) - } - } - - fun getMessagesToMove(messages: List, currentFolderId: String?): List { - return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } - } suspend fun refreshFolders( mailbox: Mailbox, From b2c76384a25dd4684e41385c94b2dc2e75746c67 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 20 Feb 2026 11:13:07 +0100 Subject: [PATCH 08/15] refactor: Remove duplicated move code in MainViewModel --- .../com/infomaniak/mail/ui/MainViewModel.kt | 140 ++---------------- .../main/thread/actions/ActionsViewModel.kt | 18 +-- .../mail/useCases/MessagesActionsUseCase.kt | 25 +++- .../com/infomaniak/mail/utils/SharedUtils.kt | 28 ++++ 4 files changed, 69 insertions(+), 142 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index 7de1efe85e..78dc98aba3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -69,12 +69,10 @@ import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher import com.infomaniak.mail.di.MailboxInfoRealm import com.infomaniak.mail.ui.main.SnackbarManager -import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.ContactUtils.getPhoneContacts import com.infomaniak.mail.utils.ContactUtils.mergeApiContactsIntoPhoneContacts -import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.MyKSuiteDataUtils import com.infomaniak.mail.utils.NetworkManager import com.infomaniak.mail.utils.NotificationUtils.Companion.cancelNotification @@ -87,10 +85,6 @@ import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.extensions.MergedContactDictionary import com.infomaniak.mail.utils.extensions.allFailed import com.infomaniak.mail.utils.extensions.appContext -import com.infomaniak.mail.utils.extensions.atLeastOneFailed -import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded -import com.infomaniak.mail.utils.extensions.getFoldersIds -import com.infomaniak.mail.utils.extensions.getUids import com.infomaniak.mail.utils.extensions.launchNoValidMailboxesActivity import com.infomaniak.mail.utils.toFolderUiTree import com.infomaniak.mail.views.itemViews.AvatarMergedContactData @@ -680,86 +674,9 @@ class MainViewModel @Inject constructor( } //endregion - //region Move - fun moveThreadsOrMessageTo( - destinationFolderId: String, - threadsUids: List, - messagesUid: List? = null, - ) = viewModelScope.launch(ioCoroutineContext) { - val destinationFolder = folderController.getFolder(destinationFolderId)!! - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - var messagesToMove = emptyList() - if (messagesUid != null) { - val messages = messagesUid.let { messageController.getMessages(it) } - messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolderId) - } else { - messagesToMove = messagesActionsUseCase.getMessagesFromThreadsToMove(threads) - } - - moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, null, messagesToMove) - } - - private suspend fun moveThreadsOrMessageTo( - destinationFolder: Folder, - threadsUids: List, - threads: List, - message: Message? = null, - messagesToMove: List, - shouldDisplaySnackbar: Boolean = true, - ) { - val mailbox = currentMailbox.value!! - - moveOutThreadsLocally(threadsUids, threads, message) - - val apiResponses = moveMessages( - mailbox = mailbox, - messagesToMove = messagesToMove, - destinationFolder = destinationFolder, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlagsLive.value, localSettings), - ) - - if (apiResponses.atLeastOneSucceeded()) { - if (shouldAutoAdvance(message, threadsUids)) autoAdvanceThreadsUids.postValue(threadsUids) - - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messagesToMove.getFoldersIds(exception = destinationFolder.id), - destinationFolderId = destinationFolder.id, - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(threadsUids) }), - ) - } - - if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) - - if (shouldDisplaySnackbar) showMoveSnackbar(threads, message, messagesToMove, apiResponses, destinationFolder) - } - - private suspend fun moveMessages( - mailbox: Mailbox, - messagesToMove: List, - destinationFolder: Folder, - alsoMoveReactionMessages: Boolean, - ): List> { - val apiResponses = ApiRepository.moveMessages( - mailboxUuid = mailbox.uuid, - messagesUids = messagesToMove.getUids(), - destinationId = destinationFolder.id, - alsoMoveReactionMessages = alsoMoveReactionMessages, - ) - - // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the - // same batch or in other batches that are target by emoji reactions did not - if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) messagesActionsUseCase.deleteEmojiReactionMessagesLocally( - messagesToMove - ) - - return apiResponses - } - - private fun showMoveSnackbar( - threads: List, - message: Message?, - messages: List, + fun showMoveSnackbar( + threadsMovedCount: Int, + messagesMoved: List, apiResponses: List>, destinationFolder: Folder, ) { @@ -768,28 +685,17 @@ class MainViewModel @Inject constructor( val snackbarTitle = when { apiResponses.allFailed() -> appContext.getString(apiResponses.first().translateError()) - message == null -> appContext.resources.getQuantityString(R.plurals.snackbarThreadMoved, threads.count(), destination) - else -> appContext.getString(R.string.snackbarMessageMoved, destination) - } - - val undoResources = apiResponses.mapNotNull { it.data?.undoResource } - val undoData = if (undoResources.isEmpty()) { - null - } else { - val undoDestinationId = message?.folderId ?: threads.first().folderId - val foldersIds = messages.getFoldersIds(exception = undoDestinationId) - foldersIds += destinationFolder.id - UndoData( - resources = apiResponses.mapNotNull { it.data?.undoResource }, - foldersIds = foldersIds, - destinationFolderId = undoDestinationId, + threadsMovedCount > 0 || messagesMoved.count() > 1 -> appContext.resources.getQuantityString( + R.plurals.snackbarThreadMoved, + threadsMovedCount, + destination ) + else -> appContext.getString(R.string.snackbarMessageMoved, destination) } + val undoData = sharedUtils.getUndoData(messagesMoved, apiResponses, destinationFolder) snackbarManager.postValue(snackbarTitle, undoData) } - //endregion - //region Display problem fun reportDisplayProblem(messageUid: String) = viewModelScope.launch(ioCoroutineContext) { @@ -867,29 +773,15 @@ class MainViewModel @Inject constructor( messagesUids: List?, ) = viewModelScope.launch(ioCoroutineContext) { val newFolderId = createNewFolderSync(name) ?: return@launch - moveThreadsOrMessageTo(newFolderId, threadsUids, messagesUids) - isMovedToNewFolder.postValue(true) - } - //endregion - - private suspend fun moveOutThreadsLocally( - threadsUids: List, - threads: List, - message: Message?, - ) { - val uidsToMove = if (message == null) { - threadsUids - } else { - mutableListOf().apply { - threads.forEach { thread -> - var nbMessagesInCurrentFolder = thread.messages.count { it.folderId == currentFolderId } - if (message.folderId == currentFolderId) nbMessagesInCurrentFolder-- - if (nbMessagesInCurrentFolder == 0) add(thread.uid) - } - } + val mailbox = currentMailbox.value ?: return@launch + val result = + messagesActionsUseCase.moveThreadsOrMessagesTo(newFolderId, threadsUids, messagesUids, mailbox, currentFolderId) + if (result != null) { + showMoveSnackbar(threadsUids.count(), result.messages, result.apiResponses, folderController.getFolder(newFolderId)!!) + isMovedToNewFolder.postValue(true) } - if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) } + //endregion private fun refreshFoldersAsync( mailbox: Mailbox, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index fc5796fac6..b7e7619173 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -42,13 +42,13 @@ import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.FolderRoleUtils +import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear import com.infomaniak.mail.utils.extensions.allFailed import com.infomaniak.mail.utils.extensions.appContext import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded -import com.infomaniak.mail.utils.extensions.getFoldersIds import com.infomaniak.mail.utils.extensions.getUids import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -66,6 +66,7 @@ class ActionsViewModel @Inject constructor( private val folderRoleUtils: FolderRoleUtils, private val messageController: MessageController, private val messagesActionsUseCase: MessagesActionsUseCase, + private val sharedUtils: SharedUtils, private val snackbarManager: SnackbarManager, private val threadController: ThreadController, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -200,20 +201,7 @@ class ActionsViewModel @Inject constructor( else -> appContext.getString(R.string.snackbarMessageMoved, destination) } - val undoResources = apiResponses.mapNotNull { it.data?.undoResource } - val undoData = if (undoResources.isEmpty()) { - null - } else { - val undoDestinationId = destinationFolder.id - val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) - foldersIds += destinationFolder.id - UndoData( - resources = apiResponses.mapNotNull { it.data?.undoResource }, - foldersIds = foldersIds, - destinationFolderId = undoDestinationId, - ) - } - + val undoData = sharedUtils.getUndoData(messagesMoved, apiResponses, destinationFolder) snackbarManager.postValue(snackbarTitle, undoData) } //endregion diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index fd914c1f52..1eefdc7e52 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -73,7 +73,7 @@ class MessagesActionsUseCase @Inject constructor( callbacks: RefreshCallbacks? = null, ): MoveMessagesResult { - val movedThreads = moveOutThreadsLocally(messages, destinationFolder) + val movedThreads = moveOutMessagesThreadsLocally(messages, destinationFolder) val featureFlags = mailbox.featureFlags val apiResponses = moveMessages( @@ -136,7 +136,7 @@ class MessagesActionsUseCase @Inject constructor( return apiResponses } - private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { + private suspend fun moveOutMessagesThreadsLocally(messages: List, destinationFolder: Folder): List { val uidsToMove = mutableListOf().apply { messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } @@ -147,6 +147,25 @@ class MessagesActionsUseCase @Inject constructor( if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) return uidsToMove } + + suspend fun moveThreadsOrMessagesTo( + destinationFolderId: String, + threadsUids: List, + messagesUid: List? = null, + mailbox: Mailbox, + currentFolderId: String?, + ): MoveMessagesResult? { + if (currentFolderId == null) return null + val destinationFolder = folderController.getFolder(destinationFolderId)!! + var messagesToMove: List + if (messagesUid != null) { + messagesToMove = messagesUid.let { messageController.getMessages(it) } + } else { + val threads = threadController.getThreads(threadsUids).ifEmpty { return null } + messagesToMove = getMessagesFromThreadsToMove(threads) + } + return moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) + } // End Region // Spam Region @@ -195,7 +214,7 @@ class MessagesActionsUseCase @Inject constructor( val undoResources = emptyList() val uids = messagesToDelete.getUids() - val uidsToMove = moveOutThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) + val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) val apiResponses = ApiRepository.deleteMessages( mailboxUuid = mailbox.uuid, diff --git a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt index 45bb3bad3b..bee26f84ae 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.mail.utils +import android.content.Context import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.models.exceptions.NetworkException import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError @@ -28,7 +29,9 @@ import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshMode import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController +import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole +import com.infomaniak.mail.data.models.MoveResult import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.message.Message @@ -36,9 +39,12 @@ import com.infomaniak.mail.data.models.snooze.BatchSnoozeResponse.Companion.comp import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh import com.infomaniak.mail.utils.extensions.getApiException +import com.infomaniak.mail.utils.extensions.getFoldersIds +import dagger.hilt.android.qualifiers.ApplicationContext import io.realm.kotlin.Realm import io.realm.kotlin.ext.toRealmList import io.sentry.Sentry @@ -49,11 +55,33 @@ import java.util.Date import javax.inject.Inject class SharedUtils @Inject constructor( + @ApplicationContext private val context: Context, private val mailboxContentRealm: RealmDatabase.MailboxContent, private val refreshController: RefreshController, private val mailboxController: MailboxController, ) { + fun getUndoData( + messagesMoved: List, + apiResponses: List>, + destinationFolder: Folder, + ): UndoData? { + val undoResources = apiResponses.mapNotNull { it.data?.undoResource } + val undoData = if (undoResources.isEmpty()) { + null + } else { + val undoDestinationId = destinationFolder.id + val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) + foldersIds += destinationFolder.id + UndoData( + resources = apiResponses.mapNotNull { it.data?.undoResource }, + foldersIds = foldersIds, + destinationFolderId = undoDestinationId, + ) + } + return undoData + } + suspend fun refreshFolders( mailbox: Mailbox, messagesFoldersIds: ImpactedFolders, From 5a18fcfc83fd42337b062aceda6492d18e7c7488 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 20 Feb 2026 14:33:15 +0100 Subject: [PATCH 09/15] refactor: Remove unnecesary comments, check unsafe calls --- .../com/infomaniak/mail/ui/MainViewModel.kt | 19 ++++- .../main/folder/ThreadListMultiSelection.kt | 9 ++- .../main/thread/actions/ActionsViewModel.kt | 76 ++++++++++--------- .../mail/useCases/MessagesActionsUseCase.kt | 64 ++++++++++------ .../com/infomaniak/mail/utils/SharedUtils.kt | 29 ------- 5 files changed, 101 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index 78dc98aba3..abaf8ab163 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -693,7 +693,7 @@ class MainViewModel @Inject constructor( else -> appContext.getString(R.string.snackbarMessageMoved, destination) } - val undoData = sharedUtils.getUndoData(messagesMoved, apiResponses, destinationFolder) + val undoData = messagesActionsUseCase.getUndoData(messagesMoved, apiResponses, destinationFolder) snackbarManager.postValue(snackbarTitle, undoData) } @@ -772,12 +772,23 @@ class MainViewModel @Inject constructor( threadsUids: List, messagesUids: List?, ) = viewModelScope.launch(ioCoroutineContext) { - val newFolderId = createNewFolderSync(name) ?: return@launch - val mailbox = currentMailbox.value ?: return@launch + val newFolderId = createNewFolderSync(name) + val mailbox = currentMailbox.value + if (newFolderId == null || mailbox == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return@launch + } + + val destinationFolder = folderController.getFolder(newFolderId) + if (destinationFolder == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return@launch + } + val result = messagesActionsUseCase.moveThreadsOrMessagesTo(newFolderId, threadsUids, messagesUids, mailbox, currentFolderId) if (result != null) { - showMoveSnackbar(threadsUids.count(), result.messages, result.apiResponses, folderController.getFolder(newFolderId)!!) + showMoveSnackbar(threadsUids.count(), result.messages, result.apiResponses, destinationFolder) isMovedToNewFolder.postValue(true) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 28ba37c6ae..b6ec5d1a38 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -72,6 +72,7 @@ class ThreadListMultiSelection { threadListFragment.binding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } val selectedThreadsCount = selectedThreadsUids.count() + val currentMailBox = currentMailbox.value ?: return@setOnItemClickListener when (menuId) { R.id.quickActionUnread -> { @@ -80,7 +81,7 @@ class ThreadListMultiSelection { threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, currentFolderId = currentFolderId, - mailbox = currentMailbox.value!! + mailbox = currentMailBox ) isMultiSelectOn = false } @@ -93,7 +94,7 @@ class ThreadListMultiSelection { actionsViewModel.archiveThreads( threads = selectedThreads.toList(), currentFolder = currentFolder.value, - mailbox = currentMailbox.value!! + mailbox = currentMailBox ) isMultiSelectOn = false } @@ -102,7 +103,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = selectedThreadsUids, - mailbox = currentMailbox.value!!, + mailbox = currentMailBox, shouldFavorite = shouldMultiselectFavorite ) isMultiSelectOn = false @@ -116,7 +117,7 @@ class ThreadListMultiSelection { actionsViewModel.deleteThreads( threads = selectedThreads.toList(), currentFolder = currentFolder.value, - mailbox = currentMailbox.value!! + mailbox = currentMailBox ) isMultiSelectOn = false } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index b7e7619173..052bcc1da4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -42,7 +42,6 @@ import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.FolderRoleUtils -import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear @@ -66,7 +65,6 @@ class ActionsViewModel @Inject constructor( private val folderRoleUtils: FolderRoleUtils, private val messageController: MessageController, private val messagesActionsUseCase: MessagesActionsUseCase, - private val sharedUtils: SharedUtils, private val snackbarManager: SnackbarManager, private val threadController: ThreadController, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -91,7 +89,7 @@ class ActionsViewModel @Inject constructor( threads: Set, currentFolderId: String?, mailbox: Mailbox, - displaySnackbar: Boolean = true + displaySnackbar: Boolean = true, ) = viewModelScope.launch(ioCoroutineContext) { val messagesToMarkAsSpam = messagesActionsUseCase.getMessagesFromThreadToSpamOrHam(threads) handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) @@ -101,7 +99,7 @@ class ActionsViewModel @Inject constructor( messages: List, currentFolderId: String?, mailbox: Mailbox, - displaySnackbar: Boolean = true + displaySnackbar: Boolean = true, ) = viewModelScope.launch(ioCoroutineContext) { val messagesToMarkAsSpam = messageController.getUnscheduledMessages(messages) handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) @@ -111,7 +109,7 @@ class ActionsViewModel @Inject constructor( messages: List, currentFolderId: String?, mailbox: Mailbox, - displaySnackbar: Boolean = true + displaySnackbar: Boolean = true, ) = viewModelScope.launch(ioCoroutineContext) { val result = messagesActionsUseCase.toggleMessagesSpamStatus( messages = messages, @@ -120,7 +118,7 @@ class ActionsViewModel @Inject constructor( callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) ) - if (displaySnackbar) { + if (displaySnackbar && result != null) { showMoveSnackbar(result.movedThreads, result.messages, result.apiResponses, result.destinationFolder) } } @@ -172,10 +170,10 @@ class ActionsViewModel @Inject constructor( val destinationFolder = folderController.getFolder(destinationFolderId) ?: return@launch val result = messagesActionsUseCase.moveMessagesTo( - destinationFolder, - currentFolderId, - mailbox, - messages, + destinationFolder = destinationFolder, + currentFolderId = currentFolderId, + mailbox = mailbox, + messages = messages, callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), ) @@ -196,12 +194,12 @@ class ActionsViewModel @Inject constructor( threadsMoved.count() > 0 || messagesMoved.count() > 1 -> appContext.resources.getQuantityString( R.plurals.snackbarThreadMoved, threadsMoved.count(), - destination + destination, ) else -> appContext.getString(R.string.snackbarMessageMoved, destination) } - val undoData = sharedUtils.getUndoData(messagesMoved, apiResponses, destinationFolder) + val undoData = messagesActionsUseCase.getUndoData(messagesMoved, apiResponses, destinationFolder) snackbarManager.postValue(snackbarTitle, undoData) } //endregion @@ -235,27 +233,36 @@ class ActionsViewModel @Inject constructor( if (shouldPermanentlyDelete) { val result = messagesActionsUseCase.permanentlyDelete( - messagesToDelete, - currentFolder, - mailbox, - onApiFinished = { activityDialogLoaderResetTrigger.postValue(Unit) }, // UI logic stays here - refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesToDelete = messagesToDelete, + currentFolder = currentFolder, + mailbox = mailbox, + onApiFinished = { activityDialogLoaderResetTrigger.postValue(Unit) }, + refreshCallbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = ::onDownloadStop), ) - showDeleteSnackbar( - apiResponses = result.apiResponses, - messages = messagesToDelete, - undoResources = result.undoResources, - undoFoldersIds = result.undoFoldersIds, - undoDestinationId = result.undoDestinationId, - numberOfImpactedThreads = messagesToDelete.count() - ) + if (result != null) { + showDeleteSnackbar( + apiResponses = result.apiResponses, + messages = messagesToDelete, + undoResources = result.undoResources, + undoFoldersIds = result.undoFoldersIds, + undoDestinationId = result.undoDestinationId, + numberOfImpactedThreads = messagesToDelete.count(), + ) + } + } else { + val destinationFolder = folderController.getFolder(FolderRole.TRASH) + if (destinationFolder == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return@launch + } + moveMessagesTo( - destinationFolderId = folderController.getFolder(FolderRole.TRASH)!!.id, + destinationFolderId = destinationFolder.id, messagesUids = messagesToDelete.getUids(), currentFolderId = currentFolder?.id, - mailbox = mailbox + mailbox = mailbox, ) } } @@ -272,7 +279,7 @@ class ActionsViewModel @Inject constructor( if (messages.count() > 1) { appContext.resources.getQuantityString( R.plurals.snackbarThreadDeletedPermanently, - numberOfImpactedThreads + numberOfImpactedThreads, ) } else { appContext.getString(R.string.snackbarMessageDeletedPermanently) @@ -310,7 +317,7 @@ class ActionsViewModel @Inject constructor( private fun handleArchiveMessage( messages: List, currentFolder: Folder?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val role = folderRoleUtils.getActionFolderRole(messages, currentFolder) @@ -326,7 +333,7 @@ class ActionsViewModel @Inject constructor( threadsUids: List, shouldRead: Boolean = true, currentFolderId: String?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) messagesActionsUseCase.toggleThreadSeenStatus(threadsUids, shouldRead, currentFolderId, mailbox, refreshCallbacks) @@ -336,7 +343,7 @@ class ActionsViewModel @Inject constructor( messages: List, shouldRead: Boolean = true, currentFolderId: String?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) messagesActionsUseCase.toggleMessagesSeenStatus(messages, shouldRead, currentFolderId, mailbox, refreshCallbacks) @@ -348,7 +355,7 @@ class ActionsViewModel @Inject constructor( fun toggleThreadsFavoriteStatus( threadsUids: List, shouldFavorite: Boolean = true, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) messagesActionsUseCase.toggleThreadFavorite(threadsUids, shouldFavorite, mailbox, callbacks) @@ -357,7 +364,7 @@ class ActionsViewModel @Inject constructor( fun toggleMessagesFavoriteStatus( messages: List, shouldFavorite: Boolean = true, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) messagesActionsUseCase.toggleMessagesFavorite(messages, shouldFavorite, mailbox, callbacks) @@ -372,8 +379,6 @@ class ActionsViewModel @Inject constructor( currentFolder = currentFolder, mailbox = mailbox, onReportSuccess = { - // We keep the call to toggleMessagesSpamStatus in the VM - // because it likely involves more UI triggers/refresh logic toggleMessagesSpamStatus( messages = messages, currentFolderId = currentFolder?.id, @@ -401,7 +406,6 @@ class ActionsViewModel @Inject constructor( val result = messagesActionsUseCase.blockUser(folderId, shortUid, mailbox) when (result) { is MessagesActionsUseCase.ApiCallResult.Success -> { - // UI Trigger stays in the ViewModel reportPhishingTrigger.postValue(Unit) snackbarManager.postValue(appContext.getString(result.messageRes)) } diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index 1eefdc7e52..875ad52235 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -40,6 +40,7 @@ import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.main.SnackbarManager +import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils @@ -156,7 +157,7 @@ class MessagesActionsUseCase @Inject constructor( currentFolderId: String?, ): MoveMessagesResult? { if (currentFolderId == null) return null - val destinationFolder = folderController.getFolder(destinationFolderId)!! + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return null var messagesToMove: List if (messagesUid != null) { messagesToMove = messagesUid.let { messageController.getMessages(it) } @@ -174,7 +175,7 @@ class MessagesActionsUseCase @Inject constructor( currentFolderId: String?, mailbox: Mailbox, callbacks: RefreshCallbacks? = null, - ): MoveMessagesResult { + ): MoveMessagesResult? { val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) @@ -183,8 +184,7 @@ class MessagesActionsUseCase @Inject constructor( } else { FolderRole.SPAM } - val destinationFolder = folderController.getFolder(destinationFolderRole)!! - + val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return null val unscheduleMessages = messageController.getUnscheduledMessages(messages) return moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, callbacks) @@ -208,13 +208,13 @@ class MessagesActionsUseCase @Inject constructor( messagesToDelete: List, currentFolder: Folder?, mailbox: Mailbox, - onApiFinished: () -> Unit, // Callback for the loader reset - refreshCallbacks: RefreshCallbacks? - ): DeleteResult { + onApiFinished: () -> Unit, + refreshCallbacks: RefreshCallbacks?, + ): DeleteResult? { val undoResources = emptyList() val uids = messagesToDelete.getUids() - - val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) + val destinationFolder = folderController.getFolder(FolderRole.TRASH) ?: return null + val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete, destinationFolder) val apiResponses = ApiRepository.deleteMessages( mailboxUuid = mailbox.uuid, @@ -255,7 +255,6 @@ class MessagesActionsUseCase @Inject constructor( } suspend fun getMessagesToDelete(messages: List) = messageController.getMessagesAndDuplicates(messages) - // End Region // Seen Region @@ -264,7 +263,7 @@ class MessagesActionsUseCase @Inject constructor( shouldRead: Boolean = true, currentFolderId: String?, mailbox: Mailbox, - refreshCallbacks: RefreshCallbacks? = null + refreshCallbacks: RefreshCallbacks? = null, ) { val threads = threadsUids.let { threadController.getThreads(threadsUids) } val isSeen = if (threads.count() == 1) threads.single().isSeen else !shouldRead @@ -278,7 +277,7 @@ class MessagesActionsUseCase @Inject constructor( shouldRead: Boolean = true, currentFolderId: String?, mailbox: Mailbox, - refreshCallbacks: RefreshCallbacks? + refreshCallbacks: RefreshCallbacks?, ) { val isSeen = if (messages.count() == 1) messages.single().isSeen else !shouldRead val messagesToToggleSeen = getMessagesToMarkAsUnseen(messages) @@ -291,7 +290,7 @@ class MessagesActionsUseCase @Inject constructor( isSeen: Boolean, currentFolderId: String?, mailbox: Mailbox, - refreshCallbacks: RefreshCallbacks? = null + refreshCallbacks: RefreshCallbacks? = null, ) { if (isSeen) { markAsUnseen(messages, mailbox, refreshCallbacks) @@ -373,7 +372,6 @@ class MessagesActionsUseCase @Inject constructor( if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(messagesUids, isSeen = false) } - private suspend fun updateSeenStatus(threadsUids: List, messagesUids: List, isSeen: Boolean) { mailboxContentRealm().write { MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) @@ -421,7 +419,7 @@ class MessagesActionsUseCase @Inject constructor( threadsUids: List, shouldFavorite: Boolean = true, mailbox: Mailbox, - callbacks: RefreshCallbacks? = null + callbacks: RefreshCallbacks? = null, ) { val threads = threadsUids.let { threadController.getThreads(threadsUids) } val isFavorite = if (threads.count() == 1) threads.single().isFavorite else !shouldFavorite @@ -438,7 +436,7 @@ class MessagesActionsUseCase @Inject constructor( messages: List, shouldFavorite: Boolean = true, mailbox: Mailbox, - callbacks: RefreshCallbacks? = null + callbacks: RefreshCallbacks? = null, ) { val isFavorite = if (messages.count() == 1) messages.single().isFavorite else !shouldFavorite @@ -455,7 +453,7 @@ class MessagesActionsUseCase @Inject constructor( messages: List, isFavorite: Boolean, mailbox: Mailbox, - callbacks: RefreshCallbacks? + callbacks: RefreshCallbacks?, ) { val uids = messages.getUids() @@ -508,7 +506,7 @@ class MessagesActionsUseCase @Inject constructor( messages: List, currentFolder: Folder?, mailbox: Mailbox, - onReportSuccess: suspend () -> Unit + onReportSuccess: suspend () -> Unit, ): ApiCallResult { val messagesUids = messages.map { it.uid } if (messagesUids.isEmpty()) return ApiCallResult.Error(RCore.string.anErrorHasOccurred) @@ -524,15 +522,13 @@ class MessagesActionsUseCase @Inject constructor( ApiCallResult.Error(response.translateError()) } } - - // End Region // Block Region suspend fun blockUser( folderId: String, shortUid: Int, - mailbox: Mailbox + mailbox: Mailbox, ): ApiCallResult { val response = ApiRepository.blockUser(mailbox.uuid, folderId, shortUid) @@ -644,7 +640,7 @@ class MessagesActionsUseCase @Inject constructor( // Undo Region suspend fun undoAction(undoData: SnackbarManager.UndoData, mailbox: Mailbox): ApiCallResult { - val (resources, foldersIds, destinationFolderId) = undoData // 1. Execute the API calls for each resource + val (resources, foldersIds, destinationFolderId) = undoData val apiResponses = resources.map { ApiRepository.undoAction(it) } if (apiResponses.atLeastOneSucceeded()) { @@ -663,6 +659,28 @@ class MessagesActionsUseCase @Inject constructor( ApiCallResult.Error(failedCall.translateError()) } } + + fun getUndoData( + messagesMoved: List, + apiResponses: List>, + destinationFolder: Folder, + ): UndoData? { + val undoResources = apiResponses.mapNotNull { it.data?.undoResource } + val undoData = if (undoResources.isEmpty()) { + null + } else { + val undoDestinationId = destinationFolder.id + val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) + foldersIds += destinationFolder.id + UndoData( + resources = apiResponses.mapNotNull { it.data?.undoResource }, + foldersIds = foldersIds, + destinationFolderId = undoDestinationId, + ) + } + return undoData + } + // End Region /** @@ -697,7 +715,7 @@ class MessagesActionsUseCase @Inject constructor( val movedThreads: List, val messages: List, val apiResponses: List>, - val destinationFolder: Folder + val destinationFolder: Folder, ) data class DeleteResult( diff --git a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt index bee26f84ae..806f88e71b 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -17,7 +17,6 @@ */ package com.infomaniak.mail.utils -import android.content.Context import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.models.exceptions.NetworkException import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError @@ -29,9 +28,7 @@ import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshMode import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController -import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole -import com.infomaniak.mail.data.models.MoveResult import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.message.Message @@ -39,12 +36,9 @@ import com.infomaniak.mail.data.models.snooze.BatchSnoozeResponse.Companion.comp import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainViewModel -import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh import com.infomaniak.mail.utils.extensions.getApiException -import com.infomaniak.mail.utils.extensions.getFoldersIds -import dagger.hilt.android.qualifiers.ApplicationContext import io.realm.kotlin.Realm import io.realm.kotlin.ext.toRealmList import io.sentry.Sentry @@ -55,33 +49,10 @@ import java.util.Date import javax.inject.Inject class SharedUtils @Inject constructor( - @ApplicationContext private val context: Context, private val mailboxContentRealm: RealmDatabase.MailboxContent, private val refreshController: RefreshController, private val mailboxController: MailboxController, ) { - - fun getUndoData( - messagesMoved: List, - apiResponses: List>, - destinationFolder: Folder, - ): UndoData? { - val undoResources = apiResponses.mapNotNull { it.data?.undoResource } - val undoData = if (undoResources.isEmpty()) { - null - } else { - val undoDestinationId = destinationFolder.id - val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) - foldersIds += destinationFolder.id - UndoData( - resources = apiResponses.mapNotNull { it.data?.undoResource }, - foldersIds = foldersIds, - destinationFolderId = undoDestinationId, - ) - } - return undoData - } - suspend fun refreshFolders( mailbox: Mailbox, messagesFoldersIds: ImpactedFolders, From 0478e12bddd0343d00fd7f472385f0f07dcd5b6e Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 20 Feb 2026 15:15:47 +0100 Subject: [PATCH 10/15] refactor: Add missing commas --- .../mail/ui/main/thread/actions/ActionsViewModel.kt | 4 ++-- .../com/infomaniak/mail/useCases/MessagesActionsUseCase.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 052bcc1da4..f9247ebcf9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -115,7 +115,7 @@ class ActionsViewModel @Inject constructor( messages = messages, currentFolderId = currentFolderId, mailbox = mailbox, - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), ) if (displaySnackbar && result != null) { @@ -383,7 +383,7 @@ class ActionsViewModel @Inject constructor( messages = messages, currentFolderId = currentFolder?.id, mailbox = mailbox, - displaySnackbar = false + displaySnackbar = false, ) } ) diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index 875ad52235..fdca4b30bb 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -219,7 +219,7 @@ class MessagesActionsUseCase @Inject constructor( val apiResponses = ApiRepository.deleteMessages( mailboxUuid = mailbox.uuid, messagesUids = uids, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings) + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings), ) onApiFinished() From 2a4baf225490373108e4103e8b3b1f236525de8b Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 23 Feb 2026 14:57:39 +0100 Subject: [PATCH 11/15] fix: Fix ai suggestions --- .../mail/ui/main/SnackbarManager.kt | 2 +- .../main/thread/actions/ActionsViewModel.kt | 32 +++++++++---------- .../mail/useCases/MessagesActionsUseCase.kt | 31 +++++++++--------- .../infomaniak/mail/utils/FolderRoleUtils.kt | 2 +- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt index 44e6b44186..e871b4babe 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt @@ -91,7 +91,7 @@ class SnackbarManager @Inject constructor() { ) data class UndoData( - val resources: List, + val resources: List?, val foldersIds: ImpactedFolders, val destinationFolderId: String?, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index f9247ebcf9..bbf1f38bf8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -105,12 +105,12 @@ class ActionsViewModel @Inject constructor( handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) } - private fun handleToggleSpamMessages( + private suspend fun handleToggleSpamMessages( messages: List, currentFolderId: String?, mailbox: Mailbox, displaySnackbar: Boolean = true, - ) = viewModelScope.launch(ioCoroutineContext) { + ) { val result = messagesActionsUseCase.toggleMessagesSpamStatus( messages = messages, currentFolderId = currentFolderId, @@ -161,14 +161,13 @@ class ActionsViewModel @Inject constructor( handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } - private fun handleMessagesMove( + private suspend fun handleMessagesMove( destinationFolderId: String, messages: List, currentFolderId: String?, mailbox: Mailbox, - ) = viewModelScope.launch(ioCoroutineContext) { - - val destinationFolder = folderController.getFolder(destinationFolderId) ?: return@launch + ) { + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return val result = messagesActionsUseCase.moveMessagesTo( destinationFolder = destinationFolder, currentFolderId = currentFolderId, @@ -223,11 +222,11 @@ class ActionsViewModel @Inject constructor( handleDeleteMessages(messagesToDelete, currentFolder, mailbox) } - private fun handleDeleteMessages( + private suspend fun handleDeleteMessages( messagesToDelete: List, currentFolder: Folder?, mailbox: Mailbox, - ) = viewModelScope.launch(ioCoroutineContext) { + ) { val shouldPermanentlyDelete = isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messagesToDelete, currentFolder)) @@ -255,7 +254,7 @@ class ActionsViewModel @Inject constructor( val destinationFolder = folderController.getFolder(FolderRole.TRASH) if (destinationFolder == null) { snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) - return@launch + return } moveMessagesTo( @@ -270,7 +269,7 @@ class ActionsViewModel @Inject constructor( private fun showDeleteSnackbar( apiResponses: List>, messages: List, - undoResources: List, + undoResources: List?, undoFoldersIds: ImpactedFolders, undoDestinationId: String?, numberOfImpactedThreads: Int, @@ -288,7 +287,7 @@ class ActionsViewModel @Inject constructor( appContext.getString(apiResponses.first().translateError()) } - val undoData = if (undoResources.isEmpty()) null else UndoData(undoResources, undoFoldersIds, undoDestinationId) + val undoData = if (undoResources.isNullOrEmpty()) null else UndoData(undoResources, undoFoldersIds, undoDestinationId) snackbarManager.postValue(snackbarTitle, undoData) } @@ -314,16 +313,15 @@ class ActionsViewModel @Inject constructor( handleArchiveMessage(messagesToMove, currentFolder, mailbox) } - private fun handleArchiveMessage( + private suspend fun handleArchiveMessage( messages: List, currentFolder: Folder?, mailbox: Mailbox, - ) = viewModelScope.launch(ioCoroutineContext) { - + ) { val role = folderRoleUtils.getActionFolderRole(messages, currentFolder) val isFromArchive = role == FolderRole.ARCHIVE val destinationFolderRole = if (isFromArchive) FolderRole.INBOX else FolderRole.ARCHIVE - val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return@launch + val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolder?.id, mailbox) } @@ -492,13 +490,13 @@ class ActionsViewModel @Inject constructor( is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred } - return appContext.getString(errorMessageRes) } //endregion //region Undo action - fun undoAction(undoData: UndoData, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + fun undoAction(undoData: UndoData?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + if (undoData == null) return@launch val result = messagesActionsUseCase.undoAction(undoData, mailbox) val message = when (result) { is MessagesActionsUseCase.ApiCallResult.Success -> appContext.getString(result.messageRes) diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index fdca4b30bb..b67cfe1271 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -39,7 +39,6 @@ import com.infomaniak.mail.data.models.mailbox.SendersRestrictions import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread -import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.FolderRoleUtils @@ -101,7 +100,6 @@ class MessagesActionsUseCase @Inject constructor( return MoveMessagesResult(movedThreads, messages, apiResponses, destinationFolder) } - suspend fun getMessagesToMove(threads: List, message: Message?) = when (message) { null -> threads.flatMap { messageController.getMovableMessages(it) } else -> listOf(message) @@ -138,10 +136,12 @@ class MessagesActionsUseCase @Inject constructor( } private suspend fun moveOutMessagesThreadsLocally(messages: List, destinationFolder: Folder): List { - val uidsToMove = mutableListOf().apply { + val uidsToMove = mutableListOf() + mailboxContentRealm().run { messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> - val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } - if (nbMessagesInCurrentFolder == 0) add(thread.uid) + val realmThread = ThreadController.getThreadBlocking(thread.uid, realm = this) ?: return@forEach + val nbMessagesInCurrentFolder = realmThread.messages.count { it.folderId != destinationFolder.id } + if (nbMessagesInCurrentFolder == 0) uidsToMove.add(thread.uid) } } @@ -211,7 +211,9 @@ class MessagesActionsUseCase @Inject constructor( onApiFinished: () -> Unit, refreshCallbacks: RefreshCallbacks?, ): DeleteResult? { - val undoResources = emptyList() + if (messagesToDelete.isEmpty()) { + return null + } val uids = messagesToDelete.getUids() val destinationFolder = folderController.getFolder(FolderRole.TRASH) ?: return null val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete, destinationFolder) @@ -244,7 +246,7 @@ class MessagesActionsUseCase @Inject constructor( return DeleteResult( apiResponses = apiResponses, uidsToMove = uidsToMove, - undoResources = undoResources, + undoResources = null, // since we are permanently deleting there isn't an undo action. undoFoldersIds = undoFoldersIds, undoDestinationId = undoDestinationId, ) @@ -547,7 +549,7 @@ class MessagesActionsUseCase @Inject constructor( blockedSenders.removeIf { it.email == email } } updateBlockedSenders(mailbox, restrictions) - null + ApiCallResult.Success(R.string.unblockButton) // We don't show a snackbar on success. It's just a confirmation } else { ApiCallResult.Error(response.translateError()) } @@ -618,7 +620,6 @@ class MessagesActionsUseCase @Inject constructor( threads: Collection, mailbox: Mailbox, ): BatchSnoozeResult = coroutineScope { - val result = SharedUtils.unsnoozeThreadsWithoutRefresh( mailbox = mailbox, threads = threads, @@ -639,8 +640,11 @@ class MessagesActionsUseCase @Inject constructor( // End Region // Undo Region - suspend fun undoAction(undoData: SnackbarManager.UndoData, mailbox: Mailbox): ApiCallResult { + suspend fun undoAction(undoData: UndoData, mailbox: Mailbox): ApiCallResult { val (resources, foldersIds, destinationFolderId) = undoData + if (resources == null) { + return ApiCallResult.Error(RCore.string.anErrorHasOccurred) + } val apiResponses = resources.map { ApiRepository.undoAction(it) } if (apiResponses.atLeastOneSucceeded()) { @@ -673,14 +677,13 @@ class MessagesActionsUseCase @Inject constructor( val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) foldersIds += destinationFolder.id UndoData( - resources = apiResponses.mapNotNull { it.data?.undoResource }, + resources = undoResources, foldersIds = foldersIds, destinationFolderId = undoDestinationId, ) } return undoData } - // End Region /** @@ -721,10 +724,8 @@ class MessagesActionsUseCase @Inject constructor( data class DeleteResult( val apiResponses: List>, val uidsToMove: List, - val undoResources: List, + val undoResources: List?, val undoFoldersIds: ImpactedFolders, val undoDestinationId: String, ) } - - diff --git a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt index 14f12067ee..e2eb08615a 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2025 Infomaniak Network SA + * Copyright (C) 2025-2026 Infomaniak Network SA * * 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 From 8a40bce96ee7a70719b17fa054ebf5c1e9ca04cd Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 23 Feb 2026 15:54:20 +0100 Subject: [PATCH 12/15] fix: Fix suggested changes by AI --- .../mail/ui/main/folder/PerformSwipeActionManager.kt | 2 +- .../infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt | 3 ++- .../infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt | 2 ++ .../ui/main/thread/actions/MultiSelectBottomSheetDialog.kt | 1 - 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 3559c3bef5..d3ad5d282f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -205,12 +205,12 @@ object PerformSwipeActionManager { } fun onHandleDelete() { - if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox ) + if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) } return descriptionDialog.deleteWithConfirmationPopup( diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index b6ec5d1a38..0ace6045da 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2023-2025 Infomaniak Network SA + * Copyright (C) 2023-2026 Infomaniak Network SA * * 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 @@ -125,6 +125,7 @@ class ThreadListMultiSelection { R.id.quickActionMenu -> { trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) val direction = if (selectedThreadsCount == 1) { + isMultiSelectOn = false ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( threadUid = selectedThreadsUids.single(), shouldLoadDistantResources = false, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index bbf1f38bf8..748e2c89d1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -129,6 +129,7 @@ class ActionsViewModel @Inject constructor( fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { if (mailbox == null) return@launch + val result = messagesActionsUseCase.unblockMail(email, mailbox) if (result is MessagesActionsUseCase.ApiCallResult.Error) { snackbarManager.postValue(appContext.getString(result.messageRes)) @@ -497,6 +498,7 @@ class ActionsViewModel @Inject constructor( //region Undo action fun undoAction(undoData: UndoData?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { if (undoData == null) return@launch + val result = messagesActionsUseCase.undoAction(undoData, mailbox) val message = when (result) { is MessagesActionsUseCase.ApiCallResult.Success -> appContext.getString(result.messageRes) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 11e50c3503..d85b5eb40b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -175,7 +175,6 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { ) }, ) - isMultiSelectOn = false } From b395c2cf0dc5b0942e2533f60ed163daf00f6c72 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 23 Feb 2026 16:22:24 +0100 Subject: [PATCH 13/15] fix: Fix PR --- app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt | 2 ++ .../mail/ui/main/folder/PerformSwipeActionManager.kt | 2 +- .../mail/ui/main/thread/actions/ActionsViewModel.kt | 5 +++-- .../com/infomaniak/mail/useCases/MessagesActionsUseCase.kt | 3 +++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index abaf8ab163..c59f9a2525 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -790,6 +790,8 @@ class MainViewModel @Inject constructor( if (result != null) { showMoveSnackbar(threadsUids.count(), result.messages, result.apiResponses, destinationFolder) isMovedToNewFolder.postValue(true) + } else { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) } } //endregion diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index d3ad5d282f..3559c3bef5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -205,12 +205,12 @@ object PerformSwipeActionManager { } fun onHandleDelete() { + if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox ) - if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) } return descriptionDialog.deleteWithConfirmationPopup( diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 748e2c89d1..716560abbe 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -156,7 +156,7 @@ class ActionsViewModel @Inject constructor( currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val messages = messagesUids.let { messageController.getMessages(it) } + val messages = messageController.getMessages(messagesUids) val messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolderId) handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) @@ -169,6 +169,7 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox, ) { val destinationFolder = folderController.getFolder(destinationFolderId) ?: return + val result = messagesActionsUseCase.moveMessagesTo( destinationFolder = destinationFolder, currentFolderId = currentFolderId, @@ -498,7 +499,7 @@ class ActionsViewModel @Inject constructor( //region Undo action fun undoAction(undoData: UndoData?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { if (undoData == null) return@launch - + val result = messagesActionsUseCase.undoAction(undoData, mailbox) val message = when (result) { is MessagesActionsUseCase.ApiCallResult.Success -> appContext.getString(result.messageRes) diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index b67cfe1271..e0fdc19682 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -157,7 +157,9 @@ class MessagesActionsUseCase @Inject constructor( currentFolderId: String?, ): MoveMessagesResult? { if (currentFolderId == null) return null + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return null + var messagesToMove: List if (messagesUid != null) { messagesToMove = messagesUid.let { messageController.getMessages(it) } @@ -165,6 +167,7 @@ class MessagesActionsUseCase @Inject constructor( val threads = threadController.getThreads(threadsUids).ifEmpty { return null } messagesToMove = getMessagesFromThreadsToMove(threads) } + return moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) } // End Region From 0a99ae350628cdccc35bb791298b212c6d532b68 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 24 Feb 2026 09:01:37 +0100 Subject: [PATCH 14/15] refactor: Add missing commas --- .../com/infomaniak/mail/useCases/MessagesActionsUseCase.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt index e0fdc19682..1f0426bd5a 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -106,9 +106,7 @@ class MessagesActionsUseCase @Inject constructor( } suspend fun getMessagesFromThreadsToMove(threads: List): List { - return threads.flatMap { - messageController.getMovableMessages(it) - } + return threads.flatMap { messageController.getMovableMessages(it) } } fun getMessagesToMove(messages: List, currentFolderId: String?): List { @@ -240,7 +238,7 @@ class MessagesActionsUseCase @Inject constructor( if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( threadsUids = uidsToMove, - hasBeenMovedOut = false + hasBeenMovedOut = false, ) val undoDestinationId = messagesToDelete.first().folderId @@ -648,6 +646,7 @@ class MessagesActionsUseCase @Inject constructor( if (resources == null) { return ApiCallResult.Error(RCore.string.anErrorHasOccurred) } + val apiResponses = resources.map { ApiRepository.undoAction(it) } if (apiResponses.atLeastOneSucceeded()) { From 3db2f008d437d2b99e039e388423c0bb41d05ef8 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 24 Feb 2026 09:18:02 +0100 Subject: [PATCH 15/15] fix: Fix sonar issues --- .../main/folderPicker/FolderPickerFragment.kt | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index 5fb284f50e..b3e6931e5b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt @@ -40,6 +40,7 @@ import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.databinding.FragmentFolderPickerBinding import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.alertDialogs.CreateFolderDialog +import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.Utils @@ -53,6 +54,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +import com.infomaniak.core.common.R as RCore @AndroidEntryPoint class FolderPickerFragment : Fragment() { @@ -70,6 +72,9 @@ class FolderPickerFragment : Fragment() { @Inject lateinit var folderPickerAdapter: FolderPickerAdapter + @Inject + lateinit var snackbarManager: SnackbarManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { @@ -150,33 +155,42 @@ class FolderPickerFragment : Fragment() { } } - private fun onFolderSelected(folder: Folder?): Unit = with(navigationArgs) { - when (action) { - FolderPickerAction.MOVE -> folder?.id?.let { - if (messagesUids != null) { - actionsViewModel.moveMessagesTo( - destinationFolderId = it, - messagesUids = messagesUids.toList(), - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!! - ) - } else { - actionsViewModel.moveThreadsTo( - destinationFolderId = it, - threadsUids = threadsUids.toList(), - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!! - ) - } - } + private fun onFolderSelected(folder: Folder?) { + when (navigationArgs.action) { + FolderPickerAction.MOVE -> handleMove(folder?.id) FolderPickerAction.SEARCH -> { searchViewModel.selectAllFoldersFilter(folder == null) searchViewModel.selectFolder(folder) } } + findNavController().popBackStack() } + private fun handleMove(folderId: String?) = with(navigationArgs) { + val mailbox = mainViewModel.currentMailbox.value + if (folderId == null || mailbox == null) { + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + return@with + } + + if (messagesUids != null) { + actionsViewModel.moveMessagesTo( + destinationFolderId = folderId, + messagesUids = messagesUids.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mailbox + ) + } else { + actionsViewModel.moveThreadsTo( + destinationFolderId = folderId, + threadsUids = threadsUids.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mailbox + ) + } + } + private fun setupSearchBar() = with(binding) { searchInputLayout.setOnClearTextClickListener { trackMoveSearchEvent(MatomoName.DeleteSearch) }