Skip to content

Commit b308012

Browse files
committed
feat(db-repository): replace destroy with destroyAllByServerId and destroyByServerId
- implement message destruction in DefaultMessageLifecycleRepository instead of TODO - add MessageQueryRepository.getAllServerIdByFolderId and back K9BackendFolder clearAllMessages/destroyMessages with the new repositories - make BackendFolder.destroyMessages and clearAllMessages suspend - introduce MessageQueryError.AccountNotFound and UnhandledError, making throwable nullable GG-ID: c-99f9e08 GG-Parent: c-145c77c
1 parent 3163de4 commit b308012

21 files changed

Lines changed: 632 additions & 53 deletions

File tree

‎app-common/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies {
2121

2222
implementation(projects.legacy.core)
2323
implementation(projects.legacy.ui.base)
24+
implementation(projects.legacy.storage)
2425
implementation(projects.core.android.account)
2526
implementation(projects.core.android.common)
2627

‎app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/MailMessageModule.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ internal val mailMessageModule = module {
7979
logger = get(),
8080
accountManager = get(),
8181
localStoreProvider = get(),
82+
messageStoreManager = get(),
8283
messageIdLegacyEntityIdFactory = get<MessageIdFactory>(),
8384
folderIdLegacyEntityIdFactory = get<FolderIdFactory>(),
8485
)

‎app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/data/repository/DefaultMessageLifecycleRepository.kt‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,24 @@ class DefaultMessageLifecycleRepository(
225225
}
226226
}
227227

