diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt index 3018387a95d..5ed1a56b149 100644 --- a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt @@ -12,6 +12,7 @@ import net.thunderbird.feature.account.AccountIdFactory import net.thunderbird.feature.account.storage.profile.AvatarDto import net.thunderbird.feature.account.storage.profile.AvatarTypeDto import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection import net.thunderbird.feature.notification.NotificationSettings @@ -199,6 +200,9 @@ open class LegacyAccountDto( @get:Synchronized var archiveFolderSelection = SpecialFolderSelection.AUTOMATIC + @get:Synchronized + var archiveGranularity = ArchiveGranularity.DEFAULT + @get:Synchronized var spamFolderSelection = SpecialFolderSelection.AUTOMATIC diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt index 8c8d0488a16..e566e795261 100644 --- a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt @@ -16,6 +16,7 @@ import net.thunderbird.core.preference.storage.StorageEditor import net.thunderbird.core.preference.storage.getEnumOrDefault import net.thunderbird.feature.account.AccountId import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection import net.thunderbird.feature.notification.NotificationLight @@ -115,6 +116,12 @@ class LegacyAccountStorageHandler( ) setArchiveFolderId(archiveFolderId, archiveFolderSelection) + archiveGranularity = getEnumStringPref( + storage, + keyGen.create(ARCHIVE_GRANULARITY_KEY), + ArchiveGranularity.DEFAULT, + ) + val spamFolderId = storage.getStringOrNull(keyGen.create("spamFolderId"))?.toLongOrNull() val spamFolderSelection = getEnumStringPref( storage, @@ -354,6 +361,7 @@ class LegacyAccountStorageHandler( editor.putString(keyGen.create("archiveFolderId"), archiveFolderId?.toString()) editor.putString(keyGen.create("spamFolderId"), spamFolderId?.toString()) editor.putString(keyGen.create("archiveFolderSelection"), archiveFolderSelection.name) + editor.putString(keyGen.create(ARCHIVE_GRANULARITY_KEY), archiveGranularity.name) editor.putString(keyGen.create("draftsFolderSelection"), draftsFolderSelection.name) editor.putString(keyGen.create("sentFolderSelection"), sentFolderSelection.name) editor.putString(keyGen.create("spamFolderSelection"), spamFolderSelection.name) @@ -470,6 +478,7 @@ class LegacyAccountStorageHandler( editor.remove(keyGen.create("archiveFolderName")) editor.remove(keyGen.create("spamFolderName")) editor.remove(keyGen.create("archiveFolderSelection")) + editor.remove(keyGen.create(ARCHIVE_GRANULARITY_KEY)) editor.remove(keyGen.create("draftsFolderSelection")) editor.remove(keyGen.create("sentFolderSelection")) editor.remove(keyGen.create("spamFolderSelection")) @@ -607,5 +616,6 @@ class LegacyAccountStorageHandler( const val IDENTITY_DESCRIPTION_KEY = "description" const val FOLDER_PATH_DELIMITER_KEY = "folderPathDelimiter" + const val ARCHIVE_GRANULARITY_KEY = "archiveGranularity" } } diff --git a/feature/mail/folder/api/src/commonMain/kotlin/net/thunderbird/feature/mail/folder/api/ArchiveGranularity.kt b/feature/mail/folder/api/src/commonMain/kotlin/net/thunderbird/feature/mail/folder/api/ArchiveGranularity.kt new file mode 100644 index 00000000000..f0780af5fe8 --- /dev/null +++ b/feature/mail/folder/api/src/commonMain/kotlin/net/thunderbird/feature/mail/folder/api/ArchiveGranularity.kt @@ -0,0 +1,22 @@ +package net.thunderbird.feature.mail.folder.api + +enum class ArchiveGranularity { + SINGLE_ARCHIVE_FOLDER, + PER_YEAR_ARCHIVE_FOLDERS, + PER_MONTH_ARCHIVE_FOLDERS, + ; + + companion object { + /** + * Default archive granularity for new accounts. + * Matches Thunderbird Desktop default (value 1 = yearly). + */ + val DEFAULT = PER_YEAR_ARCHIVE_FOLDERS + + /** + * Default for existing accounts during migration. + * Maintains backward compatibility with current single folder behavior. + */ + val MIGRATION_DEFAULT = SINGLE_ARCHIVE_FOLDER + } +} diff --git a/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index 00192187d71..b7e684a40a1 100644 --- a/legacy/core/build.gradle.kts +++ b/legacy/core/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.moshi) implementation(libs.timber) + implementation(libs.kotlinx.datetime) implementation(libs.mime4j.core) implementation(libs.mime4j.dom) implementation(libs.uri) diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderCreator.kt b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderCreator.kt new file mode 100644 index 00000000000..d1c46273560 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderCreator.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.controller + +import com.fsck.k9.backend.api.FolderInfo +import net.thunderbird.core.android.account.LegacyAccountDto + +internal interface ArchiveFolderCreator { + fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? +} diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt new file mode 100644 index 00000000000..658892921cf --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -0,0 +1,80 @@ +package com.fsck.k9.controller + +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.mailstore.LocalMessage +import java.util.Locale +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity +import com.fsck.k9.mail.FolderType as LegacyFolderType + +internal class ArchiveFolderResolver( + private val folderIdResolver: FolderIdResolver, + private val folderCreator: ArchiveFolderCreator, +) { + + fun resolveArchiveFolder( + account: LegacyAccountDto, + message: LocalMessage, + ): Long? { + val baseFolderId = account.archiveFolderId ?: return null + + return when (account.archiveGranularity) { + ArchiveGranularity.SINGLE_ARCHIVE_FOLDER -> { + baseFolderId + } + + ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS -> { + val year = message.messageDate.year + findOrCreateSubfolder(account, baseFolderId, year.toString()) ?: baseFolderId + } + + ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { + val date = message.messageDate + val year = date.year + val month = String.format(Locale.ROOT, "%02d", date.monthNumber) + + findOrCreateSubfolder(account, baseFolderId, year.toString())?.let { yearFolderId -> + findOrCreateSubfolder(account, yearFolderId, month) + } ?: baseFolderId + } + } + } + + private fun findOrCreateSubfolder( + account: LegacyAccountDto, + parentFolderId: Long, + subfolderName: String, + ): Long? { + val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null + + val delimiter = account.folderPathDelimiter + val subfolderServerId = "$parentServerId$delimiter$subfolderName" + + val existingId = folderIdResolver.getFolderId(account, subfolderServerId) + return if (existingId != null) { + existingId + } else { + val folderInfo = FolderInfo( + serverId = subfolderServerId, + name = subfolderServerId, + type = LegacyFolderType.ARCHIVE, + folderPathDelimiter = delimiter, + ) + folderCreator.createFolder(account, folderInfo) + } + } + + private val LocalMessage.messageDate: LocalDate + get() { + val epochMillis = (internalDate ?: sentDate)?.time + val timeZone = TimeZone.currentSystemDefault() + val instant = epochMillis?.let { Instant.fromEpochMilliseconds(it) } + ?: Clock.System.now() + return instant.toLocalDateTime(timeZone).date + } +} diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveOperations.kt b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveOperations.kt index 4ac43b3a105..0017a1d7b14 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveOperations.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveOperations.kt @@ -13,16 +13,17 @@ import net.thunderbird.core.logging.legacy.Log internal class ArchiveOperations( private val messagingController: MessagingController, private val featureFlagProvider: FeatureFlagProvider, + private val archiveFolderResolver: ArchiveFolderResolver, ) { fun archiveThreads(messages: List) { - archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder, archiveFolderId -> - archiveThreads(account, folderId, messagesInFolder, archiveFolderId) + archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder -> + archiveThreads(account, folderId, messagesInFolder) } } fun archiveMessages(messages: List) { - archiveByFolder("archiveMessages", messages) { account, folderId, messagesInFolder, archiveFolderId -> - archiveMessages(account, folderId, messagesInFolder, archiveFolderId) + archiveByFolder("archiveMessages", messages) { account, folderId, messagesInFolder -> + archiveMessages(account, folderId, messagesInFolder) } } @@ -37,7 +38,6 @@ internal class ArchiveOperations( account: LegacyAccountDto, folderId: Long, messagesInFolder: List, - archiveFolderId: Long, ) -> Unit, ) { actOnMessagesGroupedByAccountAndFolder(messages) { account, messageFolder, messagesInFolder -> @@ -54,7 +54,7 @@ internal class ArchiveOperations( else -> { messagingController.suppressMessages(account, messagesInFolder) messagingController.putBackground(description, null) { - action(account, sourceFolderId, messagesInFolder, archiveFolderId) + action(account, sourceFolderId, messagesInFolder) } } } @@ -65,30 +65,37 @@ internal class ArchiveOperations( account: LegacyAccountDto, sourceFolderId: Long, messages: List, - archiveFolderId: Long, ) { val messagesInThreads = messagingController.collectMessagesInThreads(account, messages) - archiveMessages(account, sourceFolderId, messagesInThreads, archiveFolderId) + archiveMessages(account, sourceFolderId, messagesInThreads) } private fun archiveMessages( account: LegacyAccountDto, sourceFolderId: Long, messages: List, - archiveFolderId: Long, ) { + val messagesByDestination = messages.groupBy { message -> + archiveFolderResolver.resolveArchiveFolder(account, message) + } + val operation = featureFlagProvider.provide("archive_marks_as_read".toFeatureFlagKey()) .whenEnabledOrNot( onEnabled = { MoveOrCopyFlavor.MOVE_AND_MARK_AS_READ }, onDisabledOrUnavailable = { MoveOrCopyFlavor.MOVE }, ) - messagingController.moveOrCopyMessageSynchronous( - account, - sourceFolderId, - messages, - archiveFolderId, - operation, - ) + + for ((destinationFolderId, messagesForFolder) in messagesByDestination) { + if (destinationFolderId != null) { + messagingController.moveOrCopyMessageSynchronous( + account, + sourceFolderId, + messagesForFolder, + destinationFolderId, + operation, + ) + } + } } private fun actOnMessagesGroupedByAccountAndFolder( diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt new file mode 100644 index 00000000000..8f935e71200 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt @@ -0,0 +1,25 @@ +package com.fsck.k9.controller + +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.createFolder +import com.fsck.k9.backend.api.updateFolders +import net.thunderbird.backend.api.BackendStorageFactory +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.logging.legacy.Log + +internal class BackendStorageArchiveFolderCreator( + private val backendStorageFactory: BackendStorageFactory, +) : ArchiveFolderCreator { + @Suppress("TooGenericExceptionCaught") + override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { + return try { + val backendStorage = backendStorageFactory.createBackendStorage(account.id) + backendStorage.updateFolders { + createFolder(folderInfo) + } + } catch (e: Exception) { + Log.e(e, "Failed to create archive subfolder: ${folderInfo.serverId}") + null + } + } +} diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/FolderIdResolver.kt b/legacy/core/src/main/java/com/fsck/k9/controller/FolderIdResolver.kt new file mode 100644 index 00000000000..3bd0e86db00 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/FolderIdResolver.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.controller + +import net.thunderbird.core.android.account.LegacyAccountDto + +internal interface FolderIdResolver { + fun getFolderServerId(account: LegacyAccountDto, folderId: Long): String? + fun getFolderId(account: LegacyAccountDto, folderServerId: String): Long? +} diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt index ef4286c3ff1..5d26eb6f8a3 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt @@ -11,6 +11,7 @@ import com.fsck.k9.mailstore.SaveMessageDataCreator import com.fsck.k9.mailstore.SpecialLocalFoldersCreator import com.fsck.k9.notification.NotificationController import com.fsck.k9.notification.NotificationStrategy +import net.thunderbird.backend.api.BackendStorageFactory import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.logging.Logger import net.thunderbird.feature.mail.folder.api.OutboxFolderManager @@ -21,6 +22,7 @@ import org.koin.dsl.binds import org.koin.dsl.module val controllerModule = module { + single { MessageStoreFolderIdResolver(messageStoreManager = get()) } single { MessagingController( get(), @@ -38,6 +40,8 @@ val controllerModule = module { get(named("syncDebug")), get(), get(), + get(), + get(), ) } binds arrayOf(MessagingControllerRegistry::class) diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/MessageStoreFolderIdResolver.kt b/legacy/core/src/main/java/com/fsck/k9/controller/MessageStoreFolderIdResolver.kt new file mode 100644 index 00000000000..456a87c4344 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/MessageStoreFolderIdResolver.kt @@ -0,0 +1,16 @@ +package com.fsck.k9.controller + +import app.k9mail.legacy.mailstore.MessageStoreManager +import net.thunderbird.core.android.account.LegacyAccountDto + +internal class MessageStoreFolderIdResolver( + private val messageStoreManager: MessageStoreManager, +) : FolderIdResolver { + override fun getFolderServerId(account: LegacyAccountDto, folderId: Long): String? { + return messageStoreManager.getMessageStore(account).getFolderServerId(folderId) + } + + override fun getFolderId(account: LegacyAccountDto, folderServerId: String): Long? { + return messageStoreManager.getMessageStore(account).getFolderId(folderServerId) + } +} diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java index d5a86f5b038..df5a1deb0d6 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -62,6 +62,7 @@ import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; import com.fsck.k9.mail.FetchProfile; +import net.thunderbird.backend.api.BackendStorageFactory; import net.thunderbird.core.common.mail.Flag; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessageDownloadState; @@ -135,6 +136,7 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final SaveMessageDataCreator saveMessageDataCreator; private final SpecialLocalFoldersCreator specialLocalFoldersCreator; private final LocalDeleteOperationDecider localDeleteOperationDecider; + private final BackendStorageFactory backendStorageFactory; private final Thread controllerThread; @@ -150,6 +152,7 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final OutboxFolderManager outboxFolderManager; private final NotificationSenderCompat notificationSender; private final NotificationDismisserCompat notificationDismisser; + private final FolderIdResolver folderIdResolver; private volatile boolean stopped = false; @@ -174,7 +177,9 @@ public static MessagingController getInstance(Context context) { FeatureFlagProvider featureFlagProvider, Logger syncDebugLogger, NotificationManager notificationManager, - OutboxFolderManager outboxFolderManager + OutboxFolderManager outboxFolderManager, + BackendStorageFactory backendStorageFactory, + FolderIdResolver folderIdResolver ) { this.context = context; this.notificationController = notificationController; @@ -191,6 +196,8 @@ public static MessagingController getInstance(Context context) { this.notificationSender = new NotificationSenderCompat(notificationManager); this.notificationDismisser = new NotificationDismisserCompat(notificationManager); this.outboxFolderManager = outboxFolderManager; + this.backendStorageFactory = backendStorageFactory; + this.folderIdResolver = folderIdResolver; controllerThread = new Thread(new Runnable() { @Override @@ -206,7 +213,14 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); - archiveOperations = new ArchiveOperations(this, featureFlagProvider); + archiveOperations = new ArchiveOperations( + this, + featureFlagProvider, + new ArchiveFolderResolver( + folderIdResolver, + new BackendStorageArchiveFolderCreator(backendStorageFactory) + ) + ); } private void initializeControllerExtensions(List controllerExtensions) { @@ -306,9 +320,9 @@ LocalStore getLocalStoreOrThrow(LegacyAccountDto account) { } } + @NonNull private String getFolderServerId(LegacyAccountDto account, long folderId) { - MessageStore messageStore = messageStoreManager.getMessageStore(account); - String folderServerId = messageStore.getFolderServerId(folderId); + final String folderServerId = folderIdResolver.getFolderServerId(account, folderId); if (folderServerId == null) { throw new IllegalStateException("Folder not found (ID: " + folderId + ")"); } @@ -316,8 +330,7 @@ private String getFolderServerId(LegacyAccountDto account, long folderId) { } private long getFolderId(LegacyAccountDto account, String folderServerId) { - MessageStore messageStore = messageStoreManager.getMessageStore(account); - Long folderId = messageStore.getFolderId(folderServerId); + final Long folderId = folderIdResolver.getFolderId(account, folderServerId); if (folderId == null) { throw new IllegalStateException("Folder not found (server ID: " + folderServerId + ")"); } diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt new file mode 100644 index 00000000000..bce81e60d3a --- /dev/null +++ b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt @@ -0,0 +1,312 @@ +package com.fsck.k9.controller + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.mailstore.LocalMessage +import java.util.Calendar +import java.util.Date +import java.util.UUID +import kotlin.test.BeforeTest +import kotlin.test.Test +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ArchiveFolderResolverTest { + @BeforeTest + fun setup() { + Log.logger = mock() + } + + private val account = LegacyAccountDto(UUID.randomUUID().toString()).apply { + archiveFolderId = BASE_ARCHIVE_FOLDER_ID + archiveGranularity = ArchiveGranularity.DEFAULT + } + + @Test + fun `resolve to single folder when granularity is SINGLE_ARCHIVE_FOLDER`() { + account.archiveGranularity = ArchiveGranularity.SINGLE_ARCHIVE_FOLDER + val message = createMessage(year = 2025, month = 11) + + val testSubject = createResolver() + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) + } + + @Test + fun `resolve to yearly folder when granularity is PER_YEAR_ARCHIVE_FOLDERS`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to null), + ) + val archiveFolderCreator = FakeArchiveFolderCreator( + createdFolderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), + ) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + assertThat(archiveFolderCreator.createdFolders[0].name).isEqualTo("Archive/2025") + } + + @Test + fun `resolve to monthly folder when granularity is PER_MONTH_ARCHIVE_FOLDERS`() { + account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf( + BASE_ARCHIVE_FOLDER_ID to "Archive", + YEARLY_FOLDER_ID to "Archive/2025", + ), + folderIds = mapOf( + "Archive/2025" to null, + "Archive/2025/11" to null, + ), + ) + val archiveFolderCreator = FakeArchiveFolderCreator( + createdFolderIds = mapOf( + "Archive/2025" to YEARLY_FOLDER_ID, + "Archive/2025/11" to MONTHLY_FOLDER_ID, + ), + ) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) + } + + @Test + fun `return null when archive folder is not configured`() { + account.archiveFolderId = null + val message = createMessage(year = 2025, month = 11) + + val testSubject = createResolver() + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isNull() + } + + @Test + fun `reuse existing yearly folder instead of creating new one`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + } + + @Test + fun `reuse existing monthly folder instead of creating new one`() { + account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf( + BASE_ARCHIVE_FOLDER_ID to "Archive", + YEARLY_FOLDER_ID to "Archive/2025", + ), + folderIds = mapOf( + "Archive/2025" to YEARLY_FOLDER_ID, + "Archive/2025/11" to MONTHLY_FOLDER_ID, + ), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) + } + + @Test + fun `use internal date when available`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + whenever(message.internalDate).thenReturn(createDate(2024, 5)) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2024" to YEARLY_FOLDER_ID), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + } + + @Test + fun `fall back to sent date when internal date is null`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11, useInternalDate = false) + whenever(message.internalDate).thenReturn(null) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + } + + @Test + fun `format month with leading zero for single digit months`() { + account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 3) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf( + BASE_ARCHIVE_FOLDER_ID to "Archive", + YEARLY_FOLDER_ID to "Archive/2025", + ), + folderIds = mapOf( + "Archive/2025" to YEARLY_FOLDER_ID, + "Archive/2025/03" to MONTHLY_FOLDER_ID, + ), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) + } + + @Test + fun `fall back to base folder when yearly subfolder creation fails`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to null), + ) + val archiveFolderCreator = FakeArchiveFolderCreator( + failAfterCalls = 0, + ) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) + } + + @Test + fun `fall back to base folder when yearly succeeds but monthly creation fails`() { + account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf( + BASE_ARCHIVE_FOLDER_ID to "Archive", + YEARLY_FOLDER_ID to "Archive/2025", + ), + folderIds = mapOf( + "Archive/2025" to null, + "Archive/2025/11" to null, + ), + ) + val archiveFolderCreator = FakeArchiveFolderCreator( + createdFolderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), + failAfterCalls = 1, + ) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) + } + + @Test + fun `use current date when both internal and sent dates are null`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = mock().apply { + whenever(internalDate).thenReturn(null) + whenever(sentDate).thenReturn(null) + } + + val currentYear = Calendar.getInstance().get(Calendar.YEAR).toString() + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/$currentYear" to YEARLY_FOLDER_ID), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + } + + @Test + fun `use account folder delimiter for subfolder paths`() { + val accountWithDotDelimiter = LegacyAccountDto(UUID.randomUUID().toString()).apply { + archiveFolderId = BASE_ARCHIVE_FOLDER_ID + archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + folderPathDelimiter = "." + } + val message = createMessage(year = 2025, month = 11) + + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive.2025" to YEARLY_FOLDER_ID), + ) + val testSubject = createResolver(folderIdResolver) + + val result = testSubject.resolveArchiveFolder(accountWithDotDelimiter, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + } + + private fun createResolver( + folderIdResolver: FolderIdResolver = FakeFolderIdResolver(), + archiveFolderCreator: ArchiveFolderCreator = FakeArchiveFolderCreator(), + ): ArchiveFolderResolver { + return ArchiveFolderResolver(folderIdResolver, archiveFolderCreator) + } + + private fun createMessage(year: Int, month: Int, useInternalDate: Boolean = true): LocalMessage { + return mock().apply { + whenever(sentDate).thenReturn(createDate(year, month)) + if (useInternalDate) { + whenever(internalDate).thenReturn(createDate(year, month)) + } + } + } + + private fun createDate(year: Int, month: Int): Date { + return Calendar.getInstance().apply { + set(Calendar.YEAR, year) + set(Calendar.MONTH, month - 1) + set(Calendar.DAY_OF_MONTH, 15) + set(Calendar.HOUR_OF_DAY, 12) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.time + } + + companion object { + private const val BASE_ARCHIVE_FOLDER_ID = 100L + private const val YEARLY_FOLDER_ID = 200L + private const val MONTHLY_FOLDER_ID = 300L + } +} diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt b/legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt new file mode 100644 index 00000000000..67d0eccd17f --- /dev/null +++ b/legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.controller + +import com.fsck.k9.backend.api.FolderInfo +import net.thunderbird.core.android.account.LegacyAccountDto + +internal class FakeArchiveFolderCreator( + private val createdFolderIds: Map = emptyMap(), + private val failAfterCalls: Int = Int.MAX_VALUE, +) : ArchiveFolderCreator { + private var callCount = 0 + val createdFolders = mutableListOf() + + override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { + callCount++ + createdFolders.add(folderInfo) + if (callCount > failAfterCalls) { + return null + } + return createdFolderIds[folderInfo.serverId] + } +} diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/FakeFolderIdResolver.kt b/legacy/core/src/test/java/com/fsck/k9/controller/FakeFolderIdResolver.kt new file mode 100644 index 00000000000..fc8e1ec494d --- /dev/null +++ b/legacy/core/src/test/java/com/fsck/k9/controller/FakeFolderIdResolver.kt @@ -0,0 +1,16 @@ +package com.fsck.k9.controller + +import net.thunderbird.core.android.account.LegacyAccountDto + +internal class FakeFolderIdResolver( + private val folderServerIds: Map = emptyMap(), + private val folderIds: Map = emptyMap(), +) : FolderIdResolver { + override fun getFolderServerId(account: LegacyAccountDto, folderId: Long): String? { + return folderServerIds[folderId] + } + + override fun getFolderId(account: LegacyAccountDto, folderServerId: String): Long? { + return folderIds[folderServerId] + } +} diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index 4d8d124014a..22071cf564e 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java +++ b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -7,6 +7,8 @@ import java.util.Set; import android.content.Context; + +import net.thunderbird.backend.api.BackendStorageFactory; import net.thunderbird.core.android.account.LegacyAccountDto; import net.thunderbird.core.featureflag.FeatureFlagProvider; import net.thunderbird.core.featureflag.FeatureFlagResult.Disabled; @@ -94,6 +96,8 @@ public class MessagingControllerTest extends K9RobolectricTest { @Mock private SpecialLocalFoldersCreator specialLocalFoldersCreator; @Mock + private BackendStorageFactory backendStorageFactory; + @Mock private SimpleMessagingListener listener; @Mock private LocalFolder localFolder; @@ -122,6 +126,8 @@ public class MessagingControllerTest extends K9RobolectricTest { private Preferences preferences; private String accountUuid; private FeatureFlagProvider featureFlagProvider; + @Mock + private FolderIdResolver folderIdResolver; @Mock private Logger syncLogger; @@ -159,7 +165,9 @@ public void setUp() throws MessagingException { featureFlagProvider, syncLogger, notificationManager, - fakeOutboxFolderManager + fakeOutboxFolderManager, + backendStorageFactory, + folderIdResolver ); configureAccount(); diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo30.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo30.kt new file mode 100644 index 00000000000..b9325c5f2f0 --- /dev/null +++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo30.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.preferences.migration + +import android.database.sqlite.SQLiteDatabase +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity + +class StorageMigrationTo30( + private val db: SQLiteDatabase, + private val migrationsHelper: StorageMigrationHelper, +) { + fun setDefaultArchiveGranularity() { + val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids") + if (accountUuidsValue.isNullOrEmpty()) { + return + } + + val accountUuids = accountUuidsValue.split(",") + for (accountUuid in accountUuids) { + setArchiveGranularityForAccount(accountUuid) + } + } + + private fun setArchiveGranularityForAccount(accountUuid: String) { + val existingValue = migrationsHelper.readValue(db, "$accountUuid.$ARCHIVE_GRANULARITY_KEY") + if (existingValue == null) { + migrationsHelper.insertValue( + db, + "$accountUuid.$ARCHIVE_GRANULARITY_KEY", + ArchiveGranularity.MIGRATION_DEFAULT.name, + ) + } + } + + private companion object { + const val ARCHIVE_GRANULARITY_KEY = "archiveGranularity" + } +} diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt index b47081eb249..7d3a9793e77 100644 --- a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt +++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt @@ -36,5 +36,6 @@ internal object StorageMigrations { if (oldVersion < 27) StorageMigrationTo27(db, migrationsHelper).addAvatarMonogram() if (oldVersion < 28) StorageMigrationTo28(db, migrationsHelper).ensureAvatarSet() if (oldVersion < 29) StorageMigrationTo29(db, migrationsHelper).renameAutoSelectFolderPreference() + if (oldVersion < 30) StorageMigrationTo30(db, migrationsHelper).setDefaultArchiveGranularity() } } diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt new file mode 100644 index 00000000000..687aa075dcb --- /dev/null +++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt @@ -0,0 +1,129 @@ +package com.fsck.k9.preferences.migration + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.key +import com.fsck.k9.preferences.createPreferencesDatabase +import java.util.UUID +import kotlin.test.Test +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StorageMigrationTo30Test { + private val database = createPreferencesDatabase() + private val migrationHelper = DefaultStorageMigrationHelper() + private val migration = StorageMigrationTo30(database, migrationHelper) + + @Before + fun setUp() { + Log.logger = TestLogger() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `archiveGranularity should be set to SINGLE_ARCHIVE_FOLDER for existing accounts`() { + val accountUuid = createAccount() + writeAccountUuids(accountUuid) + + migration.setDefaultArchiveGranularity() + + assertThat(migrationHelper.readAllValues(database)) + .key("$accountUuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + } + + @Test + fun `archiveGranularity should not overwrite existing value`() { + val accountUuid = createAccount( + "archiveGranularity" to ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name, + ) + writeAccountUuids(accountUuid) + + migration.setDefaultArchiveGranularity() + + assertThat(migrationHelper.readAllValues(database)) + .key("$accountUuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name) + } + + @Test + fun `archiveGranularity should be set for multiple accounts without the setting`() { + val account1Uuid = createAccount() + val account2Uuid = createAccount() + val account3Uuid = createAccount() + writeAccountUuids(account1Uuid, account2Uuid, account3Uuid) + + migration.setDefaultArchiveGranularity() + + val values = migrationHelper.readAllValues(database) + assertThat(values) + .key("$account1Uuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + assertThat(values) + .key("$account2Uuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + assertThat(values) + .key("$account3Uuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + } + + @Test + fun `archiveGranularity should handle mix of accounts with and without existing values`() { + val accountWithoutSetting = createAccount() + val accountWithYearly = createAccount( + "archiveGranularity" to ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS.name, + ) + val accountWithMonthly = createAccount( + "archiveGranularity" to ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name, + ) + writeAccountUuids(accountWithoutSetting, accountWithYearly, accountWithMonthly) + + migration.setDefaultArchiveGranularity() + + val values = migrationHelper.readAllValues(database) + assertThat(values) + .key("$accountWithoutSetting.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + assertThat(values) + .key("$accountWithYearly.archiveGranularity") + .isEqualTo(ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS.name) + assertThat(values) + .key("$accountWithMonthly.archiveGranularity") + .isEqualTo(ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name) + } + + @Test + fun `archiveGranularity should not be set when no accounts exist`() { + writeAccountUuids() + + migration.setDefaultArchiveGranularity() + + val values = migrationHelper.readAllValues(database) + assertThat(values.keys.filter { it.contains("archiveGranularity") }).isEqualTo(emptyList()) + } + + private fun writeAccountUuids(vararg accounts: String) { + val accountUuids = accounts.joinToString(separator = ",") + migrationHelper.insertValue(database, "accountUuids", accountUuids) + } + + private fun createAccount(vararg pairs: Pair): String { + val accountUuid = UUID.randomUUID().toString() + + for ((key, value) in pairs) { + migrationHelper.insertValue(database, "$accountUuid.$key", value) + } + + return accountUuid + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt index fdae9cdd43c..fc76277e2ce 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt @@ -13,6 +13,7 @@ import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.core.android.account.MessageFormat import net.thunderbird.core.android.account.QuoteStyle import net.thunderbird.core.android.account.ShowPictures +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection import net.thunderbird.feature.notification.NotificationLight import net.thunderbird.feature.notification.NotificationVibration @@ -139,6 +140,7 @@ class AccountSettingsDataStore( "account_remote_search_num_results" -> account.remoteSearchNumResults.toString() "account_ringtone" -> account.notificationSettings.ringtone "notification_light" -> account.notificationSettings.light.name + "archive_granularity" -> account.archiveGranularity.name else -> defValue } } @@ -174,6 +176,7 @@ class AccountSettingsDataStore( "account_remote_search_num_results" -> account.remoteSearchNumResults = value.toInt() "account_ringtone" -> setNotificationSound(value) "notification_light" -> setNotificationLight(value) + "archive_granularity" -> account.archiveGranularity = ArchiveGranularity.valueOf(value) else -> return } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt index 19591306ba6..34d700e6e0f 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt @@ -303,6 +303,16 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr } } + private fun initializeArchiveFolderPreference() { + findPreference(PREFERENCE_ARCHIVE_FOLDER)?.apply { + setOnPreferenceChangeListener { pref, newValue -> + val granularity = findPreference(PREFERENCE_ARCHIVE_GRANULARITY) + granularity?.isEnabled = (pref as? FolderListPreference)?.isNoneSelected(newValue?.toString()) == false + true + } + } + } + private fun maybeUpdateNotificationPreferences(account: LegacyAccountDto) { if (notificationSoundPreference != null || notificationLightPreference != null || @@ -422,10 +432,13 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr if (!messagingController.isMoveCapable(account)) { findPreference(PREFERENCE_ARCHIVE_FOLDER).remove() + findPreference(PREFERENCE_ARCHIVE_GRANULARITY).remove() findPreference(PREFERENCE_DRAFTS_FOLDER).remove() findPreference(PREFERENCE_SENT_FOLDER).remove() findPreference(PREFERENCE_SPAM_FOLDER).remove() findPreference(PREFERENCE_TRASH_FOLDER).remove() + } else { + initializeArchiveFolderPreference() } loadFolders(account) @@ -455,6 +468,15 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr val automaticFolder = remoteFolderInfo.automaticSpecialFolders[type] folderListPreference.setFolders(remoteFolderInfo.folders, automaticFolder) + when (type) { + FolderType.ARCHIVE if folderListPreference.selected == null -> + findPreference(PREFERENCE_ARCHIVE_GRANULARITY)?.isEnabled = false + + FolderType.ARCHIVE -> + findPreference(PREFERENCE_ARCHIVE_GRANULARITY)?.isEnabled = true + + else -> Unit + } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -529,6 +551,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private const val PREFERENCE_AUTO_SELECT_FOLDER = "auto_select_folder" private const val PREFERENCE_SUBSCRIBED_FOLDERS_ONLY = "subscribed_folders_only" private const val PREFERENCE_ARCHIVE_FOLDER = "archive_folder" + private const val PREFERENCE_ARCHIVE_GRANULARITY = "archive_granularity" private const val PREFERENCE_DRAFTS_FOLDER = "drafts_folder" private const val PREFERENCE_SENT_FOLDER = "sent_folder" private const val PREFERENCE_SPAM_FOLDER = "spam_folder" diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt index 605ba68ccb6..cb487436a01 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt @@ -34,6 +34,9 @@ constructor( private val folderNameFormatter: FolderNameFormatter by inject { parametersOf(context) } private val noFolderSelectedName = context.getString(R.string.account_settings_no_folder_selected).italicize() private lateinit var automaticFolderOption: CharSequence + val selected: Long? + get() = getSelectedValueAsString() + ?.toLongOrNull() init { entries = emptyArray() @@ -79,6 +82,8 @@ constructor( } } + fun isNoneSelected(value: String? = this.value): Boolean = getSelectedValueAsString(value).isNullOrBlank() + private fun getFolderDisplayNames(folders: List) = folders.map { folderNameFormatter.displayName(it) } private fun getFolderValues(folders: List) = folders.map { MANUAL_PREFIX + it.id.toString() } @@ -87,6 +92,10 @@ constructor( return SpannableString(this).apply { setSpan(StyleSpan(Typeface.ITALIC), 0, this.length, 0) } } + private fun getSelectedValueAsString(value: String? = this.value): String? = value + ?.substringAfter(MANUAL_PREFIX) + ?.substringBefore(AUTOMATIC_PREFIX) + companion object { const val FOLDER_VALUE_DELIMITER = "|" const val AUTOMATIC_PREFIX = "AUTOMATIC|" diff --git a/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml b/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml index 0d91d770fae..02324f28b44 100644 --- a/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml +++ b/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml @@ -144,4 +144,16 @@ @string/account_settings_remote_search_num_results_entries_all + + @string/archive_granularity_single_folder + @string/archive_granularity_yearly_folders + @string/archive_granularity_monthly_folders + + + + SINGLE_ARCHIVE_FOLDER + PER_YEAR_ARCHIVE_FOLDERS + PER_MONTH_ARCHIVE_FOLDERS + + diff --git a/legacy/ui/legacy/src/main/res/values/strings.xml b/legacy/ui/legacy/src/main/res/values/strings.xml index 623d4454eed..37fcac39f59 100644 --- a/legacy/ui/legacy/src/main/res/values/strings.xml +++ b/legacy/ui/legacy/src/main/res/values/strings.xml @@ -392,6 +392,11 @@ Sent folder Trash folder Archive folder + Archive options + When archiving messages, place them in: + A single folder + Yearly archived folders + Monthly archived folders Spam folder Show only subscribed folders diff --git a/legacy/ui/legacy/src/main/res/xml/account_settings.xml b/legacy/ui/legacy/src/main/res/xml/account_settings.xml index e3de53a2d0c..7c1c0dc5a95 100644 --- a/legacy/ui/legacy/src/main/res/xml/account_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/account_settings.xml @@ -236,6 +236,16 @@ android:title="@string/archive_folder_label" /> + +