Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,7 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.feature.mail.account.api"
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.feature.mail.account.api
import kotlinx.coroutines.flow.Flow
interface AccountManager<TAccount : BaseAccount> {
fun getAccounts(): List<TAccount>
fun getAccountsFlow(): Flow<List<TAccount>>
fun getAccount(accountUuid: String): TAccount?
fun getAccountFlow(accountUuid: String): Flow<TAccount?>
fun moveAccount(account: TAccount, newPosition: Int)
fun saveAccount(account: TAccount)
}

View file

@ -0,0 +1,7 @@
package net.thunderbird.feature.mail.account.api
interface BaseAccount {
val uuid: String
val name: String?
val email: String
}

View file

@ -0,0 +1,18 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.feature.mail.folder.api"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.outcome)
implementation(projects.feature.account.api)
implementation(projects.feature.mail.account.api)
implementation(libs.androidx.annotation)
}
}
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.feature.mail.folder.api
data class Folder(
val id: Long,
val name: String,
val type: FolderType,
val isLocalOnly: Boolean,
)

View file

@ -0,0 +1,11 @@
package net.thunderbird.feature.mail.folder.api
data class FolderDetails(
val folder: Folder,
val isInTopGroup: Boolean,
val isIntegrate: Boolean,
val isSyncEnabled: Boolean,
val isVisible: Boolean,
val isNotificationsEnabled: Boolean,
val isPushEnabled: Boolean,
)

View file

@ -0,0 +1,5 @@
package net.thunderbird.feature.mail.folder.api
typealias FolderPathDelimiter = String
const val FOLDER_DEFAULT_PATH_DELIMITER = "/"

View file

@ -0,0 +1,12 @@
package net.thunderbird.feature.mail.folder.api
enum class FolderType {
REGULAR,
INBOX,
OUTBOX,
SENT,
TRASH,
DRAFTS,
ARCHIVE,
SPAM,
}

View file

@ -0,0 +1,89 @@
package net.thunderbird.feature.mail.folder.api
import androidx.annotation.Discouraged
import kotlinx.coroutines.runBlocking
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.AccountIdFactory
/**
* Manages outbox folders for accounts.
*
* An outbox folder is a special folder used to store messages that are waiting to be sent.
* This interface provides methods for getting and creating outbox folders.
*/
interface OutboxFolderManager {
/**
* Gets the folder ID of the outbox folder for the given account.
*
* @param accountId The ID of the account.
* @param createIfMissing If true, the outbox folder will be created if it does not exist.
* @return The folder ID of the outbox folder.
* @throws IllegalStateException If the outbox folder could not be found.
*/
suspend fun getOutboxFolderId(accountId: AccountId, createIfMissing: Boolean = true): Long
/**
* Gets the outbox folder ID for the given account.
*
* This is a blocking call and should not be used on the main thread.
*
* @param accountId The account ID.
* @return The outbox folder ID.
*/
@Discouraged(message = "Avoid blocking calls from the main thread. Use getOutboxFolderId instead.")
fun getOutboxFolderIdSync(accountId: AccountId, createIfMissing: Boolean = true): Long = runBlocking {
getOutboxFolderId(accountId, createIfMissing)
}
/**
* Creates an outbox folder for the given account.
*
* @param accountId The ID of the account for which to create the outbox folder.
* @return An [Outcome] that resolves to the ID of the created outbox folder on success,
* or an [Exception] on failure.
*/
suspend fun createOutboxFolder(accountId: AccountId): Outcome<Long, Exception>
/**
* Checks if there are any pending messages in the outbox for the given account.
*
* @param accountId The ID of the account.
* @return `true` if there are pending messages, `false` otherwise.
*/
suspend fun hasPendingMessages(accountId: AccountId): Boolean
}
/**
* Gets the folder ID of the outbox folder for the given account.
*
* @param accountId The ID of the account.
* @return The folder ID of the outbox folder.
* @throws IllegalStateException If the outbox folder could not be found.
*/
@Discouraged(
message = "This is a wrapper for Java compatibility. " +
"Always use getOutboxFolderIdSync(uuid: AccountId) instead on Kotlin files.",
)
@JvmOverloads
fun OutboxFolderManager.getOutboxFolderIdSync(accountId: String, createIfMissing: Boolean = true): Long {
return getOutboxFolderIdSync(accountId = AccountIdFactory.of(accountId), createIfMissing = createIfMissing)
}
/**
* Checks if there are pending messages in the outbox folder for the given account.
*
* This is a blocking call and should not be used on the main thread.
* This is a wrapper for Java compatibility. Always use `hasPendingMessages(uuid: AccountId): Boolean`
* instead on Kotlin files.
*
* @param accountId The ID of the account.
* @return True if there are pending messages, false otherwise.
*/
@Discouraged(
message = "This is a wrapper for Java compatibility. " +
"Always use hasPendingMessages(uuid: AccountId): Boolean instead on Kotlin files.",
)
fun OutboxFolderManager.hasPendingMessagesSync(accountId: String): Boolean = runBlocking {
hasPendingMessages(accountId = AccountIdFactory.of(accountId))
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.feature.mail.folder.api
data class RemoteFolder(
val id: Long,
val serverId: String,
val name: String,
val type: FolderType,
)

View file

@ -0,0 +1,6 @@
package net.thunderbird.feature.mail.folder.api
enum class SpecialFolderSelection {
AUTOMATIC,
MANUAL,
}

View file

@ -0,0 +1,17 @@
package net.thunderbird.feature.mail.folder.api
import net.thunderbird.feature.mail.account.api.BaseAccount
// TODO move to ???
interface SpecialFolderUpdater {
/**
* Updates all account's special folders. If POP3, only Inbox is updated.
*/
fun updateSpecialFolders()
fun setSpecialFolder(type: FolderType, folderId: Long?, selection: SpecialFolderSelection)
interface Factory<TAccount : BaseAccount> {
fun create(account: TAccount): SpecialFolderUpdater
}
}

View file

@ -0,0 +1,22 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
alias(libs.plugins.dev.mokkery)
}
android {
namespace = "net.thunderbird.feature.mail.message.list"
}
dependencies {
implementation(projects.backend.api)
implementation(projects.core.android.common)
implementation(projects.core.logging.api)
implementation(projects.core.outcome)
implementation(projects.core.preference.api)
implementation(projects.core.ui.compose.designsystem)
implementation(projects.core.ui.theme.api)
implementation(projects.feature.mail.account.api)
implementation(projects.feature.mail.folder.api)
implementation(projects.legacy.mailstore)
implementation(projects.mail.common)
}

View file

@ -0,0 +1,94 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.PreviewLightDarkLandscape
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
private class ChooseArchiveFolderDialogContentParamsCol :
CollectionPreviewParameterProvider<State.ChooseArchiveFolder>(
setOf(
State.ChooseArchiveFolder(
isLoadingFolders = true,
),
State.ChooseArchiveFolder(
isLoadingFolders = false,
folders = listOf(
RemoteFolder(
id = 1,
serverId = "1",
name = "[Gmail]/All Mail",
type = FolderType.REGULAR,
),
RemoteFolder(id = 2, serverId = "2", name = "[Gmail]/Draft", type = FolderType.REGULAR),
RemoteFolder(
id = 3,
serverId = "3",
name = "[Gmail]/Sent Mail",
type = FolderType.REGULAR,
),
RemoteFolder(id = 3, serverId = "3", name = "[Gmail]/Spam", type = FolderType.REGULAR),
RemoteFolder(id = 3, serverId = "3", name = "[Gmail]/Trash", type = FolderType.REGULAR),
RemoteFolder(
id = 3,
serverId = "3",
name = "[Gmail]/Another Folder",
type = FolderType.REGULAR,
),
),
),
State.ChooseArchiveFolder(
isLoadingFolders = false,
errorMessage = "Error message",
),
),
)
@PreviewLightDarkLandscape
@Composable
private fun ChooseArchiveFolderDialogContentPreview(
@PreviewParameter(ChooseArchiveFolderDialogContentParamsCol::class) state: State.ChooseArchiveFolder,
) {
PreviewWithThemesLightDark(
useRow = true,
useScrim = true,
scrimPadding = PaddingValues(32.dp),
arrangement = Arrangement.spacedBy(24.dp),
) {
Surface(
shape = MainTheme.shapes.extraLarge,
modifier = Modifier.width(300.dp),
) {
Column {
ChooseArchiveFolderDialogContent(
state = state,
onFolderSelect = {},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
ChooseArchiveFolderDialogButtons(
state = state,
onCreateNewFolderClick = {},
onDoneClick = {},
)
}
}
}
}
}

View file

@ -0,0 +1,101 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.PreviewLightDarkLandscape
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.theme2.MainTheme
private data class CreateArchiveFolderPreviewParams(
val folderName: String,
val synchronizingMessage: String? = null,
val errorMessage: String? = null,
)
private class CreateArchiveFolderPreviewParamsCollection :
CollectionPreviewParameterProvider<CreateArchiveFolderPreviewParams>(
setOf(
CreateArchiveFolderPreviewParams(
folderName = "",
synchronizingMessage = null,
),
CreateArchiveFolderPreviewParams(
folderName = "My new awesome folder",
synchronizingMessage = null,
),
CreateArchiveFolderPreviewParams(
folderName = "A ${"very ".repeat(n = 100)} long folder name",
synchronizingMessage = null,
),
CreateArchiveFolderPreviewParams(
folderName = "",
synchronizingMessage = "Preparing sync",
),
CreateArchiveFolderPreviewParams(
folderName = "My new awesome folder",
synchronizingMessage = "Doing some sync stuff.",
),
CreateArchiveFolderPreviewParams(
folderName = "A ${"very ".repeat(n = 100)} long folder name",
synchronizingMessage = "A ${"very ".repeat(n = 100)} long sync message",
),
CreateArchiveFolderPreviewParams(
folderName = "A ${"very ".repeat(n = 100)} long folder name",
synchronizingMessage = "",
errorMessage = "Can not create folder.",
),
CreateArchiveFolderPreviewParams(
folderName = "A ${"very ".repeat(n = 100)} long folder name",
synchronizingMessage = null,
errorMessage = "A ${"very ".repeat(n = 100)} long error message",
),
),
)
@PreviewLightDarkLandscape
@Composable
private fun CreateNewArchiveFolderDialogContentPreview(
@PreviewParameter(CreateArchiveFolderPreviewParamsCollection::class) params: CreateArchiveFolderPreviewParams,
) {
PreviewWithThemesLightDark(
useRow = true,
useScrim = true,
scrimPadding = PaddingValues(32.dp),
arrangement = Arrangement.spacedBy(24.dp),
) {
Surface(
shape = MainTheme.shapes.extraLarge,
modifier = Modifier.width(300.dp),
) {
Column {
CreateNewArchiveFolderDialogContent(
folderName = params.folderName,
syncingMessage = params.synchronizingMessage,
errorMessage = params.errorMessage,
onFolderNameChange = {},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
CreateNewArchiveFolderDialogButtons(
isSynchronizing = params.synchronizingMessage?.isNotEmpty() == true,
onCreateAndSetClick = {},
onCancelClick = {},
)
}
}
}
}
}

View file