228-
override suspend fun destroy(
229-
serverIds: List<MessageServerId>,
228+
override suspend fun destroyAllByServerId(
229+
serverIds: Collection<MessageServerId>,
230230
folderId: FolderId,
231231
accountId: AccountId,
232-
): Outcome<Unit, MessageLifecycleError> {
233-
TODO("Not yet implemented")
232+
): Outcome<Unit, MessageLifecycleError> = withContext(ioDispatcher) {
233+
runLegacy("destroy ${serverIds.size} messages in '$folderId'") {
234+
val store = messageStoreManager.getMessageStore(accountId)
235+
val legacyFolderId = folderIdLegacyEntityIdFactory.toLegacyId(folderId)
236+
store.destroyMessages(legacyFolderId, serverIds.map { it.value })
237+
}
234238
}
235239

240+
override suspend fun destroyByServerId(
241+
serverId: MessageServerId,
242+
folderId: FolderId,
243+
accountId: AccountId,
244+
): Outcome<Unit, MessageLifecycleError> = destroyAllByServerId(listOf(serverId), folderId, accountId)
245+
236246
/**
237247
* Runs a legacy store operation and converts any thrown exception into a [MessageLifecycleError.UnhandledError].
238248
* [CancellationException] is rethrown so structured concurrency keeps working.
@@ -258,7 +268,13 @@ class DefaultMessageLifecycleRepository(
258268
return if (folderId != null && messageServerId != null) {
259269
messageQueryRepository
260270
.findIdByCriteria(accountId, GetMessageIdCriteria(folderId, messageServerId))
261-
.fold(onSuccess = { it }, onFailure = { throw it.throwable })
271+
.fold(
272+
onSuccess = { it },
273+
onFailure = { error ->
274+
throw error.throwable
275+
?: IllegalStateException("Could not verify whether the message exists: $error")
276+
},
277+
)
262278
} else {
263279
null
264280
}
Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package net.thunderbird.app.common.feature.mail.message.data.repository
22

3+
import app.k9mail.legacy.mailstore.MessageStoreManager
34
import com.fsck.k9.mailstore.LocalStoreProvider
5+
import kotlin.coroutines.cancellation.CancellationException
46
import kotlinx.coroutines.CoroutineDispatcher
57
import kotlinx.coroutines.Dispatchers
68
import kotlinx.coroutines.withContext
@@ -11,15 +13,18 @@ import net.thunderbird.core.logging.Logger
1113
import net.thunderbird.feature.account.AccountId
1214
import net.thunderbird.feature.mail.folder.FolderId
1315
import net.thunderbird.feature.mail.message.MessageId
16+
import net.thunderbird.feature.mail.message.MessageServerId
1417
import net.thunderbird.feature.mail.message.domain.GetMessageIdCriteria
1518
import net.thunderbird.feature.mail.message.domain.MessageQueryError
1619
import net.thunderbird.feature.mail.message.domain.MessageQueryRepository
1720

1821
private const val LOG_ID = "[repository][message-query]"
22+
1923
class DefaultMessageQueryRepository(
2024
private val logger: Logger,
2125
private val accountManager: LegacyAccountManager,
2226
private val localStoreProvider: LocalStoreProvider,
27+
private val messageStoreManager: MessageStoreManager,
2328
private val messageIdLegacyEntityIdFactory: LegacyEntityIdFactory<MessageId>,
2429
private val folderIdLegacyEntityIdFactory: LegacyEntityIdFactory<FolderId>,
2530
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -30,31 +35,68 @@ class DefaultMessageQueryRepository(
3035
): Outcome<MessageId?, MessageQueryError> = withContext(ioDispatcher) {
3136
logger.verbose { "$LOG_ID finding message id by account id = '$accountId' and criteria = $criteria" }
3237
val account = accountManager.getAccount(accountId.toString())
33-
?: error("account not found")
34-
val localStore = localStoreProvider.getInstanceByLegacyAccount(account)
35-
val lockableDatabase = localStore.database
36-
val legacyFolderId = folderIdLegacyEntityIdFactory.toLegacyId(criteria.folderId)
37-
val rawServerId = criteria.messageServerId.value
38-
val legacyMessageId = lockableDatabase.execute(false) { db ->
39-
db.query(
40-
"messages",
41-
arrayOf("id"),
42-
"folder_id = ? AND uid = ?",
43-
arrayOf(legacyFolderId.toString(), rawServerId),
44-
null,
45-
null,
46-
null,
47-
).use { cursor ->
48-
if (cursor.moveToFirst()) {
49-
val messageId = cursor.getLong(0)
50-
messageId
51-
} else {
52-
null
38+
?: return@withContext accountNotFound(accountId)
39+
40+
runLegacy(operation = "find message id by $criteria") {
41+
val lockableDatabase = localStoreProvider.getInstanceByLegacyAccount(account).database
42+
val legacyFolderId = folderIdLegacyEntityIdFactory.toLegacyId(criteria.folderId)
43+
val rawServerId = criteria.messageServerId.value
44+
val legacyMessageId = lockableDatabase.execute(false) { db ->
45+
db.query(
46+
"messages",
47+
arrayOf("id"),
48+
"folder_id = ? AND uid = ?",
49+
arrayOf(legacyFolderId.toString(), rawServerId),
50+
null,
51+
null,
52+
null,
53+
).use { cursor ->
54+
if (cursor.moveToFirst()) cursor.getLong(0) else null
5355
}
5456
}
57+
legacyMessageId?.let(messageIdLegacyEntityIdFactory::of).also { messageId ->
58+
logger.verbose { "$LOG_ID message id = '$messageId'" }
59+
}
60+
}
61+
}
62+
63+
override suspend fun getAllServerIdByFolderId(
64+
folderId: FolderId,
65+
accountId: AccountId,
66+
): Outcome<Set<MessageServerId>, MessageQueryError> = withContext(ioDispatcher) {
67+
logger.verbose { "$LOG_ID getting message server ids by folder id '$folderId' and account id = '$accountId'" }
68+
accountManager.getAccount(accountId.toString())
69+
?: return@withContext accountNotFound(accountId)
70+
71+
runLegacy(operation = "get server ids of folder '$folderId'") {
72+
val messageStore = messageStoreManager.getMessageStore(accountId)
73+
val legacyFolderId = folderIdLegacyEntityIdFactory.toLegacyId(folderId)
74+
messageStore.getMessageServerIds(legacyFolderId)
75+
.map(::MessageServerId)
76+
.toSet()
77+
.also { serverIds -> logger.verbose { "$LOG_ID found ${serverIds.size} server ids" } }
5578
}
56-
val messageId = legacyMessageId?.let(messageIdLegacyEntityIdFactory::of)
57-
logger.verbose { "message id = '$messageId'" }
58-
Outcome.success(messageId)
79+
}
80+
81+
private fun <T> accountNotFound(accountId: AccountId): Outcome<T, MessageQueryError> {
82+
logger.warn { "$LOG_ID account '$accountId' not found" }
83+
return Outcome.failure(MessageQueryError.AccountNotFound(accountId))
84+
}
85+
86+
/**
87+
* Runs a legacy store operation and converts any thrown exception into a [MessageQueryError.UnhandledError].
88+
* [CancellationException] is rethrown so structured concurrency keeps working.
89+
*/
90+
@Suppress("TooGenericExceptionCaught")
91+
private inline fun <T> runLegacy(
92+
operation: String,
93+
block: () -> T,
94+
): Outcome<T, MessageQueryError> = try {
95+
Outcome.success(block())
96+
} catch (e: CancellationException) {
97+
throw e
98+
} catch (e: Exception) {
99+
logger.error(throwable = e) { "$LOG_ID $operation failed" }
100+
Outcome.failure(MessageQueryError.UnhandledError(e))
59101
}
60102
}

