Repo created

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

7
app-common/README.md Normal file
View file

@ -0,0 +1,7 @@
# App Common
# App Common
This is the central integration point for shared code among the K-9 Mail and Thunderbird for Android applications. Its purpose is to collect and organize the individual feature modules that contain the actual functionality, as well as the "glue code" and configurations that tie them together.
By keeping the shared code focused on these boundaries, we can ensure that it remains lean and avoids unnecessary dependencies. This approach allows us to maintain a clean and modular architecture, making it easier to maintain and update the codebase.

View file

@ -0,0 +1,48 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "net.thunderbird.app.common"
buildFeatures {
buildConfig = true
}
}
dependencies {
api(projects.legacy.common)
api(projects.legacy.ui.legacy)
api(projects.feature.account.core)
api(projects.feature.launcher)
api(projects.feature.navigation.drawer.api)
implementation(projects.legacy.core)
implementation(projects.core.android.account)
implementation(projects.core.logging.api)
implementation(projects.core.logging.implComposite)
implementation(projects.core.logging.implConsole)
implementation(projects.core.logging.implLegacy)
implementation(projects.core.logging.implFile)
implementation(projects.core.featureflag)
implementation(projects.core.ui.legacy.theme2.common)
implementation(projects.feature.account.avatar.api)
implementation(projects.feature.account.avatar.impl)
implementation(projects.feature.account.setup)
implementation(projects.feature.mail.account.api)
implementation(projects.feature.migration.provider)
implementation(projects.feature.notification.api)
implementation(projects.feature.notification.impl)
implementation(projects.feature.widget.messageList)
implementation(projects.mail.protocols.imap)
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.lifecycle.process)
testImplementation(projects.feature.account.fake)
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto"
>
<application
android:allowBackup="false"
android:hasFragileUserData="false"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
/>
<queries>
<!-- Allow access to external text processing actions so they can be displayed in the text selection toolbar -->
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

View file

@ -0,0 +1,22 @@
package net.thunderbird.app.common
import com.fsck.k9.legacyCommonAppModules
import com.fsck.k9.legacyCoreModules
import com.fsck.k9.legacyUiModules
import net.thunderbird.app.common.account.appCommonAccountModule
import net.thunderbird.app.common.core.appCommonCoreModule
import net.thunderbird.app.common.feature.appCommonFeatureModule
import org.koin.core.module.Module
import org.koin.dsl.module
val appCommonModule: Module = module {
includes(legacyCommonAppModules)
includes(legacyCoreModules)
includes(legacyUiModules)
includes(
appCommonAccountModule,
appCommonCoreModule,
appCommonFeatureModule,
)
}

View file

@ -0,0 +1,154 @@
package net.thunderbird.app.common
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import androidx.lifecycle.ProcessLifecycleOwner
import app.k9mail.feature.widget.message.list.MessageListWidgetManager
import app.k9mail.legacy.di.DI
import com.fsck.k9.Core
import com.fsck.k9.K9
import com.fsck.k9.MessagingListenerProvider
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.job.WorkManagerConfigurationProvider
import com.fsck.k9.notification.NotificationChannelManager
import com.fsck.k9.ui.base.AppLanguageManager
import com.fsck.k9.ui.base.extensions.currentLocale
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import net.thunderbird.app.common.feature.LoggerLifecycleObserver
import net.thunderbird.core.common.exception.ExceptionHandler
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.logging.file.FileLogSink
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.ui.theme.manager.ThemeManager
import org.koin.android.ext.android.inject
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import androidx.work.Configuration as WorkManagerConfiguration
abstract class BaseApplication : Application(), WorkManagerConfiguration.Provider {
private val messagingController: MessagingController by inject()
private val messagingListenerProvider: MessagingListenerProvider by inject()
private val themeManager: ThemeManager by inject()
private val appLanguageManager: AppLanguageManager by inject()
private val notificationChannelManager: NotificationChannelManager by inject()
private val messageListWidgetManager: MessageListWidgetManager by inject()
private val workManagerConfigurationProvider: WorkManagerConfigurationProvider by inject()
private val logger: Logger by inject()
private val syncDebugFileLogSink: FileLogSink by inject(named("syncDebug"))
private val appCoroutineScope: CoroutineScope = MainScope()
private var appLanguageManagerInitialized = false
override fun attachBaseContext(base: Context?) {
Core.earlyInit()
// Start Koin early so it is ready by the time content providers are initialized.
DI.start(this, listOf(provideAppModule()))
Log.logger = logger
super.attachBaseContext(base)
}
override fun onCreate() {
super.onCreate()
K9.init(this)
Core.init(this)
initializeAppLanguage()
updateNotificationChannelsOnAppLanguageChanges()
themeManager.init()
messageListWidgetManager.init()
messagingListenerProvider.listeners.forEach { listener ->
messagingController.addListener(listener)
}
val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(originalHandler))
ProcessLifecycleOwner.get().lifecycle.addObserver(LoggerLifecycleObserver(syncDebugFileLogSink))
}
abstract fun provideAppModule(): Module
private fun initializeAppLanguage() {
appLanguageManager.init()
applyOverrideLocaleToConfiguration()
appLanguageManagerInitialized = true
listenForAppLanguageChanges()
}
private fun applyOverrideLocaleToConfiguration() {
appLanguageManager.getOverrideLocale()?.let { overrideLocale ->
updateConfigurationWithLocale(superResources.configuration, overrideLocale)
}
}
private fun listenForAppLanguageChanges() {
appLanguageManager.overrideLocale
.drop(1) // We already applied the initial value
.onEach { overrideLocale ->
val locale = overrideLocale ?: Locale.getDefault()
updateConfigurationWithLocale(superResources.configuration, locale)
}
.launchIn(appCoroutineScope)
}
override fun onConfigurationChanged(newConfiguration: Configuration) {
applyOverrideLocaleToConfiguration()
super.onConfigurationChanged(superResources.configuration)
}
private fun updateConfigurationWithLocale(configuration: Configuration, locale: Locale) {
Log.d("Updating application configuration with locale '$locale'")
val newConfiguration = Configuration(configuration).apply {
currentLocale = locale
}
@Suppress("DEPRECATION")
superResources.updateConfiguration(newConfiguration, superResources.displayMetrics)
}
private val superResources: Resources
get() = super.getResources()
// Creating a WebView instance triggers something that will cause the configuration of the Application's Resources
// instance to be reset to the default, i.e. not containing our locale override. Unfortunately, we're not notified
// about this event. So we're checking each time someone asks for the Resources instance whether we need to change
// the configuration again. Luckily, right now (Android 11), the platform is calling this method right after
// resetting the configuration.
override fun getResources(): Resources {
val resources = super.getResources()
if (appLanguageManagerInitialized) {
appLanguageManager.getOverrideLocale()?.let { overrideLocale ->
if (resources.configuration.currentLocale != overrideLocale) {
Log.w("Resources configuration was reset. Re-applying locale override.")
appLanguageManager.applyOverrideLocale()
applyOverrideLocaleToConfiguration()
}
}
}
return resources
}
private fun updateNotificationChannelsOnAppLanguageChanges() {
appLanguageManager.appLocale
.distinctUntilChanged()
.onEach { notificationChannelManager.updateChannels() }
.launchIn(appCoroutineScope)
}
override val workManagerConfiguration: WorkManagerConfiguration
get() = workManagerConfigurationProvider.getConfiguration()
}