@ -0,0 +1,62 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.PreviewLightDarkLandscape
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.mail.message.list.R
@PreviewLightDarkLandscape
@Composable
private fun EmailCantBeArchivedDialogButtonsPreview() {
PreviewWithThemesLightDark(
useRow = true,
useScrim = true,
scrimPadding = PaddingValues(32.dp),
arrangement = Arrangement.spacedBy(24.dp),
) {
Surface(
shape = MainTheme.shapes.extraLarge,
modifier = Modifier.width(300.dp),
) {
val state by remember {
mutableStateOf(
SetupArchiveFolderDialogContract.State.EmailCantBeArchived(
isDoNotShowDialogAgainChecked = false,
),
)
}
Column(
modifier = Modifier.padding(MainTheme.spacings.triple),
) {
TextBodyMedium(text = stringResource(R.string.setup_archive_folder_dialog_configure_archive_folder))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
EmailCantBeArchivedDialogButtons(
state = state,
onSetArchiveFolderClick = {},
onSkipClick = {},
onDoNotShowAgainChange = {},
)
}
}
}
}
}

View file

@ -0,0 +1,47 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.theme2.thunderbird.ThunderbirdTheme2
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
private class SetupArchiveFolderDialogParamCol : CollectionPreviewParameterProvider<State>(
setOf(
State.EmailCantBeArchived(isDoNotShowDialogAgainChecked = true),
State.EmailCantBeArchived(isDoNotShowDialogAgainChecked = false),
State.ChooseArchiveFolder(isLoadingFolders = false, folders = emptyList()),
State.ChooseArchiveFolder(isLoadingFolders = true, folders = emptyList()),
State.ChooseArchiveFolder(
isLoadingFolders = false,
folders = List(size = 5) {
RemoteFolder(
id = it.toLong(),
serverId = "$it",
name = "Folder 1",
type = FolderType.REGULAR,
)
},
),
State.CreateArchiveFolder(syncingMessage = null, folderName = ""),
State.CreateArchiveFolder(syncingMessage = "any message", folderName = ""),
),
)
@PreviewLightDark
@Composable
private fun SetupArchiveFolderDialogPreview(
@PreviewParameter(SetupArchiveFolderDialogParamCol::class) state: State,
) {
ThunderbirdTheme2 {
Surface(modifier = Modifier.fillMaxSize()) {
SetupArchiveFolderDialog(state = state)
}
}
}

View file

@ -0,0 +1,55 @@
package net.thunderbird.feature.mail.message.list
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.message.list.domain.DomainContract
import net.thunderbird.feature.mail.message.list.domain.usecase.BuildSwipeActions
import net.thunderbird.feature.mail.message.list.domain.usecase.CreateArchiveFolder
import net.thunderbird.feature.mail.message.list.domain.usecase.GetAccountFolders
import net.thunderbird.feature.mail.message.list.domain.usecase.SetArchiveFolder
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragment
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val featureMessageListModule = module {
factory<DomainContract.UseCase.GetAccountFolders> { GetAccountFolders(folderRepository = get()) }
factory<DomainContract.UseCase.CreateArchiveFolder> {
CreateArchiveFolder(
accountManager = get(),
backendStorageFactory = get(),
specialFolderUpdaterFactory = get(),
remoteFolderCreatorFactory = get(named("imap")),
)
}
factory<DomainContract.UseCase.SetArchiveFolder> {
SetArchiveFolder(
accountManager = get(),
backendStorageFactory = get(),
specialFolderUpdaterFactory = get(),
)
}
factory<DomainContract.UseCase.BuildSwipeActions<BaseAccount>> { parameters ->
BuildSwipeActions(
generalSettingsManager = get(),
accountManager = get(),
storage = parameters.get(),
)
}
viewModel { parameters ->
SetupArchiveFolderDialogViewModel(
accountUuid = parameters.get(),
logger = get(),
getAccountFolders = get(),
createArchiveFolder = get(),
setArchiveFolder = get(),
resourceManager = get(),
generalSettingsManager = get(),
) as SetupArchiveFolderDialogContract.ViewModel
}
factory<SetupArchiveFolderDialogFragmentFactory> {
SetupArchiveFolderDialogFragment.Factory
}
}

View file

@ -0,0 +1,71 @@
package net.thunderbird.feature.mail.message.list.domain
import com.fsck.k9.mail.folders.FolderServerId
import kotlinx.coroutines.flow.Flow
import net.thunderbird.core.common.action.SwipeActions
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.RemoteFolder
interface DomainContract {
interface UseCase {
fun interface GetAccountFolders {
suspend operator fun invoke(accountUuid: String): Outcome<List<RemoteFolder>, AccountFolderError>
}
fun interface CreateArchiveFolder {
operator fun invoke(
accountUuid: String,
folderName: String,
): Flow<Outcome<CreateArchiveFolderOutcome.Success, CreateArchiveFolderOutcome.Error>>
}
fun interface SetArchiveFolder {
suspend operator fun invoke(
accountUuid: String,
folder: RemoteFolder,
): Outcome<SetAccountFolderOutcome.Success, SetAccountFolderOutcome.Error>
}
fun interface BuildSwipeActions<out TAccount : BaseAccount> {
operator fun invoke(
accountUuids: Set<String>,
isIncomingServerPop3: (TAccount) -> Boolean,
hasArchiveFolder: (TAccount) -> Boolean,
): Map<String, SwipeActions>
}
}
}
data class AccountFolderError(val exception: Exception)
sealed interface SetAccountFolderOutcome {
data object Success : SetAccountFolderOutcome
sealed interface Error : SetAccountFolderOutcome {
data object AccountNotFound : Error
data class UnhandledError(val throwable: Throwable) : Error
}
}
sealed interface CreateArchiveFolderOutcome {
sealed interface Success : CreateArchiveFolderOutcome {
data object LocalFolderCreated : Success
data class SyncStarted(val serverId: FolderServerId) : Success
data object UpdatingSpecialFolders : Success
data object Created : Success
}
sealed interface Error : CreateArchiveFolderOutcome {
data class LocalFolderCreationError(val folderName: String) : Error
data class InvalidFolderName(val folderName: String) : Error
data object AccountNotFound : Error
data class UnhandledError(val throwable: Throwable) : Error
sealed interface SyncError : Error {
data class Failed(
val serverId: FolderServerId,
val message: String,
val exception: Exception?,
) : SyncError
}
}
}

View file

@ -0,0 +1,72 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import net.thunderbird.core.common.action.SwipeAction
import net.thunderbird.core.common.action.SwipeActions
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.core.preference.storage.Storage
import net.thunderbird.core.preference.storage.getEnumOrDefault
import net.thunderbird.feature.mail.account.api.AccountManager
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.message.list.domain.DomainContract
class BuildSwipeActions(
private val generalSettingsManager: GeneralSettingsManager,
private val accountManager: AccountManager<BaseAccount>,
storage: Storage,
) : DomainContract.UseCase.BuildSwipeActions<BaseAccount> {
private val defaultLeftSwipeAction = storage.getEnumOrDefault(
key = SwipeActions.KEY_SWIPE_ACTION_LEFT,
default = SwipeAction.ToggleRead,
)
private val defaultRightSwipeAction = storage.getEnumOrDefault(
key = SwipeActions.KEY_SWIPE_ACTION_RIGHT,
default = SwipeAction.ToggleRead,
)
override fun invoke(
accountUuids: Set<String>,
isIncomingServerPop3: (BaseAccount) -> Boolean,
hasArchiveFolder: (BaseAccount) -> Boolean,
): Map<String, SwipeActions> {
val shouldShowSetupArchiveFolderDialog = generalSettingsManager
.getConfig().display
.shouldShowSetupArchiveFolderDialog
return accountUuids
.mapNotNull { uuid -> accountManager.getAccount(uuid) }
.associate { account ->
account.uuid to SwipeActions(
leftAction = buildSwipeAction(
account = account,
defaultSwipeAction = defaultLeftSwipeAction,
isIncomingServerPop3 = isIncomingServerPop3,
hasArchiveFolder = hasArchiveFolder,
shouldShowSetupArchiveFolderDialog = shouldShowSetupArchiveFolderDialog,
),
rightAction = buildSwipeAction(
account = account,
defaultSwipeAction = defaultRightSwipeAction,
isIncomingServerPop3 = isIncomingServerPop3,
hasArchiveFolder = hasArchiveFolder,
shouldShowSetupArchiveFolderDialog = shouldShowSetupArchiveFolderDialog,
),
)
}
}
private fun buildSwipeAction(
account: BaseAccount,
defaultSwipeAction: SwipeAction,
isIncomingServerPop3: BaseAccount.() -> Boolean,
hasArchiveFolder: BaseAccount.() -> Boolean,
shouldShowSetupArchiveFolderDialog: Boolean,
): SwipeAction = when (defaultSwipeAction) {
SwipeAction.Archive if account.isIncomingServerPop3() -> SwipeAction.ArchiveDisabled
SwipeAction.Archive if account.hasArchiveFolder().not() && shouldShowSetupArchiveFolderDialog ->
SwipeAction.ArchiveSetupArchiveFolder
SwipeAction.Archive if account.hasArchiveFolder().not() -> SwipeAction.ArchiveDisabled
else -> defaultSwipeAction
}
}

View file