‎app-common/src/test/kotlin/net/thunderbird/app/common/feature/mail/message/data/repository/DefaultMessageLifecycleRepositoryTest.kt‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,115 @@ class DefaultMessageLifecycleRepositoryTest {
483483

484484
// endregion [copyAll]
485485

486+
// region [destroyAllByServerId]
487+
488+
@Test
489+
fun `destroyAllByServerId should destroy the messages by folder and server ids when the store succeeds`() =
490+
runTest {
491+
// Arrange
492+
val serverIds = listOf(MessageServerId("100"), MessageServerId("101"))
493+
494+
// Act
495+
val outcome = testSubject.destroyAllByServerId(serverIds, folderId, accountId)
496+
497+
// Assert
498+
assertThat(outcome).isEqualTo(Outcome.success(Unit))
499+
verify(messageStore).destroyMessages(FOLDER_ID, listOf("100", "101"))
500+
}
501+
502+
@Test
503+
fun `destroyAllByServerId should forward an empty collection to the store`() = runTest {
504+
// Act
505+
val outcome = testSubject.destroyAllByServerId(emptyList(), folderId, accountId)
506+
507+
// Assert
508+
assertThat(outcome).isEqualTo(Outcome.success(Unit))
509+
verify(messageStore).destroyMessages(FOLDER_ID, emptyList())
510+
}
511+
512+
@Test
513+
fun `destroyAllByServerId should return UnhandledError when the store throws`() = runTest {
514+
// Arrange
515+
val exception = IllegalStateException("database is locked")
516+
whenever(messageStore.destroyMessages(FOLDER_ID, listOf("100", "101"))).thenThrow(exception)
517+
518+
// Act
519+
val outcome = testSubject.destroyAllByServerId(
520+
serverIds = listOf(MessageServerId("100"), MessageServerId("101")),
521+
folderId = folderId,
522+
accountId = accountId,
523+
)
524+
525+
// Assert
526+
assertThat(outcome).isEqualTo(Outcome.failure(MessageLifecycleError.UnhandledError(exception)))
527+
}
528+
529+
@Test
530+
fun `destroyAllByServerId should return UnhandledError when the account is unknown`() = runTest {
531+
// Act
532+
val outcome = testSubject.destroyAllByServerId(
533+
serverIds = listOf(MessageServerId(SERVER_ID)),
534+
folderId = folderId,
535+
accountId = AccountIdFactory.of(UNKNOWN_ACCOUNT_ID),
536+
)
537+
538+
// Assert
539+
assertThat(outcome).isInstanceOf<Outcome.Failure<MessageLifecycleError>>()
540+
.transform { it.error }
541+
.isInstanceOf<MessageLifecycleError.UnhandledError>()
542+
.transform { it.throwable }
543+
.isInstanceOf<IllegalStateException>()
544+
verifyNoInteractions(messageStore)
545+
}
546+
547+
// endregion [destroyAllByServerId]
548+
549+
// region [destroyByServerId]
550+
551+
@Test
552+
fun `destroyByServerId should destroy the single message by folder and server id when the store succeeds`() =
553+
runTest {
554+
// Act
555+
val outcome = testSubject.destroyByServerId(MessageServerId(SERVER_ID), folderId, accountId)
556+
557+
// Assert
558+
assertThat(outcome).isEqualTo(Outcome.success(Unit))
559+
verify(messageStore).destroyMessages(FOLDER_ID, listOf(SERVER_ID))
560+
}
561+
562+
@Test
563+
fun `destroyByServerId should return UnhandledError when the store throws`() = runTest {
564+
// Arrange
565+
val exception = IllegalStateException("database is locked")
566+
whenever(messageStore.destroyMessages(FOLDER_ID, listOf(SERVER_ID))).thenThrow(exception)
567+
568+
// Act
569+
val outcome = testSubject.destroyByServerId(MessageServerId(SERVER_ID), folderId, accountId)
570+
571+
// Assert
572+
assertThat(outcome).isEqualTo(Outcome.failure(MessageLifecycleError.UnhandledError(exception)))
573+
}
574+
575+
@Test
576+
fun `destroyByServerId should return UnhandledError when the account is unknown`() = runTest {
577+
// Act
578+
val outcome = testSubject.destroyByServerId(
579+
serverId = MessageServerId(SERVER_ID),
580+
folderId = folderId,
581+
accountId = AccountIdFactory.of(UNKNOWN_ACCOUNT_ID),
582+
)
583+
584+
// Assert
585+
assertThat(outcome).isInstanceOf<Outcome.Failure<MessageLifecycleError>>()
586+
.transform { it.error }
587+
.isInstanceOf<MessageLifecycleError.UnhandledError>()
588+
.transform { it.throwable }
589+
.isInstanceOf<IllegalStateException>()
590+
verifyNoInteractions(messageStore)
591+
}
592+
593+
// endregion [destroyByServerId]
594+
486595
private fun stubSaveMessageData() {
487596
whenever(saveMessageDataCreator.createSaveMessageData(legacyMessage, LegacyMessageDownloadState.FULL, null))
488597
.thenReturn(saveMessageData)
@@ -554,6 +663,11 @@ private class FakeMessageQueryRepository : MessageQueryRepository {
554663
lastCriteria = criteria
555664
return result
556665
}
666+
667+
override suspend fun getAllServerIdByFolderId(
668+
folderId: FolderId,
669+
accountId: AccountId,
670+
): Outcome<Set<MessageServerId>, MessageQueryError> = error("Not used by these tests")
557671
}
558672

559673
private class FakeMessageDataMapper(

0 commit comments

Comments
 (0)