View file

@ -0,0 +1,27 @@
package net.thunderbird.app.common.account
import android.content.res.Resources
import app.k9mail.core.ui.legacy.theme2.common.R
import net.thunderbird.core.android.account.AccountManager
internal class AccountColorPicker(
private val accountManager: AccountManager,
private val resources: Resources,
) {
fun pickColor(): Int {
val accounts = accountManager.getAccounts()
val usedAccountColors = accounts.map { it.chipColor }.toSet()
val accountColors = resources.getIntArray(R.array.account_colors).toList()
val availableColors = accountColors - usedAccountColors
if (availableColors.isEmpty()) {
return accountColors.random()
}
val defaultAccountColors = resources.getIntArray(R.array.default_account_colors)
return availableColors.shuffled().minByOrNull { color ->
val index = defaultAccountColors.indexOf(color)
if (index != -1) index else defaultAccountColors.size
} ?: error("availableColors must not be empty")
}
}

View file

@ -0,0 +1,169 @@
package net.thunderbird.app.common.account
import android.content.Context
import app.k9mail.feature.account.common.domain.entity.Account
import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
import app.k9mail.feature.account.setup.AccountSetupExternalContract
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import com.fsck.k9.Core
import com.fsck.k9.Preferences
import com.fsck.k9.account.DeletePolicyProvider
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace
import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra
import com.fsck.k9.mail.store.imap.ImapStoreSettings.isSendClientInfo
import com.fsck.k9.mail.store.imap.ImapStoreSettings.isUseCompression
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
import com.fsck.k9.preferences.UnifiedInboxConfigurator
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.common.mail.Protocols
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.feature.account.avatar.AvatarMonogramCreator
import net.thunderbird.feature.account.storage.profile.AvatarDto
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
// TODO Move to feature/account/setup
@Suppress("LongParameterList")
internal class AccountCreator(
private val accountColorPicker: AccountColorPicker,
private val localFoldersCreator: SpecialLocalFoldersCreator,
private val preferences: Preferences,
private val context: Context,
private val messagingController: MessagingController,
private val deletePolicyProvider: DeletePolicyProvider,
private val avatarMonogramCreator: AvatarMonogramCreator,
private val unifiedInboxConfigurator: UnifiedInboxConfigurator,
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : AccountSetupExternalContract.AccountCreator {
@Suppress("TooGenericExceptionCaught")
override suspend fun createAccount(account: Account): AccountCreatorResult {
return try {
withContext(coroutineDispatcher) { AccountCreatorResult.Success(create(account)) }
} catch (e: Exception) {
Log.e(e, "Error while creating new account")
AccountCreatorResult.Error(e.message ?: "Unknown create account error")
}
}
private suspend fun create(account: Account): String {
val newAccount = preferences.newAccount(account.uuid)
newAccount.email = account.emailAddress
newAccount.avatar = AvatarDto(
avatarType = AvatarTypeDto.MONOGRAM,
avatarMonogram = avatarMonogramCreator.create(account.options.accountName, account.emailAddress),
avatarImageUri = null,
avatarIconName = null,
)
newAccount.setIncomingServerSettings(account.incomingServerSettings)
newAccount.outgoingServerSettings = account.outgoingServerSettings
newAccount.oAuthState = account.authorizationState
newAccount.name = account.options.accountName
newAccount.senderName = account.options.displayName
if (account.options.emailSignature != null) {
newAccount.signatureUse = true
newAccount.signature = account.options.emailSignature
}
newAccount.isNotifyNewMail = account.options.showNotification
newAccount.automaticCheckIntervalMinutes = account.options.checkFrequencyInMinutes
newAccount.displayCount = account.options.messageDisplayCount
newAccount.deletePolicy = deletePolicyProvider.getDeletePolicy(newAccount.incomingServerSettings.type)
newAccount.chipColor = accountColorPicker.pickColor()
localFoldersCreator.createSpecialLocalFolders(newAccount)
account.specialFolderSettings?.let { specialFolderSettings ->
newAccount.setSpecialFolders(specialFolderSettings)
}
newAccount.markSetupFinished()
preferences.saveAccount(newAccount)
unifiedInboxConfigurator.configureUnifiedInbox()
Core.setServicesEnabled(context)
messagingController.refreshFolderListBlocking(newAccount)
if (account.options.checkFrequencyInMinutes == -1) {
messagingController.checkMail(newAccount, false, true, false, null)
}
return newAccount.uuid
}
/**
* Set special folders by name.
*
* Since the folder list hasn't been synced yet, we don't have database IDs for the folders. So we use the same
* mechanism that is used when importing settings. See [com.fsck.k9.mailstore.SpecialFolderUpdater] for details.
*/
private fun LegacyAccount.setSpecialFolders(specialFolders: SpecialFolderSettings) {
importedArchiveFolder = specialFolders.archiveSpecialFolderOption.toFolderServerId()
archiveFolderSelection = specialFolders.archiveSpecialFolderOption.toFolderSelection()
importedDraftsFolder = specialFolders.draftsSpecialFolderOption.toFolderServerId()
draftsFolderSelection = specialFolders.draftsSpecialFolderOption.toFolderSelection()
importedSentFolder = specialFolders.sentSpecialFolderOption.toFolderServerId()
sentFolderSelection = specialFolders.sentSpecialFolderOption.toFolderSelection()
importedSpamFolder = specialFolders.spamSpecialFolderOption.toFolderServerId()
spamFolderSelection = specialFolders.spamSpecialFolderOption.toFolderSelection()
importedTrashFolder = specialFolders.trashSpecialFolderOption.toFolderServerId()
trashFolderSelection = specialFolders.trashSpecialFolderOption.toFolderSelection()
}
private fun SpecialFolderOption.toFolderServerId(): String? {
return when (this) {
is SpecialFolderOption.None -> null
is SpecialFolderOption.Regular -> remoteFolder.serverId.serverId
is SpecialFolderOption.Special -> remoteFolder.serverId.serverId
}
}
private fun SpecialFolderOption.toFolderSelection(): SpecialFolderSelection {
return when (this) {
is SpecialFolderOption.None -> {
if (isAutomatic) SpecialFolderSelection.AUTOMATIC else SpecialFolderSelection.MANUAL
}
is SpecialFolderOption.Regular -> {
SpecialFolderSelection.MANUAL
}
is SpecialFolderOption.Special -> {
if (isAutomatic) SpecialFolderSelection.AUTOMATIC else SpecialFolderSelection.MANUAL
}
}
}
}
private fun LegacyAccount.setIncomingServerSettings(serverSettings: ServerSettings) {
if (serverSettings.type == Protocols.IMAP) {
useCompression = serverSettings.isUseCompression
isSendClientInfoEnabled = serverSettings.isSendClientInfo
incomingServerSettings = serverSettings.copy(
extra = createExtra(
autoDetectNamespace = serverSettings.autoDetectNamespace,
pathPrefix = serverSettings.pathPrefix,
),
)
} else {
incomingServerSettings = serverSettings
}
}

View file

@ -0,0 +1,66 @@
package net.thunderbird.app.common.account
import app.k9mail.feature.account.setup.AccountSetupExternalContract
import net.thunderbird.app.common.account.data.DefaultAccountProfileLocalDataSource
import net.thunderbird.app.common.account.data.DefaultLegacyAccountWrapperManager
import net.thunderbird.core.android.account.AccountDefaultsProvider
import net.thunderbird.core.android.account.LegacyAccountWrapperManager
import net.thunderbird.feature.account.avatar.AvatarMonogramCreator
import net.thunderbird.feature.account.avatar.DefaultAvatarMonogramCreator
import net.thunderbird.feature.account.core.AccountCoreExternalContract.AccountProfileLocalDataSource
import net.thunderbird.feature.account.core.featureAccountCoreModule
import net.thunderbird.feature.account.storage.legacy.featureAccountStorageLegacyModule
import org.koin.android.ext.koin.androidApplication
import org.koin.dsl.module
internal val appCommonAccountModule = module {
includes(
featureAccountCoreModule,
featureAccountStorageLegacyModule,
)
single<LegacyAccountWrapperManager> {
DefaultLegacyAccountWrapperManager(
accountManager = get(),
accountDataMapper = get(),
)
}
single<AccountProfileLocalDataSource> {
DefaultAccountProfileLocalDataSource(
accountManager = get(),
dataMapper = get(),
)
}
single<AccountDefaultsProvider> {
DefaultAccountDefaultsProvider(
resourceProvider = get(),
featureFlagProvider = get(),
)
}
factory {
AccountColorPicker(
accountManager = get(),
resources = get(),
)
}
factory<AvatarMonogramCreator> {
DefaultAvatarMonogramCreator()
}
factory<AccountSetupExternalContract.AccountCreator> {
AccountCreator(
accountColorPicker = get(),
localFoldersCreator = get(),
preferences = get(),
context = androidApplication(),
deletePolicyProvider = get(),
messagingController = get(),
avatarMonogramCreator = get(),
unifiedInboxConfigurator = get(),
)
}
}

View file

@ -0,0 +1,138 @@
package net.thunderbird.app.common.account
import com.fsck.k9.CoreResourceProvider
import net.thunderbird.core.android.account.AccountDefaultsProvider
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT_AUTO
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_READ_RECEIPT
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTED_TEXT_SHOWN
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_PREFIX
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_STYLE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REMOTE_SEARCH_NUM_RESULTS
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REPLY_AFTER_QUOTE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_RINGTONE_URI
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_ASCENDING
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_TYPE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_STRIP_SIGNATURE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SYNC_INTERVAL
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_VISIBLE_LIMIT
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.UNASSIGNED_ACCOUNT_NUMBER
import net.thunderbird.core.android.account.Expunge
import net.thunderbird.core.android.account.FolderMode
import net.thunderbird.core.android.account.Identity
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.android.account.ShowPictures
import net.thunderbird.core.featureflag.FeatureFlagProvider
import net.thunderbird.core.featureflag.toFeatureFlagKey
import net.thunderbird.core.preference.storage.Storage
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationLight
import net.thunderbird.feature.notification.NotificationSettings
import net.thunderbird.feature.notification.NotificationVibration
@Suppress("MagicNumber")
internal class DefaultAccountDefaultsProvider(
private val resourceProvider: CoreResourceProvider,
private val featureFlagProvider: FeatureFlagProvider,
) : AccountDefaultsProvider {
override fun applyDefaults(account: LegacyAccount) = with(account) {
applyLegacyDefaults()
}
override fun applyOverwrites(account: LegacyAccount, storage: Storage) = with(account) {
if (storage.contains("${account.uuid}.notifyNewMail")) {
isNotifyNewMail = storage.getBoolean("${account.uuid}.notifyNewMail", false)
isNotifySelfNewMail = storage.getBoolean("${account.uuid}.notifySelfNewMail", true)
} else {
isNotifyNewMail = featureFlagProvider.provide(
"email_notification_default".toFeatureFlagKey(),
).whenEnabledOrNot(
onEnabled = { true },
onDisabledOrUnavailable = { false },
)
isNotifySelfNewMail = featureFlagProvider.provide(
"email_notification_default".toFeatureFlagKey(),
).whenEnabledOrNot(
onEnabled = { true },
onDisabledOrUnavailable = { false },
)
}
}
@Suppress("LongMethod")
private fun LegacyAccount.applyLegacyDefaults() {
automaticCheckIntervalMinutes = DEFAULT_SYNC_INTERVAL
idleRefreshMinutes = 24
displayCount = DEFAULT_VISIBLE_LIMIT
accountNumber = UNASSIGNED_ACCOUNT_NUMBER
isNotifyNewMail = true
folderNotifyNewMailMode = FolderMode.ALL
isNotifySync = false
isNotifySelfNewMail = true
isNotifyContactsMailOnly = false
isIgnoreChatMessages = false
messagesNotificationChannelVersion = 0
folderDisplayMode = FolderMode.NOT_SECOND_CLASS
folderSyncMode = FolderMode.FIRST_CLASS
folderPushMode = FolderMode.NONE
sortType = DEFAULT_SORT_TYPE
setSortAscending(DEFAULT_SORT_TYPE, DEFAULT_SORT_ASCENDING)
showPictures = ShowPictures.NEVER
isSignatureBeforeQuotedText = false
expungePolicy = Expunge.EXPUNGE_IMMEDIATELY
importedAutoExpandFolder = null
legacyInboxFolder = null
maxPushFolders = 10
isSubscribedFoldersOnly = false
maximumPolledMessageAge = -1
maximumAutoDownloadMessageSize = DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE
messageFormat = DEFAULT_MESSAGE_FORMAT
isMessageFormatAuto = DEFAULT_MESSAGE_FORMAT_AUTO
isMessageReadReceipt = DEFAULT_MESSAGE_READ_RECEIPT
quoteStyle = DEFAULT_QUOTE_STYLE
quotePrefix = DEFAULT_QUOTE_PREFIX
isDefaultQuotedTextShown = DEFAULT_QUOTED_TEXT_SHOWN
isReplyAfterQuote = DEFAULT_REPLY_AFTER_QUOTE
isStripSignature = DEFAULT_STRIP_SIGNATURE
isSyncRemoteDeletions = true
openPgpKey = NO_OPENPGP_KEY
isRemoteSearchFullText = false
remoteSearchNumResults = DEFAULT_REMOTE_SEARCH_NUM_RESULTS
isUploadSentMessages = true
isMarkMessageAsReadOnView = true
isMarkMessageAsReadOnDelete = true
isAlwaysShowCcBcc = false
lastSyncTime = 0L
lastFolderListRefreshTime = 0L
setArchiveFolderId(null, SpecialFolderSelection.AUTOMATIC)
setDraftsFolderId(null, SpecialFolderSelection.AUTOMATIC)
setSentFolderId(null, SpecialFolderSelection.AUTOMATIC)
setSpamFolderId(null, SpecialFolderSelection.AUTOMATIC)
setTrashFolderId(null, SpecialFolderSelection.AUTOMATIC)
identities = ArrayList<Identity>()
val identity = Identity(
signatureUse = false,
signature = null,
description = resourceProvider.defaultIdentityDescription(),
)
identities.add(identity)
updateNotificationSettings {
NotificationSettings(
isRingEnabled = true,
ringtone = DEFAULT_RINGTONE_URI,
light = NotificationLight.Disabled,
vibration = NotificationVibration.DEFAULT,
)
}
resetChangeMarkers()
}
}

View file

@ -0,0 +1,38 @@
package net.thunderbird.app.common.account.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import net.thunderbird.core.android.account.LegacyAccountWrapperManager
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.core.AccountCoreExternalContract.AccountProfileLocalDataSource
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.storage.mapper.AccountProfileDataMapper
internal class DefaultAccountProfileLocalDataSource(
private val accountManager: LegacyAccountWrapperManager,
private val dataMapper: AccountProfileDataMapper,
) : AccountProfileLocalDataSource {
override fun getById(accountId: AccountId): Flow<AccountProfile?> {
return accountManager.getById(accountId)
.map { account ->
account?.let { dto ->
dataMapper.toDomain(dto.profile)
}
}
}
override suspend fun update(accountProfile: AccountProfile) {
val currentAccount = accountManager.getById(accountProfile.id)
.firstOrNull() ?: return
val accountProfile = dataMapper.toDto(accountProfile)
val updatedAccount = currentAccount.copy(
profile = accountProfile,
)
accountManager.update(updatedAccount)
}
}

View file

@ -0,0 +1,38 @@
package net.thunderbird.app.common.account.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import net.thunderbird.core.android.account.AccountManager
import net.thunderbird.core.android.account.LegacyAccountWrapper
import net.thunderbird.core.android.account.LegacyAccountWrapperManager
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.storage.legacy.mapper.DefaultLegacyAccountWrapperDataMapper
internal class DefaultLegacyAccountWrapperManager(
private val accountManager: AccountManager,
private val accountDataMapper: DefaultLegacyAccountWrapperDataMapper,
) : LegacyAccountWrapperManager {
override fun getAll(): Flow<List<LegacyAccountWrapper>> {
return accountManager.getAccountsFlow()
.map { list ->
list.map { account ->
accountDataMapper.toDomain(account)
}
}
}
override fun getById(id: AccountId): Flow<LegacyAccountWrapper?> {
return accountManager.getAccountFlow(id.asRaw()).map { account ->
account?.let {
accountDataMapper.toDomain(it)
}
}
}
override suspend fun update(account: LegacyAccountWrapper) {
accountManager.saveAccount(
accountDataMapper.toDto(account),
)
}
}

View file

@ -0,0 +1,70 @@
package net.thunderbird.app.common.core
import android.content.Context
import kotlin.time.ExperimentalTime
import net.thunderbird.app.common.core.logging.DefaultLogLevelManager
import net.thunderbird.core.common.inject.getList
import net.thunderbird.core.common.inject.singleListOf
import net.thunderbird.core.logging.DefaultLogger
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogLevelManager
import net.thunderbird.core.logging.LogLevelProvider
import net.thunderbird.core.logging.LogSink
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.logging.composite.CompositeLogSink
import net.thunderbird.core.logging.console.ConsoleLogSink
import net.thunderbird.core.logging.file.AndroidFileSystemManager
import net.thunderbird.core.logging.file.FileLogSink
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
val appCommonCoreModule: Module = module {
single<LogLevelManager> {
DefaultLogLevelManager()
}.bind<LogLevelProvider>()
singleListOf<LogSink>(
{ ConsoleLogSink(level = LogLevel.VERBOSE) },
)
single<CompositeLogSink> {
CompositeLogSink(
logLevelProvider = get(),
sinks = getList(),
)
}
single<Logger> {
@OptIn(ExperimentalTime::class)
DefaultLogger(
sink = get<CompositeLogSink>(),
)
}
single<CompositeLogSink>(named(SYNC_DEBUG_LOG)) {
CompositeLogSink(
logLevelProvider = get(),
sinks = getList(),
)
}
single<FileLogSink>(named(SYNC_DEBUG_LOG)) {
FileLogSink(
level = LogLevel.DEBUG,
fileName = "thunderbird-sync-debug",
fileLocation = get<Context>().filesDir.path,
fileSystemManager = AndroidFileSystemManager(get<Context>().contentResolver),
)
}
single<Logger>(named(SYNC_DEBUG_LOG)) {
@OptIn(ExperimentalTime::class)
DefaultLogger(
sink = get<CompositeLogSink>(named(SYNC_DEBUG_LOG)),
)
}
}
internal const val SYNC_DEBUG_LOG = "syncDebug"

View file

@ -0,0 +1,22 @@
package net.thunderbird.app.common.core.logging
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import net.thunderbird.app.common.BuildConfig
import net.thunderbird.core.logging.LogLevel
import net.thunderbird.core.logging.LogLevelManager
class DefaultLogLevelManager : LogLevelManager {
private val defaultLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
private val logLevel = MutableStateFlow(defaultLevel)
override fun override(level: LogLevel) {
logLevel.update { level }
}
override fun restoreDefault() {
override(defaultLevel)
}
override fun current(): LogLevel = logLevel.value
}

View file

@ -0,0 +1,17 @@
package net.thunderbird.app.common.feature
import android.content.Context
import app.k9mail.feature.launcher.FeatureLauncherExternalContract
import com.fsck.k9.activity.MessageList
internal class AccountSetupFinishedLauncher(
private val context: Context,
) : FeatureLauncherExternalContract.AccountSetupFinishedLauncher {
override fun launch(accountUuid: String?) {
if (accountUuid != null) {
MessageList.launch(context, accountUuid)
} else {
MessageList.launch(context)
}
}
}

View file

@ -0,0 +1,27 @@
package net.thunderbird.app.common.feature
import app.k9mail.feature.launcher.FeatureLauncherExternalContract
import app.k9mail.feature.launcher.di.featureLauncherModule
import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract
import net.thunderbird.feature.notification.impl.inject.featureNotificationModule
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
internal val appCommonFeatureModule = module {
includes(featureLauncherModule)
includes(featureNotificationModule)
factory<FeatureLauncherExternalContract.AccountSetupFinishedLauncher> {
AccountSetupFinishedLauncher(
context = androidContext(),
)
}
single<NavigationDrawerExternalContract.DrawerConfigLoader> {
NavigationDrawerConfigLoader(get())
}
single<NavigationDrawerExternalContract.DrawerConfigWriter> {
NavigationDrawerConfigWriter(get())
}
}

View file

@ -0,0 +1,22 @@
package net.thunderbird.app.common.feature
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.thunderbird.core.logging.file.FileLogSink
class LoggerLifecycleObserver(val fileLogSink: FileLogSink?) : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
fileLogSink?.let {
owner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
it.flushAndCloseBuffer()
}
}
}
}
}