@ -0,0 +1,117 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.createFolder
import com.fsck.k9.backend.api.updateFolders
import com.fsck.k9.mail.folders.FolderServerId
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import net.thunderbird.backend.api.BackendStorageFactory
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome
import net.thunderbird.backend.api.folder.RemoteFolderCreator
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.AccountManager
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.mail.folder.api.SpecialFolderUpdater
import net.thunderbird.feature.mail.message.list.domain.CreateArchiveFolderOutcome
import net.thunderbird.feature.mail.message.list.domain.DomainContract
import com.fsck.k9.mail.FolderType as LegacyFolderType
class CreateArchiveFolder(
private val accountManager: AccountManager<BaseAccount>,
private val backendStorageFactory: BackendStorageFactory<BaseAccount>,
private val remoteFolderCreatorFactory: RemoteFolderCreator.Factory,
private val specialFolderUpdaterFactory: SpecialFolderUpdater.Factory<BaseAccount>,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : DomainContract.UseCase.CreateArchiveFolder {
override fun invoke(
accountUuid: String,
folderName: String,
): Flow<Outcome<CreateArchiveFolderOutcome.Success, CreateArchiveFolderOutcome.Error>> = flow {
if (folderName.isBlank()) {
emit(Outcome.failure(CreateArchiveFolderOutcome.Error.InvalidFolderName(folderName = folderName)))
return@flow
}
val account = withContext(ioDispatcher) {
accountManager.getAccount(accountUuid)
} ?: run {
emit(Outcome.failure(CreateArchiveFolderOutcome.Error.AccountNotFound))
return@flow
}
val backendStorage = backendStorageFactory.createBackendStorage(account)
val folderInfo = FolderInfo(
serverId = folderName,
name = folderName,
type = LegacyFolderType.ARCHIVE,
)
try {
val folderId = withContext(ioDispatcher) {
backendStorage.updateFolders {
createFolder(folderInfo)
}
}
if (folderId == null) {
emit(
Outcome.failure(
CreateArchiveFolderOutcome.Error.LocalFolderCreationError(folderName = folderName),
),
)
} else {
emit(Outcome.success(CreateArchiveFolderOutcome.Success.LocalFolderCreated))
val serverId = FolderServerId(folderInfo.serverId)
emit(Outcome.success(CreateArchiveFolderOutcome.Success.SyncStarted(serverId = serverId)))
val remoteFolderCreator = remoteFolderCreatorFactory.create(account)
val outcome = remoteFolderCreator
.create(folderServerId = serverId, mustCreate = false, folderType = LegacyFolderType.ARCHIVE)
when (outcome) {
is Outcome.Failure<RemoteFolderCreationOutcome.Error> -> emit(
Outcome.failure(
CreateArchiveFolderOutcome.Error.SyncError.Failed(
serverId = serverId,
message = outcome.error.toString(),
exception = null,
),
),
)
is Outcome.Success<RemoteFolderCreationOutcome.Success> -> handleRemoteFolderCreationSuccess(
localFolderId = folderId,
account = account,
emit = ::emit,
)
}
}
} catch (e: MessagingException) {
emit(Outcome.failure(CreateArchiveFolderOutcome.Error.UnhandledError(throwable = e)))
}
}
private suspend fun handleRemoteFolderCreationSuccess(
localFolderId: Long,
account: BaseAccount,
emit: suspend (Outcome<CreateArchiveFolderOutcome.Success, CreateArchiveFolderOutcome.Error>) -> Unit,
) {
val specialFolderUpdater = specialFolderUpdaterFactory.create(account)
emit(Outcome.success(CreateArchiveFolderOutcome.Success.UpdatingSpecialFolders))
withContext(ioDispatcher) {
specialFolderUpdater.setSpecialFolder(
type = FolderType.ARCHIVE,
folderId = localFolderId,
selection = SpecialFolderSelection.MANUAL,
)
specialFolderUpdater.updateSpecialFolders()
accountManager.saveAccount(account)
}
emit(Outcome.success(CreateArchiveFolderOutcome.Success.Created))
}
}

View file

@ -0,0 +1,30 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import app.k9mail.legacy.mailstore.FolderRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.domain.AccountFolderError
import net.thunderbird.feature.mail.message.list.domain.DomainContract
class GetAccountFolders(
private val folderRepository: FolderRepository,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : DomainContract.UseCase.GetAccountFolders {
override suspend fun invoke(accountUuid: String): Outcome<List<RemoteFolder>, AccountFolderError> =
withContext(ioDispatcher) {
try {
Outcome.success(
data = folderRepository
.getRemoteFolders(accountUuid)
.filter { it.type == FolderType.REGULAR || it.type == FolderType.ARCHIVE },
)
} catch (e: MessagingException) {
Outcome.failure(error = AccountFolderError(exception = e))
}
}
}

View file

@ -0,0 +1,60 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.thunderbird.backend.api.BackendStorageFactory
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.AccountManager
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.mail.folder.api.SpecialFolderUpdater
import net.thunderbird.feature.mail.message.list.domain.DomainContract
import net.thunderbird.feature.mail.message.list.domain.SetAccountFolderOutcome
import com.fsck.k9.mail.FolderType as LegacyFolderType
internal class SetArchiveFolder(
private val accountManager: AccountManager<BaseAccount>,
private val backendStorageFactory: BackendStorageFactory<BaseAccount>,
private val specialFolderUpdaterFactory: SpecialFolderUpdater.Factory<BaseAccount>,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : DomainContract.UseCase.SetArchiveFolder {
override suspend fun invoke(
accountUuid: String,
folder: RemoteFolder,
): Outcome<SetAccountFolderOutcome.Success, SetAccountFolderOutcome.Error> {
val account = withContext(ioDispatcher) {
accountManager.getAccount(accountUuid)
} ?: return Outcome.Failure(SetAccountFolderOutcome.Error.AccountNotFound)
val backend = backendStorageFactory.createBackendStorage(account)
val specialFolderUpdater = specialFolderUpdaterFactory.create(account)
return try {
withContext(ioDispatcher) {
backend
.createFolderUpdater()
.use { updater ->
updater.changeFolder(
folderServerId = folder.serverId,
name = folder.name,
type = LegacyFolderType.ARCHIVE,
)
specialFolderUpdater.setSpecialFolder(
type = FolderType.ARCHIVE,
folderId = folder.id,
selection = SpecialFolderSelection.MANUAL,
)
specialFolderUpdater.updateSpecialFolders()
accountManager.saveAccount(account)
Outcome.success(SetAccountFolderOutcome.Success)
}
}
} catch (e: MessagingException) {
Outcome.Failure(SetAccountFolderOutcome.Error.UnhandledError(throwable = e))
}
}
}

View file

@ -0,0 +1,25 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
import net.thunderbird.feature.mail.message.list.R
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
@Composable
internal fun RowScope.ChooseArchiveFolderDialogButtons(
state: State.ChooseArchiveFolder,
onCreateNewFolderClick: () -> Unit,
onDoneClick: () -> Unit,
) {
ButtonText(
text = stringResource(R.string.setup_archive_folder_dialog_create_new_folder),
onClick = onCreateNewFolderClick,
)
ButtonText(
text = stringResource(R.string.setup_archive_folder_dialog_done),
enabled = state.isLoadingFolders.not(),
onClick = onDoneClick,
)
}

View file

@ -0,0 +1,122 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.atom.CircularProgressIndicator
import app.k9mail.core.ui.compose.designsystem.atom.button.RadioButton
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.R
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
@Composable
internal fun ChooseArchiveFolderDialogContent(
state: State.ChooseArchiveFolder,
onFolderSelect: (RemoteFolder) -> Unit,
modifier: Modifier = Modifier,
) {
Crossfade(
targetState = state.isLoadingFolders,
modifier = modifier.fillMaxWidth(),
) { isLoading ->
when {
isLoading -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(MainTheme.spacings.triple),
) {
CircularProgressIndicator()
}
}
state.errorMessage?.isNotBlank() == true -> {
Column(
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
modifier = Modifier
.fillMaxWidth()
.padding(MainTheme.spacings.triple),
) {
Icon(
imageVector = Icons.Outlined.ErrorOutline,
tint = MainTheme.colors.error,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
TextBodyMedium(
text = stringResource(R.string.setup_archive_folder_dialog_error_retrieve_folders),
)
TextBodyMedium(
text = stringResource(
R.string.setup_archive_folder_dialog_error_retrieve_folders_detailed_message,
state.errorMessage,
),
)
}
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 240.dp),
) {
items(items = state.folders) { folder ->
RemoteFolderListItem(
folderName = folder.name,
isSelected = state.selectedFolder == folder,
onFolderSelect = { onFolderSelect(folder) },
)
}
}
}
}
}
}
@Composable
private fun RemoteFolderListItem(
folderName: String,
isSelected: Boolean,
onFolderSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
role = Role.RadioButton,
onClick = onFolderSelect,
)
.padding(horizontal = MainTheme.spacings.oneHalf),
) {
RadioButton(
selected = isSelected,
label = { TextBodyMedium(text = folderName) },
onClick = onFolderSelect,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MainTheme.spacings.oneHalf),
)
}
}

View file

@ -0,0 +1,25 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
import net.thunderbird.feature.mail.message.list.R
@Composable
internal fun RowScope.CreateNewArchiveFolderDialogButtons(
isSynchronizing: Boolean,
onCancelClick: () -> Unit,
onCreateAndSetClick: () -> Unit,
) {
ButtonText(
onClick = onCancelClick,
text = stringResource(R.string.setup_archive_folder_dialog_cancel),
enabled = isSynchronizing.not(),
)
ButtonText(
onClick = onCreateAndSetClick,
text = stringResource(R.string.setup_archive_folder_dialog_create_and_set_new_folder),
enabled = isSynchronizing.not(),
)
}

View file

@ -0,0 +1,49 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall
import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.mail.message.list.R
@Composable
internal fun CreateNewArchiveFolderDialogContent(
folderName: String,
onFolderNameChange: (String) -> Unit,
modifier: Modifier = Modifier,
syncingMessage: String? = null,
errorMessage: String? = null,
) {
Column(
modifier = modifier
.padding(horizontal = MainTheme.spacings.oneHalf),
) {
TextInput(
onTextChange = onFolderNameChange,
text = folderName,
label = stringResource(R.string.setup_archive_folder_dialog_create_new_folder),
isEnabled = syncingMessage.isNullOrEmpty(),
errorMessage = errorMessage,
)
AnimatedVisibility(
visible = syncingMessage != null,
modifier = Modifier.padding(horizontal = MainTheme.spacings.quadruple),
) {
requireNotNull(syncingMessage)
Spacer(modifier = Modifier.height(MainTheme.spacings.oneHalf))
TextBodySmall(
text = syncingMessage,
color = MainTheme.colors.onSurfaceVariant,
)
}
}
}

View file

@ -0,0 +1,65 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.selection.toggleable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import app.k9mail.core.ui.compose.designsystem.atom.Checkbox
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.mail.message.list.R
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
@Composable
internal fun EmailCantBeArchivedDialogButtons(
state: State.EmailCantBeArchived,
onSetArchiveFolderClick: () -> Unit,
onSkipClick: () -> Unit,
onDoNotShowAgainChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.oneHalf),
) {
Box(
modifier = Modifier.align(Alignment.End),
) {
Row {
ButtonText(
text = stringResource(R.string.setup_archive_folder_dialog_skip_for_now),
onClick = onSkipClick,
)
ButtonText(
text = stringResource(R.string.setup_archive_folder_dialog_set_archive_folder),
onClick = onSetArchiveFolderClick,
)
}
}
Row(
modifier = Modifier
.align(Alignment.Start)
.toggleable(
value = state.isDoNotShowDialogAgainChecked,
role = Role.Checkbox,
onValueChange = { onDoNotShowAgainChange(!state.isDoNotShowDialogAgainChecked) },
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.half),
) {
Checkbox(
checked = state.isDoNotShowDialogAgainChecked,
onCheckedChange = null,
)
TextLabelSmall(text = stringResource(R.string.setup_archive_folder_dialog_dont_show_again))
}
}
}

View file

@ -0,0 +1,210 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.designsystem.organism.BasicDialog
import app.k9mail.core.ui.compose.designsystem.organism.BasicDialogDefaults
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.R
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.Event
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.ViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun SetupArchiveFolderDialog(
accountUuid: String,
onDismissDialog: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ViewModel = koinViewModel<ViewModel> { parametersOf(accountUuid) },
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
SetupArchiveFolderDialogContract.Effect.DismissDialog -> onDismissDialog()
}
}
LaunchedEffect(onDismissDialog, state.value) {
if (state.value is State.Closed) {
onDismissDialog()
}
}
SetupArchiveFolderDialog(
state = state.value,
onNextClick = { dispatch(Event.MoveNext) },
onDoneClick = { dispatch(Event.OnDoneClicked) },
onDismissRequest = { dispatch(Event.OnDismissClicked) },
onDismissClick = { dispatch(Event.OnDismissClicked) },
onDoNotShowAgainChange = { isChecked ->
dispatch(
Event.OnDoNotShowDialogAgainChanged(
isChecked = isChecked,
),
)
},
onFolderSelect = { folder -> dispatch(Event.OnFolderSelected(folder)) },
onCreateAndSetClick = { folderName -> dispatch(Event.OnCreateFolderClicked(folderName)) },
modifier = modifier,
)
}
@Composable
internal fun SetupArchiveFolderDialog(
state: State,
modifier: Modifier = Modifier,
onNextClick: () -> Unit = {},
onDoneClick: () -> Unit = {},
onDismissRequest: () -> Unit = {},
onDismissClick: () -> Unit = {},
onDoNotShowAgainChange: (Boolean) -> Unit = {},
onFolderSelect: (RemoteFolder) -> Unit = {},
onCreateAndSetClick: (folderName: String) -> Unit = {},
) {
if (state !is State.Closed) {
val canBeDismissed = remember(state) {
state !is State.CreateArchiveFolder || state.syncingMessage.isNullOrBlank()
}
var folderName by rememberSaveable(state) {
mutableStateOf(
(state as? State.CreateArchiveFolder)?.folderName ?: "",
)
}
BasicDialog(
headlineText = when (state) {
is State.ChooseArchiveFolder -> stringResource(R.string.setup_archive_folder_dialog_set_archive_folder)
is State.CreateArchiveFolder -> stringResource(R.string.setup_archive_folder_dialog_create_new_folder)
is State.EmailCantBeArchived ->
stringResource(R.string.setup_archive_folder_dialog_email_can_not_be_archived)
else -> error("Invalid state: $state")
},
supportingText = when (state) {
is State.EmailCantBeArchived ->
stringResource(R.string.setup_archive_folder_dialog_configure_archive_folder)
else -> null
},
onDismissRequest = onDismissRequest,
content = {
SetupArchiveFolderDialogContent(
state = state,
folderName = folderName,
onFolderSelect = onFolderSelect,
onFolderNameChange = { newFolderName -> folderName = newFolderName },
)
},
buttons = {
SetupArchiveFolderDialogButtons(
state = state,
folderName = folderName,
onDoneClick = onDoneClick,
onNextClick = onNextClick,
onDismissClick = onDismissClick,
onCreateAndSetClick = onCreateAndSetClick,
onDoNotShowAgainChange = onDoNotShowAgainChange,
)
},
properties = DialogProperties(
dismissOnBackPress = canBeDismissed,
dismissOnClickOutside = canBeDismissed,
),
contentPadding = if (state is State.EmailCantBeArchived) {
PaddingValues()
} else {
BasicDialogDefaults.contentPadding
},
showDividers = state !is State.EmailCantBeArchived,
modifier = modifier,
)
}
}
@Composable
private fun SetupArchiveFolderDialogContent(
state: State,
folderName: String,
onFolderSelect: (RemoteFolder) -> Unit,
onFolderNameChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = state,
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(
targetOffsetX = { -it },
)
},
contentKey = { it::class },
modifier = modifier,
) { state ->
when (state) {
is State.ChooseArchiveFolder -> ChooseArchiveFolderDialogContent(
state = state,
onFolderSelect = onFolderSelect,
)
is State.CreateArchiveFolder -> CreateNewArchiveFolderDialogContent(
folderName = folderName,
syncingMessage = state.syncingMessage,
errorMessage = state.errorMessage,
onFolderNameChange = onFolderNameChange,
)
else -> Spacer(modifier = Modifier.height(MainTheme.spacings.half))
}
}
}
@Composable
private fun RowScope.SetupArchiveFolderDialogButtons(
state: State,
folderName: String,
onDoneClick: () -> Unit,
onNextClick: () -> Unit,
onDismissClick: () -> Unit,
onCreateAndSetClick: (String) -> Unit,
onDoNotShowAgainChange: (Boolean) -> Unit,
) {
when (state) {
is State.ChooseArchiveFolder -> ChooseArchiveFolderDialogButtons(
state = state,
onDoneClick = onDoneClick,
onCreateNewFolderClick = onNextClick,
)
is State.Closed -> Unit
is State.CreateArchiveFolder -> CreateNewArchiveFolderDialogButtons(
isSynchronizing = state.syncingMessage?.isNotBlank() == true,
onCancelClick = onDismissClick,
onCreateAndSetClick = { onCreateAndSetClick(folderName) },
)
is State.EmailCantBeArchived -> EmailCantBeArchivedDialogButtons(
state = state,
onSetArchiveFolderClick = onNextClick,
onSkipClick = onDismissClick,
onDoNotShowAgainChange = onDoNotShowAgainChange,
)
}
}

View file

@ -0,0 +1,48 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.compose.runtime.Stable
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import net.thunderbird.feature.mail.folder.api.RemoteFolder
sealed interface SetupArchiveFolderDialogContract {
abstract class ViewModel(
initialState: State,
) : BaseViewModel<State, Event, Effect>(initialState), UnidirectionalViewModel<State, Event, Effect>
sealed interface State {
val isDoNotShowDialogAgainChecked: Boolean
data class EmailCantBeArchived(override val isDoNotShowDialogAgainChecked: Boolean = false) : State
data class Closed(override val isDoNotShowDialogAgainChecked: Boolean = false) : State
@Stable
data class ChooseArchiveFolder(
val isLoadingFolders: Boolean,
override val isDoNotShowDialogAgainChecked: Boolean = false,
val folders: List<RemoteFolder> = emptyList(),
val selectedFolder: RemoteFolder? = folders.firstOrNull(),
val errorMessage: String? = null,
) : State
data class CreateArchiveFolder(
val folderName: String,
override val isDoNotShowDialogAgainChecked: Boolean = false,
val syncingMessage: String? = null,
val errorMessage: String? = null,
) : State
}
sealed interface Event {
data object MoveNext : Event
data object OnDoneClicked : Event
data object OnDismissClicked : Event
data class OnFolderSelected(val folder: RemoteFolder) : Event
data class OnCreateFolderClicked(val newFolderName: String) : Event
data class OnDoNotShowDialogAgainChanged(val isChecked: Boolean) : Event
}
sealed interface Effect {
data object DismissDialog : Effect
}
}

View file

@ -0,0 +1,68 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import net.thunderbird.core.ui.theme.api.FeatureThemeProvider
import org.koin.android.ext.android.inject
internal class SetupArchiveFolderDialogFragment : DialogFragment() {
private val themeProvider: FeatureThemeProvider by inject<FeatureThemeProvider>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
val accountUuid = requireNotNull(requireArguments().getString(ACCOUNT_UUID_ARG)) {
"The $ACCOUNT_UUID_ARG argument is missing from the arguments bundle."
}
dialog?.requestWindowFeature(Window.FEATURE_NO_TITLE)
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
themeProvider.WithTheme {
SetupArchiveFolderDialog(
accountUuid = accountUuid,
onDismissDialog = {
dismiss()
setFragmentResult(
SetupArchiveFolderDialogFragmentFactory.RESULT_CODE_DISMISS_REQUEST_KEY,
Bundle.EMPTY,
)
},
)
}
}
}
}
companion object Factory : SetupArchiveFolderDialogFragmentFactory {
private const val TAG = "SetupArchiveFolderDialogFragmentFactory"
private const val ACCOUNT_UUID_ARG = "SetupArchiveFolderDialogFragmentFactory_accountUuid"
override fun show(accountUuid: String, fragmentManager: FragmentManager) {
SetupArchiveFolderDialogFragment()
.apply {
arguments = bundleOf(ACCOUNT_UUID_ARG to accountUuid)
}
.show(fragmentManager, TAG)
}
}
}
interface SetupArchiveFolderDialogFragmentFactory {
companion object {
const val RESULT_CODE_DISMISS_REQUEST_KEY = "SetupArchiveFolderDialogFragmentFactory_dialog_dismiss"
}
fun show(accountUuid: String, fragmentManager: FragmentManager)
}

View file

@ -0,0 +1,310 @@
package net.thunderbird.feature.mail.message.list.ui.dialog
import androidx.lifecycle.viewModelScope
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import net.thunderbird.core.common.resources.StringsResourceManager
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.outcome.handle
import net.thunderbird.core.outcome.handleAsync
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.core.preference.update
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.R
import net.thunderbird.feature.mail.message.list.domain.CreateArchiveFolderOutcome
import net.thunderbird.feature.mail.message.list.domain.DomainContract
import net.thunderbird.feature.mail.message.list.domain.SetAccountFolderOutcome
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.Effect
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.Event
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.State
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract.ViewModel
internal class SetupArchiveFolderDialogViewModel(
private val accountUuid: String,
private val logger: Logger,
private val getAccountFolders: DomainContract.UseCase.GetAccountFolders,
private val createArchiveFolder: DomainContract.UseCase.CreateArchiveFolder,
private val setArchiveFolder: DomainContract.UseCase.SetArchiveFolder,
private val resourceManager: StringsResourceManager,
private val generalSettingsManager: GeneralSettingsManager,
) : ViewModel(
initialState = if (generalSettingsManager.getConfig().display.shouldShowSetupArchiveFolderDialog) {
State.EmailCantBeArchived()
} else {
State.Closed(isDoNotShowDialogAgainChecked = true)
},
) {
override fun event(event: Event) {
when (event) {
Event.MoveNext -> onNext(state = state.value)
Event.OnDoneClicked -> onDoneClicked(state = state.value)
Event.OnDismissClicked -> onDismissClicked()
is Event.OnDoNotShowDialogAgainChanged -> onDoNotShowDialogAgainChanged(isChecked = event.isChecked)
is Event.OnCreateFolderClicked -> onCreateFolderClicked(newFolderName = event.newFolderName)
is Event.OnFolderSelected -> onFolderSelected(folder = event.folder)
}
}
private fun onNext(state: State) {
when (state) {
is State.ChooseArchiveFolder -> updateState {
State.CreateArchiveFolder(folderName = "")
}
is State.EmailCantBeArchived -> {
updateState { State.ChooseArchiveFolder(isLoadingFolders = true) }
viewModelScope.launch {
getAccountFolders(accountUuid = accountUuid).handle(
onSuccess = { folders ->
updateState {
State.ChooseArchiveFolder(
isLoadingFolders = false,
folders = folders,
)
}
},
onFailure = { error ->
updateState {
State.ChooseArchiveFolder(
isLoadingFolders = false,
errorMessage = error.exception.message,
)
}
},
)
}
}
else -> error("The '$state' state doesn't support the MoveNext event")
}
}
private fun onDoneClicked(state: State) {
check(state is State.ChooseArchiveFolder) { "The '$state' state doesn't support the OnDoneClicked event" }
checkNotNull(state.selectedFolder) {
"The selected folder is null. This should not happen."
}
viewModelScope.launch {
setArchiveFolder(accountUuid = accountUuid, folder = state.selectedFolder).handle(
onSuccess = {
updateState { State.Closed() }
emitEffect(Effect.DismissDialog)
},
onFailure = { error ->
updateState {
when (error) {
SetAccountFolderOutcome.Error.AccountNotFound ->
state.copy(
errorMessage = resourceManager.stringResource(
R.string.setup_archive_folder_set_archive_error_account_not_found,
accountUuid,
),
)
is SetAccountFolderOutcome.Error.UnhandledError -> state.copy(
errorMessage = resourceManager.stringResource(
R.string.setup_archive_folder_unhandled_error,
error.throwable.message,
),
)
}
}
},
)
}
}
private fun onDismissClicked() {
viewModelScope.launch {
generalSettingsManager.update { settings ->
settings.copy(
display = settings.display.copy(
shouldShowSetupArchiveFolderDialog = state.value.isDoNotShowDialogAgainChecked.not(),
),
)
}
updateState { State.Closed() }
emitEffect(Effect.DismissDialog)
}
}
private fun onDoNotShowDialogAgainChanged(isChecked: Boolean) {
updateState { state ->
when (state) {
is State.EmailCantBeArchived -> state.copy(
isDoNotShowDialogAgainChecked = isChecked,
)
else -> state
}
}
}
private fun onCreateFolderClicked(newFolderName: String) {
updateState { state ->
when (state) {
is State.CreateArchiveFolder -> state.copy(
folderName = newFolderName,
syncingMessage = resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_syncing,
),
errorMessage = null,
)
else -> state
}
}
createArchiveFolder(accountUuid = accountUuid, folderName = newFolderName)
.onEach { outcome ->
outcome.handleAsync(
onSuccess = ::onCreateArchiveFolderSuccess,
onFailure = ::onCreateArchiveFolderError,
)
}
.launchIn(viewModelScope)
}
private suspend fun onCreateArchiveFolderSuccess(event: CreateArchiveFolderOutcome.Success) {
when (event) {
CreateArchiveFolderOutcome.Success.LocalFolderCreated -> {
updateState { state ->
when (state) {
is State.CreateArchiveFolder -> state.copy(
syncingMessage = resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_local_folder_created,
),
)
else -> state
}
}
logger.debug { "Folder created" }
}
CreateArchiveFolderOutcome.Success.Created -> {
updateState { state ->
when (state) {
is State.CreateArchiveFolder -> state.copy(
syncingMessage = resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_remote_folder_created,
),
)
else -> state
}
}
delay(100.milliseconds)
updateState { State.Closed() }
emitEffect(Effect.DismissDialog)
logger.debug { "Sync finished" }
}
is CreateArchiveFolderOutcome.Success.SyncStarted -> {
updateState { state ->
when (state) {
is State.CreateArchiveFolder -> state.copy(
syncingMessage = resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_creating_folder_email_provider,
),
)
else -> state
}
}
logger.debug { "Started sync for ${event.serverId}" }
}
CreateArchiveFolderOutcome.Success.UpdatingSpecialFolders ->
updateState { state ->
when (state) {
is State.CreateArchiveFolder -> state.copy(
syncingMessage = resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_updating_special_folder_rules,
),
)
else -> state
}
}
}
}
private fun onCreateArchiveFolderError(error: CreateArchiveFolderOutcome.Error) {
val errorMessage = when (error) {
CreateArchiveFolderOutcome.Error.AccountNotFound ->
resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_account_not_found,
accountUuid,
).also {
logger.error { it }
}
is CreateArchiveFolderOutcome.Error.SyncError.Failed ->
resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_failed_sync_folder,
error.serverId,
error.message,
).also {
logger.error(
throwable = error.exception,
message = { it },
)
}
is CreateArchiveFolderOutcome.Error.UnhandledError -> resourceManager.stringResource(
R.string.setup_archive_folder_unhandled_error,
error.throwable.message,
).also {
logger.error(throwable = error.throwable, message = { it })
}
is CreateArchiveFolderOutcome.Error.InvalidFolderName -> when {
error.folderName.isBlank() -> resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_error_folder_name_blank,
)
else -> resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_invalid_folder_name,
error.folderName,
)
}
is CreateArchiveFolderOutcome.Error.LocalFolderCreationError -> resourceManager.stringResource(
R.string.setup_archive_folder_create_archive_folder_failed_create_local_folder,
error.folderName,
)
}
updateState { state ->
when (state) {
is State.CreateArchiveFolder -> state.copy(
errorMessage = errorMessage,
syncingMessage = null,
)
else -> state
}
}
}
private fun onFolderSelected(folder: RemoteFolder) {
updateState { state ->
when (state) {
is State.ChooseArchiveFolder -> state.copy(selectedFolder = folder)
else -> state
}
}
}
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="setup_archive_folder_dialog_dont_show_again">Do not show this message again</string>
<string name="setup_archive_folder_dialog_email_can_not_be_archived">Email can not be archived</string>
<string name="setup_archive_folder_dialog_configure_archive_folder">Configure archive folder now</string>
<string name="setup_archive_folder_dialog_skip_for_now">Skip for now</string>
<string name="setup_archive_folder_dialog_set_archive_folder">Set archive folder</string>
<string name="setup_archive_folder_dialog_error_retrieve_folders">Oops, something went wrong while retrieving account folders!</string>
<string name="setup_archive_folder_dialog_error_retrieve_folders_detailed_message">Error details: %1$s</string>
<string name="setup_archive_folder_dialog_create_new_folder">Create new folder</string>
<string name="setup_archive_folder_dialog_done">Done</string>
<string name="setup_archive_folder_dialog_cancel">Cancel</string>
<string name="setup_archive_folder_dialog_create_and_set_new_folder"><![CDATA[Create & set new folder]]></string>
<string name="setup_archive_folder_set_archive_error_account_not_found">Couldn\'t find an account associated to the \'%1$s\' UUID</string>
<string name="setup_archive_folder_unhandled_error">Unhandled error: %1$s</string>
<string name="setup_archive_folder_create_archive_folder_syncing">Syncing…</string>
<string name="setup_archive_folder_create_archive_folder_local_folder_created">Local folder created. Starting synchronization…</string>
<string name="setup_archive_folder_create_archive_folder_remote_folder_created">Remote folder created.</string>
<string name="setup_archive_folder_create_archive_folder_creating_folder_email_provider">Creating folder on email provider…</string>
<string name="setup_archive_folder_create_archive_folder_updating_special_folder_rules">Updating special folder rules…</string>
<string name="setup_archive_folder_create_archive_folder_account_not_found">Account (%1$s) not found</string>
<string name="setup_archive_folder_create_archive_folder_failed_create_local_folder">Failed to create local folder \'%1$s\'.</string>
<string name="setup_archive_folder_create_archive_folder_failed_sync_folder">Failed sync folder \'%1$s\' with remote. Message: %2$s</string>
<string name="setup_archive_folder_create_archive_folder_error_folder_name_blank">Folder name cannot be blank</string>
<string name="setup_archive_folder_create_archive_folder_invalid_folder_name">Invalid folder name \'%1$s\'</string>
</resources>

View file

@ -0,0 +1,28 @@
package net.thunderbird.feature.mail.message.list
import kotlin.test.Test
import net.thunderbird.core.common.resources.StringsResourceManager
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.test.KoinTest
import org.koin.test.verify.definition
import org.koin.test.verify.verify
@OptIn(KoinExperimentalAPI::class)
class FeatureMessageListModuleKtTest : KoinTest {
@Test
fun `should have a valid di module`() {
featureMessageListModule.verify(
extraTypes = listOf(
Logger::class,
StringsResourceManager::class,
GeneralSettingsManager::class,
),
injections = listOf(
definition<SetupArchiveFolderDialogContract.ViewModel>(SetupArchiveFolderDialogContract.State::class),
),
)
}
}

View file

@ -0,0 +1,394 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import assertk.all
import assertk.assertThat
import assertk.assertions.containsOnly
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import kotlin.random.Random
import kotlin.test.Test
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import net.thunderbird.core.common.action.SwipeAction
import net.thunderbird.core.common.action.SwipeActions
import net.thunderbird.core.preference.GeneralSettings
import net.thunderbird.core.preference.GeneralSettingsManager
import net.thunderbird.core.preference.display.DisplaySettings
import net.thunderbird.core.preference.network.NetworkSettings
import net.thunderbird.core.preference.notification.NotificationPreference
import net.thunderbird.core.preference.privacy.PrivacySettings
import net.thunderbird.core.preference.storage.Storage
import net.thunderbird.feature.mail.message.list.fakes.FakeAccount
import net.thunderbird.feature.mail.message.list.fakes.FakeAccountManager
@OptIn(ExperimentalUuidApi::class)
@Suppress("MaxLineLength")
class BuildSwipeActionsTest {
private val defaultGeneralSettings
get() = GeneralSettings(
display = DisplaySettings(),
network = NetworkSettings(),
notification = NotificationPreference(),
privacy = PrivacySettings(),
)
@Test
fun `invoke should return empty map when empty account uuids is provided`() {
// Arrange
val uuids = setOf<String>()
val testSubject = createTestSubject(
accountsUuids = List(size = 10) { Uuid.random().toHexString() },
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).isEmpty()
}
@Test
fun `invoke should return map with SwipeActions(ToggleRead, ToggleRead) when no user preference is stored`() {
// Arrange
val uuid = Uuid.random().toHexString()
val uuids = setOf(uuid)
val testSubject = createTestSubject(
accountsUuids = uuids.toList(),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).all {
hasSize(size = 1)
containsOnly(
uuid to SwipeActions(
leftAction = SwipeAction.ToggleRead,
rightAction = SwipeAction.ToggleRead,
),
)
}
}
@Test
fun `invoke should return map with multiple keys when multiple accounts`() {
// Arrange
val accountsSize = 10
val uuids = List(size = accountsSize) { Uuid.random().toHexString() }
val testSubject = createTestSubject(
accountsUuids = uuids.toList(),
)
// Act
val actions = testSubject(
accountUuids = uuids.toSet(),
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).all {
hasSize(accountsSize)
containsOnly(
elements = uuids
.associateWith {
SwipeActions(
leftAction = SwipeAction.ToggleRead,
rightAction = SwipeAction.ToggleRead,
)
}
.map { it.key to it.value }
.toTypedArray(),
)
}
}
@Test
fun `invoke should return map with SwipeActions(None, ToggleRead) when left action is stored as None but right is not`() {
// Arrange
val uuid = Uuid.random().toHexString()
val uuids = setOf(uuid)
val testSubject = createTestSubject(
accountsUuids = uuids.toList(),
storageValues = mapOf(
SwipeActions.KEY_SWIPE_ACTION_LEFT to SwipeAction.None.name,
),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).all {
hasSize(size = 1)
containsOnly(
uuid to SwipeActions(
leftAction = SwipeAction.None,
rightAction = SwipeAction.ToggleRead,
),
)
}
}
@Test
fun `invoke should return map with SwipeActions(ToggleRead, Delete) when left action is not stored but right is stored as Delete`() {
// Arrange
val uuid = Uuid.random().toHexString()
val uuids = setOf(uuid)
val testSubject = createTestSubject(
accountsUuids = uuids.toList(),
storageValues = mapOf(
SwipeActions.KEY_SWIPE_ACTION_RIGHT to SwipeAction.Delete.name,
),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).all {
hasSize(size = 1)
containsOnly(
uuid to SwipeActions(
leftAction = SwipeAction.ToggleRead,
rightAction = SwipeAction.Delete,
),
)
}
}
@Test
fun `invoke should return map with SwipeActions(Archive, Archive) when both stored actions are Archive, account isn't pop3 and has archive folder`() {
// Arrange
val uuid = Uuid.random().toHexString()
val uuids = setOf(uuid)
val testSubject = createTestSubject(
accountsUuids = uuids.toList(),
storageValues = mapOf(
SwipeActions.KEY_SWIPE_ACTION_LEFT to SwipeAction.Archive.name,
SwipeActions.KEY_SWIPE_ACTION_RIGHT to SwipeAction.Archive.name,
),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { false },
hasArchiveFolder = { true },
)
// Assert
assertThat(actions).all {
hasSize(size = 1)
containsOnly(
uuid to SwipeActions(
leftAction = SwipeAction.Archive,
rightAction = SwipeAction.Archive,
),
)
}
}
@Test
fun `invoke should return map with SwipeActions(ArchiveDisabled, ArchiveDisabled) when both stored actions are Archive, account is pop3`() {
// Arrange
val uuid = Uuid.random().toHexString()
val uuids = setOf(uuid)
val testSubject = createTestSubject(
accountsUuids = uuids.toList(),
storageValues = mapOf(
SwipeActions.KEY_SWIPE_ACTION_LEFT to SwipeAction.Archive.name,
SwipeActions.KEY_SWIPE_ACTION_RIGHT to SwipeAction.Archive.name,
),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { true },
hasArchiveFolder = { true },
)
// Assert
assertThat(actions).all {
hasSize(size = 1)
containsOnly(
uuid to SwipeActions(
leftAction = SwipeAction.ArchiveDisabled,
rightAction = SwipeAction.ArchiveDisabled,
),
)
}
}
@Test
fun `invoke should return map with SwipeActions(ArchiveSetupArchiveFolder, ArchiveSetupArchiveFolder) when both stored actions are Archive, account isn't pop3, has not archive folder and shouldShowSetupArchiveFolderDialog is true`() {
// Arrange
val uuid = Uuid.random().toHexString()
val uuids = setOf(uuid)
val testSubject = createTestSubject(
initialGeneralSettings = defaultGeneralSettings.copy(
display = defaultGeneralSettings.display.copy(shouldShowSetupArchiveFolderDialog = true),
),
accountsUuids = uuids.toList(),
storageValues = mapOf(
SwipeActions.KEY_SWIPE_ACTION_LEFT to SwipeAction.Archive.name,
SwipeActions.KEY_SWIPE_ACTION_RIGHT to SwipeAction.Archive.name,
),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).all {
hasSize(size = 1)
containsOnly(
uuid to SwipeActions(
leftAction = SwipeAction.ArchiveSetupArchiveFolder,
rightAction = SwipeAction.ArchiveSetupArchiveFolder,
),
)
}
}
@Test
fun `invoke should return map with different SwipeAction Archive when multiple accounts that includes pop3 accounts or accounts without archive folder`() {
// Arrange
val uuidPop3 = "pop3-account"
val uuidWithoutArchiveFolder = "no-archive-folder-account"
val uuidWithArchiveFolder = "archive-folder-account"
val uuids = setOf(
uuidPop3,
uuidWithoutArchiveFolder,
uuidWithArchiveFolder,
)
val testSubject = createTestSubject(
initialGeneralSettings = defaultGeneralSettings.copy(
display = defaultGeneralSettings.display.copy(shouldShowSetupArchiveFolderDialog = true),
),
accountsUuids = uuids.toList(),
storageValues = mapOf(
SwipeActions.KEY_SWIPE_ACTION_LEFT to SwipeAction.Archive.name,
SwipeActions.KEY_SWIPE_ACTION_RIGHT to SwipeAction.Archive.name,
),
)
// Act
val actions = testSubject(
accountUuids = uuids,
isIncomingServerPop3 = { it.uuid == uuidPop3 },
hasArchiveFolder = { it.uuid == uuidWithArchiveFolder },
)
// Assert
assertThat(actions).all {
hasSize(size = 3)
containsOnly(
uuidPop3 to SwipeActions(
leftAction = SwipeAction.ArchiveDisabled,
rightAction = SwipeAction.ArchiveDisabled,
),
uuidWithoutArchiveFolder to SwipeActions(
leftAction = SwipeAction.ArchiveSetupArchiveFolder,
rightAction = SwipeAction.ArchiveSetupArchiveFolder,
),
uuidWithArchiveFolder to SwipeActions(
leftAction = SwipeAction.Archive,
rightAction = SwipeAction.Archive,
),
)
}
}
@Test
fun `invoke should return empty map when account uuid doesn't exists in AccountManager`() {
// Arrange
val uuids = List(size = Random.nextInt(from = 1, until = 100)) { Uuid.random().toHexString() }
val accountManagerUuids =
List(size = Random.nextInt(from = 1, until = 100)) { Uuid.random().toHexString() } - uuids
val testSubject = createTestSubject(accountsUuids = accountManagerUuids)
// Act
val actions = testSubject(
accountUuids = uuids.toSet(),
isIncomingServerPop3 = { false },
hasArchiveFolder = { false },
)
// Assert
assertThat(actions).isEmpty()
}
private fun createTestSubject(
initialGeneralSettings: GeneralSettings = defaultGeneralSettings,
accountsUuids: List<String>,
storageValues: Map<String, String> = mapOf(),
): BuildSwipeActions = BuildSwipeActions(
generalSettingsManager = FakeGeneralSettingsManager(initialGeneralSettings),
accountManager = FakeAccountManager(accounts = accountsUuids.map { FakeAccount(uuid = it) }),
storage = FakeStorage(storageValues),
)
}
private class FakeGeneralSettingsManager(
initialGeneralSettings: GeneralSettings,
) : GeneralSettingsManager {
private val generalSettings = MutableStateFlow(initialGeneralSettings)
override fun getSettings(): GeneralSettings = generalSettings.value
override fun getSettingsFlow(): Flow<GeneralSettings> = generalSettings
override fun save(config: GeneralSettings) {
error("not implemented")
}
override fun getConfig(): GeneralSettings = generalSettings.value
override fun getConfigFlow(): Flow<GeneralSettings> = generalSettings
}
private class FakeStorage(
private val values: Map<String, String>,
) : Storage {
override fun isEmpty(): Boolean = error("not implemented")
override fun contains(key: String): Boolean = error("not implemented")
override fun getAll(): Map<String, String> = error("not implemented")
override fun getBoolean(key: String, defValue: Boolean): Boolean = error("not implemented")
override fun getInt(key: String, defValue: Int): Int = error("not implemented")
override fun getLong(key: String, defValue: Long): Long = error("not implemented")
override fun getString(key: String): String = error("not implemented")
override fun getStringOrDefault(key: String, defValue: String): String = error("not implemented")
override fun getStringOrNull(key: String): String? = values[key]
}

