diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepository.kt index d92c71317b8..74eb3c227fb 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepository.kt @@ -57,6 +57,7 @@ internal interface ConversationFolderRepository { suspend fun fetchConversationFolders(): Either suspend fun addConversationToFolder(conversationId: QualifiedID, folderId: String): Either suspend fun removeConversationFromFolder(conversationId: QualifiedID, folderId: String): Either + suspend fun removeFolder(folderId: String): Either suspend fun syncConversationFoldersFromLocal(): Either suspend fun observeFolders(): Flow>> } @@ -143,6 +144,10 @@ internal class ConversationFolderDataSource internal constructor( } } + override suspend fun removeFolder(folderId: String): Either = wrapStorageRequest { + conversationFolderDAO.removeFolder(folderId) + } + override suspend fun syncConversationFoldersFromLocal(): Either { kaliumLogger.withFeatureId(CONVERSATIONS_FOLDERS).v("Syncing conversation folders from local") return wrapStorageRequest { conversationFolderDAO.getFoldersWithConversations().map { it.toModel() } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt index 842a6765fc8..6f4256b7ae5 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/ConversationScope.kt @@ -63,6 +63,8 @@ import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCa import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCaseImpl import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCase import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCaseImpl +import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCase +import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCaseImpl import com.wire.kalium.logic.feature.conversation.guestroomlink.CanCreatePasswordProtectedLinksUseCase import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkUseCase import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkUseCaseImpl @@ -390,4 +392,6 @@ class ConversationScope internal constructor( get() = ObserveUserFoldersUseCaseImpl(conversationFolderRepository) val moveConversationToFolder: MoveConversationToFolderUseCase get() = MoveConversationToFolderUseCaseImpl(conversationFolderRepository) + val removeConversationFromFolder: RemoveConversationFromFolderUseCase + get() = RemoveConversationFromFolderUseCaseImpl(conversationFolderRepository) } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/folder/RemoveConversationFromFolderUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/folder/RemoveConversationFromFolderUseCase.kt new file mode 100644 index 00000000000..b1a1f36dffc --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/conversation/folder/RemoveConversationFromFolderUseCase.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.kalium.logic.feature.conversation.folder + +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.conversation.folders.ConversationFolderRepository +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.flatMap +import com.wire.kalium.logic.functional.fold +import com.wire.kalium.util.KaliumDispatcher +import com.wire.kalium.util.KaliumDispatcherImpl +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** + * This use case will remove a conversation from the selected folder and if the folder is empty, it will remove the folder. + */ +interface RemoveConversationFromFolderUseCase { + /** + * @param conversationId the id of the conversation + * @param folderId the id of the folder + * @return the [Result] indicating a successful operation, otherwise a [CoreFailure] + */ + suspend operator fun invoke(conversationId: ConversationId, folderId: String): Result + + sealed interface Result { + data object Success : Result + data class Failure(val cause: CoreFailure) : Result + } +} + +internal class RemoveConversationFromFolderUseCaseImpl( + private val conversationFolderRepository: ConversationFolderRepository, + private val dispatchers: KaliumDispatcher = KaliumDispatcherImpl +) : RemoveConversationFromFolderUseCase { + override suspend fun invoke( + conversationId: ConversationId, + folderId: String + ): RemoveConversationFromFolderUseCase.Result = withContext(dispatchers.io) { + conversationFolderRepository.removeConversationFromFolder(conversationId, folderId) + .flatMap { + if (conversationFolderRepository.observeConversationsFromFolder(folderId).first().isEmpty()) { + conversationFolderRepository.removeFolder(folderId) + } else { + Either.Right(Unit) + } + } + .flatMap { + conversationFolderRepository.syncConversationFoldersFromLocal() + } + .fold({ + RemoveConversationFromFolderUseCase.Result.Failure(it) + }, { + RemoveConversationFromFolderUseCase.Result.Success + }) + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepositoryTest.kt index 77407d39b67..2fadbc3e3fe 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepositoryTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/conversation/folders/ConversationFolderRepositoryTest.kt @@ -216,6 +216,20 @@ class ConversationFolderRepositoryTest { coVerify { arrangement.conversationFolderDAO.getFoldersWithConversations() }.wasInvoked() } + @Test + fun givenValidFolderIdWhenRemovingFolderThenShouldRemoveSuccessfully() = runTest { + // given + val folderId = "folder1" + val arrangement = Arrangement().withSuccessfulFolderRemoval() + + // when + val result = arrangement.repository.removeFolder(folderId) + + // then + result.shouldSucceed() + coVerify { arrangement.conversationFolderDAO.removeFolder(eq(folderId)) }.wasInvoked() + } + private class Arrangement { @Mock @@ -278,5 +292,10 @@ class ConversationFolderRepositoryTest { coEvery { conversationFolderDAO.removeConversationFromFolder(any(), any()) }.returns(Unit) return this } + + suspend fun withSuccessfulFolderRemoval(): Arrangement { + coEvery { conversationFolderDAO.removeFolder(any()) }.returns(Unit) + return this + } } } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/folder/RemoveConversationFromFolderUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/folder/RemoveConversationFromFolderUseCaseTest.kt new file mode 100644 index 00000000000..9f9d290c498 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/conversation/folder/RemoveConversationFromFolderUseCaseTest.kt @@ -0,0 +1,180 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.conversation.folder + +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents +import com.wire.kalium.logic.data.conversation.folders.ConversationFolderRepository +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCase.Result +import com.wire.kalium.logic.framework.TestConversationDetails +import com.wire.kalium.logic.functional.Either +import io.mockative.Mock +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertIs + +class RemoveConversationFromFolderUseCaseTest { + + @Test + fun givenValidConversationAndFolder_WhenRemoveAndSyncSuccessful_ThenReturnSuccess() = runTest { + val testConversationId = ConversationId("conversation-value", "conversation-domain") + val testFolderId = "test-folder-id" + + val (arrangement, removeConversationUseCase) = Arrangement() + .withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit)) + .withObserveConversationsFromFolder(testFolderId, flowOf(emptyList())) + .withRemoveFolder(testFolderId, Either.Right(Unit)) + .withSyncFolders(Either.Right(Unit)) + .arrange() + + val result = removeConversationUseCase(testConversationId, testFolderId) + + assertIs(result) + + coVerify { + arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.removeFolder(testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.syncConversationFoldersFromLocal() + }.wasInvoked(exactly = once) + } + + @Test + fun givenFolderNotEmpty_WhenRemoveAndSyncSuccessful_ThenReturnSuccessWithoutFolderRemoval() = runTest { + val testConversationId = ConversationId("conversation-value", "conversation-domain") + val testFolderId = "test-folder-id" + + val (arrangement, removeConversationUseCase) = Arrangement() + .withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit)) + .withObserveConversationsFromFolder( + testFolderId, + flowOf(listOf(ConversationDetailsWithEvents(TestConversationDetails.CONVERSATION_GROUP))) + ) + .withSyncFolders(Either.Right(Unit)) + .arrange() + + val result = removeConversationUseCase(testConversationId, testFolderId) + + assertIs(result) + + coVerify { + arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.removeFolder(testFolderId) + }.wasNotInvoked() + + coVerify { + arrangement.conversationFolderRepository.syncConversationFoldersFromLocal() + }.wasInvoked(exactly = once) + } + + @Test + fun givenErrorDuringFolderRemoval_WhenObservedEmpty_ThenReturnFailure() = runTest { + val testConversationId = ConversationId("conversation-value", "conversation-domain") + val testFolderId = "test-folder-id" + + val (arrangement, removeConversationUseCase) = Arrangement() + .withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit)) + .withObserveConversationsFromFolder(testFolderId, flowOf(emptyList())) + .withRemoveFolder(testFolderId, Either.Left(CoreFailure.Unknown(null))) + .arrange() + + val result = removeConversationUseCase(testConversationId, testFolderId) + + assertIs(result) + + coVerify { + arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId) + }.wasInvoked(exactly = once) + + coVerify { + arrangement.conversationFolderRepository.removeFolder(testFolderId) + }.wasInvoked(exactly = once) + } + + private class Arrangement { + @Mock + val conversationFolderRepository = mock(ConversationFolderRepository::class) + + private val removeConversationFromFolderUseCase = RemoveConversationFromFolderUseCaseImpl( + conversationFolderRepository + ) + + suspend fun withRemoveConversationFromFolder( + conversationId: ConversationId, + folderId: String, + either: Either + ) = apply { + coEvery { + conversationFolderRepository.removeConversationFromFolder(conversationId, folderId) + }.returns(either) + } + + suspend fun withObserveConversationsFromFolder( + folderId: String, + flow: Flow> + ) = apply { + coEvery { + conversationFolderRepository.observeConversationsFromFolder(folderId) + }.returns(flow) + } + + suspend fun withRemoveFolder( + folderId: String, + either: Either + ) = apply { + coEvery { + conversationFolderRepository.removeFolder(folderId) + }.returns(either) + } + + suspend fun withSyncFolders(either: Either) = apply { + coEvery { + conversationFolderRepository.syncConversationFoldersFromLocal() + }.returns(either) + } + + fun arrange(block: Arrangement.() -> Unit = { }) = apply(block).let { this to removeConversationFromFolderUseCase } + } +} diff --git a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/ConversationFolders.sq b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/ConversationFolders.sq index b5b165d38d7..1ebe1a85260 100644 --- a/persistence/src/commonMain/db_user/com/wire/kalium/persistence/ConversationFolders.sq +++ b/persistence/src/commonMain/db_user/com/wire/kalium/persistence/ConversationFolders.sq @@ -11,7 +11,6 @@ CREATE TABLE LabeledConversation ( conversation_id TEXT AS QualifiedIDEntity NOT NULL, folder_id TEXT NOT NULL, - FOREIGN KEY (conversation_id) REFERENCES Conversation(qualified_id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (folder_id) REFERENCES ConversationFolder(id) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY (folder_id, conversation_id) @@ -68,3 +67,6 @@ DELETE FROM LabeledConversation; clearFolders: DELETE FROM ConversationFolder; + +deleteFolder: +DELETE FROM ConversationFolder WHERE id = ?; diff --git a/persistence/src/commonMain/db_user/migrations/96.sqm b/persistence/src/commonMain/db_user/migrations/96.sqm new file mode 100644 index 00000000000..ad012d44dd2 --- /dev/null +++ b/persistence/src/commonMain/db_user/migrations/96.sqm @@ -0,0 +1,10 @@ +DROP TABLE LabeledConversation; + +CREATE TABLE LabeledConversation ( + conversation_id TEXT AS QualifiedIDEntity NOT NULL, + folder_id TEXT NOT NULL, + + FOREIGN KEY (folder_id) REFERENCES ConversationFolder(id) ON DELETE CASCADE ON UPDATE CASCADE, + + PRIMARY KEY (folder_id, conversation_id) +); diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAO.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAO.kt index 463aae0e637..c936fed9da3 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAO.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAO.kt @@ -29,4 +29,5 @@ interface ConversationFolderDAO { suspend fun addConversationToFolder(conversationId: QualifiedIDEntity, folderId: String) suspend fun removeConversationFromFolder(conversationId: QualifiedIDEntity, folderId: String) suspend fun observeFolders(): Flow> + suspend fun removeFolder(folderId: String) } diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOImpl.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOImpl.kt index 7bd01e0e57b..1f71b24e653 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOImpl.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOImpl.kt @@ -21,6 +21,7 @@ import app.cash.sqldelight.coroutines.asFlow import com.wire.kalium.persistence.ConversationFolder import com.wire.kalium.persistence.ConversationFoldersQueries import com.wire.kalium.persistence.GetAllFoldersWithConversations +import com.wire.kalium.persistence.LabeledConversation import com.wire.kalium.persistence.dao.QualifiedIDEntity import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsEntity import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsMapper @@ -45,6 +46,10 @@ class ConversationFolderDAOImpl internal constructor( .flowOn(coroutineContext) } + override suspend fun removeFolder(folderId: String) = withContext(coroutineContext) { + conversationFoldersQueries.deleteFolder(folderId) + } + override suspend fun getFoldersWithConversations(): List = withContext(coroutineContext) { val labeledConversationList = conversationFoldersQueries.getAllFoldersWithConversations().executeAsList().map(::toEntity) @@ -69,6 +74,11 @@ class ConversationFolderDAOImpl internal constructor( conversationId = row.conversation_id ) + private fun toEntity(row: LabeledConversation) = ConversationLabelEntity( + folderId = row.folder_id, + conversationId = row.conversation_id + ) + private fun toEntity(row: ConversationFolder) = ConversationFolderEntity( id = row.id, name = row.name, diff --git a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderEntity.kt b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderEntity.kt index 77ceccda8f5..9c1b6a38582 100644 --- a/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderEntity.kt +++ b/persistence/src/commonMain/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderEntity.kt @@ -39,6 +39,11 @@ data class LabeledConversationEntity( val conversationId: QualifiedIDEntity? ) +data class ConversationLabelEntity( + val conversationId: QualifiedIDEntity, + val folderId: String +) + enum class ConversationFolderTypeEntity { USER, FAVORITE diff --git a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOTest.kt b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOTest.kt index d533ccc6dec..eeeef07a24e 100644 --- a/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOTest.kt +++ b/persistence/src/commonTest/kotlin/com/wire/kalium/persistence/dao/conversation/folder/ConversationFolderDAOTest.kt @@ -26,6 +26,7 @@ import com.wire.kalium.persistence.db.UserDatabaseBuilder import com.wire.kalium.persistence.utils.stubs.newConversationEntity import com.wire.kalium.persistence.utils.stubs.newUserEntity import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -209,6 +210,81 @@ class ConversationFolderDAOTest : BaseDatabaseTest() { assertEquals(conversationEntity1.id, favoriteFolderResult.first().conversationViewEntity.id) } + @Test + fun givenExistingFolder_whenRemovingFolder_thenFolderShouldBeDeleted() = runTest { + val folderId = "folderId1" + + val folder = folderWithConversationsEntity( + id = folderId, + name = "Test Folder", + type = ConversationFolderTypeEntity.USER, + conversationIdList = listOf(conversationEntity1.id) + ) + + db.conversationFolderDAO.updateConversationFolders(listOf(folder)) + assertEquals(1, db.conversationFolderDAO.getFoldersWithConversations().size) + + db.conversationFolderDAO.removeFolder(folderId) + + val result = db.conversationFolderDAO.getFoldersWithConversations() + assertTrue(result.none { it.id == folderId }) + } + + @Test + fun givenNonExistentFolder_whenRemovingFolder_thenNoErrorShouldBeThrown() = runTest { + val nonExistentFolderId = "nonExistentFolderId" + + db.conversationFolderDAO.removeFolder(nonExistentFolderId) + + val result = db.conversationFolderDAO.getFoldersWithConversations() + assertTrue(result.isEmpty()) + } + + @Test + fun givenFolderWithConversations_whenRemovingFolder_thenFolderAndConversationsShouldBeDeleted() = runTest { + val folderId = "folderId1" + + val folder = folderWithConversationsEntity( + id = folderId, + name = "Test Folder", + type = ConversationFolderTypeEntity.USER, + conversationIdList = listOf(conversationEntity1.id) + ) + + db.conversationFolderDAO.updateConversationFolders(listOf(folder)) + db.conversationFolderDAO.removeFolder(folderId) + + val folderResult = db.conversationFolderDAO.getFoldersWithConversations() + assertTrue(folderResult.none { it.id == folderId }) + + val conversationResult = db.conversationFolderDAO.observeConversationListFromFolder(folderId).firstOrNull() + assertTrue(conversationResult.isNullOrEmpty()) + } + + @Test + fun givenMultipleFolders_whenRemovingOneFolder_thenOthersShouldRemain() = runTest { + val folder1 = folderWithConversationsEntity( + id = "folderId1", + name = "Folder 1", + type = ConversationFolderTypeEntity.USER, + conversationIdList = listOf(conversationEntity1.id) + ) + + val folder2 = folderWithConversationsEntity( + id = "folderId2", + name = "Folder 2", + type = ConversationFolderTypeEntity.USER, + conversationIdList = listOf() + ) + + db.conversationFolderDAO.updateConversationFolders(listOf(folder1, folder2)) + db.conversationFolderDAO.removeFolder("folderId1") + + val result = db.conversationFolderDAO.getFoldersWithConversations() + assertEquals(1, result.size) + assertTrue(result.any { it.id == "folderId2" }) + } + companion object { fun folderWithConversationsEntity( id: String = "folderId",