View file

@ -0,0 +1,12 @@
package net.thunderbird.app.common.feature
import com.fsck.k9.preferences.DrawerConfigManager
import kotlinx.coroutines.flow.Flow
import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract
internal class NavigationDrawerConfigLoader(private val drawerConfigManager: DrawerConfigManager) :
NavigationDrawerExternalContract.DrawerConfigLoader {
override fun loadDrawerConfigFlow(): Flow<NavigationDrawerExternalContract.DrawerConfig> {
return drawerConfigManager.getConfigFlow()
}
}

View file

@ -0,0 +1,13 @@
package net.thunderbird.app.common.feature
import com.fsck.k9.preferences.DrawerConfigManager
import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract
import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract.DrawerConfig
internal class NavigationDrawerConfigWriter(
private val drawerConfigManager: DrawerConfigManager,
) : NavigationDrawerExternalContract.DrawerConfigWriter {
override fun writeDrawerConfig(drawerConfig: DrawerConfig) {
drawerConfigManager.save(drawerConfig)
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="InsecureBaseConfiguration,AcceptsUserCertificates"
>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View file

@ -0,0 +1,262 @@
package net.thunderbird.app.common.account
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import com.fsck.k9.CoreResourceProvider
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT_AUTO
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_READ_RECEIPT
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTED_TEXT_SHOWN
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_PREFIX
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_STYLE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REMOTE_SEARCH_NUM_RESULTS
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REPLY_AFTER_QUOTE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_RINGTONE_URI
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_ASCENDING
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_TYPE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_STRIP_SIGNATURE
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SYNC_INTERVAL
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_VISIBLE_LIMIT
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.UNASSIGNED_ACCOUNT_NUMBER
import net.thunderbird.core.android.account.Expunge
import net.thunderbird.core.android.account.FolderMode
import net.thunderbird.core.android.account.Identity
import net.thunderbird.core.android.account.LegacyAccount
import net.thunderbird.core.android.account.ShowPictures
import net.thunderbird.core.featureflag.FeatureFlagResult
import net.thunderbird.core.preference.storage.Storage
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationLight
import net.thunderbird.feature.notification.NotificationSettings
import net.thunderbird.feature.notification.NotificationVibration
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
class DefaultAccountDefaultsProviderTest {
@Suppress("LongMethod")
@Test
fun `applyDefaults should return default values`() {
// arrange
val resourceProvider = mock<CoreResourceProvider> {
on { defaultIdentityDescription() } doReturn "Default Identity"
}
val account = LegacyAccount(
uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b",
isSensitiveDebugLoggingEnabled = { false },
)
val identities = listOf(
Identity(
signatureUse = false,
signature = null,
description = resourceProvider.defaultIdentityDescription(),
),
)
val notificationSettings = NotificationSettings(
isRingEnabled = true,
ringtone = DEFAULT_RINGTONE_URI,
light = NotificationLight.Disabled,
vibration = NotificationVibration.DEFAULT,
)
val testSubject = DefaultAccountDefaultsProvider(
resourceProvider = resourceProvider,
featureFlagProvider = {
FeatureFlagResult.Disabled
},
)
// act
testSubject.applyDefaults(account)
// assert
assertThat(account.automaticCheckIntervalMinutes).isEqualTo(DEFAULT_SYNC_INTERVAL)
assertThat(account.idleRefreshMinutes).isEqualTo(24)
assertThat(account.displayCount).isEqualTo(DEFAULT_VISIBLE_LIMIT)
assertThat(account.accountNumber).isEqualTo(UNASSIGNED_ACCOUNT_NUMBER)
assertThat(account.isNotifyNewMail).isTrue()
assertThat(account.folderNotifyNewMailMode).isEqualTo(FolderMode.ALL)
assertThat(account.isNotifySync).isFalse()
assertThat(account.isNotifySelfNewMail).isTrue()
assertThat(account.isNotifyContactsMailOnly).isFalse()
assertThat(account.isIgnoreChatMessages).isFalse()
assertThat(account.messagesNotificationChannelVersion).isEqualTo(0)
assertThat(account.folderDisplayMode).isEqualTo(FolderMode.NOT_SECOND_CLASS)
assertThat(account.folderSyncMode).isEqualTo(FolderMode.FIRST_CLASS)
assertThat(account.folderPushMode).isEqualTo(FolderMode.NONE)
assertThat(account.sortType).isEqualTo(DEFAULT_SORT_TYPE)
assertThat(account.isSortAscending(DEFAULT_SORT_TYPE)).isEqualTo(DEFAULT_SORT_ASCENDING)
assertThat(account.showPictures).isEqualTo(ShowPictures.NEVER)
assertThat(account.isSignatureBeforeQuotedText).isFalse()
assertThat(account.expungePolicy).isEqualTo(Expunge.EXPUNGE_IMMEDIATELY)
assertThat(account.importedAutoExpandFolder).isNull()
assertThat(account.legacyInboxFolder).isNull()
assertThat(account.maxPushFolders).isEqualTo(10)
assertThat(account.isSubscribedFoldersOnly).isFalse()
assertThat(account.maximumPolledMessageAge).isEqualTo(-1)
assertThat(account.maximumAutoDownloadMessageSize).isEqualTo(DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE)
assertThat(account.messageFormat).isEqualTo(DEFAULT_MESSAGE_FORMAT)
assertThat(account.isMessageFormatAuto).isEqualTo(DEFAULT_MESSAGE_FORMAT_AUTO)
assertThat(account.isMessageReadReceipt).isEqualTo(DEFAULT_MESSAGE_READ_RECEIPT)
assertThat(account.quoteStyle).isEqualTo(DEFAULT_QUOTE_STYLE)
assertThat(account.quotePrefix).isEqualTo(DEFAULT_QUOTE_PREFIX)
assertThat(account.isDefaultQuotedTextShown).isEqualTo(DEFAULT_QUOTED_TEXT_SHOWN)
assertThat(account.isReplyAfterQuote).isEqualTo(DEFAULT_REPLY_AFTER_QUOTE)
assertThat(account.isStripSignature).isEqualTo(DEFAULT_STRIP_SIGNATURE)
assertThat(account.isSyncRemoteDeletions).isTrue()
assertThat(account.openPgpKey).isEqualTo(NO_OPENPGP_KEY)
assertThat(account.isRemoteSearchFullText).isFalse()
assertThat(account.remoteSearchNumResults).isEqualTo(DEFAULT_REMOTE_SEARCH_NUM_RESULTS)
assertThat(account.isUploadSentMessages).isTrue()
assertThat(account.isMarkMessageAsReadOnView).isTrue()
assertThat(account.isMarkMessageAsReadOnDelete).isTrue()
assertThat(account.isAlwaysShowCcBcc).isFalse()
assertThat(account.lastSyncTime).isEqualTo(0L)
assertThat(account.lastFolderListRefreshTime).isEqualTo(0L)
assertThat(account.archiveFolderId).isNull()
assertThat(account.archiveFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC)
assertThat(account.draftsFolderId).isNull()
assertThat(account.draftsFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC)
assertThat(account.sentFolderId).isNull()
assertThat(account.sentFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC)
assertThat(account.spamFolderId).isNull()
assertThat(account.spamFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC)
assertThat(account.trashFolderId).isNull()
assertThat(account.trashFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC)
assertThat(account.archiveFolderId).isNull()
assertThat(account.archiveFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC)
assertThat(account.identities).isEqualTo(identities)
assertThat(account.notificationSettings).isEqualTo(notificationSettings)
assertThat(account.isChangedVisibleLimits).isFalse()
}
@Test
fun `applyOverwrites should return patched account when disabled`() {
// arrange
val resourceProvider = mock<CoreResourceProvider> {
on { defaultIdentityDescription() } doReturn "Default Identity"
}
val account = LegacyAccount(
uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b",
isSensitiveDebugLoggingEnabled = { false },
)
val storage = mock<Storage> {
on { contains("${account.uuid}.notifyNewMail") } doReturn false
on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false
on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false
}
val testSubject = DefaultAccountDefaultsProvider(
resourceProvider = resourceProvider,
featureFlagProvider = {
FeatureFlagResult.Disabled
},
)
// act
testSubject.applyOverwrites(account, storage)
// assert
assertThat(account.isNotifyNewMail).isFalse()
assertThat(account.isNotifySelfNewMail).isFalse()
}
@Test
fun `applyOverwrites should return patched account when enabled`() {
// arrange
val resourceProvider = mock<CoreResourceProvider> {
on { defaultIdentityDescription() } doReturn "Default Identity"
}
val account = LegacyAccount(
uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b",
isSensitiveDebugLoggingEnabled = { false },
)
val storage = mock<Storage> {
on { contains("${account.uuid}.notifyNewMail") } doReturn false
on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false
on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false
}
val testSubject = DefaultAccountDefaultsProvider(
resourceProvider = resourceProvider,
featureFlagProvider = {
FeatureFlagResult.Enabled
},
)
// act
testSubject.applyOverwrites(account, storage)
// assert
assertThat(account.isNotifyNewMail).isTrue()
assertThat(account.isNotifySelfNewMail).isTrue()
}
@Suppress("MaxLineLength")
@Test
fun `applyOverwrites updates account notification values from storage when storage contains isNotifyNewMail value`() {
// arrange
val resourceProvider = mock<CoreResourceProvider> {
on { defaultIdentityDescription() } doReturn "Default Identity"
}
val account = LegacyAccount(
uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b",
isSensitiveDebugLoggingEnabled = { false },
)
val storage = mock<Storage> {
on { contains("${account.uuid}.notifyNewMail") } doReturn true
on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false
on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false
}
val testSubject = DefaultAccountDefaultsProvider(
resourceProvider = resourceProvider,
featureFlagProvider = {
FeatureFlagResult.Enabled
},
)
// act
testSubject.applyOverwrites(account, storage)
// assert
assertThat(account.isNotifyNewMail).isFalse()
assertThat(account.isNotifySelfNewMail).isFalse()
}
@Suppress("MaxLineLength")
@Test
fun `applyOverwrites updates account notification values from featureFlag values when storage does not contain isNotifyNewMail value`() {
// arrange
val resourceProvider = mock<CoreResourceProvider> {
on { defaultIdentityDescription() } doReturn "Default Identity"
}
val account = LegacyAccount(
uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b",
isSensitiveDebugLoggingEnabled = { false },
)
val storage = mock<Storage> {
on { contains("${account.uuid}.notifyNewMail") } doReturn false
on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false
on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false
}
val testSubject = DefaultAccountDefaultsProvider(
resourceProvider = resourceProvider,
featureFlagProvider = {
FeatureFlagResult.Enabled
},
)
// act
testSubject.applyOverwrites(account, storage)
// assert
assertThat(account.isNotifyNewMail).isTrue()
assertThat(account.isNotifySelfNewMail).isTrue()
}
}

View file

@ -0,0 +1,158 @@
package net.thunderbird.app.common.account.data
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import kotlinx.coroutines.test.runTest
import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_COLOR
import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_NAME
import net.thunderbird.core.android.account.Identity
import net.thunderbird.core.android.account.LegacyAccountWrapper
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.profile.AccountAvatar
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountAvatarDataMapper
import net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountProfileDataMapper
import net.thunderbird.feature.account.storage.profile.AvatarDto
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
import net.thunderbird.feature.account.storage.profile.ProfileDto
import org.junit.Test
class DefaultAccountProfileLocalDataSourceTest {
@Test
fun `getById should return account profile`() = runTest {
// arrange
val accountId = AccountIdFactory.create()
val legacyAccount = createLegacyAccount(accountId)
val accountProfile = createAccountProfile(accountId)
val testSubject = createTestSubject(legacyAccount)
// act & assert
testSubject.getById(accountId).test {
assertThat(awaitItem()).isEqualTo(accountProfile)
}
}
@Test
fun `getById should return null when account is not found`() = runTest {
// arrange
val accountId = AccountIdFactory.create()
val testSubject = createTestSubject(null)
// act & assert
testSubject.getById(accountId).test {
assertThat(awaitItem()).isEqualTo(null)
}
}
@Test
fun `update should save account profile`() = runTest {
// arrange
val accountId = AccountIdFactory.create()
val legacyAccount = createLegacyAccount(accountId)
val accountProfile = createAccountProfile(accountId)
val updatedName = "updatedName"
val updatedAccountProfile = accountProfile.copy(name = updatedName)
val testSubject = createTestSubject(legacyAccount)
// act & assert
testSubject.getById(accountId).test {
assertThat(awaitItem()).isEqualTo(accountProfile)
testSubject.update(updatedAccountProfile)
assertThat(awaitItem()).isEqualTo(updatedAccountProfile)
}
}
private companion object Companion {
fun createLegacyAccount(
id: AccountId,
displayName: String = PROFILE_NAME,
color: Int = PROFILE_COLOR,
): LegacyAccountWrapper {
return LegacyAccountWrapper(
isSensitiveDebugLoggingEnabled = { true },
id = id,
name = displayName,
email = "demo@example.com",
profile = ProfileDto(
id = id,
name = displayName,
color = color,
avatar = AvatarDto(
avatarType = AvatarTypeDto.ICON,
avatarMonogram = null,
avatarImageUri = null,
avatarIconName = "star",
),
),
identities = listOf(
Identity(
signatureUse = false,
description = "Demo User",
),
),
incomingServerSettings = ServerSettings(
type = "imap",
host = "imap.example.com",
port = 993,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "test",
password = "password",
clientCertificateAlias = null,
),
outgoingServerSettings = ServerSettings(
type = "smtp",
host = "smtp.example.com",
port = 465,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "test",
password = "password",
clientCertificateAlias = null,
),
)
}
private fun createAccountProfile(
accountId: AccountId,
name: String = PROFILE_NAME,
color: Int = PROFILE_COLOR,
): AccountProfile {
return AccountProfile(
id = accountId,
name = name,
color = color,
avatar = AccountAvatar.Icon(
name = "star",
),
)
}
private fun createTestSubject(
legacyAccount: LegacyAccountWrapper?,
): DefaultAccountProfileLocalDataSource {
return DefaultAccountProfileLocalDataSource(
accountManager = FakeLegacyAccountWrapperManager(
initialAccounts = if (legacyAccount != null) {
listOf(legacyAccount)
} else {
emptyList()
},
),
dataMapper = DefaultAccountProfileDataMapper(
avatarMapper = DefaultAccountAvatarDataMapper(),
),
)
}
}
}

View file

@ -0,0 +1,36 @@
package net.thunderbird.app.common.account.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import net.thunderbird.core.android.account.LegacyAccountWrapper
import net.thunderbird.core.android.account.LegacyAccountWrapperManager
import net.thunderbird.feature.account.AccountId
internal class FakeLegacyAccountWrapperManager(
initialAccounts: List<LegacyAccountWrapper> = emptyList(),
) : LegacyAccountWrapperManager {
private val accountsState = MutableStateFlow(
initialAccounts,
)
private val accounts: StateFlow<List<LegacyAccountWrapper>> = accountsState
override fun getAll(): Flow<List<LegacyAccountWrapper>> = accounts
override fun getById(id: AccountId): Flow<LegacyAccountWrapper?> = accounts
.map { list ->
list.find { it.id == id }
}
override suspend fun update(account: LegacyAccountWrapper) {
accountsState.update { currentList ->
currentList.toMutableList().apply {
removeIf { it.uuid == account.uuid }
add(account)
}
}
}
}