Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ internal class AccountCreator(
}
}

private fun create(account: Account): String {
private suspend fun create(account: Account): String {
val newAccount = preferences.newAccount(account.uuid)

newAccount.email = account.emailAddress
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,6 @@ open class LegacyAccount(
@set:Synchronized
var inboxFolderId: Long? = null

@get:Synchronized
@set:Synchronized
var outboxFolderId: Long? = null

@get:Synchronized
@set:Synchronized
var draftsFolderId: Long? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ data class LegacyAccountWrapper(
val importedArchiveFolder: String? = null,
val importedSpamFolder: String? = null,
val inboxFolderId: Long? = null,
val outboxFolderId: Long? = null,
val draftsFolderId: Long? = null,
val sentFolderId: Long? = null,
val trashFolderId: Long? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ class LegacyAccountWrapperTest {
importedArchiveFolder = null,
importedSpamFolder = null,
inboxFolderId = null,
outboxFolderId = null,
draftsFolderId = null,
sentFolderId = null,
trashFolderId = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@file:OptIn(ExperimentalTime::class)

package net.thunderbird.core.common.cache

import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

class TimeLimitedCache<TKey : Any, TValue : Any?>(
private val clock: Clock = Clock.System,
private val cache: MutableMap<TKey, Entry<TValue>> = mutableMapOf(),
) : Cache<TKey, TimeLimitedCache.Entry<TValue>> {
companion object {
private val DEFAULT_EXPIRATION_TIME = 1.hours
}

override fun get(key: TKey): Entry<TValue>? {
recycle(key)
return cache[key]
}

fun getValue(key: TKey): TValue? = get(key)?.value

fun set(key: TKey, value: TValue, expiresIn: Duration = DEFAULT_EXPIRATION_TIME) {
set(key, Entry(value, creationTime = clock.now(), expiresIn))
}

override fun set(key: TKey, value: Entry<TValue>) {
cache[key] = value
}

override fun hasKey(key: TKey): Boolean {
recycle(key)
return key in cache
}

override fun clear() {
cache.clear()
}

fun clearExpired() {
cache.entries.removeAll { (_, entry) ->
entry.expiresAt < clock.now()
}
}

private fun recycle(key: TKey) {
val entry = cache[key] ?: return
if (entry.expiresAt < clock.now()) {
cache.remove(key)
}
}

data class Entry<TValue : Any?>(
val value: TValue,
val creationTime: Instant,
val expiresIn: Duration,
val expiresAt: Instant = creationTime + expiresIn,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package net.thunderbird.core.common.cache

import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import kotlin.test.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
import net.thunderbird.core.testing.TestClock

@OptIn(ExperimentalTime::class)
class TimeLimitedCacheTest {

private val clock = TestClock()
private val cache = TimeLimitedCache<String, String>(clock = clock)

@Test
fun `getValue should return null when entry present and expired`() {
// Arrange
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds)

// Act
val result = cache.getValue(KEY)

// Assert
assertThat(result).isNull()
}

@Test
fun `hasKey should answer false when cache has entry and validity expired`() {
// Arrange
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds)

// Act
val result = cache.hasKey(KEY)

// Assert
assertThat(result).isFalse()
}

@Test
fun `should keep cache when time progresses within expiration`() {
// Arrange
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
clock.advanceTimeBy(EXPIRES_IN - 1.milliseconds)

// Act
val result = cache.getValue(KEY)

// Assert
assertThat(result).isEqualTo(VALUE)
}

@Test
fun `clearExpired should remove only expired entries`() {
// Arrange
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
cache.set(KEY_2, VALUE_2, expiresIn = EXPIRES_IN * 2)
clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds)

// Act
cache.clearExpired()

// Assert
assertThat(cache.getValue(KEY)).isNull()
assertThat(cache.getValue(KEY_2)).isEqualTo(VALUE_2)
}

@Test
fun `get should return Entry with correct metadata when not expired`() {
// Arrange
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)

// Act
val entry = cache[KEY]

// Assert
assertThat(entry).isNotNull()
entry!!
assertThat(entry.value).isEqualTo(VALUE)
assertThat(entry.expiresIn).isEqualTo(EXPIRES_IN)
assertThat(entry.expiresAt).isEqualTo(entry.creationTime + EXPIRES_IN)
}

private companion object {
const val KEY = "key"
const val KEY_2 = "key2"
const val VALUE = "value"
const val VALUE_2 = "value2"
val EXPIRES_IN: Duration = 500.milliseconds
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ class LegacyAccountStorageHandler(
importedSpamFolder = storage.getStringOrNull(keyGen.create("spamFolderName"))

inboxFolderId = storage.getStringOrNull(keyGen.create("inboxFolderId"))?.toLongOrNull()
outboxFolderId = storage.getStringOrNull(keyGen.create("outboxFolderId"))?.toLongOrNull()

val draftsFolderId = storage.getStringOrNull(keyGen.create("draftsFolderId"))?.toLongOrNull()
val draftsFolderSelection = getEnumStringPref<SpecialFolderSelection>(
Expand Down Expand Up @@ -349,7 +348,6 @@ class LegacyAccountStorageHandler(
editor.putString(keyGen.create("archiveFolderName"), importedArchiveFolder)
editor.putString(keyGen.create("spamFolderName"), importedSpamFolder)
editor.putString(keyGen.create("inboxFolderId"), inboxFolderId?.toString())
editor.putString(keyGen.create("outboxFolderId"), outboxFolderId?.toString())
editor.putString(keyGen.create("draftsFolderId"), draftsFolderId?.toString())
editor.putString(keyGen.create("sentFolderId"), sentFolderId?.toString())
editor.putString(keyGen.create("trashFolderId"), trashFolderId?.toString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ class DefaultLegacyAccountWrapperDataMapper : DataMapper<LegacyAccountWrapper, L
importedArchiveFolder = dto.importedArchiveFolder,
importedSpamFolder = dto.importedSpamFolder,
inboxFolderId = dto.inboxFolderId,
outboxFolderId = dto.outboxFolderId,
draftsFolderId = dto.draftsFolderId,
sentFolderId = dto.sentFolderId,
trashFolderId = dto.trashFolderId,
Expand Down Expand Up @@ -150,7 +149,6 @@ class DefaultLegacyAccountWrapperDataMapper : DataMapper<LegacyAccountWrapper, L
importedArchiveFolder = domain.importedArchiveFolder
importedSpamFolder = domain.importedSpamFolder
inboxFolderId = domain.inboxFolderId
outboxFolderId = domain.outboxFolderId
draftsFolderId = domain.draftsFolderId
sentFolderId = domain.sentFolderId
trashFolderId = domain.trashFolderId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ class DefaultLegacyAccountWrapperDataMapperTest {
assertThat(result.importedArchiveFolder).isEqualTo("importedArchiveFolder")
assertThat(result.importedSpamFolder).isEqualTo("importedSpamFolder")
assertThat(result.inboxFolderId).isEqualTo(1)
assertThat(result.outboxFolderId).isEqualTo(2)
assertThat(result.draftsFolderId).isEqualTo(3)
assertThat(result.sentFolderId).isEqualTo(4)
assertThat(result.trashFolderId).isEqualTo(5)
Expand Down Expand Up @@ -224,7 +223,6 @@ class DefaultLegacyAccountWrapperDataMapperTest {
importedArchiveFolder = "importedArchiveFolder"
importedSpamFolder = "importedSpamFolder"
inboxFolderId = 1
outboxFolderId = 2
draftsFolderId = 3
sentFolderId = 4
trashFolderId = 5
Expand Down Expand Up @@ -339,7 +337,6 @@ class DefaultLegacyAccountWrapperDataMapperTest {
importedArchiveFolder = "importedArchiveFolder",
importedSpamFolder = "importedSpamFolder",
inboxFolderId = 1,
outboxFolderId = 2,
draftsFolderId = 3,
sentFolderId = 4,
trashFolderId = 5,
Expand Down
3 changes: 3 additions & 0 deletions feature/mail/folder/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ android {
kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.outcome)
implementation(projects.feature.account.api)
implementation(projects.feature.mail.account.api)
implementation(libs.androidx.annotation)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package net.thunderbird.feature.mail.folder.api

import androidx.annotation.Discouraged
import kotlinx.coroutines.runBlocking
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.AccountIdFactory

/**
* Manages outbox folders for accounts.
*
* An outbox folder is a special folder used to store messages that are waiting to be sent.
* This interface provides methods for getting and creating outbox folders.
*/
interface OutboxFolderManager {
/**
* Gets the folder ID of the outbox folder for the given account.
*
* @param accountId The ID of the account.
* @param createIfMissing If true, the outbox folder will be created if it does not exist.
* @return The folder ID of the outbox folder.
* @throws IllegalStateException If the outbox folder could not be found.
*/
suspend fun getOutboxFolderId(accountId: AccountId, createIfMissing: Boolean = true): Long

/**
* Gets the outbox folder ID for the given account.
*
* This is a blocking call and should not be used on the main thread.
*
* @param accountId The account ID.
* @return The outbox folder ID.
*/
@Discouraged(message = "Avoid blocking calls from the main thread. Use getOutboxFolderId instead.")
fun getOutboxFolderIdSync(accountId: AccountId, createIfMissing: Boolean = true): Long = runBlocking {
getOutboxFolderId(accountId, createIfMissing)
}

/**
* Creates an outbox folder for the given account.
*
* @param accountId The ID of the account for which to create the outbox folder.
* @return An [Outcome] that resolves to the ID of the created outbox folder on success,
* or an [Exception] on failure.
*/
suspend fun createOutboxFolder(accountId: AccountId): Outcome<Long, Exception>

/**
* Checks if there are any pending messages in the outbox for the given account.
*
* @param accountId The ID of the account.
* @return `true` if there are pending messages, `false` otherwise.
*/
suspend fun hasPendingMessages(accountId: AccountId): Boolean
}

/**
* Gets the folder ID of the outbox folder for the given account.
*
* @param accountId The ID of the account.
* @return The folder ID of the outbox folder.
* @throws IllegalStateException If the outbox folder could not be found.
*/
@Discouraged(
message = "This is a wrapper for Java compatibility. " +
"Always use getOutboxFolderIdSync(uuid: AccountId) instead on Kotlin files.",
)
@JvmOverloads
fun OutboxFolderManager.getOutboxFolderIdSync(accountId: String, createIfMissing: Boolean = true): Long {
return getOutboxFolderIdSync(accountId = AccountIdFactory.of(accountId), createIfMissing = createIfMissing)
}

/**
* Checks if there are pending messages in the outbox folder for the given account.
*
* This is a blocking call and should not be used on the main thread.
* This is a wrapper for Java compatibility. Always use `hasPendingMessages(uuid: AccountId): Boolean`
* instead on Kotlin files.
*
* @param accountId The ID of the account.
* @return True if there are pending messages, false otherwise.
*/
@Discouraged(
message = "This is a wrapper for Java compatibility. " +
"Always use hasPendingMessages(uuid: AccountId): Boolean instead on Kotlin files.",
)
fun OutboxFolderManager.hasPendingMessagesSync(accountId: String): Boolean = runBlocking {
hasPendingMessages(accountId = AccountIdFactory.of(accountId))
}
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,11 @@ internal class SettingsImportViewModel(
}
}

private fun importSettings(contentUri: Uri, generalSettings: Boolean, accounts: List<AccountUuid>): ImportResults {
private suspend fun importSettings(
contentUri: Uri,
generalSettings: Boolean,
accounts: List<AccountUuid>,
): ImportResults {
val inputStream = contentResolver.openInputStream(contentUri)
?: error("Failed to open settings file for reading: $contentUri")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ class SettingsImportViewModelTest {
assertThat(uiModelLiveData.value!!.statusText).isEqualTo(StatusText.IMPORTING_PROGRESS)

settingsImporter.stub {
on { importSettings(inputStream, false, listOf("uuid-1")) } doReturn ImportResults(
onBlocking { importSettings(inputStream, false, listOf("uuid-1")) } doReturn ImportResults(
globalSettings = false,
importedAccounts = listOf(
AccountDescriptionPair(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ val featureWidgetMessageListModule = module {
messageListRepository = get(),
messageHelper = get(),
generalSettingsManager = get(),
outboxFolderManager = get(),
)
}
}
Loading