From af15d2b0532ef5357aa74fccf6d942135dcf4347 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/20] 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/StorageMigrationTo30.kt | 36 +++++++++++++++++++ .../migration/StorageMigrations.kt | 2 +- .../arrays_account_settings_strings.xml | 12 +++++++ .../ui/legacy/src/main/res/values/strings.xml | 5 +++ .../src/main/res/xml/account_settings.xml | 10 ++++++ 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 feature/mail/folder/api/src/commonMain/kotlin/net/thunderbird/feature/mail/folder/api/ArchiveGranularity.kt create mode 100644 legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo30.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 3018387a95d..5ed1a56b149 100644 --- a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountDto.kt @@ -12,6 +12,7 @@ import net.thunderbird.feature.account.AccountIdFactory import net.thunderbird.feature.account.storage.profile.AvatarDto import net.thunderbird.feature.account.storage.profile.AvatarTypeDto import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection import net.thunderbird.feature.notification.NotificationSettings @@ -199,6 +200,9 @@ open class LegacyAccountDto( @get:Synchronized var archiveFolderSelection = SpecialFolderSelection.AUTOMATIC + @get:Synchronized + var archiveGranularity = ArchiveGranularity.DEFAULT + @get:Synchronized var spamFolderSelection = SpecialFolderSelection.AUTOMATIC diff --git a/feature/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/StorageMigrationTo30.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo30.kt new file mode 100644 index 00000000000..b9325c5f2f0 --- /dev/null +++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrationTo30.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.preferences.migration + +import android.database.sqlite.SQLiteDatabase +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity + +class StorageMigrationTo30( + private val db: SQLiteDatabase, + private val migrationsHelper: StorageMigrationHelper, +) { + fun setDefaultArchiveGranularity() { + val accountUuidsValue = migrationsHelper.readValue(db, "accountUuids") + if (accountUuidsValue.isNullOrEmpty()) { + return + } + + val accountUuids = accountUuidsValue.split(",") + for (accountUuid in accountUuids) { + setArchiveGranularityForAccount(accountUuid) + } + } + + private fun setArchiveGranularityForAccount(accountUuid: String) { + val existingValue = migrationsHelper.readValue(db, "$accountUuid.$ARCHIVE_GRANULARITY_KEY") + if (existingValue == null) { + migrationsHelper.insertValue( + db, + "$accountUuid.$ARCHIVE_GRANULARITY_KEY", + ArchiveGranularity.MIGRATION_DEFAULT.name, + ) + } + } + + private companion object { + const val ARCHIVE_GRANULARITY_KEY = "archiveGranularity" + } +} diff --git a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt index b47081eb249..5fe6f641d5a 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() @@ -36,5 +35,6 @@ internal object StorageMigrations { if (oldVersion < 27) StorageMigrationTo27(db, migrationsHelper).addAvatarMonogram() if (oldVersion < 28) StorageMigrationTo28(db, migrationsHelper).ensureAvatarSet() if (oldVersion < 29) StorageMigrationTo29(db, migrationsHelper).renameAutoSelectFolderPreference() + if (oldVersion < 30) StorageMigrationTo30(db, migrationsHelper).setDefaultArchiveGranularity() } } diff --git a/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml b/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml index 0d91d770fae..02324f28b44 100644 --- a/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml +++ b/legacy/ui/legacy/src/main/res/values/arrays_account_settings_strings.xml @@ -144,4 +144,16 @@ @string/account_settings_remote_search_num_results_entries_all + + @string/archive_granularity_single_folder + @string/archive_granularity_yearly_folders + @string/archive_granularity_monthly_folders + + + + SINGLE_ARCHIVE_FOLDER + PER_YEAR_ARCHIVE_FOLDERS + PER_MONTH_ARCHIVE_FOLDERS + + diff --git a/legacy/ui/legacy/src/main/res/values/strings.xml b/legacy/ui/legacy/src/main/res/values/strings.xml index 623d4454eed..37fcac39f59 100644 --- a/legacy/ui/legacy/src/main/res/values/strings.xml +++ b/legacy/ui/legacy/src/main/res/values/strings.xml @@ -392,6 +392,11 @@ Sent folder Trash folder Archive folder + Archive options + When archiving messages, place them in: + A single folder + Yearly archived folders + Monthly archived folders Spam folder Show only subscribed folders diff --git a/legacy/ui/legacy/src/main/res/xml/account_settings.xml b/legacy/ui/legacy/src/main/res/xml/account_settings.xml index e3de53a2d0c..7c1c0dc5a95 100644 --- a/legacy/ui/legacy/src/main/res/xml/account_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/account_settings.xml @@ -236,6 +236,16 @@ android:title="@string/archive_folder_label" /> + + Date: Sun, 16 Nov 2025 17:23:12 +0100 Subject: [PATCH 02/20] 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 4ac43b3a105..4be07c570cf 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.core.logging.legacy.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("archive_marks_as_read".toFeatureFlagKey()) .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 d5a86f5b038..dca593a17ab 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 @@ -206,7 +206,7 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); - archiveOperations = new ArchiveOperations(this, featureFlagProvider); + archiveOperations = new ArchiveOperations(this, featureFlagProvider, new ArchiveFolderResolver()); } private void initializeControllerExtensions(List controllerExtensions) { From d09d6f6d5efc9d74cb7d8e6bb3413e3719fbac13 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/20] 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 4be07c570cf..b28815d5e41 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("archive_marks_as_read".toFeatureFlagKey()) @@ -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 b6fa2c00ee787e53c114b8f41e05d42f799544ab 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/20] 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 b28815d5e41..6b28a3627fa 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.core.logging.legacy.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 dca593a17ab..46947c215f4 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 @@ -206,7 +206,7 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); 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 a34639271619db27a0c89efd485853fd0b641a3c 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/20] 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 ef4286c3ff1..a8814b2334c 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 @@ -38,6 +39,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 46947c215f4..f7d43741155 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 @@ -69,6 +69,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; @@ -135,6 +136,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; @@ -174,7 +176,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 @@ -206,7 +210,7 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); 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 a8a198153629c866e3f2ee0b72e6ce2a6f28b566 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/20] 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 4d8d124014a..6401417497a 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; @@ -94,6 +95,8 @@ public class MessagingControllerTest extends K9RobolectricTest { @Mock private SpecialLocalFoldersCreator specialLocalFoldersCreator; @Mock + private LegacyAccountDtoBackendStorageFactory backendStorageFactory; + @Mock private SimpleMessagingListener listener; @Mock private LocalFolder localFolder; @@ -159,7 +162,8 @@ public void setUp() throws MessagingException { featureFlagProvider, syncLogger, notificationManager, - fakeOutboxFolderManager + fakeOutboxFolderManager, + backendStorageFactory ); configureAccount(); From d57b9e7194b8776ddd6cce5a40dcc93d1d132285 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/20] 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 c4153b54662609beda09497f571a4c728e82b975 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/20] 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 f7d43741155..361ea68f087 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 @@ -210,7 +210,14 @@ public void run() { draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); 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 b898228df80b9d5e568e28dcdd971d5c1f63106e 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/20] 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 3e9ca2729ca854c1b41f037a8a5c0f3680f6674b 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/20] 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 55f1e0c20bc9e743044b0108ca1aac5b8721c960 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/20] 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 6b28a3627fa..0017a1d7b14 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveOperations.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/ArchiveOperations.kt @@ -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 3d9a71f40ec8f81f81b1987a9cf48e4d6b0e5f6b 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/20] 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 1f8f03eb37877d04bba67a35bb4213b32d46ef45 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/20] test: add StorageMigrationTo29Test for archiveGranularity migration --- .../migration/StorageMigrationTo29Test.kt | 3 - .../migration/StorageMigrationTo30Test.kt | 129 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt 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 253ccca2e85..652b1ca9d2f 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") diff --git a/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt new file mode 100644 index 00000000000..687aa075dcb --- /dev/null +++ b/legacy/storage/src/test/java/com/fsck/k9/preferences/migration/StorageMigrationTo30Test.kt @@ -0,0 +1,129 @@ +package com.fsck.k9.preferences.migration + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.key +import com.fsck.k9.preferences.createPreferencesDatabase +import java.util.UUID +import kotlin.test.Test +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.mail.folder.api.ArchiveGranularity +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StorageMigrationTo30Test { + private val database = createPreferencesDatabase() + private val migrationHelper = DefaultStorageMigrationHelper() + private val migration = StorageMigrationTo30(database, migrationHelper) + + @Before + fun setUp() { + Log.logger = TestLogger() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `archiveGranularity should be set to SINGLE_ARCHIVE_FOLDER for existing accounts`() { + val accountUuid = createAccount() + writeAccountUuids(accountUuid) + + migration.setDefaultArchiveGranularity() + + assertThat(migrationHelper.readAllValues(database)) + .key("$accountUuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + } + + @Test + fun `archiveGranularity should not overwrite existing value`() { + val accountUuid = createAccount( + "archiveGranularity" to ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name, + ) + writeAccountUuids(accountUuid) + + migration.setDefaultArchiveGranularity() + + assertThat(migrationHelper.readAllValues(database)) + .key("$accountUuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name) + } + + @Test + fun `archiveGranularity should be set for multiple accounts without the setting`() { + val account1Uuid = createAccount() + val account2Uuid = createAccount() + val account3Uuid = createAccount() + writeAccountUuids(account1Uuid, account2Uuid, account3Uuid) + + migration.setDefaultArchiveGranularity() + + val values = migrationHelper.readAllValues(database) + assertThat(values) + .key("$account1Uuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + assertThat(values) + .key("$account2Uuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + assertThat(values) + .key("$account3Uuid.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + } + + @Test + fun `archiveGranularity should handle mix of accounts with and without existing values`() { + val accountWithoutSetting = createAccount() + val accountWithYearly = createAccount( + "archiveGranularity" to ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS.name, + ) + val accountWithMonthly = createAccount( + "archiveGranularity" to ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name, + ) + writeAccountUuids(accountWithoutSetting, accountWithYearly, accountWithMonthly) + + migration.setDefaultArchiveGranularity() + + val values = migrationHelper.readAllValues(database) + assertThat(values) + .key("$accountWithoutSetting.archiveGranularity") + .isEqualTo(ArchiveGranularity.MIGRATION_DEFAULT.name) + assertThat(values) + .key("$accountWithYearly.archiveGranularity") + .isEqualTo(ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS.name) + assertThat(values) + .key("$accountWithMonthly.archiveGranularity") + .isEqualTo(ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS.name) + } + + @Test + fun `archiveGranularity should not be set when no accounts exist`() { + writeAccountUuids() + + migration.setDefaultArchiveGranularity() + + val values = migrationHelper.readAllValues(database) + assertThat(values.keys.filter { it.contains("archiveGranularity") }).isEqualTo(emptyList()) + } + + private fun writeAccountUuids(vararg accounts: String) { + val accountUuids = accounts.joinToString(separator = ",") + migrationHelper.insertValue(database, "accountUuids", accountUuids) + } + + private fun createAccount(vararg pairs: Pair): String { + val accountUuid = UUID.randomUUID().toString() + + for ((key, value) in pairs) { + migrationHelper.insertValue(database, "$accountUuid.$key", value) + } + + return accountUuid + } +} From 129b0f7c0d2b6113f7d5db3df7670bc835fa55cc 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/20] 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 8c8d0488a16..7944e9965e3 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, @@ -354,6 +361,7 @@ class LegacyAccountStorageHandler( editor.putString(keyGen.create("archiveFolderId"), archiveFolderId?.toString()) editor.putString(keyGen.create("spamFolderId"), spamFolderId?.toString()) editor.putString(keyGen.create("archiveFolderSelection"), archiveFolderSelection.name) + editor.putString(keyGen.create(ARCHIVE_GRANULARITY_KEY), archiveGranularity.name) editor.putString(keyGen.create("draftsFolderSelection"), draftsFolderSelection.name) editor.putString(keyGen.create("sentFolderSelection"), sentFolderSelection.name) editor.putString(keyGen.create("spamFolderSelection"), spamFolderSelection.name) @@ -470,6 +478,7 @@ class LegacyAccountStorageHandler( editor.remove(keyGen.create("archiveFolderName")) editor.remove(keyGen.create("spamFolderName")) editor.remove(keyGen.create("archiveFolderSelection")) + editor.remove(keyGen.create(ARCHIVE_GRANULARITY_KEY)) editor.remove(keyGen.create("draftsFolderSelection")) editor.remove(keyGen.create("sentFolderSelection")) editor.remove(keyGen.create("spamFolderSelection")) @@ -607,5 +616,6 @@ class LegacyAccountStorageHandler( const val IDENTITY_DESCRIPTION_KEY = "description" const val FOLDER_PATH_DELIMITER_KEY = "folderPathDelimiter" + const val ARCHIVE_GRANULARITY_KEY = "archiveGranularity" } } diff --git a/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index 00192187d71..b7e684a40a1 100644 --- a/legacy/core/build.gradle.kts +++ b/legacy/core/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.moshi) implementation(libs.timber) + implementation(libs.kotlinx.datetime) implementation(libs.mime4j.core) implementation(libs.mime4j.dom) implementation(libs.uri) diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/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 d4fd28462bd0fe3990eb816f8a8aafe08a8fa216 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:19:14 -0300 Subject: [PATCH 15/20] 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 5fe6f641d5a..7d3a9793e77 100644 --- a/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt +++ b/legacy/storage/src/main/java/com/fsck/k9/preferences/migration/StorageMigrations.kt @@ -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 652b1ca9d2f..253ccca2e85 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 99bd6452c013f7f702a12c5a51074c3a2ba2f2de Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:20:24 -0300 Subject: [PATCH 16/20] 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 0e37089aa3a4bc2269ff97d96dab84a390a728f6 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:20:48 -0300 Subject: [PATCH 17/20] 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 7c83cc11a558f48914479f29380f7f383ebb9618 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:21:10 -0300 Subject: [PATCH 18/20] 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 7944e9965e3..e566e795261 100644 --- a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt @@ -16,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 1bbf20adb584a317b759814955a7027061d8342f Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 08:21:42 -0300 Subject: [PATCH 19/20] refactor: replace `LegacyAccountDtoBackendStorageFactory` with `BackendStorageFactory` --- .../BackendStorageArchiveFolderCreator.kt | 6 +++--- .../java/com/fsck/k9/controller/KoinModule.kt | 6 ++++-- .../k9/controller/MessagingController.java | 18 ++++++++++-------- .../k9/controller/MessagingControllerTest.java | 10 +++++++--- 4 files changed, 24 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 a8814b2334c..5d26eb6f8a3 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt @@ -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 @@ -22,6 +22,7 @@ import org.koin.dsl.binds import org.koin.dsl.module val controllerModule = module { + single { MessageStoreFolderIdResolver(messageStoreManager = get()) } single { MessagingController( get(), @@ -39,7 +40,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 361ea68f087..df5a1deb0d6 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -62,6 +62,7 @@ import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; import com.fsck.k9.mail.FetchProfile; +import net.thunderbird.backend.api.BackendStorageFactory; import net.thunderbird.core.common.mail.Flag; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessageDownloadState; @@ -69,7 +70,6 @@ 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; @@ -136,7 +136,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; @@ -152,6 +152,7 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final OutboxFolderManager outboxFolderManager; private final NotificationSenderCompat notificationSender; private final NotificationDismisserCompat notificationDismisser; + private final FolderIdResolver folderIdResolver; private volatile boolean stopped = false; @@ -177,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 +197,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 @@ -214,7 +217,7 @@ public void run() { this, featureFlagProvider, new ArchiveFolderResolver( - new MessageStoreFolderIdResolver(messageStoreManager), + folderIdResolver, new BackendStorageArchiveFolderCreator(backendStorageFactory) ) ); @@ -317,9 +320,9 @@ LocalStore getLocalStoreOrThrow(LegacyAccountDto account) { } } + @NonNull private String getFolderServerId(LegacyAccountDto account, long folderId) { - MessageStore messageStore = messageStoreManager.getMessageStore(account); - String folderServerId = messageStore.getFolderServerId(folderId); + final String folderServerId = folderIdResolver.getFolderServerId(account, folderId); if (folderServerId == null) { throw new IllegalStateException("Folder not found (ID: " + folderId + ")"); } @@ -327,8 +330,7 @@ private String getFolderServerId(LegacyAccountDto account, long folderId) { } private long getFolderId(LegacyAccountDto account, String folderServerId) { - MessageStore messageStore = messageStoreManager.getMessageStore(account); - Long folderId = messageStore.getFolderId(folderServerId); + final Long folderId = folderIdResolver.getFolderId(account, folderServerId); if (folderId == null) { throw new IllegalStateException("Folder not found (server ID: " + folderServerId + ")"); } diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index 6401417497a..22071cf564e 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java +++ b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -7,6 +7,8 @@ import java.util.Set; import android.content.Context; + +import net.thunderbird.backend.api.BackendStorageFactory; import net.thunderbird.core.android.account.LegacyAccountDto; import net.thunderbird.core.featureflag.FeatureFlagProvider; import net.thunderbird.core.featureflag.FeatureFlagResult.Disabled; @@ -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; @@ -95,7 +96,7 @@ public class MessagingControllerTest extends K9RobolectricTest { @Mock private SpecialLocalFoldersCreator specialLocalFoldersCreator; @Mock - private LegacyAccountDtoBackendStorageFactory backendStorageFactory; + private BackendStorageFactory backendStorageFactory; @Mock private SimpleMessagingListener listener; @Mock @@ -125,6 +126,8 @@ public class MessagingControllerTest extends K9RobolectricTest { private Preferences preferences; private String accountUuid; private FeatureFlagProvider featureFlagProvider; + @Mock + private FolderIdResolver folderIdResolver; @Mock private Logger syncLogger; @@ -163,7 +166,8 @@ public void setUp() throws MessagingException { syncLogger, notificationManager, fakeOutboxFolderManager, - backendStorageFactory + backendStorageFactory, + folderIdResolver ); configureAccount(); From 56d57bd44f0da832cf3ff6bb731fa0364d5f008e Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 16 Apr 2026 09:39:51 -0300 Subject: [PATCH 20/20] 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 19591306ba6..34d700e6e0f 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt @@ -303,6 +303,16 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr } } + private fun initializeArchiveFolderPreference() { + findPreference(PREFERENCE_ARCHIVE_FOLDER)?.apply { + setOnPreferenceChangeListener { pref, newValue -> + val granularity = findPreference(PREFERENCE_ARCHIVE_GRANULARITY) + granularity?.isEnabled = (pref as? FolderListPreference)?.isNoneSelected(newValue?.toString()) == false + true + } + } + } + private fun maybeUpdateNotificationPreferences(account: LegacyAccountDto) { if (notificationSoundPreference != null || notificationLightPreference != null || @@ -422,10 +432,13 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr if (!messagingController.isMoveCapable(account)) { findPreference(PREFERENCE_ARCHIVE_FOLDER).remove() + findPreference(PREFERENCE_ARCHIVE_GRANULARITY).remove() findPreference(PREFERENCE_DRAFTS_FOLDER).remove() findPreference(PREFERENCE_SENT_FOLDER).remove() findPreference(PREFERENCE_SPAM_FOLDER).remove() findPreference(PREFERENCE_TRASH_FOLDER).remove() + } else { + initializeArchiveFolderPreference() } loadFolders(account) @@ -455,6 +468,15 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr val automaticFolder = remoteFolderInfo.automaticSpecialFolders[type] folderListPreference.setFolders(remoteFolderInfo.folders, automaticFolder) + when (type) { + FolderType.ARCHIVE if folderListPreference.selected == null -> + findPreference(PREFERENCE_ARCHIVE_GRANULARITY)?.isEnabled = false + + FolderType.ARCHIVE -> + findPreference(PREFERENCE_ARCHIVE_GRANULARITY)?.isEnabled = true + + else -> Unit + } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -529,6 +551,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private const val PREFERENCE_AUTO_SELECT_FOLDER = "auto_select_folder" private const val PREFERENCE_SUBSCRIBED_FOLDERS_ONLY = "subscribed_folders_only" private const val PREFERENCE_ARCHIVE_FOLDER = "archive_folder" + private const val PREFERENCE_ARCHIVE_GRANULARITY = "archive_granularity" private const val PREFERENCE_DRAFTS_FOLDER = "drafts_folder" private const val PREFERENCE_SENT_FOLDER = "sent_folder" private const val PREFERENCE_SPAM_FOLDER = "spam_folder" diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt index 605ba68ccb6..cb487436a01 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/FolderListPreference.kt @@ -34,6 +34,9 @@ constructor( private val folderNameFormatter: FolderNameFormatter by inject { parametersOf(context) } private val noFolderSelectedName = context.getString(R.string.account_settings_no_folder_selected).italicize() private lateinit var automaticFolderOption: CharSequence + val selected: Long? + get() = getSelectedValueAsString() + ?.toLongOrNull() init { entries = emptyArray() @@ -79,6 +82,8 @@ constructor( } } + fun isNoneSelected(value: String? = this.value): Boolean = getSelectedValueAsString(value).isNullOrBlank() + private fun getFolderDisplayNames(folders: List) = folders.map { folderNameFormatter.displayName(it) } private fun getFolderValues(folders: List) = folders.map { MANUAL_PREFIX + it.id.toString() } @@ -87,6 +92,10 @@ constructor( return SpannableString(this).apply { setSpan(StyleSpan(Typeface.ITALIC), 0, this.length, 0) } } + private fun getSelectedValueAsString(value: String? = this.value): String? = value + ?.substringAfter(MANUAL_PREFIX) + ?.substringBefore(AUTOMATIC_PREFIX) + companion object { const val FOLDER_VALUE_DELIMITER = "|" const val AUTOMATIC_PREFIX = "AUTOMATIC|"