Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
7
feature/mail/account/api/build.gradle.kts
Normal file
7
feature/mail/account/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.kmp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.feature.mail.account.api"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.thunderbird.feature.mail.account.api
|
||||
|
||||
interface BaseAccount {
|
||||
val uuid: String
|
||||
val name: String?
|
||||
val email: String
|
||||
}
|
||||
18
feature/mail/folder/api/build.gradle.kts
Normal file
18
feature/mail/folder/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.feature.mail.folder.api
|
||||
|
||||
typealias FolderPathDelimiter = String
|
||||
|
||||
const val FOLDER_DEFAULT_PATH_DELIMITER = "/"
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package net.thunderbird.feature.mail.folder.api
|
||||
|
||||
enum class FolderType {
|
||||
REGULAR,
|
||||
INBOX,
|
||||
OUTBOX,
|
||||
SENT,
|
||||
TRASH,
|
||||
DRAFTS,
|
||||
ARCHIVE,
|
||||
SPAM,
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package net.thunderbird.feature.mail.folder.api
|
||||
|
||||
enum class SpecialFolderSelection {
|
||||
AUTOMATIC,
|
||||
MANUAL,
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
22
feature/mail/message/list/build.gradle.kts
Normal file
22
feature/mail/message/list/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
feature/mail/message/list/src/main/res/values/strings.xml
Normal file
26
feature/mail/message/list/src/main/res/values/strings.xml
Normal 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>
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue