From f140e87287bb91ab86fc1b23f4c190f81c56910c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 17:00:08 +0100 Subject: [PATCH 01/22] feat(settings): add archive granularity options - Add ArchiveGranularity enum with DEFAULT and MIGRATION_DEFAULT - Update LegacyAccountDto with archiveGranularity property - Add database migration v28->v29 for backward compatibility - Add UI preference with Desktop-matching strings - Archive options disabled until archive folder selected --- .../core/android/account/LegacyAccountDto.kt | 4 ++++ .../mail/folder/api/ArchiveGranularity.kt | 22 +++++++++++++++++++ .../migration/StorageMigrations.kt | 1 - .../arrays_account_settings_strings.xml | 12 ++++++++++ .../ui/legacy/src/main/res/values/strings.xml | 5 +++++ .../src/main/res/xml/account_settings.xml | 10 +++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 feature/mail/folder/api/src/commonMain/kotlin/net/thunderbird/feature/mail/folder/api/ArchiveGranularity.kt 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 40111a68997..9b326a816e1 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/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/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 b6dc4f600fd..bd94d30c138 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 @@ -15,7 +15,6 @@ internal object StorageMigrations { if (oldVersion < 6) StorageMigrationTo6(db, migrationsHelper).performLegacyMigrations() if (oldVersion < 7) StorageMigrationTo7(db, migrationsHelper).rewriteEnumOrdinalsToNames() if (oldVersion < 8) StorageMigrationTo8(db, migrationsHelper).rewriteTheme() - // 9: "Temporarily disable Push" is no longer necessary if (oldVersion < 10) StorageMigrationTo10(db, migrationsHelper).removeSavedFolderSettings() if (oldVersion < 11) StorageMigrationTo11(db, migrationsHelper).upgradeMessageViewContentFontSize() if (oldVersion < 12) StorageMigrationTo12(db, migrationsHelper).removeStoreAndTransportUri() 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 c667eb2dc28..7ccd6d1fdfa 100644 --- a/legacy/ui/legacy/src/main/res/values/strings.xml +++ b/legacy/ui/legacy/src/main/res/values/strings.xml @@ -397,6 +397,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 0f4d1c72b38..20b7b287d7e 100644 --- a/legacy/ui/legacy/src/main/res/xml/account_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/account_settings.xml @@ -139,6 +139,16 @@ android:title="@string/archive_folder_label" /> + + Date: Sun, 16 Nov 2025 17:23:12 +0100 Subject: [PATCH 02/22] feat(archive): integrate ArchiveFolderResolver Added ArchiveFolderResolver skeleton with date-based routing stubs. Updated ArchiveOperations to group messages by destination folder. Messages are now batched by their resolved archive targets. --- .../k9/controller/ArchiveFolderResolver.kt | 73 +++++++++++++++++++ .../fsck/k9/controller/ArchiveOperations.kt | 25 +++++-- .../k9/controller/MessagingController.java | 2 +- 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt 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..a578dba793e --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -0,0 +1,73 @@ +package com.fsck.k9.controller + +import com.fsck.k9.mailstore.LocalMessage +import java.util.Calendar +import java.util.Date +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity + +/** + * Resolves the destination folder ID for archiving messages based on account's archive granularity setting. + * + * TODO: Add folder creation/lookup logic for yearly and monthly subfolders + */ +internal class ArchiveFolderResolver { + + /** + * Determines the target archive folder ID for a message based on account settings. + * + * @param account The account containing archive settings + * @param message The message to archive (used to extract date for yearly/monthly routing) + * @return The folder ID to archive to, or null if no archive folder configured + */ + 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 -> { + // TODO: Implement yearly folder resolution + // Extract year from message date, find/create folder like "Archive/2025" + baseFolderId + } + + ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { + // TODO: Implement monthly folder resolution + // Extract year/month from message date, find/create folder like "Archive/2025/11" + baseFolderId + } + } + } + + /** + * Extracts the date from a message for archive folder routing. + * Prefers internalDate (when received), falls back to sentDate. + */ + private fun getMessageDate(message: LocalMessage): Date { + return message.internalDate ?: message.sentDate ?: Date() + } + + /** + * Extracts year from a date. + */ + private fun getYear(date: Date): Int { + val calendar = Calendar.getInstance() + calendar.time = date + return calendar.get(Calendar.YEAR) + } + + /** + * Extracts month from a date (1-12). + */ + private fun getMonth(date: Date): Int { + val calendar = Calendar.getInstance() + calendar.time = date + return calendar.get(Calendar.MONTH) + 1 // Calendar.MONTH is 0-based + } +} 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 c87585c981b..c3387f6d665 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,6 +13,7 @@ import net.thunderbird.legacy.logging.Log internal class ArchiveOperations( private val messagingController: MessagingController, private val featureFlagProvider: FeatureFlagProvider, + private val archiveFolderResolver: ArchiveFolderResolver = ArchiveFolderResolver(), ) { fun archiveThreads(messages: List) { archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder, archiveFolderId -> @@ -77,18 +78,28 @@ internal class ArchiveOperations( messages: List, archiveFolderId: Long, ) { + // Group messages by their resolved archive destination folder + // This allows yearly/monthly granularity to route messages to different subfolders + val messagesByDestination = messages.groupBy { message -> + archiveFolderResolver.resolveArchiveFolder(account, message) ?: archiveFolderId + } + val operation = featureFlagProvider.provide(GeneratedFeatureFlagKey.ARCHIVE_MARKS_AS_READ) .whenEnabledOrNot( onEnabled = { MoveOrCopyFlavor.MOVE_AND_MARK_AS_READ }, onDisabledOrUnavailable = { MoveOrCopyFlavor.MOVE }, ) - messagingController.moveOrCopyMessageSynchronous( - account, - sourceFolderId, - messages, - archiveFolderId, - operation, - ) + + // Archive each group to its respective destination folder + for ((destinationFolderId, messagesForFolder) in messagesByDestination) { + messagingController.moveOrCopyMessageSynchronous( + account, + sourceFolderId, + messagesForFolder, + destinationFolderId, + operation, + ) + } } private fun actOnMessagesGroupedByAccountAndFolder( 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 266d9a71c04..ee3d9ca7952 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 @@ -207,7 +207,7 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator, localMessageUidPrefixProvider); notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); - archiveOperations = new ArchiveOperations(this, featureFlagProvider); + archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver()); } private void initializeControllerExtensions(List controllerExtensions) { From 56985019eb7aeeff5eb804ed04cfa770ec8da4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 17:27:25 +0100 Subject: [PATCH 03/22] refactor(archive): remove redundant archiveFolderId parameter Resolver handles all destination logic internally, so the parameter is no longer needed in archiveMessages and archiveThreads methods. --- .../fsck/k9/controller/ArchiveOperations.kt | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) 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 c3387f6d665..c1a1e689df9 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 @@ -16,14 +16,14 @@ internal class ArchiveOperations( private val archiveFolderResolver: 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) } } @@ -38,7 +38,6 @@ internal class ArchiveOperations( account: LegacyAccountDto, folderId: Long, messagesInFolder: List, - archiveFolderId: Long, ) -> Unit, ) { actOnMessagesGroupedByAccountAndFolder(messages) { account, messageFolder, messagesInFolder -> @@ -55,7 +54,7 @@ internal class ArchiveOperations( else -> { messagingController.suppressMessages(account, messagesInFolder) messagingController.putBackground(description, null) { - action(account, sourceFolderId, messagesInFolder, archiveFolderId) + action(account, sourceFolderId, messagesInFolder) } } } @@ -66,22 +65,20 @@ 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, ) { // Group messages by their resolved archive destination folder // This allows yearly/monthly granularity to route messages to different subfolders val messagesByDestination = messages.groupBy { message -> - archiveFolderResolver.resolveArchiveFolder(account, message) ?: archiveFolderId + archiveFolderResolver.resolveArchiveFolder(account, message) } val operation = featureFlagProvider.provide(GeneratedFeatureFlagKey.ARCHIVE_MARKS_AS_READ) @@ -92,13 +89,15 @@ internal class ArchiveOperations( // Archive each group to its respective destination folder for ((destinationFolderId, messagesForFolder) in messagesByDestination) { - messagingController.moveOrCopyMessageSynchronous( - account, - sourceFolderId, - messagesForFolder, - destinationFolderId, - operation, - ) + if (destinationFolderId != null) { + messagingController.moveOrCopyMessageSynchronous( + account, + sourceFolderId, + messagesForFolder, + destinationFolderId, + operation, + ) + } } } From b4bb730e6ac00a5a57b7e917faafd349b4d35b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 17:32:25 +0100 Subject: [PATCH 04/22] feat(archive): implement date-based folder resolution Resolver now creates date-based subfolders on demand. Messages are routed to yearly (Archive/YYYY) or monthly (Archive/YYYY/MM) subfolders based on their internal/sent date and account granularity setting. --- .../k9/controller/ArchiveFolderResolver.kt | 84 ++++++++++++------- .../fsck/k9/controller/ArchiveOperations.kt | 2 +- .../k9/controller/MessagingController.java | 2 +- 3 files changed, 55 insertions(+), 33 deletions(-) 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 index a578dba793e..259e4a975ae 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -1,25 +1,23 @@ package com.fsck.k9.controller +import app.k9mail.legacy.mailstore.CreateFolderInfo +import app.k9mail.legacy.mailstore.MessageStoreManager +import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.FolderSettingsProvider import com.fsck.k9.mailstore.LocalMessage import java.util.Calendar import java.util.Date import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.logging.legacy.Log import net.thunderbird.feature.mail.folder.api.ArchiveGranularity +import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER +import com.fsck.k9.mail.FolderType as LegacyFolderType + +internal class ArchiveFolderResolver( + private val messageStoreManager: MessageStoreManager, + private val preferences: Preferences, +) { -/** - * Resolves the destination folder ID for archiving messages based on account's archive granularity setting. - * - * TODO: Add folder creation/lookup logic for yearly and monthly subfolders - */ -internal class ArchiveFolderResolver { - - /** - * Determines the target archive folder ID for a message based on account settings. - * - * @param account The account containing archive settings - * @param message The message to archive (used to extract date for yearly/monthly routing) - * @return The folder ID to archive to, or null if no archive folder configured - */ fun resolveArchiveFolder( account: LegacyAccountDto, message: LocalMessage, @@ -32,42 +30,66 @@ internal class ArchiveFolderResolver { } ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS -> { - // TODO: Implement yearly folder resolution - // Extract year from message date, find/create folder like "Archive/2025" - baseFolderId + val year = getYear(getMessageDate(message)) + findOrCreateSubfolder(account, baseFolderId, year.toString()) } ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { - // TODO: Implement monthly folder resolution - // Extract year/month from message date, find/create folder like "Archive/2025/11" - baseFolderId + val date = getMessageDate(message) + val year = getYear(date) + val month = String.format("%02d", getMonth(date)) + + val yearFolderId = findOrCreateSubfolder(account, baseFolderId, year.toString()) + ?: return baseFolderId + + findOrCreateSubfolder(account, yearFolderId, month) } } } - /** - * Extracts the date from a message for archive folder routing. - * Prefers internalDate (when received), falls back to sentDate. - */ + private fun findOrCreateSubfolder( + account: LegacyAccountDto, + parentFolderId: Long, + subfolderName: String, + ): Long? { + val messageStore = messageStoreManager.getMessageStore(account) + + val parentServerId = messageStore.getFolderServerId(parentFolderId) ?: return null + + val delimiter = FOLDER_DEFAULT_PATH_DELIMITER + val subfolderServerId = "$parentServerId$delimiter$subfolderName" + + messageStore.getFolderId(subfolderServerId)?.let { return it } + + return try { + val folderSettingsProvider = FolderSettingsProvider(preferences, account) + val folderInfo = CreateFolderInfo( + serverId = subfolderServerId, + name = subfolderServerId, + type = LegacyFolderType.ARCHIVE, + settings = folderSettingsProvider.getFolderSettings(subfolderServerId), + ) + val folderIds = messageStore.createFolders(listOf(folderInfo)) + folderIds.firstOrNull() + } catch (e: Exception) { + Log.e(e, "Failed to create archive subfolder: $subfolderServerId") + null + } + } + private fun getMessageDate(message: LocalMessage): Date { return message.internalDate ?: message.sentDate ?: Date() } - /** - * Extracts year from a date. - */ private fun getYear(date: Date): Int { val calendar = Calendar.getInstance() calendar.time = date return calendar.get(Calendar.YEAR) } - /** - * Extracts month from a date (1-12). - */ private fun getMonth(date: Date): Int { val calendar = Calendar.getInstance() calendar.time = date - return calendar.get(Calendar.MONTH) + 1 // Calendar.MONTH is 0-based + return calendar.get(Calendar.MONTH) + 1 } } 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 c1a1e689df9..9a5bfe2a4c9 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,7 +13,7 @@ import net.thunderbird.legacy.logging.Log internal class ArchiveOperations( private val messagingController: MessagingController, private val featureFlagProvider: FeatureFlagProvider, - private val archiveFolderResolver: ArchiveFolderResolver = ArchiveFolderResolver(), + private val archiveFolderResolver: ArchiveFolderResolver, ) { fun archiveThreads(messages: List) { archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder -> 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 ee3d9ca7952..17f9cc98b5d 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 @@ -207,7 +207,7 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator, localMessageUidPrefixProvider); notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); - archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver()); + archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver(messageStoreManager, preferences)); } private void initializeControllerExtensions(List controllerExtensions) { From 184c9eba973e8f0e5f5632db8d0c4a2ac6ee89b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 17:38:08 +0100 Subject: [PATCH 05/22] refactor(archive): use BackendStorage for folder creation Use BackendStorage.updateFolders() instead of MessageStore.createFolders() to ensure folders are properly created both locally and remotely. This matches the pattern used in CreateArchiveFolder use case and ensures IMAP folder creation is queued for sync. --- .../k9/controller/ArchiveFolderResolver.kt | 19 ++++++++++--------- .../java/com/fsck/k9/controller/KoinModule.kt | 2 ++ .../k9/controller/MessagingController.java | 8 ++++++-- 3 files changed, 18 insertions(+), 11 deletions(-) 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 index 259e4a975ae..ce310c10eca 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -1,9 +1,10 @@ package com.fsck.k9.controller -import app.k9mail.legacy.mailstore.CreateFolderInfo import app.k9mail.legacy.mailstore.MessageStoreManager -import com.fsck.k9.Preferences -import com.fsck.k9.mailstore.FolderSettingsProvider +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.createFolder +import com.fsck.k9.backend.api.updateFolders +import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory import com.fsck.k9.mailstore.LocalMessage import java.util.Calendar import java.util.Date @@ -15,7 +16,7 @@ import com.fsck.k9.mail.FolderType as LegacyFolderType internal class ArchiveFolderResolver( private val messageStoreManager: MessageStoreManager, - private val preferences: Preferences, + private val backendStorageFactory: LegacyAccountDtoBackendStorageFactory, ) { fun resolveArchiveFolder( @@ -62,15 +63,15 @@ internal class ArchiveFolderResolver( messageStore.getFolderId(subfolderServerId)?.let { return it } return try { - val folderSettingsProvider = FolderSettingsProvider(preferences, account) - val folderInfo = CreateFolderInfo( + val backendStorage = backendStorageFactory.createBackendStorage(account) + val folderInfo = FolderInfo( serverId = subfolderServerId, name = subfolderServerId, type = LegacyFolderType.ARCHIVE, - settings = folderSettingsProvider.getFolderSettings(subfolderServerId), ) - val folderIds = messageStore.createFolders(listOf(folderInfo)) - folderIds.firstOrNull() + backendStorage.updateFolders { + createFolder(folderInfo) + } } catch (e: Exception) { Log.e(e, "Failed to create archive subfolder: $subfolderServerId") null 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 6752760637d..07377b4e315 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 @@ -6,6 +6,7 @@ import app.k9mail.legacy.message.controller.MessageCountsProvider import app.k9mail.legacy.message.controller.MessagingControllerRegistry import com.fsck.k9.Preferences import com.fsck.k9.backend.BackendManager +import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory import com.fsck.k9.mailstore.LocalStoreProvider import com.fsck.k9.mailstore.SaveMessageDataCreator import com.fsck.k9.mailstore.SpecialLocalFoldersCreator @@ -40,6 +41,7 @@ val controllerModule = module { get(named("syncDebug")), get(), get(), + get(), ) } binds arrayOf(MessagingControllerRegistry::class) 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 17f9cc98b5d..3549f38f3d6 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 @@ -68,6 +68,7 @@ import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mail.power.PowerManager; import com.fsck.k9.mail.power.WakeLock; +import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory; import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.LocalMessage; import com.fsck.k9.mailstore.LocalStore; @@ -132,6 +133,7 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final SaveMessageDataCreator saveMessageDataCreator; private final SpecialLocalFoldersCreator specialLocalFoldersCreator; private final LocalDeleteOperationDecider localDeleteOperationDecider; + private final LegacyAccountDtoBackendStorageFactory backendStorageFactory; private final Thread controllerThread; @@ -173,7 +175,8 @@ public static MessagingController getInstance(Context context) { FeatureFlagProvider featureFlagProvider, Logger syncDebugLogger, NotificationManager notificationManager, - OutboxFolderManager outboxFolderManager + OutboxFolderManager outboxFolderManager, + LegacyAccountDtoBackendStorageFactory backendStorageFactory ) { this.context = context; this.notificationController = notificationController; @@ -191,6 +194,7 @@ public static MessagingController getInstance(Context context) { this.notificationSender = new NotificationSenderCompat(notificationManager); this.notificationDismisser = new NotificationDismisserCompat(notificationManager); this.outboxFolderManager = outboxFolderManager; + this.backendStorageFactory = backendStorageFactory; controllerThread = new Thread(new Runnable() { @Override @@ -207,7 +211,7 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator, localMessageUidPrefixProvider); notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); - archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver(messageStoreManager, preferences)); + archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver(messageStoreManager, backendStorageFactory)); } private void initializeControllerExtensions(List controllerExtensions) { From 3cf2c3bccc7a46a76f44ae85a67dcc6e720c4be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 17:40:30 +0100 Subject: [PATCH 06/22] test: update MessagingControllerTest for new constructor Add LegacyAccountDtoBackendStorageFactory mock to test to match updated MessagingController constructor signature. --- .../com/fsck/k9/controller/MessagingControllerTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 510a1122666..bc62101a39b 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 @@ -17,6 +17,7 @@ import com.fsck.k9.backend.BackendManager; import com.fsck.k9.backend.api.Backend; import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory; import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateChainException; import com.fsck.k9.mail.CertificateValidationException; @@ -96,6 +97,8 @@ public class MessagingControllerTest extends K9RobolectricTest { @Mock private SpecialLocalFoldersCreator specialLocalFoldersCreator; @Mock + private LegacyAccountDtoBackendStorageFactory backendStorageFactory; + @Mock private SimpleMessagingListener listener; @Mock private LocalFolder localFolder; @@ -163,7 +166,8 @@ public void setUp() throws MessagingException { featureFlagProvider, syncLogger, notificationManager, - fakeOutboxFolderManager + fakeOutboxFolderManager, + backendStorageFactory ); configureAccount(); From 7ed6411ffc0e4bb9a9a215387d15e101830676d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 17:55:19 +0100 Subject: [PATCH 07/22] test: add unit tests for ArchiveFolderResolver Test coverage includes: - Single folder granularity (no folder creation) - Yearly folder creation and reuse - Monthly folder creation and reuse - Date handling (internal date vs sent date) - Error handling for folder creation failures - Month formatting with leading zeros --- .../k9/controller/ArchiveFolderResolver.kt | 2 + .../controller/ArchiveFolderResolverTest.kt | 246 ++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt 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 index ce310c10eca..9973a426ae1 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -74,6 +74,8 @@ internal class ArchiveFolderResolver( } } catch (e: Exception) { Log.e(e, "Failed to create archive subfolder: $subfolderServerId") + // TODO: Inform the user that archive folder creation failed. Currently returns null which + // will skip archiving the message. Consider showing a notification or error message. null } } 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..0e4a6ef7e07 --- /dev/null +++ b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt @@ -0,0 +1,246 @@ +package com.fsck.k9.controller + +import app.k9mail.legacy.mailstore.ListenableMessageStore +import app.k9mail.legacy.mailstore.MessageStoreManager +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import com.fsck.k9.backend.api.BackendFolderUpdater +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory +import com.fsck.k9.mailstore.LocalMessage +import com.fsck.k9.mail.FolderType +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.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ArchiveFolderResolverTest { + @BeforeTest + fun setup() { + Log.logger = mock() + } + private val messageStoreManager: MessageStoreManager = mock() + private val messageStore: ListenableMessageStore = mock() + private val backendStorageFactory: LegacyAccountDtoBackendStorageFactory = mock() + private val backendStorage: BackendStorage = mock() + private val backendFolderUpdater: BackendFolderUpdater = mock() + + private val resolver = ArchiveFolderResolver(messageStoreManager, backendStorageFactory) + + 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 result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) + verify(messageStoreManager, never()).getMessageStore(any()) + } + + @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) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) + whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) + whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) + whenever(backendFolderUpdater.createFolders(any>())).thenReturn(setOf(YEARLY_FOLDER_ID)) + + val result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + } + + @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) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) + whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") + whenever(messageStore.getFolderId("Archive/2025/11")).thenReturn(null) + whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) + whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) + whenever(backendFolderUpdater.createFolders(any>())).thenReturn(setOf(YEARLY_FOLDER_ID), setOf(MONTHLY_FOLDER_ID)) + + val result = resolver.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 result = resolver.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) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) + + val result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + verify(backendStorageFactory, never()).createBackendStorage(any()) + } + + @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) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) + whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") + whenever(messageStore.getFolderId("Archive/2025/11")).thenReturn(MONTHLY_FOLDER_ID) + + val result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) + verify(backendStorageFactory, never()).createBackendStorage(any()) + } + + @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)) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2024")).thenReturn(YEARLY_FOLDER_ID) + + val result = resolver.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) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) + + val result = resolver.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) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) + whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") + whenever(messageStore.getFolderId("Archive/2025/03")).thenReturn(MONTHLY_FOLDER_ID) + + val result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) + } + + @Test + fun `return null when yearly subfolder creation fails`() { + account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) + whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) + whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) + whenever(backendFolderUpdater.createFolders(any>())).thenThrow(RuntimeException("Folder creation failed")) + + val result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isNull() + } + + @Test + fun `return null when yearly folder creation succeeds but monthly creation fails`() { + account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS + val message = createMessage(year = 2025, month = 11) + + whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) + whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") + whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) + whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") + whenever(messageStore.getFolderId("Archive/2025/11")).thenReturn(null) + whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) + whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) + whenever(backendFolderUpdater.createFolders(any>())) + .thenReturn(setOf(YEARLY_FOLDER_ID)) + .thenThrow(RuntimeException("Monthly folder creation failed")) + + val result = resolver.resolveArchiveFolder(account, message) + + assertThat(result).isNull() + } + + 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 + } +} From 54c79af812669d0599ce1b56d851072a4dbfb7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 18:28:49 +0100 Subject: [PATCH 08/22] refactor: extract thin interfaces for ArchiveFolderResolver Applied Interface Segregation Principle by extracting two thin interfaces from thick dependencies: - FolderIdResolver (2 methods) replaces MessageStoreManager (60+ methods) - ArchiveFolderCreator (1 method) replaces BackendStorageFactory Added adapter implementations for production use and created simple fakes for testing. Tests now use 15-line fakes instead of 200+ line fakes that implemented entire thick interfaces. --- .../k9/controller/ArchiveFolderCreator.kt | 8 + .../k9/controller/ArchiveFolderResolver.kt | 37 ++-- .../BackendStorageArchiveFolderCreator.kt | 26 +++ .../fsck/k9/controller/FolderIdResolver.kt | 8 + .../MessageStoreFolderIdResolver.kt | 16 ++ .../k9/controller/MessagingController.java | 9 +- .../controller/ArchiveFolderResolverTest.kt | 163 ++++++++++-------- .../k9/controller/FakeArchiveFolderCreator.kt | 19 ++ .../k9/controller/FakeFolderIdResolver.kt | 16 ++ 9 files changed, 205 insertions(+), 97 deletions(-) create mode 100644 legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderCreator.kt create mode 100644 legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt create mode 100644 legacy/core/src/main/java/com/fsck/k9/controller/FolderIdResolver.kt create mode 100644 legacy/core/src/main/java/com/fsck/k9/controller/MessageStoreFolderIdResolver.kt create mode 100644 legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt create mode 100644 legacy/core/src/test/java/com/fsck/k9/controller/FakeFolderIdResolver.kt 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 index 9973a426ae1..e05f285707d 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -1,22 +1,17 @@ package com.fsck.k9.controller -import app.k9mail.legacy.mailstore.MessageStoreManager import com.fsck.k9.backend.api.FolderInfo -import com.fsck.k9.backend.api.createFolder -import com.fsck.k9.backend.api.updateFolders -import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory import com.fsck.k9.mailstore.LocalMessage import java.util.Calendar import java.util.Date import net.thunderbird.core.android.account.LegacyAccountDto -import net.thunderbird.core.logging.legacy.Log import net.thunderbird.feature.mail.folder.api.ArchiveGranularity import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER import com.fsck.k9.mail.FolderType as LegacyFolderType internal class ArchiveFolderResolver( - private val messageStoreManager: MessageStoreManager, - private val backendStorageFactory: LegacyAccountDtoBackendStorageFactory, + private val folderIdResolver: FolderIdResolver, + private val folderCreator: ArchiveFolderCreator, ) { fun resolveArchiveFolder( @@ -53,31 +48,19 @@ internal class ArchiveFolderResolver( parentFolderId: Long, subfolderName: String, ): Long? { - val messageStore = messageStoreManager.getMessageStore(account) - - val parentServerId = messageStore.getFolderServerId(parentFolderId) ?: return null + val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null val delimiter = FOLDER_DEFAULT_PATH_DELIMITER val subfolderServerId = "$parentServerId$delimiter$subfolderName" - messageStore.getFolderId(subfolderServerId)?.let { return it } + folderIdResolver.getFolderId(account, subfolderServerId)?.let { return it } - return try { - val backendStorage = backendStorageFactory.createBackendStorage(account) - val folderInfo = FolderInfo( - serverId = subfolderServerId, - name = subfolderServerId, - type = LegacyFolderType.ARCHIVE, - ) - backendStorage.updateFolders { - createFolder(folderInfo) - } - } catch (e: Exception) { - Log.e(e, "Failed to create archive subfolder: $subfolderServerId") - // TODO: Inform the user that archive folder creation failed. Currently returns null which - // will skip archiving the message. Consider showing a notification or error message. - null - } + val folderInfo = FolderInfo( + serverId = subfolderServerId, + name = subfolderServerId, + type = LegacyFolderType.ARCHIVE, + ) + return folderCreator.createFolder(account, folderInfo) } private fun getMessageDate(message: LocalMessage): Date { 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..57f18631020 --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt @@ -0,0 +1,26 @@ +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 com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.logging.legacy.Log + +internal class BackendStorageArchiveFolderCreator( + private val backendStorageFactory: LegacyAccountDtoBackendStorageFactory, +) : ArchiveFolderCreator { + override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { + return try { + val backendStorage = backendStorageFactory.createBackendStorage(account) + backendStorage.updateFolders { + createFolder(folderInfo) + } + } catch (e: Exception) { + Log.e(e, "Failed to create archive subfolder: ${folderInfo.serverId}") + // TODO: Inform the user that archive folder creation failed. Currently returns null which + // will skip archiving the message. Consider showing a notification or error message. + 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/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 3549f38f3d6..7f1bd46308c 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 @@ -211,7 +211,14 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator, localMessageUidPrefixProvider); notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); - archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver(messageStoreManager, backendStorageFactory)); + archiveOperations = new ArchiveOperations( + this, + featureFlagProvider, + new ArchiveFolderResolver( + new MessageStoreFolderIdResolver(messageStoreManager), + new BackendStorageArchiveFolderCreator(backendStorageFactory) + ) + ); } private void initializeControllerExtensions(List controllerExtensions) { 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 index 0e4a6ef7e07..944db1e9387 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt @@ -1,16 +1,9 @@ package com.fsck.k9.controller -import app.k9mail.legacy.mailstore.ListenableMessageStore -import app.k9mail.legacy.mailstore.MessageStoreManager import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isNull -import com.fsck.k9.backend.api.BackendFolderUpdater -import com.fsck.k9.backend.api.BackendStorage -import com.fsck.k9.backend.api.FolderInfo -import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory import com.fsck.k9.mailstore.LocalMessage -import com.fsck.k9.mail.FolderType import java.util.Calendar import java.util.Date import java.util.UUID @@ -20,10 +13,7 @@ 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.any import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class ArchiveFolderResolverTest { @@ -31,13 +21,6 @@ class ArchiveFolderResolverTest { fun setup() { Log.logger = mock() } - private val messageStoreManager: MessageStoreManager = mock() - private val messageStore: ListenableMessageStore = mock() - private val backendStorageFactory: LegacyAccountDtoBackendStorageFactory = mock() - private val backendStorage: BackendStorage = mock() - private val backendFolderUpdater: BackendFolderUpdater = mock() - - private val resolver = ArchiveFolderResolver(messageStoreManager, backendStorageFactory) private val account = LegacyAccountDto(UUID.randomUUID().toString()).apply { archiveFolderId = BASE_ARCHIVE_FOLDER_ID @@ -49,10 +32,10 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.SINGLE_ARCHIVE_FOLDER val message = createMessage(year = 2025, month = 11) + val resolver = createResolver() val result = resolver.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) - verify(messageStoreManager, never()).getMessageStore(any()) } @Test @@ -60,12 +43,14 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 11) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) - whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) - whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) - whenever(backendFolderUpdater.createFolders(any>())).thenReturn(setOf(YEARLY_FOLDER_ID)) + 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 resolver = createResolver(folderIdResolver, archiveFolderCreator) val result = resolver.resolveArchiveFolder(account, message) @@ -77,14 +62,23 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 11) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) - whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") - whenever(messageStore.getFolderId("Archive/2025/11")).thenReturn(null) - whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) - whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) - whenever(backendFolderUpdater.createFolders(any>())).thenReturn(setOf(YEARLY_FOLDER_ID), setOf(MONTHLY_FOLDER_ID)) + 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 resolver = createResolver(folderIdResolver, archiveFolderCreator) val result = resolver.resolveArchiveFolder(account, message) @@ -96,6 +90,7 @@ class ArchiveFolderResolverTest { account.archiveFolderId = null val message = createMessage(year = 2025, month = 11) + val resolver = createResolver() val result = resolver.resolveArchiveFolder(account, message) assertThat(result).isNull() @@ -106,14 +101,15 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 11) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), + ) + val resolver = createResolver(folderIdResolver) val result = resolver.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(YEARLY_FOLDER_ID) - verify(backendStorageFactory, never()).createBackendStorage(any()) } @Test @@ -121,16 +117,21 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 11) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) - whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") - whenever(messageStore.getFolderId("Archive/2025/11")).thenReturn(MONTHLY_FOLDER_ID) + 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 resolver = createResolver(folderIdResolver) val result = resolver.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) - verify(backendStorageFactory, never()).createBackendStorage(any()) } @Test @@ -139,9 +140,11 @@ class ArchiveFolderResolverTest { val message = createMessage(year = 2025, month = 11) whenever(message.internalDate).thenReturn(createDate(2024, 5)) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2024")).thenReturn(YEARLY_FOLDER_ID) + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2024" to YEARLY_FOLDER_ID), + ) + val resolver = createResolver(folderIdResolver) val result = resolver.resolveArchiveFolder(account, message) @@ -154,9 +157,11 @@ class ArchiveFolderResolverTest { val message = createMessage(year = 2025, month = 11, useInternalDate = false) whenever(message.internalDate).thenReturn(null) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), + ) + val resolver = createResolver(folderIdResolver) val result = resolver.resolveArchiveFolder(account, message) @@ -168,11 +173,17 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 3) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(YEARLY_FOLDER_ID) - whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") - whenever(messageStore.getFolderId("Archive/2025/03")).thenReturn(MONTHLY_FOLDER_ID) + 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 resolver = createResolver(folderIdResolver) val result = resolver.resolveArchiveFolder(account, message) @@ -184,12 +195,14 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 11) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) - whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) - whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) - whenever(backendFolderUpdater.createFolders(any>())).thenThrow(RuntimeException("Folder creation failed")) + val folderIdResolver = FakeFolderIdResolver( + folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), + folderIds = mapOf("Archive/2025" to null), + ) + val archiveFolderCreator = FakeArchiveFolderCreator( + failAfterCalls = 0, + ) + val resolver = createResolver(folderIdResolver, archiveFolderCreator) val result = resolver.resolveArchiveFolder(account, message) @@ -201,22 +214,34 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS val message = createMessage(year = 2025, month = 11) - whenever(messageStoreManager.getMessageStore(account)).thenReturn(messageStore) - whenever(messageStore.getFolderServerId(BASE_ARCHIVE_FOLDER_ID)).thenReturn("Archive") - whenever(messageStore.getFolderId("Archive/2025")).thenReturn(null) - whenever(messageStore.getFolderServerId(YEARLY_FOLDER_ID)).thenReturn("Archive/2025") - whenever(messageStore.getFolderId("Archive/2025/11")).thenReturn(null) - whenever(backendStorageFactory.createBackendStorage(account)).thenReturn(backendStorage) - whenever(backendStorage.createFolderUpdater()).thenReturn(backendFolderUpdater) - whenever(backendFolderUpdater.createFolders(any>())) - .thenReturn(setOf(YEARLY_FOLDER_ID)) - .thenThrow(RuntimeException("Monthly folder creation failed")) + 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 resolver = createResolver(folderIdResolver, archiveFolderCreator) val result = resolver.resolveArchiveFolder(account, message) assertThat(result).isNull() } + 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)) 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..3c93613ce06 --- /dev/null +++ b/legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt @@ -0,0 +1,19 @@ +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 + + override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { + callCount++ + 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] + } +} From bfc00fca9e58a4a6805615533ecc2f5c3bcdffd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 18:31:18 +0100 Subject: [PATCH 09/22] chore: fix detekt static analysis issues Resolved detekt warnings by adding explicit locale to String.format, suppressing intentional generic exception catching, and suppressing early return count warnings for null safety checks. --- .../java/com/fsck/k9/controller/ArchiveFolderResolver.kt | 5 ++++- .../fsck/k9/controller/BackendStorageArchiveFolderCreator.kt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) 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 index e05f285707d..7204174bec2 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -4,6 +4,7 @@ import com.fsck.k9.backend.api.FolderInfo import com.fsck.k9.mailstore.LocalMessage import java.util.Calendar import java.util.Date +import java.util.Locale import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.feature.mail.folder.api.ArchiveGranularity import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER @@ -14,6 +15,7 @@ internal class ArchiveFolderResolver( private val folderCreator: ArchiveFolderCreator, ) { + @Suppress("ReturnCount") fun resolveArchiveFolder( account: LegacyAccountDto, message: LocalMessage, @@ -33,7 +35,7 @@ internal class ArchiveFolderResolver( ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { val date = getMessageDate(message) val year = getYear(date) - val month = String.format("%02d", getMonth(date)) + val month = String.format(Locale.ROOT, "%02d", getMonth(date)) val yearFolderId = findOrCreateSubfolder(account, baseFolderId, year.toString()) ?: return baseFolderId @@ -43,6 +45,7 @@ internal class ArchiveFolderResolver( } } + @Suppress("ReturnCount") private fun findOrCreateSubfolder( account: LegacyAccountDto, parentFolderId: Long, 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 index 57f18631020..7a6c5a44c05 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt @@ -10,6 +10,7 @@ import net.thunderbird.core.logging.legacy.Log internal class BackendStorageArchiveFolderCreator( private val backendStorageFactory: LegacyAccountDtoBackendStorageFactory, ) : ArchiveFolderCreator { + @Suppress("TooGenericExceptionCaught") override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { return try { val backendStorage = backendStorageFactory.createBackendStorage(account) From 291c5b6db4d38fdb2b1a1517ec34a1c34176e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 18:59:19 +0100 Subject: [PATCH 10/22] test: rename resolver to testSubject in ArchiveFolderResolverTest --- .../controller/ArchiveFolderResolverTest.kt | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) 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 index 944db1e9387..4cb48841fb6 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt @@ -32,8 +32,8 @@ class ArchiveFolderResolverTest { account.archiveGranularity = ArchiveGranularity.SINGLE_ARCHIVE_FOLDER val message = createMessage(year = 2025, month = 11) - val resolver = createResolver() - val result = resolver.resolveArchiveFolder(account, message) + val testSubject = createResolver() + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) } @@ -50,9 +50,9 @@ class ArchiveFolderResolverTest { val archiveFolderCreator = FakeArchiveFolderCreator( createdFolderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), ) - val resolver = createResolver(folderIdResolver, archiveFolderCreator) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(YEARLY_FOLDER_ID) } @@ -78,9 +78,9 @@ class ArchiveFolderResolverTest { "Archive/2025/11" to MONTHLY_FOLDER_ID, ), ) - val resolver = createResolver(folderIdResolver, archiveFolderCreator) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) } @@ -90,8 +90,8 @@ class ArchiveFolderResolverTest { account.archiveFolderId = null val message = createMessage(year = 2025, month = 11) - val resolver = createResolver() - val result = resolver.resolveArchiveFolder(account, message) + val testSubject = createResolver() + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isNull() } @@ -105,9 +105,9 @@ class ArchiveFolderResolverTest { folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), folderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), ) - val resolver = createResolver(folderIdResolver) + val testSubject = createResolver(folderIdResolver) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(YEARLY_FOLDER_ID) } @@ -127,9 +127,9 @@ class ArchiveFolderResolverTest { "Archive/2025/11" to MONTHLY_FOLDER_ID, ), ) - val resolver = createResolver(folderIdResolver) + val testSubject = createResolver(folderIdResolver) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) } @@ -144,9 +144,9 @@ class ArchiveFolderResolverTest { folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), folderIds = mapOf("Archive/2024" to YEARLY_FOLDER_ID), ) - val resolver = createResolver(folderIdResolver) + val testSubject = createResolver(folderIdResolver) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(YEARLY_FOLDER_ID) } @@ -161,9 +161,9 @@ class ArchiveFolderResolverTest { folderServerIds = mapOf(BASE_ARCHIVE_FOLDER_ID to "Archive"), folderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), ) - val resolver = createResolver(folderIdResolver) + val testSubject = createResolver(folderIdResolver) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(YEARLY_FOLDER_ID) } @@ -183,9 +183,9 @@ class ArchiveFolderResolverTest { "Archive/2025/03" to MONTHLY_FOLDER_ID, ), ) - val resolver = createResolver(folderIdResolver) + val testSubject = createResolver(folderIdResolver) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(MONTHLY_FOLDER_ID) } @@ -202,9 +202,9 @@ class ArchiveFolderResolverTest { val archiveFolderCreator = FakeArchiveFolderCreator( failAfterCalls = 0, ) - val resolver = createResolver(folderIdResolver, archiveFolderCreator) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isNull() } @@ -228,9 +228,9 @@ class ArchiveFolderResolverTest { createdFolderIds = mapOf("Archive/2025" to YEARLY_FOLDER_ID), failAfterCalls = 1, ) - val resolver = createResolver(folderIdResolver, archiveFolderCreator) + val testSubject = createResolver(folderIdResolver, archiveFolderCreator) - val result = resolver.resolveArchiveFolder(account, message) + val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isNull() } From 5cf12ae1edef8dbd7cd476e2df50c224fac8f74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 19:03:09 +0100 Subject: [PATCH 11/22] chore: remove unnecessary comments --- .../src/main/java/com/fsck/k9/controller/ArchiveOperations.kt | 3 --- .../fsck/k9/controller/BackendStorageArchiveFolderCreator.kt | 2 -- 2 files changed, 5 deletions(-) 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 9a5bfe2a4c9..b23ae371de8 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 @@ -75,8 +75,6 @@ internal class ArchiveOperations( sourceFolderId: Long, messages: List, ) { - // Group messages by their resolved archive destination folder - // This allows yearly/monthly granularity to route messages to different subfolders val messagesByDestination = messages.groupBy { message -> archiveFolderResolver.resolveArchiveFolder(account, message) } @@ -87,7 +85,6 @@ internal class ArchiveOperations( onDisabledOrUnavailable = { MoveOrCopyFlavor.MOVE }, ) - // Archive each group to its respective destination folder for ((destinationFolderId, messagesForFolder) in messagesByDestination) { if (destinationFolderId != null) { messagingController.moveOrCopyMessageSynchronous( 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 index 7a6c5a44c05..839ffa00d54 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt @@ -19,8 +19,6 @@ internal class BackendStorageArchiveFolderCreator( } } catch (e: Exception) { Log.e(e, "Failed to create archive subfolder: ${folderInfo.serverId}") - // TODO: Inform the user that archive folder creation failed. Currently returns null which - // will skip archiving the message. Consider showing a notification or error message. null } } From d4e3988dfe3f376f2a571f12f7fed81a2d59894f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 19:05:17 +0100 Subject: [PATCH 12/22] fix: connect archive granularity UI to account settings Add getString/putString handlers in AccountSettingsDataStore to properly save and load archive_granularity preference changes. --- .../fsck/k9/ui/settings/account/AccountSettingsDataStore.kt | 3 +++ 1 file changed, 3 insertions(+) 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 } From 5c0f3a240ce263ec75507bd931c01b2a13b7af48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Sun, 16 Nov 2025 19:13:46 +0100 Subject: [PATCH 13/22] test: add StorageMigrationTo29Test for archiveGranularity migration --- .../fsck/k9/preferences/migration/StorageMigrationTo29Test.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt index 84133342127..3ba18d1eb9c 100644 --- a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt +++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt @@ -26,13 +26,10 @@ class StorageMigrationTo29Test { @Test fun `migration should rename account_setup_auto_expand_folder to auto_select_folder`() { - // Arrange: Insert old data into the database migrationHelper.insertValue(database, "account_setup_auto_expand_folder", "some value") - // Act: Run the migration migration.renameAutoSelectFolderPreference() - // Assert: Verify the results val values = migrationHelper.readAllValues(database) assertThat(values).key("auto_select_folder").isEqualTo("some value") assertThat(values).doesNotContainKey("account_setup_auto_expand_folder") From a11d937d9c92ecac2d2734218e6047f4a6b76a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fausto=20N=C3=BA=C3=B1ez=20Alberro?= Date: Mon, 19 Jan 2026 17:17:43 +0100 Subject: [PATCH 14/22] refactor: rebase and attend to review changes --- .../legacy/LegacyAccountStorageHandler.kt | 10 +++ legacy/core/build.gradle.kts | 1 + .../k9/controller/ArchiveFolderResolver.kt | 72 +++++++++---------- .../controller/ArchiveFolderResolverTest.kt | 49 +++++++++++-- .../k9/controller/FakeArchiveFolderCreator.kt | 2 + 5 files changed, 92 insertions(+), 42 deletions(-) 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 0d994597cd4..48d75e5459c 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 @@ -17,6 +17,7 @@ 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.FOLDER_DEFAULT_PATH_DELIMITER +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.NotificationSettings @@ -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, @@ -358,6 +365,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) @@ -474,6 +482,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")) @@ -613,5 +622,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/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index 3ad61ee3f87..4729786e519 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/ArchiveFolderResolver.kt b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt index 7204174bec2..6fca9243cf7 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -2,12 +2,14 @@ package com.fsck.k9.controller import com.fsck.k9.backend.api.FolderInfo import com.fsck.k9.mailstore.LocalMessage -import java.util.Calendar -import java.util.Date import java.util.Locale +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +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 net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER import com.fsck.k9.mail.FolderType as LegacyFolderType internal class ArchiveFolderResolver( @@ -15,7 +17,6 @@ internal class ArchiveFolderResolver( private val folderCreator: ArchiveFolderCreator, ) { - @Suppress("ReturnCount") fun resolveArchiveFolder( account: LegacyAccountDto, message: LocalMessage, @@ -28,24 +29,22 @@ internal class ArchiveFolderResolver( } ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS -> { - val year = getYear(getMessageDate(message)) - findOrCreateSubfolder(account, baseFolderId, year.toString()) + val year = message.messageDate.year + findOrCreateSubfolder(account, baseFolderId, year.toString()) ?: baseFolderId } ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { - val date = getMessageDate(message) - val year = getYear(date) - val month = String.format(Locale.ROOT, "%02d", getMonth(date)) + val date = message.messageDate + val year = date.year + val month = String.format(Locale.ROOT, "%02d", date.monthNumber) - val yearFolderId = findOrCreateSubfolder(account, baseFolderId, year.toString()) - ?: return baseFolderId - - findOrCreateSubfolder(account, yearFolderId, month) + findOrCreateSubfolder(account, baseFolderId, year.toString())?.let { yearFolderId -> + findOrCreateSubfolder(account, yearFolderId, month) + } ?: baseFolderId } } } - @Suppress("ReturnCount") private fun findOrCreateSubfolder( account: LegacyAccountDto, parentFolderId: Long, @@ -53,32 +52,29 @@ internal class ArchiveFolderResolver( ): Long? { val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null - val delimiter = FOLDER_DEFAULT_PATH_DELIMITER + val delimiter = account.folderPathDelimiter val subfolderServerId = "$parentServerId$delimiter$subfolderName" - folderIdResolver.getFolderId(account, subfolderServerId)?.let { return it } - - val folderInfo = FolderInfo( - serverId = subfolderServerId, - name = subfolderServerId, - type = LegacyFolderType.ARCHIVE, - ) - return folderCreator.createFolder(account, folderInfo) - } - - private fun getMessageDate(message: LocalMessage): Date { - return message.internalDate ?: message.sentDate ?: Date() - } - - private fun getYear(date: Date): Int { - val calendar = Calendar.getInstance() - calendar.time = date - return calendar.get(Calendar.YEAR) + val existingId = folderIdResolver.getFolderId(account, subfolderServerId) + return if (existingId != null) { + existingId + } else { + val folderInfo = FolderInfo( + serverId = subfolderServerId, + name = subfolderServerId, + type = LegacyFolderType.ARCHIVE, + ) + folderCreator.createFolder(account, folderInfo) + } } - private fun getMonth(date: Date): Int { - val calendar = Calendar.getInstance() - calendar.time = date - return calendar.get(Calendar.MONTH) + 1 - } + @OptIn(ExperimentalTime::class) + private val LocalMessage.messageDate: LocalDate + get() { + val epochMillis = (internalDate ?: sentDate)?.time + val timeZone = TimeZone.currentSystemDefault() + val instant = epochMillis?.let { kotlin.time.Instant.fromEpochMilliseconds(it) } + ?: Clock.System.now() + return instant.toLocalDateTime(timeZone).date + } } 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 index 4cb48841fb6..bce81e60d3a 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt @@ -55,6 +55,7 @@ class ArchiveFolderResolverTest { val result = testSubject.resolveArchiveFolder(account, message) assertThat(result).isEqualTo(YEARLY_FOLDER_ID) + assertThat(archiveFolderCreator.createdFolders[0].name).isEqualTo("Archive/2025") } @Test @@ -191,7 +192,7 @@ class ArchiveFolderResolverTest { } @Test - fun `return null when yearly subfolder creation fails`() { + 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) @@ -206,11 +207,11 @@ class ArchiveFolderResolverTest { val result = testSubject.resolveArchiveFolder(account, message) - assertThat(result).isNull() + assertThat(result).isEqualTo(BASE_ARCHIVE_FOLDER_ID) } @Test - fun `return null when yearly folder creation succeeds but monthly creation fails`() { + 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) @@ -232,7 +233,47 @@ class ArchiveFolderResolverTest { val result = testSubject.resolveArchiveFolder(account, message) - assertThat(result).isNull() + 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( 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 index 3c93613ce06..67d0eccd17f 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt +++ b/legacy/core/src/test/java/com/fsck/k9/controller/FakeArchiveFolderCreator.kt @@ -8,9 +8,11 @@ internal class FakeArchiveFolderCreator( 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 } From 77b13989b61e3fb458b445ca66c083ae7578658c Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:19:14 -0300 Subject: [PATCH 15/22] chore: revert unwanted changes --- .../com/fsck/k9/preferences/migration/StorageMigrations.kt | 1 + .../fsck/k9/preferences/migration/StorageMigrationTo29Test.kt | 3 +++ 2 files changed, 4 insertions(+) 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 bd94d30c138..b6dc4f600fd 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 @@ -15,6 +15,7 @@ internal object StorageMigrations { if (oldVersion < 6) StorageMigrationTo6(db, migrationsHelper).performLegacyMigrations() if (oldVersion < 7) StorageMigrationTo7(db, migrationsHelper).rewriteEnumOrdinalsToNames() if (oldVersion < 8) StorageMigrationTo8(db, migrationsHelper).rewriteTheme() + // 9: "Temporarily disable Push" is no longer necessary if (oldVersion < 10) StorageMigrationTo10(db, migrationsHelper).removeSavedFolderSettings() if (oldVersion < 11) StorageMigrationTo11(db, migrationsHelper).upgradeMessageViewContentFontSize() if (oldVersion < 12) StorageMigrationTo12(db, migrationsHelper).removeStoreAndTransportUri() diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt index 3ba18d1eb9c..84133342127 100644 --- a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt +++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo29Test.kt @@ -26,10 +26,13 @@ class StorageMigrationTo29Test { @Test fun `migration should rename account_setup_auto_expand_folder to auto_select_folder`() { + // Arrange: Insert old data into the database migrationHelper.insertValue(database, "account_setup_auto_expand_folder", "some value") + // Act: Run the migration migration.renameAutoSelectFolderPreference() + // Assert: Verify the results val values = migrationHelper.readAllValues(database) assertThat(values).key("auto_select_folder").isEqualTo("some value") assertThat(values).doesNotContainKey("account_setup_auto_expand_folder") From 9dd3c3a6adbebb04846bba0c84bd504d0857420d Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:20:24 -0300 Subject: [PATCH 16/22] chore: remove ExperimentalTime usage in ArchiveFolderResolver The `kotlin.time.Instant` API is no longer experimental, so the `@OptIn(ExperimentalTime::class)` annotation and explicit package prefix are no longer necessary. --- .../java/com/fsck/k9/controller/ArchiveFolderResolver.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index 6fca9243cf7..863822f1d28 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -4,7 +4,7 @@ import com.fsck.k9.backend.api.FolderInfo import com.fsck.k9.mailstore.LocalMessage import java.util.Locale import kotlin.time.Clock -import kotlin.time.ExperimentalTime +import kotlin.time.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -68,12 +68,11 @@ internal class ArchiveFolderResolver( } } - @OptIn(ExperimentalTime::class) private val LocalMessage.messageDate: LocalDate get() { val epochMillis = (internalDate ?: sentDate)?.time val timeZone = TimeZone.currentSystemDefault() - val instant = epochMillis?.let { kotlin.time.Instant.fromEpochMilliseconds(it) } + val instant = epochMillis?.let { Instant.fromEpochMilliseconds(it) } ?: Clock.System.now() return instant.toLocalDateTime(timeZone).date } From ce44a630173a7c4510f00f6a8a59053791f0e355 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:20:48 -0300 Subject: [PATCH 17/22] fix: add missing folder path delimiter in the folder creation --- .../main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt | 1 + 1 file changed, 1 insertion(+) 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 index 863822f1d28..658892921cf 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveFolderResolver.kt @@ -63,6 +63,7 @@ internal class ArchiveFolderResolver( serverId = subfolderServerId, name = subfolderServerId, type = LegacyFolderType.ARCHIVE, + folderPathDelimiter = delimiter, ) folderCreator.createFolder(account, folderInfo) } From e8b5b802c55ac759b9518b7b7d6af8a239762fae Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:21:10 -0300 Subject: [PATCH 18/22] style: fix import order --- .../account/storage/legacy/LegacyAccountStorageHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 48d75e5459c..cfc562314d8 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,8 +16,8 @@ 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.FOLDER_DEFAULT_PATH_DELIMITER 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 import net.thunderbird.feature.notification.NotificationSettings From f5efee71182e2684d7769fc40e8412d9195f20f7 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:21:42 -0300 Subject: [PATCH 19/22] refactor: replace `LegacyAccountDtoBackendStorageFactory` with `BackendStorageFactory` --- .../BackendStorageArchiveFolderCreator.kt | 6 +++--- .../java/com/fsck/k9/controller/KoinModule.kt | 6 ++++-- .../k9/controller/MessagingController.java | 19 +++++++++++-------- .../controller/MessagingControllerTest.java | 10 +++++++--- 4 files changed, 25 insertions(+), 16 deletions(-) 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 index 839ffa00d54..8f935e71200 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt @@ -3,17 +3,17 @@ 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 com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory +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: LegacyAccountDtoBackendStorageFactory, + private val backendStorageFactory: BackendStorageFactory, ) : ArchiveFolderCreator { @Suppress("TooGenericExceptionCaught") override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { return try { - val backendStorage = backendStorageFactory.createBackendStorage(account) + val backendStorage = backendStorageFactory.createBackendStorage(account.id) backendStorage.updateFolders { createFolder(folderInfo) } 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 07377b4e315..ed798e30e2b 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 @@ -6,12 +6,12 @@ import app.k9mail.legacy.message.controller.MessageCountsProvider import app.k9mail.legacy.message.controller.MessagingControllerRegistry import com.fsck.k9.Preferences import com.fsck.k9.backend.BackendManager -import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory import com.fsck.k9.mailstore.LocalStoreProvider 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 @@ -23,6 +23,7 @@ import org.koin.dsl.binds import org.koin.dsl.module val controllerModule = module { + single { MessageStoreFolderIdResolver(messageStoreManager = get()) } single { MessagingController( get(), @@ -41,7 +42,8 @@ val controllerModule = module { get(named("syncDebug")), get(), get(), - get(), + get(), + get(), ) } binds arrayOf(MessagingControllerRegistry::class) 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 7f1bd46308c..98d745a6705 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,13 +62,14 @@ 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; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mail.power.PowerManager; import com.fsck.k9.mail.power.WakeLock; -import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory; import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.LocalMessage; import com.fsck.k9.mailstore.LocalStore; @@ -133,7 +134,7 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final SaveMessageDataCreator saveMessageDataCreator; private final SpecialLocalFoldersCreator specialLocalFoldersCreator; private final LocalDeleteOperationDecider localDeleteOperationDecider; - private final LegacyAccountDtoBackendStorageFactory backendStorageFactory; + private final BackendStorageFactory backendStorageFactory; private final Thread controllerThread; @@ -150,6 +151,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; @@ -176,7 +178,8 @@ public static MessagingController getInstance(Context context) { Logger syncDebugLogger, NotificationManager notificationManager, OutboxFolderManager outboxFolderManager, - LegacyAccountDtoBackendStorageFactory backendStorageFactory + BackendStorageFactory backendStorageFactory, + FolderIdResolver folderIdResolver ) { this.context = context; this.notificationController = notificationController; @@ -195,6 +198,7 @@ public static MessagingController getInstance(Context context) { this.notificationDismisser = new NotificationDismisserCompat(notificationManager); this.outboxFolderManager = outboxFolderManager; this.backendStorageFactory = backendStorageFactory; + this.folderIdResolver = folderIdResolver; controllerThread = new Thread(new Runnable() { @Override @@ -215,7 +219,7 @@ public void run() { this, featureFlagProvider, new ArchiveFolderResolver( - new MessageStoreFolderIdResolver(messageStoreManager), + folderIdResolver, new BackendStorageArchiveFolderCreator(backendStorageFactory) ) ); @@ -318,9 +322,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 + ")"); } @@ -328,8 +332,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/MessagingControllerTest.java b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index bc62101a39b..0932de14705 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; @@ -17,7 +19,6 @@ import com.fsck.k9.backend.BackendManager; import com.fsck.k9.backend.api.Backend; import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mailstore.LegacyAccountDtoBackendStorageFactory; import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateChainException; import com.fsck.k9.mail.CertificateValidationException; @@ -97,7 +98,7 @@ public class MessagingControllerTest extends K9RobolectricTest { @Mock private SpecialLocalFoldersCreator specialLocalFoldersCreator; @Mock - private LegacyAccountDtoBackendStorageFactory backendStorageFactory; + private BackendStorageFactory backendStorageFactory; @Mock private SimpleMessagingListener listener; @Mock @@ -127,6 +128,8 @@ public class MessagingControllerTest extends K9RobolectricTest { private Preferences preferences; private String accountUuid; private FeatureFlagProvider featureFlagProvider; + @Mock + private FolderIdResolver folderIdResolver; @Mock private Logger syncLogger; @@ -167,7 +170,8 @@ public void setUp() throws MessagingException { syncLogger, notificationManager, fakeOutboxFolderManager, - backendStorageFactory + backendStorageFactory, + folderIdResolver ); configureAccount(); From 07089b71cef0bc0adb9253462c757e6034b93c79 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 09:39:51 -0300 Subject: [PATCH 20/22] feat(ui): toggle archive granularity based on folder selection --- .../account/AccountSettingsFragment.kt | 23 +++++++++++++++++++ .../settings/account/FolderListPreference.kt | 9 ++++++++ 2 files changed, 32 insertions(+) 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 b236b221c94..7d5fd135ec3 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 @@ -314,6 +314,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 || @@ -433,10 +443,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) @@ -466,6 +479,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?) { @@ -541,6 +563,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|" From 4164068e5b8bf4c8206df030dba148a52ea39eda Mon Sep 17 00:00:00 2001 From: Danielle Date: Mon, 24 Aug 2026 17:58:18 -0400 Subject: [PATCH 21/22] Rebased with main, re-added fnune's changes, and updated the logger implementation. --- .../BackendStorageArchiveFolderCreator.kt | 5 +- .../k9/controller/MessagingController.java | 5 +- .../migration/StorageMigrationTo31.kt | 36 +++++ .../migration/StorageMigrations.kt | 1 + .../migration/StorageMigrationTo31Test.kt | 129 ++++++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo31.kt create mode 100644 legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo31Test.kt 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 index 8f935e71200..47693586e2e 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/BackendStorageArchiveFolderCreator.kt @@ -5,10 +5,11 @@ 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 +import net.thunderbird.core.logging.Logger internal class BackendStorageArchiveFolderCreator( private val backendStorageFactory: BackendStorageFactory, + private val logger: Logger, ) : ArchiveFolderCreator { @Suppress("TooGenericExceptionCaught") override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? { @@ -18,7 +19,7 @@ internal class BackendStorageArchiveFolderCreator( createFolder(folderInfo) } } catch (e: Exception) { - Log.e(e, "Failed to create archive subfolder: ${folderInfo.serverId}") + logger.error(throwable = e) { "Failed to create archive subfolder: ${folderInfo.serverId}" } null } } 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 98d745a6705..eeca817f5b6 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 @@ -220,7 +220,10 @@ public void run() { featureFlagProvider, new ArchiveFolderResolver( folderIdResolver, - new BackendStorageArchiveFolderCreator(backendStorageFactory) + new BackendStorageArchiveFolderCreator( + backendStorageFactory, + syncDebugLogger + ) ) ); } diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo31.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo31.kt new file mode 100644 index 00000000000..ac7a96c1c2b --- /dev/null +++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo31.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 StorageMigrationTo31( + 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 b6dc4f600fd..bfb08ea061a 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 @@ -37,5 +37,6 @@ internal object StorageMigrations { if (oldVersion < 28) StorageMigrationTo28(db, migrationsHelper).ensureAvatarSet() if (oldVersion < 29) StorageMigrationTo29(db, migrationsHelper).renameAutoSelectFolderPreference() if (oldVersion < 30) StorageMigrationTo30(db, migrationsHelper).migrateAnimationSetting() + if (oldVersion < 31) StorageMigrationTo31(db, migrationsHelper).setDefaultArchiveGranularity() } } diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo31Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo31Test.kt new file mode 100644 index 00000000000..035b3e09968 --- /dev/null +++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo31Test.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.testing.TestLogger +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity +import net.thunderbird.legacy.logging.Log +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StorageMigrationTo31Test { + private val database = createPreferencesDatabase() + private val migrationHelper = DefaultStorageMigrationHelper() + private val migration = StorageMigrationTo31(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 + } +} From 71964c3d1291a1ca20142e8dfc43d3863dd48272 Mon Sep 17 00:00:00 2001 From: Danielle Date: Tue, 25 Aug 2026 11:05:12 -0400 Subject: [PATCH 22/22] Fixed legacy log import in test. --- .../java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index bce81e60d3a..2bd473bd105 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt +++ b/legacy/core/src/test/java/com/fsck/k9/controller/ArchiveFolderResolverTest.kt @@ -11,8 +11,8 @@ 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 net.thunderbird.legacy.logging.Log import org.mockito.kotlin.mock import org.mockito.kotlin.whenever