View file

@ -0,0 +1,446 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.folders.FolderServerId
import dev.mokkery.matcher.any
import dev.mokkery.matcher.eq
import dev.mokkery.spy
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlin.test.Test
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome
import net.thunderbird.backend.api.folder.RemoteFolderCreator
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.mail.message.list.domain.CreateArchiveFolderOutcome
import net.thunderbird.feature.mail.message.list.fakes.FakeAccount
import net.thunderbird.feature.mail.message.list.fakes.FakeAccountManager
import net.thunderbird.feature.mail.message.list.fakes.FakeBackendFolderUpdater
import net.thunderbird.feature.mail.message.list.fakes.FakeBackendStorageFactory
import net.thunderbird.feature.mail.message.list.fakes.FakeSpecialFolderUpdaterFactory
import com.fsck.k9.mail.FolderType as LegacyFolderType
@OptIn(ExperimentalUuidApi::class)
@Suppress("MaxLineLength")
class CreateArchiveFolderTest {
@Test
fun `invoke should emit InvalidFolderName and complete flow when folderName is invalid`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid = accountUuid)
val accountManager = spy(FakeAccountManager(accounts))
val testSubject = createTestSubject(accountManager = accountManager)
val folderName = ""
// Act
testSubject(accountUuid, folderName).test {
// Assert
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<CreateArchiveFolderOutcome.Error>>()
.prop("error") { it.error }
.isInstanceOf<CreateArchiveFolderOutcome.Error.InvalidFolderName>()
.prop("folderName") { it.folderName }
.isEqualTo(folderName)
verify(exactly(0)) { accountManager.getAccount(accountUuid = any()) }
awaitComplete()
}
}
@Test
fun `invoke should emit AccountNotFound and complete flow when no account uuid matches with account list`() =
runTest {
// Arrange
val accountUuid = "any-non-expected-account-uuid"
val accounts = createAccountList()
val accountManager = spy(FakeAccountManager(accounts))
val testSubject = createTestSubject(accountManager = accountManager)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<CreateArchiveFolderOutcome.Error>>()
.prop("error") { it.error }
.isEqualTo(CreateArchiveFolderOutcome.Error.AccountNotFound)
verify(exactly(1)) { accountManager.getAccount(accountUuid) }
awaitComplete()
}
}
@Test
fun `invoke should emit UnhandledError and complete flow when BackendStorage createFolder throws MessagingException`() =
runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid)
val exception = MessagingException("this is an error")
val backendFolderUpdater = FakeBackendFolderUpdater(exception)
val remoteFolderCreatorFactory = spy(FakeRemoteFolderCreatorFactory(outcome = null))
val testSubject = createTestSubject(
accounts = accounts,
backendStorageFactory = FakeBackendStorageFactory(backendFolderUpdater),
remoteFolderCreatorFactory = remoteFolderCreatorFactory,
)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<CreateArchiveFolderOutcome.Error>>()
.prop("error") { it.error }
.isInstanceOf<CreateArchiveFolderOutcome.Error.UnhandledError>()
.prop("throwable") { it.throwable }
.hasMessage(exception.message)
verify(exactly(0)) { remoteFolderCreatorFactory.create(account = any()) }
awaitComplete()
}
}
@Test
fun `invoke should emit LocalFolderCreationError and complete flow when BackendStorage createFolder returns null`() =
runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid)
val backendStorageFactory = FakeBackendStorageFactory(
FakeBackendFolderUpdater(
returnEmptySetWhenCreatingFolders = true,
),
)
val remoteFolderCreatorFactory = spy(FakeRemoteFolderCreatorFactory(outcome = null))
val testSubject = createTestSubject(
accounts = accounts,
backendStorageFactory = backendStorageFactory,
)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<CreateArchiveFolderOutcome.Error>>()
.prop("error") { it.error }
.isInstanceOf<CreateArchiveFolderOutcome.Error.LocalFolderCreationError>()
.prop("folderName") { it.folderName }
.isEqualTo(folderName)
verify(exactly(1)) {
// verify doesn't support verifying the extension function `createFolder`,
// thus we verify the call of `createFolders(list)` instead.
backendStorageFactory.backendFolderUpdater.createFolders(
eq(
listOf(
FolderInfo(
serverId = folderName,
name = folderName,
type = LegacyFolderType.ARCHIVE,
),
),
),
)
}
verify(exactly(0)) { remoteFolderCreatorFactory.create(account = any()) }
awaitComplete()
}
}
@Test
fun `invoke should emit LocalFolderCreated when BackendStorage createFolder returns folderId`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid = accountUuid)
val backendStorageFactory = FakeBackendStorageFactory(
FakeBackendFolderUpdater(),
)
val testSubject = createTestSubject(
accounts = accounts,
remoteFolderCreatorOutcome = Outcome.success(RemoteFolderCreationOutcome.Success.Created),
backendStorageFactory = backendStorageFactory,
)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Success<CreateArchiveFolderOutcome.Success>>()
.prop("data") { it.data }
.isEqualTo(CreateArchiveFolderOutcome.Success.LocalFolderCreated)
verify(exactly(1)) {
// verify doesn't support verifying the extension function `createFolder`,
// thus we verify the call of `createFolders(list)` instead.
backendStorageFactory.backendFolderUpdater.createFolders(
eq(
listOf(
FolderInfo(
serverId = folderName,
name = folderName,
type = LegacyFolderType.ARCHIVE,
),
),
),
)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `invoke should emit SyncStarted when local folder synchronization with remote starts`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid)
val backendStorageFactory = FakeBackendStorageFactory(
FakeBackendFolderUpdater(),
)
val testSubject = createTestSubject(
accounts = accounts,
remoteFolderCreatorOutcome = Outcome.success(RemoteFolderCreationOutcome.Success.Created),
backendStorageFactory = backendStorageFactory,
)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
skipItems(count = 1) // Skip LocalFolderCreated event.
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Success<CreateArchiveFolderOutcome.Success>>()
.prop("data") { it.data }
.isInstanceOf<CreateArchiveFolderOutcome.Success.SyncStarted>()
.prop("serverId") { it.serverId }
.isEqualTo(FolderServerId(folderName))
verify(exactly(1)) {
// verify doesn't support verifying the extension function `createFolder`,
// thus we verify the call of `createFolders(list)` instead.
backendStorageFactory.backendFolderUpdater.createFolders(
eq(
listOf(
FolderInfo(
serverId = folderName,
name = folderName,
type = LegacyFolderType.ARCHIVE,
),
),
),
)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `invoke should emit SyncError when remote folder creation fails for any reason`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid)
val backendStorageFactory = FakeBackendStorageFactory(
FakeBackendFolderUpdater(),
)
val error = RemoteFolderCreationOutcome.Error.AlreadyExists
val testSubject = createTestSubject(
accounts = accounts,
remoteFolderCreatorOutcome = Outcome.failure(error),
backendStorageFactory = backendStorageFactory,
)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
skipItems(count = 2) // Skip LocalFolderCreated and SyncStarted event.
val outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Failure<CreateArchiveFolderOutcome.Error>>()
.prop("error") { it.error }
.isInstanceOf<CreateArchiveFolderOutcome.Error.SyncError.Failed>()
.isEqualTo(
CreateArchiveFolderOutcome.Error.SyncError.Failed(
serverId = FolderServerId(folderName),
message = error.toString(),
exception = null,
),
)
verify(exactly(1)) {
// verify doesn't support verifying the extension function `createFolder`,
// thus we verify the call of `createFolders(list)` instead.
backendStorageFactory.backendFolderUpdater.createFolders(
eq(
listOf(
FolderInfo(
serverId = folderName,
name = folderName,
type = LegacyFolderType.ARCHIVE,
),
),
),
)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
@Suppress("LongMethod")
fun `invoke should emit Success when local and remote folder creation succeed`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = createAccountList(accountUuid)
val accountManager = spy(FakeAccountManager(accounts))
val backendStorageFactory = FakeBackendStorageFactory(
FakeBackendFolderUpdater(),
)
val specialFolderUpdaterFactory = FakeSpecialFolderUpdaterFactory()
val remoteFolderCreatorFactory = FakeRemoteFolderCreatorFactory(
Outcome.success(RemoteFolderCreationOutcome.Success.Created),
)
val testSubject = createTestSubject(
accountManager = accountManager,
remoteFolderCreatorFactory = remoteFolderCreatorFactory,
backendStorageFactory = backendStorageFactory,
specialFolderUpdaterFactory = specialFolderUpdaterFactory,
)
val folderName = "TheFolder"
// Act
testSubject(accountUuid, folderName).test {
// Assert
skipItems(count = 2) // Skip LocalFolderCreated and SyncStarted event.
var outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Success<CreateArchiveFolderOutcome.Success>>()
.prop("data") { it.data }
.isEqualTo(CreateArchiveFolderOutcome.Success.UpdatingSpecialFolders)
outcome = awaitItem()
assertThat(outcome)
.isInstanceOf<Outcome.Success<CreateArchiveFolderOutcome.Success>>()
.prop("data") { it.data }
.isEqualTo(CreateArchiveFolderOutcome.Success.Created)
verify(exactly(1)) { accountManager.getAccount(accountUuid) }
verify(exactly(1)) {
// verify doesn't support verifying the extension function `createFolder`,
// thus we verify the call of `createFolders(list)` instead.
backendStorageFactory.backendFolderUpdater.createFolders(
eq(
listOf(
FolderInfo(
serverId = folderName,
name = folderName,
type = LegacyFolderType.ARCHIVE,
),
),
),
)
}
verifySuspend(exactly(1)) {
remoteFolderCreatorFactory.instance.create(
folderServerId = FolderServerId(folderName),
mustCreate = false,
folderType = LegacyFolderType.ARCHIVE,
)
}
verify(exactly(1)) {
specialFolderUpdaterFactory.specialFolderUpdater.setSpecialFolder(
type = FolderType.ARCHIVE,
folderId = any(),
selection = SpecialFolderSelection.MANUAL,
)
}
verify(exactly(1)) {
specialFolderUpdaterFactory.specialFolderUpdater.updateSpecialFolders()
}
verify(exactly(1)) {
accountManager.saveAccount(account = any())
}
awaitComplete()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun createTestSubject(
accounts: List<BaseAccount> = emptyList(),
accountManager: FakeAccountManager = FakeAccountManager(accounts),
backendStorageFactory: FakeBackendStorageFactory = FakeBackendStorageFactory(),
remoteFolderCreatorOutcome: Outcome<
RemoteFolderCreationOutcome.Success,
RemoteFolderCreationOutcome.Error,
>? = null,
remoteFolderCreatorFactory: FakeRemoteFolderCreatorFactory = FakeRemoteFolderCreatorFactory(
outcome = remoteFolderCreatorOutcome,
),
specialFolderUpdaterFactory: FakeSpecialFolderUpdaterFactory = FakeSpecialFolderUpdaterFactory(),
): CreateArchiveFolder =
CreateArchiveFolder(
accountManager = accountManager,
backendStorageFactory = backendStorageFactory,
remoteFolderCreatorFactory = remoteFolderCreatorFactory,
specialFolderUpdaterFactory = specialFolderUpdaterFactory,
ioDispatcher = UnconfinedTestDispatcher(),
)
private fun createAccountList(
accountUuid: String = Uuid.random().toHexString(),
size: Int = 10,
) = List(size = size) {
FakeAccount(uuid = if (it == 0) accountUuid else Uuid.random().toHexString())
}
}
private open class FakeRemoteFolderCreatorFactory(
protected open val outcome: Outcome<RemoteFolderCreationOutcome.Success, RemoteFolderCreationOutcome.Error>?,
) : RemoteFolderCreator.Factory {
open var instance: RemoteFolderCreator = spy<RemoteFolderCreator>(FakeRemoteFolderCreator())
protected set
override fun create(account: BaseAccount): RemoteFolderCreator = instance
private open inner class FakeRemoteFolderCreator : RemoteFolderCreator {
override suspend fun create(
folderServerId: FolderServerId,
mustCreate: Boolean,
folderType: LegacyFolderType,
): Outcome<RemoteFolderCreationOutcome.Success, RemoteFolderCreationOutcome.Error> =
outcome ?: error("Not expected to be called in this context.")
}
}

View file

@ -0,0 +1,274 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import app.k9mail.legacy.mailstore.FolderRepository
import assertk.all
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.containsOnly
import assertk.assertions.hasMessage
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import kotlin.random.Random
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.domain.AccountFolderError
import org.mockito.Mockito.`when`
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
private const val VALID_ACCOUNT_UUID = "valid_account_uuid"
private const val INVALID_ACCOUNT_UUID = "invalid_account_uuid"
@Suppress("MaxLineLength")
class GetAccountFoldersTest {
@Test
fun `invoke should return REGULAR and ARCHIVE folders when repository returns a list of folders`() = runTest {
// Arrange
val accountUuid = VALID_ACCOUNT_UUID
val regularFoldersSize = 10
val remoteFolders = createRemoteFolders(
regularFoldersSize = regularFoldersSize,
addInboxFolder = true,
addOutboxFolder = true,
addSentFolder = true,
addTrashFolder = true,
addArchiveFolder = true,
addSpamFolder = true,
)
val testSubject = createTestSubject(accountUuid, remoteFolders)
// Act
val folders = testSubject(accountUuid)
// Assert
assertThat(folders)
.isInstanceOf<Outcome.Success<List<RemoteFolder>>>()
.prop(name = "data") { it.data }
.all {
hasSize(regularFoldersSize + 1) // +1 counting Archive folder.
transform { remoteFolders -> remoteFolders.map { it.type } }
.containsOnly(FolderType.REGULAR, FolderType.ARCHIVE)
}
}
@Test
fun `invoke should return only REGULAR folders when repository returns only REGULAR folders`() = runTest {
// Arrange
val accountUuid = VALID_ACCOUNT_UUID
val regularFoldersSize = Random.nextInt(from = 1, until = 100)
val remoteFolders = createRemoteFolders(
regularFoldersSize = regularFoldersSize,
)
val testSubject = createTestSubject(accountUuid, remoteFolders)
// Act
val folders = testSubject(accountUuid)
// Assert
assertThat(folders)
.isInstanceOf<Outcome.Success<List<RemoteFolder>>>()
.prop(name = "data") { it.data }
.all {
hasSize(regularFoldersSize)
transform { remoteFolders -> remoteFolders.map { it.type } }
.containsOnly(FolderType.REGULAR)
}
}
@Test
fun `invoke should return only ARCHIVE folder when repository returns only ARCHIVE folder`() = runTest {
// Arrange
val accountUuid = VALID_ACCOUNT_UUID
val remoteFolders = createRemoteFolders(
regularFoldersSize = 0,
addArchiveFolder = true,
)
val testSubject = createTestSubject(accountUuid, remoteFolders)
// Act
val folders = testSubject(accountUuid)
// Assert
assertThat(folders)
.isInstanceOf<Outcome.Success<List<RemoteFolder>>>()
.prop(name = "data") { it.data }
.all {
hasSize(1)
transform { remoteFolders -> remoteFolders.map { it.type } }
.containsOnly(FolderType.ARCHIVE)
}
}
@Test
fun `invoke should return an empty list when repository returns no REGULAR or ARCHIVE folders`() = runTest {
// Arrange
val accountUuid = VALID_ACCOUNT_UUID
val remoteFolders = createRemoteFolders(
regularFoldersSize = 0,
addInboxFolder = true,
addOutboxFolder = true,
addSentFolder = true,
addTrashFolder = true,
addArchiveFolder = false,
addSpamFolder = true,
)
val testSubject = createTestSubject(accountUuid, remoteFolders)
// Act
val folders = testSubject(accountUuid)
// Assert
assertThat(folders)
.isInstanceOf<Outcome.Success<List<RemoteFolder>>>()
.prop(name = "data") { it.data }
.isEmpty()
}
@Test
fun `invoke should return failure when repository throws MessagingException`() = runTest {
// Arrange
val accountUuid = VALID_ACCOUNT_UUID
val errorMessage = "this is an error"
val messagingException = MessagingException(errorMessage)
val remoteFolders = listOf<RemoteFolder>()
val testSubject = createTestSubject(
accountUuid = accountUuid,
folders = remoteFolders,
exception = messagingException,
)
// Act
val folders = testSubject(accountUuid)
// Assert
assertThat(folders)
.isInstanceOf<Outcome.Failure<AccountFolderError>>()
.prop("error") { it.error }
.prop(AccountFolderError::exception)
.isInstanceOf<MessagingException>()
.hasMessage(errorMessage)
}
@Test
fun `invoke should propagate exception when repository throws other types of exceptions`() = runTest {
// Arrange
val accountUuid = VALID_ACCOUNT_UUID
val errorMessage = "not handled exception"
val messagingException = RuntimeException(errorMessage)
val remoteFolders = listOf<RemoteFolder>()
val testSubject = createTestSubject(
accountUuid = accountUuid,
folders = remoteFolders,
exception = messagingException,
)
// Act & Assert
assertFailure { testSubject(accountUuid) }
.isInstanceOf<RuntimeException>()
.hasMessage(errorMessage)
}
@Test
fun `invoke should handle invalid or non-existent account UUID`() = runTest {
// Arrange
val accountUuid = INVALID_ACCOUNT_UUID
val remoteFolders = createRemoteFolders(
regularFoldersSize = 100,
addInboxFolder = true,
addOutboxFolder = true,
addSentFolder = true,
addTrashFolder = true,
addArchiveFolder = true,
addSpamFolder = true,
)
val testSubject = createTestSubject(accountUuid, remoteFolders)
// Act
val folders = testSubject(accountUuid)
// Assert
assertThat(folders)
.isInstanceOf<Outcome.Success<List<RemoteFolder>>>()
.prop(name = "data") { it.data }
.isEmpty()
}
private fun createRemoteFolders(
regularFoldersSize: Int,
addInboxFolder: Boolean = false,
addOutboxFolder: Boolean = false,
addSentFolder: Boolean = false,
addTrashFolder: Boolean = false,
addArchiveFolder: Boolean = false,
addSpamFolder: Boolean = false,
): List<RemoteFolder> {
fun createRemoteFolder(id: Long, type: FolderType) = RemoteFolder(
id = id,
name = "${type.name}-$id",
serverId = "${type.name}-$id",
type = type,
)
return buildList {
var id = 1L
if (addInboxFolder) {
add(createRemoteFolder(id = id++, type = FolderType.INBOX))
}
if (addOutboxFolder) {
add(createRemoteFolder(id = id++, type = FolderType.OUTBOX))
}
if (addSentFolder) {
add(createRemoteFolder(id = id++, type = FolderType.SENT))
}
if (addTrashFolder) {
add(createRemoteFolder(id = id++, type = FolderType.TRASH))
}
if (addArchiveFolder) {
add(createRemoteFolder(id = id++, type = FolderType.ARCHIVE))
}
if (addSpamFolder) {
add(createRemoteFolder(id = id++, type = FolderType.SPAM))
}
if (regularFoldersSize > 0) {
addAll(
elements = List(size = regularFoldersSize) { index ->
createRemoteFolder(id = id + index, type = FolderType.REGULAR)
},
)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun createTestSubject(
accountUuid: String,
folders: List<RemoteFolder>,
exception: Exception? = null,
): GetAccountFolders {
val folderRepository = mock<FolderRepository>()
when {
exception != null -> {
`when`(folderRepository.getRemoteFolders(eq(accountUuid)))
.thenThrow(exception)
}
accountUuid == VALID_ACCOUNT_UUID -> {
`when`(folderRepository.getRemoteFolders(eq(accountUuid)))
.thenReturn(folders)
}
accountUuid == INVALID_ACCOUNT_UUID ->
`when`(folderRepository.getRemoteFolders(eq(accountUuid)))
.thenReturn(emptyList())
}
return GetAccountFolders(folderRepository, ioDispatcher = UnconfinedTestDispatcher())
}
}

View file

@ -0,0 +1,186 @@
package net.thunderbird.feature.mail.message.list.domain.usecase
import assertk.assertThat
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import dev.mokkery.matcher.any
import dev.mokkery.matcher.matching
import dev.mokkery.spy
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import kotlin.random.Random
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.mail.message.list.domain.SetAccountFolderOutcome
import net.thunderbird.feature.mail.message.list.fakes.FakeAccount
import net.thunderbird.feature.mail.message.list.fakes.FakeAccountManager
import net.thunderbird.feature.mail.message.list.fakes.FakeBackendFolderUpdater
import net.thunderbird.feature.mail.message.list.fakes.FakeBackendStorageFactory
import net.thunderbird.feature.mail.message.list.fakes.FakeSpecialFolderUpdaterFactory
import org.junit.Test
import com.fsck.k9.mail.FolderType as LegacyFolderType
@OptIn(ExperimentalUuidApi::class)
@Suppress("MaxLineLength")
class SetArchiveFolderTest {
@Test
fun `invoke should successfully create folder and update account when given valid input`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = listOf(FakeAccount(uuid = accountUuid))
val fakeBackendStorageFactory = FakeBackendStorageFactory()
val fakeAccountManager = spy(FakeAccountManager(accounts))
val fakeSpecialFolderUpdaterFactory = FakeSpecialFolderUpdaterFactory()
val testSubject =
createTestSubject(fakeAccountManager, fakeBackendStorageFactory, fakeSpecialFolderUpdaterFactory)
val folder = createRemoteFolder()
// Act
val outcome = testSubject(accountUuid, folder)
// Assert
assertThat(outcome)
.isInstanceOf<Outcome.Success<SetAccountFolderOutcome.Success>>()
.prop(name = "data") { it.data }
.isEqualTo(SetAccountFolderOutcome.Success)
verify(exactly(1)) {
fakeBackendStorageFactory.backendFolderUpdater.changeFolder(
folderServerId = folder.serverId,
name = folder.name,
type = LegacyFolderType.ARCHIVE,
)
}
verify(exactly(1)) { fakeBackendStorageFactory.backendFolderUpdater.close() }
verify(exactly(1)) {
fakeSpecialFolderUpdaterFactory.specialFolderUpdater.setSpecialFolder(
type = FolderType.ARCHIVE,
folderId = folder.id,
selection = SpecialFolderSelection.MANUAL,
)
}
verify(exactly(1)) {
fakeSpecialFolderUpdaterFactory.specialFolderUpdater.updateSpecialFolders()
}
verify(exactly(1)) {
fakeAccountManager.saveAccount(
account = matching {
it.uuid == accountUuid
},
)
}
}
@Test
fun `invoke should return AccountNotFound when account is not found`() = runTest {
// Arrange
val accounts = listOf<FakeAccount>()
val testSubject = createTestSubject(accounts)
val accountUuid = Uuid.random().toHexString()
val folder = createRemoteFolder()
// Act
val outcome = testSubject(accountUuid, folder)
// Assert
assertThat(outcome)
.isInstanceOf<Outcome.Failure<SetAccountFolderOutcome.Error>>()
.prop(name = "error") { it.error }
.isEqualTo(SetAccountFolderOutcome.Error.AccountNotFound)
}
@Test
fun `invoke should return UnhandledError when changeFolder throws MessagingException`() = runTest {
// Arrange
val accountUuid = Uuid.random().toHexString()
val accounts = listOf(FakeAccount(uuid = accountUuid))
val exception = MessagingException("this is an error")
val fakeBackendStorageFactory = FakeBackendStorageFactory(
backendFolderUpdater = FakeBackendFolderUpdater(exception = exception),
)
val fakeAccountManager = spy(FakeAccountManager(accounts))
val fakeSpecialFolderUpdaterFactory = FakeSpecialFolderUpdaterFactory()
val testSubject =
createTestSubject(fakeAccountManager, fakeBackendStorageFactory, fakeSpecialFolderUpdaterFactory)
val folder = createRemoteFolder()
// Act
val outcome = testSubject(accountUuid, folder)
// Assert
assertThat(outcome)
.isInstanceOf<Outcome.Failure<SetAccountFolderOutcome.Error>>()
.prop(name = "error") { it.error }
.isInstanceOf<SetAccountFolderOutcome.Error.UnhandledError>()
.prop("throwable") { it.throwable }
.hasMessage(exception.message)
verify(exactly(1)) {
fakeBackendStorageFactory.backendFolderUpdater.changeFolder(
folderServerId = folder.serverId,
name = folder.name,
type = LegacyFolderType.ARCHIVE,
)
}
verify(exactly(1)) { fakeBackendStorageFactory.backendFolderUpdater.close() }
verify(exactly(0)) {
fakeSpecialFolderUpdaterFactory.specialFolderUpdater.setSpecialFolder(
type = any(),
folderId = any(),
selection = any(),
)
}
verify(exactly(0)) {
fakeSpecialFolderUpdaterFactory.specialFolderUpdater.updateSpecialFolders()
}
verify(exactly(0)) {
fakeAccountManager.saveAccount(account = any())
}
}
private fun createTestSubject(
accounts: List<BaseAccount>,
backendStorageFactory: FakeBackendStorageFactory = FakeBackendStorageFactory(),
specialFolderUpdaterFactory: FakeSpecialFolderUpdaterFactory = FakeSpecialFolderUpdaterFactory(),
): SetArchiveFolder = createTestSubject(
accountManager = FakeAccountManager(accounts),
backendStorageFactory = backendStorageFactory,
specialFolderUpdaterFactory = specialFolderUpdaterFactory,
)
private fun createTestSubject(
accountManager: FakeAccountManager,
backendStorageFactory: FakeBackendStorageFactory = FakeBackendStorageFactory(),
specialFolderUpdaterFactory: FakeSpecialFolderUpdaterFactory = FakeSpecialFolderUpdaterFactory(),
): SetArchiveFolder {
return SetArchiveFolder(
accountManager = accountManager,
backendStorageFactory = backendStorageFactory,
specialFolderUpdaterFactory = specialFolderUpdaterFactory,
)
}
private fun createRemoteFolder(
id: Long = Random.nextLong(),
serverId: String = "remote_folder_$id",
name: String = serverId,
): RemoteFolder = RemoteFolder(
id = id,
serverId = serverId,
name = name,
type = FolderType.ARCHIVE,
)
}

View file

@ -0,0 +1,11 @@
package net.thunderbird.feature.mail.message.list.fakes
import net.thunderbird.feature.mail.account.api.BaseAccount
internal data class FakeAccount(
override val uuid: String,
override val email: String = "fake@mail.com",
) : BaseAccount {
override val name: String?
get() = email
}

View file

@ -0,0 +1,25 @@
package net.thunderbird.feature.mail.message.list.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import net.thunderbird.feature.mail.account.api.AccountManager
import net.thunderbird.feature.mail.account.api.BaseAccount
internal open class FakeAccountManager(
private val accounts: List<BaseAccount>,
) : AccountManager<BaseAccount> {
override fun getAccounts(): List<BaseAccount> = accounts
override fun getAccountsFlow(): Flow<List<BaseAccount>> = flowOf(accounts)
override fun getAccount(accountUuid: String): BaseAccount? = accounts.firstOrNull { it.uuid == accountUuid }
override fun getAccountFlow(accountUuid: String): Flow<BaseAccount?> = flowOf(getAccount(accountUuid))
override fun moveAccount(
account: BaseAccount,
newPosition: Int,
) = error("not implemented.")
override fun saveAccount(account: BaseAccount) = Unit
}

View file

@ -0,0 +1,34 @@
package net.thunderbird.feature.mail.message.list.fakes
import com.fsck.k9.backend.api.BackendFolderUpdater
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.FolderType
internal open class FakeBackendFolderUpdater(
private val exception: Exception? = null,
private val returnEmptySetWhenCreatingFolders: Boolean = false,
) : BackendFolderUpdater {
private val ids = mutableSetOf<Long>()
override fun createFolders(folders: List<FolderInfo>): Set<Long> {
return when {
exception != null -> throw exception
returnEmptySetWhenCreatingFolders -> emptySet()
else -> ids.apply {
var last = ids.lastOrNull() ?: 0
addAll(folders.map { ++last })
}
}
}
override fun deleteFolders(folderServerIds: List<String>) {
if (exception != null) throw exception
}
override fun changeFolder(folderServerId: String, name: String, type: FolderType) {
if (exception != null) throw exception
}
override fun close() {
if (exception != null) throw exception
}
}

View file

@ -0,0 +1,30 @@
package net.thunderbird.feature.mail.message.list.fakes
import com.fsck.k9.backend.api.BackendFolder
import com.fsck.k9.backend.api.BackendFolderUpdater
import com.fsck.k9.backend.api.BackendStorage
import dev.mokkery.spy
import net.thunderbird.backend.api.BackendStorageFactory
import net.thunderbird.feature.mail.account.api.BaseAccount
internal open class FakeBackendStorageFactory(
backendFolderUpdater: FakeBackendFolderUpdater = FakeBackendFolderUpdater(),
) : BackendStorageFactory<BaseAccount> {
val backendFolderUpdater = spy(backendFolderUpdater)
override fun createBackendStorage(account: BaseAccount): BackendStorage = object : BackendStorage {
override fun getFolder(folderServerId: String): BackendFolder = error("not implemented.")
override fun getFolderServerIds(): List<String> = error("not implemented.")
override fun createFolderUpdater(): BackendFolderUpdater = backendFolderUpdater
override fun getExtraString(name: String): String? = error("not implemented.")
override fun setExtraString(name: String, value: String) = error("not implemented.")
override fun getExtraNumber(name: String): Long? = error("not implemented.")
override fun setExtraNumber(name: String, value: Long) = error("not implemented.")
}
}

View file

@ -0,0 +1,23 @@
package net.thunderbird.feature.mail.message.list.fakes
import dev.mokkery.spy
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.FolderType
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.mail.folder.api.SpecialFolderUpdater
internal class FakeSpecialFolderUpdaterFactory : SpecialFolderUpdater.Factory<BaseAccount> {
val specialFolderUpdater = spy<SpecialFolderUpdater>(FakeSpecialFolderUpdater())
override fun create(account: BaseAccount): SpecialFolderUpdater = specialFolderUpdater
}
private open class FakeSpecialFolderUpdater : SpecialFolderUpdater {
override fun updateSpecialFolders() = Unit
override fun setSpecialFolder(
type: FolderType,
folderId: Long?,
selection: SpecialFolderSelection,
) = Unit
}