Refactor message cursor handling into dedicated repository

Solution:
- Add MessageCursorRepository to manage cursor lifecycle
- Delegate getConversationCursor to the new repository
- Limit RustMessageListQuery to reusing the active paginator only
- First try to reuse Mailbox scroller to create a cursor
- Fall back to a separate cursor scroller scoped to the repository

ET-4801
This commit is contained in:
Serdar Ozturk
2026-04-10 11:58:39 +01:00
committed by MargeBot
parent cf69e9121b
commit 38f3e1f5d4
11 changed files with 799 additions and 119 deletions
@@ -29,6 +29,7 @@ import ch.protonmail.android.mailmessage.data.local.RustMessageListQueryImpl
import ch.protonmail.android.mailmessage.data.local.RustRsvpEventDataSource
import ch.protonmail.android.mailmessage.data.local.RustRsvpEventDataSourceImpl
import ch.protonmail.android.mailmessage.data.repository.InMemoryAvatarImageStateRepositoryImpl
import ch.protonmail.android.mailmessage.data.repository.MessageCursorRepositoryImpl
import ch.protonmail.android.mailmessage.data.repository.PreviousScheduleSendTimeInMemoryRepository
import ch.protonmail.android.mailmessage.data.repository.RsvpEventRepositoryImpl
import ch.protonmail.android.mailmessage.data.repository.RustMessageActionRepository
@@ -37,6 +38,7 @@ import ch.protonmail.android.mailmessage.data.repository.RustMessageRepositoryIm
import ch.protonmail.android.mailmessage.domain.repository.InMemoryAvatarImageStateRepository
import ch.protonmail.android.mailmessage.domain.repository.MessageActionRepository
import ch.protonmail.android.mailmessage.domain.repository.MessageBodyRepository
import ch.protonmail.android.mailmessage.domain.repository.MessageCursorRepository
import ch.protonmail.android.mailmessage.domain.repository.MessageRepository
import ch.protonmail.android.mailmessage.domain.repository.PreviousScheduleSendTimeRepository
import ch.protonmail.android.mailmessage.domain.repository.RsvpEventRepository
@@ -87,6 +89,10 @@ object MailMessageModule {
@Singleton
fun bindMessageRepository(impl: RustMessageRepositoryImpl): MessageRepository
@Binds
@Singleton
fun bindMessageCursorRepository(impl: MessageCursorRepositoryImpl): MessageCursorRepository
@Binds
@Singleton
fun bindsInMemoryAvatarImageStateRepository(
@@ -19,18 +19,14 @@
package ch.protonmail.android.mailmessage.data.local
import arrow.core.Either
import ch.protonmail.android.mailcommon.data.mapper.LocalConversationId
import ch.protonmail.android.mailcommon.data.mapper.LocalLabelAsAction
import ch.protonmail.android.mailcommon.data.mapper.LocalLabelId
import ch.protonmail.android.mailcommon.data.mapper.LocalMessageId
import ch.protonmail.android.mailcommon.data.mapper.LocalMessageMetadata
import ch.protonmail.android.mailcommon.data.mapper.RemoteMessageId
import ch.protonmail.android.mailcommon.data.wrapper.ConversationCursor
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError
import ch.protonmail.android.mailcommon.domain.model.DataError
import ch.protonmail.android.mailcommon.domain.model.UndoSendError
import ch.protonmail.android.mailcommon.domain.model.UndoableOperation
import ch.protonmail.android.maillabel.domain.model.LabelId
import ch.protonmail.android.mailmessage.domain.model.MessageId
import ch.protonmail.android.mailmessage.domain.model.MessageScrollerFetchNewStatus
import ch.protonmail.android.mailmessage.domain.model.PreviousScheduleSendTime
@@ -63,12 +59,6 @@ interface RustMessageDataSource {
suspend fun observeMessage(userId: UserId, messageId: LocalMessageId): Flow<Either<DataError, LocalMessageMetadata>>
suspend fun getConversationCursor(
firstPage: LocalConversationId,
userId: UserId,
labelId: LabelId
): Either<ConversationCursorError, ConversationCursor>
suspend fun getSenderImage(
userId: UserId,
address: String,
@@ -20,22 +20,17 @@ package ch.protonmail.android.mailmessage.data.local
import arrow.core.Either
import arrow.core.left
import ch.protonmail.android.mailcommon.data.mapper.LocalConversationId
import ch.protonmail.android.mailcommon.data.mapper.LocalLabelAsAction
import ch.protonmail.android.mailcommon.data.mapper.LocalLabelId
import ch.protonmail.android.mailcommon.data.mapper.LocalMessageId
import ch.protonmail.android.mailcommon.data.mapper.LocalMessageMetadata
import ch.protonmail.android.mailcommon.data.mapper.RemoteMessageId
import ch.protonmail.android.mailcommon.data.wrapper.ConversationCursor
import ch.protonmail.android.mailcommon.domain.annotation.MissingRustApi
import ch.protonmail.android.mailcommon.domain.coroutines.IODispatcher
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError.InvalidState
import ch.protonmail.android.mailcommon.domain.model.DataError
import ch.protonmail.android.mailcommon.domain.model.UndoSendError
import ch.protonmail.android.mailcommon.domain.model.UndoableOperation
import ch.protonmail.android.maillabel.data.local.RustMailboxFactory
import ch.protonmail.android.maillabel.domain.model.LabelId
import ch.protonmail.android.mailmessage.data.mapper.toLocalMessageId
import ch.protonmail.android.mailmessage.data.mapper.toMessageScrollerFetchNewStatus
import ch.protonmail.android.mailmessage.data.mapper.toPreviousScheduleSendTime
@@ -62,7 +57,6 @@ import ch.protonmail.android.mailmessage.data.usecase.RustUnstarMessages
import ch.protonmail.android.mailmessage.domain.model.MessageId
import ch.protonmail.android.mailmessage.domain.model.MessageScrollerFetchNewStatus
import ch.protonmail.android.mailmessage.domain.model.PreviousScheduleSendTime
import ch.protonmail.android.mailmessage.domain.model.toConversationCursorError
import ch.protonmail.android.mailpagination.domain.model.PageKey
import ch.protonmail.android.mailpagination.domain.model.PaginationError
import ch.protonmail.android.mailsession.data.usecase.ExecuteWithUserSession
@@ -169,41 +163,6 @@ class RustMessageDataSourceImpl @Inject constructor(
return rustMessageQuery.observeMessage(session, messageId).flowOn(ioDispatcher)
}
override suspend fun getConversationCursor(
firstPage: LocalConversationId,
userId: UserId,
labelId: LabelId
): Either<ConversationCursorError, ConversationCursor> = withContext(ioDispatcher) {
// we are relying on the exiting pager being open already
val result = rustMessageListQuery.getCursor(userId = userId, conversationId = firstPage, labelId = labelId)
?: initializeCursor(userId, labelId, firstPage).apply {
Timber.d("rust-message cursor unable to get cursor, retrieving conversations and retrying")
}
return@withContext when {
result == null -> InvalidState.left()
else -> {
result.mapLeft {
it.toConversationCursorError()
}
}
}
}
private suspend fun initializeCursor(
userId: UserId,
labelId: LabelId,
firstPage: LocalConversationId
) = rustMessageListQuery.getMessages(userId, PageKey.DefaultPageKey().copy(labelId = labelId))
.onLeft {
Timber.e("rust-message cursor unable to recover and get conversations")
}
.fold(
ifLeft = { null },
ifRight = { rustMessageListQuery.getCursor(userId, labelId, firstPage) }
)
override suspend fun getSenderImage(
userId: UserId,
address: String,
@@ -39,10 +39,10 @@ interface RustMessageListQuery {
suspend fun updateShowSpamTrashFilter(showSpamTrash: Boolean)
suspend fun getCursor(
suspend fun getCursorFromActivePaginator(
userId: UserId,
labelId: LabelId,
conversationId: LocalConversationId
firstPage: LocalConversationId
): Either<PaginationError, MailMessageCursorWrapper>?
fun observeScrollerFetchNewStatus(): Flow<MessageScrollerStatusUpdate>
@@ -23,7 +23,6 @@ import arrow.core.left
import ch.protonmail.android.mailcommon.data.mapper.LocalConversationId
import ch.protonmail.android.mailcommon.domain.model.DataError
import ch.protonmail.android.maillabel.data.local.RustMailboxFactory
import ch.protonmail.android.maillabel.data.mapper.toLocalLabelId
import ch.protonmail.android.maillabel.data.wrapper.MailboxWrapper
import ch.protonmail.android.maillabel.domain.model.LabelId
import ch.protonmail.android.mailmessage.data.MessageRustCoroutineScope
@@ -134,43 +133,39 @@ class RustMessageListQueryImpl @Inject constructor(
return deferred.await()
}
override suspend fun getCursor(
override suspend fun getCursorFromActivePaginator(
userId: UserId,
labelId: LabelId,
conversationId: LocalConversationId
firstPage: LocalConversationId
): Either<PaginationError, MailMessageCursorWrapper>? {
val pageDescriptor = PageDescriptor.Default(userId = userId, labelId = labelId)
val initError = paginatorMutex.withLock {
if (shouldInitPaginatorForCursor(pageDescriptor)) {
Timber.d(
"rust-message-query: Initializing paginator for getCursor with page desc=%s",
pageDescriptor
)
val mailbox = rustMailboxFactory.create(userId, labelId.toLocalLabelId()).getOrNull()
if (mailbox == null) {
Timber.e(
"rust-message-query: Unable to create mailbox for userId=%s and labelId=%s",
userId,
labelId
)
PaginationError.Other(DataError.Local.IllegalStateError).left()
} else {
initPaginator(pageDescriptor, mailbox)
return paginatorMutex.withLock {
val state = paginatorState
when {
state == null -> {
Timber.d("rust-message-query: No active paginator to reuse for cursor")
null
}
} else {
Timber.d(
"rust-message-query: Reusing existing paginator for getCursor with page desc=%s",
pageDescriptor
)
null
state.pageDescriptor != pageDescriptor -> {
Timber.d(
"rust-message-query: Active paginator cannot be reused for cursor. active=%s requested=%s",
state.pageDescriptor,
pageDescriptor
)
null
}
else -> {
Timber.d(
"rust-message-query: Reusing active mailbox paginator for cursor, scrollerId=%s",
state.paginatorWrapper.getScrollerId()
)
state.paginatorWrapper.getCursor(firstPage)
}
}
}
if (initError != null) return initError
return paginatorState?.paginatorWrapper?.getCursor(conversationId)
}
override fun observeScrollerFetchNewStatus(): Flow<MessageScrollerStatusUpdate> =
@@ -283,9 +278,6 @@ class RustMessageListQueryImpl @Inject constructor(
paginatorState?.pageDescriptor != pageDescriptor ||
pageKey.pageToLoad == PageToLoad.First
private fun shouldInitPaginatorForCursor(pageDescriptor: PageDescriptor): Boolean =
paginatorState == null || paginatorState?.pageDescriptor != pageDescriptor
private fun destroy() {
if (paginatorState == null) {
Timber.d("rust-message-query: no paginator to destroy")
@@ -0,0 +1,232 @@
/*
* Copyright (c) 2026 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailmessage.data.repository
import arrow.core.Either
import arrow.core.left
import ch.protonmail.android.mailcommon.data.mapper.LocalConversationId
import ch.protonmail.android.mailcommon.data.repository.RustConversationCursorImpl
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError
import ch.protonmail.android.mailcommon.domain.model.CursorId
import ch.protonmail.android.mailcommon.domain.repository.ConversationCursor
import ch.protonmail.android.maillabel.data.local.RustMailboxFactory
import ch.protonmail.android.maillabel.data.mapper.toLocalLabelId
import ch.protonmail.android.maillabel.data.wrapper.MailboxWrapper
import ch.protonmail.android.maillabel.domain.model.LabelId
import ch.protonmail.android.mailmessage.data.local.RustMessageListQuery
import ch.protonmail.android.mailmessage.data.local.debugTypeName
import ch.protonmail.android.mailmessage.data.usecase.CreateRustMessagesPaginator
import ch.protonmail.android.mailmessage.data.wrapper.MailMessageCursorWrapper
import ch.protonmail.android.mailmessage.data.wrapper.MessagePaginatorWrapper
import ch.protonmail.android.mailmessage.domain.model.toConversationCursorError
import ch.protonmail.android.mailmessage.domain.repository.MessageCursorRepository
import ch.protonmail.android.mailsnooze.data.mapper.toLocalConversationId
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.proton.core.domain.entity.UserId
import timber.log.Timber
import uniffi.mail_uniffi.MessageScrollerLiveQueryCallback
import uniffi.mail_uniffi.MessageScrollerUpdate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MessageCursorRepositoryImpl @Inject constructor(
private val rustMailboxFactory: RustMailboxFactory,
private val createRustMessagesPaginator: CreateRustMessagesPaginator,
private val rustMessageListQuery: RustMessageListQuery
) : MessageCursorRepository {
private var cursorPaginatorState: CursorPaginatorState? = null
private val cursorPaginatorMutex = Mutex()
override suspend fun getCursor(
firstPage: CursorId,
userId: UserId,
labelId: LabelId
): Either<ConversationCursorError, ConversationCursor> {
val firstPageId = firstPage.messageId?.toLocalConversationId()
?: firstPage.conversationId.toLocalConversationId()
return getCursorWrapper(
userId = userId,
labelId = labelId,
firstPage = firstPageId
).map { cursorWrapper ->
RustConversationCursorImpl(firstPage, cursorWrapper)
}
}
/**
* Cursor lookup strategy:
*
* 1. Try to obtain the cursor from the active mailbox paginator.
* 2. If mailbox paginator reuse is unavailable or fails, fall back to a dedicated
* cursor paginator managed by this repository.
*/
private suspend fun getCursorWrapper(
userId: UserId,
labelId: LabelId,
firstPage: LocalConversationId
): Either<ConversationCursorError, MailMessageCursorWrapper> {
when (
val mailboxCursorResult = rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = firstPage
)
) {
null -> {
Timber.d(
"message-cursor-repository: No reusable mailbox paginator cursor, falling back"
)
}
is Either.Right -> {
Timber.d("message-cursor-repository: Reused mailbox paginator cursor")
if (hasCursorPaginator()) destroyCursorPaginator()
return mailboxCursorResult
}
is Either.Left -> {
Timber.w(
"message-cursor-repository: Failed to get cursor from active mailbox paginator, " +
"falling back. error=%s",
mailboxCursorResult.value
)
}
}
return getCursorFromCursorPaginator(
userId = userId,
labelId = labelId,
firstPage = firstPage
)
}
private suspend fun getCursorFromCursorPaginator(
userId: UserId,
labelId: LabelId,
firstPage: LocalConversationId
): Either<ConversationCursorError, MailMessageCursorWrapper> {
val pageDescriptor = PageDescriptor(userId = userId, labelId = labelId)
val initError = cursorPaginatorMutex.withLock {
val state = cursorPaginatorState
if (state == null || state.pageDescriptor != pageDescriptor) {
Timber.d(
"message-cursor-repository: Initializing cursor paginator for pageDescriptor=%s",
pageDescriptor
)
val mailbox = rustMailboxFactory.create(userId, labelId.toLocalLabelId()).getOrNull()
if (mailbox == null) {
Timber.e(
"message-cursor-repository: Unable to create mailbox for userId=%s and labelId=%s",
userId,
labelId
)
ConversationCursorError.InvalidState
} else {
initCursorPaginator(pageDescriptor, mailbox)
}
} else {
Timber.d(
"message-cursor-repository: Reusing existing cursor paginator, scrollerId=%s",
state.paginatorWrapper.getScrollerId()
)
null
}
}
if (initError != null) return initError.left()
return cursorPaginatorMutex.withLock {
cursorPaginatorState?.paginatorWrapper?.getCursor(firstPage)?.mapLeft {
it.toConversationCursorError()
} ?: ConversationCursorError.InvalidState.left()
}
}
private suspend fun initCursorPaginator(
pageDescriptor: PageDescriptor,
mailbox: MailboxWrapper
): ConversationCursorError? {
destroyCursorPaginator()
return createRustMessagesPaginator(
mailbox = mailbox,
callback = object : MessageScrollerLiveQueryCallback {
override fun onUpdate(update: MessageScrollerUpdate) {
Timber.d(
"message-cursor-repository: Cursor paginator update=%s",
update.debugTypeName()
)
}
}
).fold(
ifLeft = { error ->
Timber.e(
"message-cursor-repository: Failed to create cursor paginator. error=%s",
error
)
ConversationCursorError.Other(error)
},
ifRight = { paginator ->
Timber.d(
"message-cursor-repository: Cursor paginator created, scrollerId=%s",
paginator.getScrollerId()
)
cursorPaginatorState = CursorPaginatorState(
paginatorWrapper = paginator,
pageDescriptor = pageDescriptor
)
null
}
)
}
private fun destroyCursorPaginator() {
val state = cursorPaginatorState ?: run {
Timber.d("message-cursor-repository: No cursor paginator to destroy")
return
}
Timber.d(
"message-cursor-repository: Destroying cursor paginator, scrollerId=%s",
state.paginatorWrapper.getScrollerId()
)
state.paginatorWrapper.disconnect()
cursorPaginatorState = null
}
private fun hasCursorPaginator(): Boolean = cursorPaginatorState != null
private fun String.toLocalConversationId() = LocalConversationId(this.toULong())
private data class CursorPaginatorState(
val paginatorWrapper: MessagePaginatorWrapper,
val pageDescriptor: PageDescriptor
)
private data class PageDescriptor(
val userId: UserId,
val labelId: LabelId
)
}
@@ -20,8 +20,6 @@ package ch.protonmail.android.mailmessage.data.repository
import java.io.File
import arrow.core.Either
import ch.protonmail.android.mailcommon.data.mapper.LocalConversationId
import ch.protonmail.android.mailcommon.data.repository.RustConversationCursorImpl
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError
import ch.protonmail.android.mailcommon.domain.model.CursorId
import ch.protonmail.android.mailcommon.domain.model.DataError
@@ -31,7 +29,6 @@ import ch.protonmail.android.mailcommon.domain.repository.UndoRepository
import ch.protonmail.android.maillabel.data.mapper.toLocalLabelId
import ch.protonmail.android.maillabel.domain.model.LabelId
import ch.protonmail.android.mailmessage.data.local.RustMessageDataSource
import ch.protonmail.android.mailmessage.data.mapper.toLocalConversationId
import ch.protonmail.android.mailmessage.data.mapper.toLocalMessageId
import ch.protonmail.android.mailmessage.data.mapper.toMessage
import ch.protonmail.android.mailmessage.data.mapper.toRemoteMessageId
@@ -41,6 +38,7 @@ import ch.protonmail.android.mailmessage.domain.model.MessageScrollerFetchNewSta
import ch.protonmail.android.mailmessage.domain.model.PreviousScheduleSendTime
import ch.protonmail.android.mailmessage.domain.model.RemoteMessageId
import ch.protonmail.android.mailmessage.domain.model.SenderImage
import ch.protonmail.android.mailmessage.domain.repository.MessageCursorRepository
import ch.protonmail.android.mailmessage.domain.repository.MessageRepository
import ch.protonmail.android.mailpagination.domain.model.PageKey
import ch.protonmail.android.mailpagination.domain.model.PaginationError
@@ -55,7 +53,8 @@ import javax.inject.Inject
@Suppress("TooManyFunctions")
class RustMessageRepositoryImpl @Inject constructor(
private val rustMessageDataSource: RustMessageDataSource,
private val undoRepository: UndoRepository
private val undoRepository: UndoRepository,
private val messageCursorRepository: MessageCursorRepository
) : MessageRepository {
override suspend fun updateShowSpamTrashFilter(showSpamTrash: Boolean) =
@@ -110,16 +109,8 @@ class RustMessageRepositoryImpl @Inject constructor(
firstPage: CursorId,
userId: UserId,
labelId: LabelId
): Either<ConversationCursorError, ConversationCursor> = rustMessageDataSource
.getConversationCursor(
firstPage = firstPage.messageId?.toLocalConversationId()
?: firstPage.conversationId.toLocalConversationId(),
userId = userId,
labelId = labelId
)
.map {
RustConversationCursorImpl(firstPage, it)
}
): Either<ConversationCursorError, ConversationCursor> =
messageCursorRepository.getCursor(firstPage, userId, labelId)
override suspend fun moveTo(
userId: UserId,
@@ -194,8 +185,6 @@ class RustMessageRepositoryImpl @Inject constructor(
override suspend fun blockSender(userId: UserId, email: String): Either<DataError, Unit> =
rustMessageDataSource.blockSender(userId, email)
private fun String.toLocalConversationId() = LocalConversationId(this.toULong())
override suspend fun isMessageSenderBlocked(userId: UserId, messageId: MessageId): Either<DataError, Boolean> =
rustMessageDataSource.isMessageSenderBlocked(userId, messageId.toLocalMessageId())
}
@@ -24,7 +24,6 @@ import ch.protonmail.android.mailcommon.domain.model.ConversationId
import ch.protonmail.android.mailcommon.domain.model.DataError
import ch.protonmail.android.mailcommon.domain.sample.UserIdSample
import ch.protonmail.android.maillabel.data.local.RustMailboxFactory
import ch.protonmail.android.maillabel.data.mapper.toLocalLabelId
import ch.protonmail.android.maillabel.data.wrapper.MailboxWrapper
import ch.protonmail.android.maillabel.domain.model.SystemLabelId
import ch.protonmail.android.mailmessage.data.local.RustMessageListQueryImpl.Companion.NONE_FOLLOWUP_GRACE_MS
@@ -621,14 +620,12 @@ class RustMessageListQueryImplTest {
}
@Test
fun `getCursor returns cursor from existing paginator without reinitializing`() = runTest {
fun `getCursorFromActivePaginator returns cursor from existing paginator without reinitializing`() = runTest {
// Given
val userId = UserIdSample.Primary
val labelId = SystemLabelId.Inbox.labelId
val conversationId = ConversationId("111").toLocalConversationId()
val expectedCursor = mockk<MailMessageCursorWrapper>()
val mailbox = mockk<MailboxWrapper>()
val callbackSlot = slot<MessageScrollerLiveQueryCallback>()
val paginator = mockk<MessagePaginatorWrapper> {
coEvery { nextPage() } coAnswers {
@@ -656,7 +653,6 @@ class RustMessageListQueryImplTest {
)
} returns paginator.right()
// Initialize paginator first so getCursor can reuse it
rustMessageListQuery.getMessages(
userId = userId,
pageKey = PageKey.DefaultPageKey(
@@ -666,10 +662,10 @@ class RustMessageListQueryImplTest {
)
// When
val actual = rustMessageListQuery.getCursor(
val actual = rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
conversationId = conversationId
firstPage = conversationId
)
// Then
@@ -681,7 +677,76 @@ class RustMessageListQueryImplTest {
callback = any()
)
}
coVerify(exactly = 0) { rustMailboxFactory.create(userId, labelId.toLocalLabelId()) }
}
@Test
fun `getCursorFromActivePaginator returns null when there is no active paginator`() = runTest {
// Given
val labelId = SystemLabelId.Inbox.labelId
val conversationId = ConversationId("111").toLocalConversationId()
// When
val actual = rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = conversationId
)
// Then
assertEquals(null, actual)
}
@Test
fun `getCursorFromActivePaginator returns null when active paginator label does not match`() = runTest {
// Given
val activeLabelId = SystemLabelId.Inbox.labelId
val requestedLabelId = SystemLabelId.Archive.labelId
val conversationId = ConversationId("111").toLocalConversationId()
val callbackSlot = slot<MessageScrollerLiveQueryCallback>()
val paginator = mockk<MessagePaginatorWrapper> {
coEvery { nextPage() } coAnswers {
callbackSlot.captured.onUpdate(
MessageScrollerUpdate.List(
MessageScrollerListUpdate.Append(
items = expectedMessages,
scrollerId = DefaultScrollerId
)
)
)
Unit.right()
}
coEvery { filterUnread(false) } just Runs
coEvery { showSpamAndTrash(false) } just Runs
every { getScrollerId() } returns DefaultScrollerId
}
coEvery { rustMailboxFactory.create(userId) } returns mailbox.right()
coEvery {
createRustMessagesPaginator(
mailbox = mailbox,
callback = capture(callbackSlot)
)
} returns paginator.right()
rustMessageListQuery.getMessages(
userId = userId,
pageKey = PageKey.DefaultPageKey(
labelId = activeLabelId,
pageToLoad = PageToLoad.First
)
)
// When
val actual = rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = requestedLabelId,
firstPage = conversationId
)
// Then
assertEquals(null, actual)
coVerify(exactly = 0) { paginator.getCursor(any()) }
}
companion object {
@@ -0,0 +1,407 @@
/*
* Copyright (c) 2026 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailmessage.data.repository
import arrow.core.left
import arrow.core.right
import ch.protonmail.android.mailcommon.data.mapper.LocalConversationId
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError
import ch.protonmail.android.mailcommon.domain.model.ConversationId
import ch.protonmail.android.mailcommon.domain.model.CursorId
import ch.protonmail.android.mailcommon.domain.model.CursorResult
import ch.protonmail.android.mailcommon.domain.model.DataError
import ch.protonmail.android.mailcommon.domain.sample.UserIdSample
import ch.protonmail.android.maillabel.data.local.RustMailboxFactory
import ch.protonmail.android.maillabel.data.mapper.toLocalLabelId
import ch.protonmail.android.maillabel.data.wrapper.MailboxWrapper
import ch.protonmail.android.maillabel.domain.model.SystemLabelId
import ch.protonmail.android.mailmessage.data.local.RustMessageListQuery
import ch.protonmail.android.mailmessage.data.mapper.toLocalConversationId
import ch.protonmail.android.mailmessage.data.usecase.CreateRustMessagesPaginator
import ch.protonmail.android.mailmessage.data.wrapper.MailMessageCursorWrapper
import ch.protonmail.android.mailmessage.data.wrapper.MessagePaginatorWrapper
import ch.protonmail.android.mailpagination.domain.model.PaginationError
import ch.protonmail.android.test.utils.rule.MainDispatcherRule
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import io.mockk.Runs
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.slot
import org.junit.Rule
import uniffi.mail_uniffi.MessageScrollerLiveQueryCallback
import kotlin.test.assertTrue
import kotlin.text.toULong
internal class MessageCursorRepositoryImplTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val rustMailboxFactory = mockk<RustMailboxFactory>()
private val createRustMessagesPaginator = mockk<CreateRustMessagesPaginator>()
private val rustMessageListQuery = mockk<RustMessageListQuery>()
private val repository = MessageCursorRepositoryImpl(
rustMailboxFactory = rustMailboxFactory,
createRustMessagesPaginator = createRustMessagesPaginator,
rustMessageListQuery = rustMessageListQuery
)
private val cursorWrapper = mockk<MailMessageCursorWrapper> {
coEvery { nextPage() } returns CursorResult.Cursor(ConversationId("101"))
every { previousPage() } returns CursorResult.Cursor(ConversationId("99"))
every { goForwards() } just Runs
every { goBackwards() } just Runs
every { disconnect() } just Runs
}
private val secondCursorWrapper = mockk<MailMessageCursorWrapper> {
coEvery { nextPage() } returns CursorResult.Cursor(ConversationId("201"))
every { previousPage() } returns CursorResult.Cursor(ConversationId("199"))
every { goForwards() } just Runs
every { goBackwards() } just Runs
every { disconnect() } just Runs
}
@Test
fun `getCursor reuses active mailbox paginator cursor when available`() = runTest {
// Given
val userId = UserIdSample.Primary
val labelId = SystemLabelId.Inbox.labelId
val firstPage = CursorId(ConversationId("100"), "102")
val messageId = LocalConversationId("102".toULong())
coEvery {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = messageId
)
} returns cursorWrapper.right()
// When
val actual = repository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = labelId
)
// Then
assertTrue(actual.isRight())
assertEquals(
ConversationId("100"),
(actual.getOrNull()?.current as? CursorResult.Cursor)?.conversationId
)
coVerify(exactly = 1) {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = messageId
)
}
coVerify(exactly = 0) { rustMailboxFactory.create(any(), any()) }
coVerify(exactly = 0) { createRustMessagesPaginator(any(), any()) }
}
@Test
fun `getCursor falls back to dedicated cursor paginator when active mailbox paginator returns null`() = runTest {
// Given
val userId = UserIdSample.Primary
val labelId = SystemLabelId.Inbox.labelId
val firstPage = CursorId(ConversationId("100"), null)
val conversationId = firstPage.conversationId.toLocalConversationId()
val mailbox = mockk<MailboxWrapper>()
val callbackSlot = slot<MessageScrollerLiveQueryCallback>()
val paginator = mockk<MessagePaginatorWrapper> {
every { getScrollerId() } returns "cursor-scroller-id"
coEvery { disconnect() } just Runs
coEvery { getCursor(conversationId) } returns cursorWrapper.right()
}
coEvery {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = conversationId
)
} returns null
coEvery {
rustMailboxFactory.create(userId, labelId.toLocalLabelId())
} returns mailbox.right()
coEvery {
createRustMessagesPaginator(
mailbox = mailbox,
callback = capture(callbackSlot)
)
} returns paginator.right()
// When
val actual = repository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = labelId
)
// Then
assertTrue(actual.isRight())
assertEquals(
ConversationId("100"),
(actual.getOrNull()?.current as? CursorResult.Cursor)?.conversationId
)
coVerify(exactly = 1) {
rustMailboxFactory.create(userId, labelId.toLocalLabelId())
}
coVerify(exactly = 1) {
createRustMessagesPaginator(
mailbox = mailbox,
callback = any()
)
}
coVerify(exactly = 1) { paginator.getCursor(conversationId) }
}
@Test
fun `getCursor falls back to dedicated cursor paginator when active mailbox paginator returns error`() = runTest {
// Given
val userId = UserIdSample.Primary
val labelId = SystemLabelId.Inbox.labelId
val firstPage = CursorId(ConversationId("100"), null)
val conversationId = firstPage.conversationId.toLocalConversationId()
val mailbox = mockk<MailboxWrapper>()
val callbackSlot = slot<MessageScrollerLiveQueryCallback>()
val paginator = mockk<MessagePaginatorWrapper> {
every { getScrollerId() } returns "cursor-scroller-id"
coEvery { disconnect() } just Runs
coEvery { getCursor(conversationId) } returns cursorWrapper.right()
}
coEvery {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = conversationId
)
} returns PaginationError.Other(DataError.Local.IllegalStateError).left()
coEvery {
rustMailboxFactory.create(userId, labelId.toLocalLabelId())
} returns mailbox.right()
coEvery {
createRustMessagesPaginator(
mailbox = mailbox,
callback = capture(callbackSlot)
)
} returns paginator.right()
// When
val actual = repository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = labelId
)
// Then
assertTrue(actual.isRight())
coVerify(exactly = 1) {
rustMailboxFactory.create(userId, labelId.toLocalLabelId())
}
coVerify(exactly = 1) { paginator.getCursor(conversationId) }
}
@Test
fun `getCursor returns InvalidState when fallback mailbox cannot be created`() = runTest {
// Given
val userId = UserIdSample.Primary
val labelId = SystemLabelId.Inbox.labelId
val firstPage = CursorId(ConversationId("100"), null)
val conversationId = firstPage.conversationId.toLocalConversationId()
coEvery {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = conversationId
)
} returns null
coEvery {
rustMailboxFactory.create(userId, labelId.toLocalLabelId())
} returns DataError.Local.Unknown.left()
// When
val actual = repository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = labelId
)
// Then
assertEquals(ConversationCursorError.InvalidState.left(), actual)
coVerify(exactly = 0) { createRustMessagesPaginator(any(), any()) }
}
@Test
fun `getCursor reuses existing dedicated cursor paginator for same user and label`() = runTest {
// Given
val userId = UserIdSample.Primary
val labelId = SystemLabelId.Inbox.labelId
val firstPage = CursorId(ConversationId("100"), null)
val secondPage = CursorId(ConversationId("200"), null)
val firstConversationId = firstPage.conversationId.toLocalConversationId()
val secondConversationId = secondPage.conversationId.toLocalConversationId()
val mailbox = mockk<MailboxWrapper>()
val callbackSlot = slot<MessageScrollerLiveQueryCallback>()
val paginator = mockk<MessagePaginatorWrapper> {
every { getScrollerId() } returns "cursor-scroller-id"
coEvery { disconnect() } just Runs
coEvery { getCursor(firstConversationId) } returns cursorWrapper.right()
coEvery { getCursor(secondConversationId) } returns secondCursorWrapper.right()
}
coEvery {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = labelId,
firstPage = any()
)
} returns null
coEvery {
rustMailboxFactory.create(userId, labelId.toLocalLabelId())
} returns mailbox.right()
coEvery {
createRustMessagesPaginator(
mailbox = mailbox,
callback = capture(callbackSlot)
)
} returns paginator.right()
// When
val firstResult = repository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = labelId
)
val secondResult = repository.getCursor(
firstPage = secondPage,
userId = userId,
labelId = labelId
)
// Then
assertTrue(firstResult.isRight())
assertTrue(secondResult.isRight())
coVerify(exactly = 1) {
createRustMessagesPaginator(
mailbox = mailbox,
callback = any()
)
}
coVerify(exactly = 1) { paginator.getCursor(firstConversationId) }
coVerify(exactly = 1) { paginator.getCursor(secondConversationId) }
}
@Test
fun `getCursor recreates dedicated cursor paginator when label changes`() = runTest {
// Given
val userId = UserIdSample.Primary
val firstLabelId = SystemLabelId.Inbox.labelId
val secondLabelId = SystemLabelId.Archive.labelId
val firstPage = CursorId(ConversationId("100"), null)
val secondPage = CursorId(ConversationId("200"), null)
val firstConversationId = firstPage.conversationId.toLocalConversationId()
val secondConversationId = secondPage.conversationId.toLocalConversationId()
val firstMailbox = mockk<MailboxWrapper>()
val secondMailbox = mockk<MailboxWrapper>()
val firstCallbackSlot = slot<MessageScrollerLiveQueryCallback>()
val secondCallbackSlot = slot<MessageScrollerLiveQueryCallback>()
val firstPaginator = mockk<MessagePaginatorWrapper> {
every { getScrollerId() } returns "cursor-scroller-id-1"
coEvery { disconnect() } just Runs
coEvery { getCursor(firstConversationId) } returns cursorWrapper.right()
}
val secondPaginator = mockk<MessagePaginatorWrapper> {
every { getScrollerId() } returns "cursor-scroller-id-2"
coEvery { disconnect() } just Runs
coEvery { getCursor(secondConversationId) } returns secondCursorWrapper.right()
}
coEvery {
rustMessageListQuery.getCursorFromActivePaginator(
userId = userId,
labelId = any(),
firstPage = any()
)
} returns null
coEvery {
rustMailboxFactory.create(userId, firstLabelId.toLocalLabelId())
} returns firstMailbox.right()
coEvery {
rustMailboxFactory.create(userId, secondLabelId.toLocalLabelId())
} returns secondMailbox.right()
coEvery {
createRustMessagesPaginator(
mailbox = firstMailbox,
callback = capture(firstCallbackSlot)
)
} returns firstPaginator.right()
coEvery {
createRustMessagesPaginator(
mailbox = secondMailbox,
callback = capture(secondCallbackSlot)
)
} returns secondPaginator.right()
// When
val firstResult = repository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = firstLabelId
)
val secondResult = repository.getCursor(
firstPage = secondPage,
userId = userId,
labelId = secondLabelId
)
// Then
assertTrue(firstResult.isRight())
assertTrue(secondResult.isRight())
coVerify(exactly = 1) { firstPaginator.disconnect() }
coVerify(exactly = 1) { firstPaginator.getCursor(firstConversationId) }
coVerify(exactly = 1) { secondPaginator.getCursor(secondConversationId) }
}
}
@@ -23,13 +23,12 @@ import app.cash.turbine.test
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import ch.protonmail.android.mailcommon.data.repository.RustConversationCursorImpl
import ch.protonmail.android.mailcommon.data.wrapper.ConversationCursor
import ch.protonmail.android.mailcommon.domain.model.ConversationId
import ch.protonmail.android.mailcommon.domain.model.CursorId
import ch.protonmail.android.mailcommon.domain.model.CursorResult
import ch.protonmail.android.mailcommon.domain.model.DataError
import ch.protonmail.android.mailcommon.domain.model.UndoableOperation
import ch.protonmail.android.mailcommon.domain.repository.ConversationCursor
import ch.protonmail.android.mailcommon.domain.repository.UndoRepository
import ch.protonmail.android.maillabel.data.mapper.toLocalLabelId
import ch.protonmail.android.maillabel.domain.model.SystemLabelId
@@ -40,11 +39,13 @@ import ch.protonmail.android.mailmessage.data.mapper.toMessage
import ch.protonmail.android.mailmessage.data.mapper.toMessageId
import ch.protonmail.android.mailmessage.data.mapper.toRemoteMessageId
import ch.protonmail.android.mailmessage.domain.model.SenderImage
import ch.protonmail.android.mailmessage.domain.repository.MessageCursorRepository
import ch.protonmail.android.mailpagination.domain.model.PageKey
import ch.protonmail.android.testdata.message.rust.LocalMessageIdSample
import ch.protonmail.android.testdata.message.rust.LocalMessageTestData
import ch.protonmail.android.testdata.message.rust.RemoteMessageIdSample
import ch.protonmail.android.testdata.user.UserIdTestData
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -55,7 +56,6 @@ import junit.framework.TestCase.assertNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import me.proton.core.domain.entity.UserId
import uniffi.mail_uniffi.Id
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -64,9 +64,10 @@ internal class RustMessageRepositoryImplTest {
private val rustMessageDataSource = mockk<RustMessageDataSource>()
private val undoRepository = mockk<UndoRepository>()
private val messageCursorRepository = mockk<MessageCursorRepository>()
private val labelId = LabelIdSample.RustLabel1
private val userId = UserId("userId")
private val repository = RustMessageRepositoryImpl(rustMessageDataSource, undoRepository)
private val repository = RustMessageRepositoryImpl(rustMessageDataSource, undoRepository, messageCursorRepository)
@Test
fun `getLocalMessages should return list of messages`() = runTest {
@@ -181,12 +182,17 @@ internal class RustMessageRepositoryImplTest {
fun `when getConversationCursor returns a cursor with the first messsageId`() = runTest {
// Given
val conversationCursor = mockk<ConversationCursor> {
every { previousPage() } returns CursorResult.Cursor(ConversationId("200"))
coEvery { nextPage() } returns CursorResult.Cursor(ConversationId("300"))
every { current } returns CursorResult.Cursor(ConversationId("100"))
every { previous } returns CursorResult.Cursor(ConversationId("200"))
every { next } returns CursorResult.Cursor(ConversationId("300"))
coEvery { moveForward() } just Runs
coEvery { moveBackward() } just Runs
coEvery { invalidatePrevious() } just Runs
every { close() } just Runs
}
val firstPage = Id(100.toULong())
val firstPage = CursorId(conversationId = ConversationId("99"), messageId = "100")
coEvery {
rustMessageDataSource.getConversationCursor(
messageCursorRepository.getCursor(
firstPage = firstPage,
userId = userId,
labelId = labelId
@@ -196,14 +202,14 @@ internal class RustMessageRepositoryImplTest {
// When
val result = repository.getConversationCursor(
firstPage = CursorId(ConversationId("100"), null),
firstPage = firstPage,
userId = userId,
labelId = labelId
)
// Then
assertTrue(result.isRight())
assertTrue(result.getOrNull() is RustConversationCursorImpl)
assertTrue(result.getOrNull() is ConversationCursor)
assertEquals("100", (result.getOrNull()?.current as? CursorResult.Cursor)?.conversationId?.id)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Proton Technologies AG
* This file is part of Proton Technologies AG and Proton Mail.
*
* Proton Mail 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.
*
* Proton Mail 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 Proton Mail. If not, see <https://www.gnu.org/licenses/>.
*/
package ch.protonmail.android.mailmessage.domain.repository
import arrow.core.Either
import ch.protonmail.android.mailcommon.domain.model.ConversationCursorError
import ch.protonmail.android.mailcommon.domain.model.CursorId
import ch.protonmail.android.mailcommon.domain.repository.ConversationCursor
import ch.protonmail.android.maillabel.domain.model.LabelId
import me.proton.core.domain.entity.UserId
interface MessageCursorRepository {
suspend fun getCursor(
firstPage: CursorId,
userId: UserId,
labelId: LabelId
): Either<ConversationCursorError, ConversationCursor>
}