Add AutoDiscoveryService
to run auto discovery steps in parallel
This commit is contained in:
parent
2d0a11aab5
commit
c314b79dba
6 changed files with 305 additions and 0 deletions
|
@ -0,0 +1,10 @@
|
|||
package app.k9mail.autodiscovery.api
|
||||
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
|
||||
/**
|
||||
* Tries to find mail server settings for a given email address.
|
||||
*/
|
||||
interface AutoDiscoveryService {
|
||||
suspend fun discover(email: EmailAddress): AutoDiscoveryResult?
|
||||
}
|
13
feature/autodiscovery/service/build.gradle.kts
Normal file
13
feature/autodiscovery/service/build.gradle.kts
Normal file
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.feature.autodiscovery.autoconfig)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kxml2)
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package app.k9mail.autodiscovery.service
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineStart.LAZY
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
||||
/**
|
||||
* Runs a list of [AutoDiscoveryRunnable]s with descending priority in parallel and returns the result with the highest
|
||||
* priority.
|
||||
*
|
||||
* As soon as an [AutoDiscoveryRunnable] returns a non-`null` result, runnables with a lower priority are canceled.
|
||||
*/
|
||||
internal class PriorityParallelRunner(
|
||||
private val runnables: List<AutoDiscoveryRunnable>,
|
||||
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
suspend fun run(): AutoDiscoveryResult? {
|
||||
return coroutineScope {
|
||||
val deferredList = buildList(capacity = runnables.size) {
|
||||
// Create coroutines in reverse order. So ones with lower priority are created first.
|
||||
for (runnable in runnables.reversed()) {
|
||||
val lowerPriorityCoroutines = toList()
|
||||
|
||||
val deferred = async(coroutineDispatcher, start = LAZY) {
|
||||
runnable.run()?.also {
|
||||
// We've got a non-null result, so cancel all coroutines with lower priority.
|
||||
lowerPriorityCoroutines.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
add(deferred)
|
||||
}
|
||||
}.asReversed()
|
||||
|
||||
for (deferred in deferredList) {
|
||||
deferred.start()
|
||||
}
|
||||
|
||||
@Suppress("SwallowedException")
|
||||
deferredList
|
||||
.map { deferred ->
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
.asSequence()
|
||||
.filterNotNull()
|
||||
.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Deferred<AutoDiscoveryResult?>>.cancelAll() {
|
||||
for (deferred in this) {
|
||||
deferred.cancel()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package app.k9mail.autodiscovery.service
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryService
|
||||
import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig
|
||||
import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery
|
||||
import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery
|
||||
import app.k9mail.autodiscovery.autoconfig.createProviderAutoconfigDiscovery
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Uses Thunderbird's Autoconfig mechanism to find mail server settings for a given email address.
|
||||
*/
|
||||
class RealAutoDiscoveryService(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) : AutoDiscoveryService {
|
||||
|
||||
override suspend fun discover(email: EmailAddress): AutoDiscoveryResult? {
|
||||
val config = AutoconfigUrlConfig(
|
||||
httpsOnly = false,
|
||||
includeEmailAddress = false,
|
||||
)
|
||||
|
||||
val providerDiscovery = createProviderAutoconfigDiscovery(okHttpClient, config)
|
||||
val ispDbDiscovery = createIspDbAutoconfigDiscovery(okHttpClient)
|
||||
val mxDiscovery = createMxLookupAutoconfigDiscovery(okHttpClient)
|
||||
|
||||
val runnables = listOf(
|
||||
providerDiscovery,
|
||||
ispDbDiscovery,
|
||||
mxDiscovery,
|
||||
).flatMap { discovery ->
|
||||
discovery.initDiscovery(email)
|
||||
}
|
||||
|
||||
val runner = PriorityParallelRunner(runnables)
|
||||
return runner.run()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
package app.k9mail.autodiscovery.service
|
||||
|
||||
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
|
||||
import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS
|
||||
import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS
|
||||
import app.k9mail.autodiscovery.api.ImapServerSettings
|
||||
import app.k9mail.autodiscovery.api.SmtpServerSettings
|
||||
import app.k9mail.core.common.net.toHostname
|
||||
import app.k9mail.core.common.net.toPort
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PriorityParallelRunnerTest {
|
||||
@Test
|
||||
fun `first runnable returning a success result should cancel remaining runnables`() = runTest {
|
||||
var runnableTwoStarted = false
|
||||
var runnableThreeStarted = false
|
||||
var runnableTwoCompleted = false
|
||||
var runnableThreeCompleted = false
|
||||
val runnableOne = AutoDiscoveryRunnable {
|
||||
delay(100)
|
||||
DISCOVERY_RESULT_ONE
|
||||
}
|
||||
val runnableTwo = AutoDiscoveryRunnable {
|
||||
runnableTwoStarted = true
|
||||
delay(200)
|
||||
runnableTwoCompleted = true
|
||||
DISCOVERY_RESULT_TWO
|
||||
}
|
||||
val runnableThree = AutoDiscoveryRunnable {
|
||||
runnableThreeStarted = true
|
||||
delay(200)
|
||||
runnableThreeCompleted = false
|
||||
DISCOVERY_RESULT_TWO
|
||||
}
|
||||
val runner = PriorityParallelRunner(
|
||||
runnables = listOf(runnableOne, runnableTwo, runnableThree),
|
||||
coroutineDispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
|
||||
var result: AutoDiscoveryResult? = null
|
||||
launch {
|
||||
result = runner.run()
|
||||
}
|
||||
|
||||
testScheduler.advanceTimeBy(50)
|
||||
|
||||
assertThat(result).isNull()
|
||||
assertThat(runnableTwoStarted).isTrue()
|
||||
assertThat(runnableTwoCompleted).isFalse()
|
||||
assertThat(runnableThreeStarted).isTrue()
|
||||
assertThat(runnableThreeCompleted).isFalse()
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE)
|
||||
assertThat(runnableTwoCompleted).isFalse()
|
||||
assertThat(runnableThreeCompleted).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highest priority result should be used even if it takes longer to be produced`() = runTest {
|
||||
var runnableTwoCompleted = false
|
||||
val runnableOne = AutoDiscoveryRunnable {
|
||||
delay(100)
|
||||
DISCOVERY_RESULT_ONE
|
||||
}
|
||||
val runnableTwo = AutoDiscoveryRunnable {
|
||||
runnableTwoCompleted = true
|
||||
DISCOVERY_RESULT_TWO
|
||||
}
|
||||
val runner = PriorityParallelRunner(
|
||||
runnables = listOf(runnableOne, runnableTwo),
|
||||
coroutineDispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
|
||||
var result: AutoDiscoveryResult? = null
|
||||
launch {
|
||||
result = runner.run()
|
||||
}
|
||||
|
||||
testScheduler.advanceTimeBy(50)
|
||||
|
||||
assertThat(result).isNull()
|
||||
assertThat(runnableTwoCompleted).isTrue()
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wait for higher priority runnable to complete`() = runTest {
|
||||
var runnableOneCompleted = false
|
||||
var runnableTwoCompleted = false
|
||||
val runnableOne = AutoDiscoveryRunnable {
|
||||
delay(100)
|
||||
runnableOneCompleted = true
|
||||
NO_DISCOVERY_RESULT
|
||||
}
|
||||
val runnableTwo = AutoDiscoveryRunnable {
|
||||
runnableTwoCompleted = true
|
||||
DISCOVERY_RESULT_TWO
|
||||
}
|
||||
val runner = PriorityParallelRunner(
|
||||
runnables = listOf(runnableOne, runnableTwo),
|
||||
coroutineDispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
|
||||
var result: AutoDiscoveryResult? = null
|
||||
launch {
|
||||
result = runner.run()
|
||||
}
|
||||
|
||||
testScheduler.advanceTimeBy(50)
|
||||
|
||||
assertThat(result).isNull()
|
||||
assertThat(runnableOneCompleted).isFalse()
|
||||
assertThat(runnableTwoCompleted).isTrue()
|
||||
|
||||
testScheduler.advanceTimeBy(100)
|
||||
|
||||
assertThat(result).isEqualTo(DISCOVERY_RESULT_TWO)
|
||||
assertThat(runnableOneCompleted).isTrue()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val NO_DISCOVERY_RESULT: AutoDiscoveryResult? = null
|
||||
|
||||
private val DISCOVERY_RESULT_ONE = AutoDiscoveryResult(
|
||||
ImapServerSettings(
|
||||
hostname = "imap.domain.example".toHostname(),
|
||||
port = 993.toPort(),
|
||||
connectionSecurity = TLS,
|
||||
authenticationType = PasswordCleartext,
|
||||
username = "user@domain.example",
|
||||
),
|
||||
SmtpServerSettings(
|
||||
hostname = "smtp.domain.example".toHostname(),
|
||||
port = 587.toPort(),
|
||||
connectionSecurity = StartTLS,
|
||||
authenticationType = PasswordCleartext,
|
||||
username = "user@domain.example",
|
||||
),
|
||||
)
|
||||
|
||||
private val DISCOVERY_RESULT_TWO = AutoDiscoveryResult(
|
||||
ImapServerSettings(
|
||||
hostname = "imap.domain.example".toHostname(),
|
||||
port = 143.toPort(),
|
||||
connectionSecurity = StartTLS,
|
||||
authenticationType = PasswordCleartext,
|
||||
username = "user@domain.example",
|
||||
),
|
||||
SmtpServerSettings(
|
||||
hostname = "smtp.domain.example".toHostname(),
|
||||
port = 465.toPort(),
|
||||
connectionSecurity = TLS,
|
||||
authenticationType = PasswordCleartext,
|
||||
username = "user@domain.example",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -45,6 +45,7 @@ include(
|
|||
":feature:autodiscovery:providersxml",
|
||||
":feature:autodiscovery:srvrecords",
|
||||
":feature:autodiscovery:autoconfig",
|
||||
":feature:autodiscovery:service",
|
||||
)
|
||||
|
||||
include(
|
||||
|
|
Loading…
Reference in a new issue