Extract common functionality to AutoconfigFetcher
This commit is contained in:
parent
9ae4c89c4e
commit
08786a37a1
8 changed files with 100 additions and 144 deletions
|
@ -1,21 +1,14 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscovery
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
|
||||
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.ErrorResponse
|
||||
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.SuccessResponse
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import app.k9mail.core.common.mail.toDomain
|
||||
import com.fsck.k9.logging.Timber
|
||||
import java.io.IOException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class AutoconfigDiscovery internal constructor(
|
||||
private val urlProvider: AutoconfigUrlProvider,
|
||||
private val fetcher: HttpFetcher,
|
||||
private val parser: SuspendableAutoconfigParser,
|
||||
private val autoconfigFetcher: AutoconfigFetcher,
|
||||
) : AutoDiscovery {
|
||||
|
||||
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
|
||||
|
@ -25,29 +18,10 @@ class AutoconfigDiscovery internal constructor(
|
|||
|
||||
return autoconfigUrls.map { autoconfigUrl ->
|
||||
AutoDiscoveryRunnable {
|
||||
getAutoconfig(email, autoconfigUrl)
|
||||
autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAutoconfig(email: EmailAddress, autoconfigUrl: HttpUrl): AutoDiscoveryResult? {
|
||||
return try {
|
||||
when (val fetchResult = fetcher.fetch(autoconfigUrl)) {
|
||||
is SuccessResponse -> {
|
||||
fetchResult.inputStream.use { inputStream ->
|
||||
parser.parseSettings(inputStream, email)
|
||||
}
|
||||
}
|
||||
is ErrorResponse -> null
|
||||
}
|
||||
} catch (e: AutoconfigParserException) {
|
||||
Timber.d(e, "Failed to parse config from URL: %s", autoconfigUrl)
|
||||
null
|
||||
} catch (e: IOException) {
|
||||
Timber.d(e, "Error fetching Autoconfig from URL: %s", autoconfigUrl)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createProviderAutoconfigDiscovery(
|
||||
|
@ -67,7 +41,9 @@ private fun createAutoconfigDiscovery(
|
|||
okHttpClient: OkHttpClient,
|
||||
urlProvider: AutoconfigUrlProvider,
|
||||
): AutoconfigDiscovery {
|
||||
val fetcher = OkHttpFetcher(okHttpClient)
|
||||
val parser = SuspendableAutoconfigParser(RealAutoconfigParser())
|
||||
return AutoconfigDiscovery(urlProvider, fetcher, parser)
|
||||
val autoconfigFetcher = RealAutoconfigFetcher(
|
||||
fetcher = OkHttpFetcher(okHttpClient),
|
||||
parser = SuspendableAutoconfigParser(RealAutoconfigParser()),
|
||||
)
|
||||
return AutoconfigDiscovery(urlProvider, autoconfigFetcher)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Fetches and parses Autoconfig settings.
|
||||
*/
|
||||
internal interface AutoconfigFetcher {
|
||||
suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult?
|
||||
}
|
|
@ -3,14 +3,11 @@ package app.k9mail.autodiscovery.autoconfig
|
|||
import app.k9mail.autodiscovery.api.AutoDiscovery
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
|
||||
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.ErrorResponse
|
||||
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.SuccessResponse
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import app.k9mail.core.common.mail.toDomain
|
||||
import app.k9mail.core.common.net.Domain
|
||||
import com.fsck.k9.logging.Timber
|
||||
import java.io.IOException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MxLookupAutoconfigDiscovery internal constructor(
|
||||
|
@ -18,8 +15,7 @@ class MxLookupAutoconfigDiscovery internal constructor(
|
|||
private val baseDomainExtractor: BaseDomainExtractor,
|
||||
private val subDomainExtractor: SubDomainExtractor,
|
||||
private val urlProvider: AutoconfigUrlProvider,
|
||||
private val fetcher: HttpFetcher,
|
||||
private val parser: SuspendableAutoconfigParser,
|
||||
private val autoconfigFetcher: AutoconfigFetcher,
|
||||
) : AutoDiscovery {
|
||||
|
||||
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
|
||||
|
@ -48,7 +44,7 @@ class MxLookupAutoconfigDiscovery internal constructor(
|
|||
|
||||
for (domainToCheck in listOfNotNull(mxSubDomain, mxBaseDomain)) {
|
||||
for (autoconfigUrl in urlProvider.getAutoconfigUrls(domainToCheck)) {
|
||||
val discoveryResult = getAutoconfig(email, autoconfigUrl)
|
||||
val discoveryResult = autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email)
|
||||
if (discoveryResult != null) {
|
||||
return discoveryResult
|
||||
}
|
||||
|
@ -75,35 +71,19 @@ class MxLookupAutoconfigDiscovery internal constructor(
|
|||
private fun getNextSubDomain(domain: Domain): Domain? {
|
||||
return subDomainExtractor.extractSubDomain(domain)
|
||||
}
|
||||
|
||||
private suspend fun getAutoconfig(email: EmailAddress, autoconfigUrl: HttpUrl): AutoDiscoveryResult? {
|
||||
return try {
|
||||
when (val fetchResult = fetcher.fetch(autoconfigUrl)) {
|
||||
is SuccessResponse -> {
|
||||
fetchResult.inputStream.use { inputStream ->
|
||||
parser.parseSettings(inputStream, email)
|
||||
}
|
||||
}
|
||||
is ErrorResponse -> null
|
||||
}
|
||||
} catch (e: AutoconfigParserException) {
|
||||
Timber.d(e, "Failed to parse config from URL: %s", autoconfigUrl)
|
||||
null
|
||||
} catch (e: IOException) {
|
||||
Timber.d(e, "Error fetching Autoconfig from URL: %s", autoconfigUrl)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createMxLookupAutoconfigDiscovery(okHttpClient: OkHttpClient): MxLookupAutoconfigDiscovery {
|
||||
val baseDomainExtractor = OkHttpBaseDomainExtractor()
|
||||
val autoconfigFetcher = RealAutoconfigFetcher(
|
||||
fetcher = OkHttpFetcher(okHttpClient),
|
||||
parser = SuspendableAutoconfigParser(RealAutoconfigParser()),
|
||||
)
|
||||
return MxLookupAutoconfigDiscovery(
|
||||
mxResolver = SuspendableMxResolver(MiniDnsMxResolver()),
|
||||
baseDomainExtractor = baseDomainExtractor,
|
||||
subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor),
|
||||
urlProvider = IspDbAutoconfigUrlProvider(),
|
||||
fetcher = OkHttpFetcher(okHttpClient),
|
||||
parser = SuspendableAutoconfigParser(RealAutoconfigParser()),
|
||||
autoconfigFetcher = autoconfigFetcher,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.ErrorResponse
|
||||
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.SuccessResponse
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import com.fsck.k9.logging.Timber
|
||||
import java.io.IOException
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
internal class RealAutoconfigFetcher(
|
||||
private val fetcher: HttpFetcher,
|
||||
private val parser: SuspendableAutoconfigParser,
|
||||
) : AutoconfigFetcher {
|
||||
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult? {
|
||||
return try {
|
||||
when (val fetchResult = fetcher.fetch(autoconfigUrl)) {
|
||||
is SuccessResponse -> {
|
||||
fetchResult.inputStream.use { inputStream ->
|
||||
parser.parseSettings(inputStream, email)
|
||||
}
|
||||
}
|
||||
is ErrorResponse -> null
|
||||
}
|
||||
} catch (e: AutoconfigParserException) {
|
||||
Timber.d(e, "Failed to parse config from URL: %s", autoconfigUrl)
|
||||
null
|
||||
} catch (e: IOException) {
|
||||
Timber.d(e, "Error fetching Autoconfig from URL: %s", autoconfigUrl)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE
|
||||
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_TWO
|
||||
import app.k9mail.core.common.mail.toEmailAddress
|
||||
import app.k9mail.core.common.net.toDomain
|
||||
import assertk.assertThat
|
||||
|
@ -15,47 +17,37 @@ private val IRRELEVANT_EMAIL_ADDRESS = "irrelevant@domain.example".toEmailAddres
|
|||
|
||||
class AutoconfigDiscoveryTest {
|
||||
private val urlProvider = MockAutoconfigUrlProvider()
|
||||
private val fetcher = MockHttpFetcher()
|
||||
private val parser = MockAutoconfigParser()
|
||||
private val discovery = AutoconfigDiscovery(urlProvider, fetcher, SuspendableAutoconfigParser(parser))
|
||||
private val autoconfigFetcher = MockAutoconfigFetcher()
|
||||
private val discovery = AutoconfigDiscovery(urlProvider, autoconfigFetcher)
|
||||
|
||||
@Test
|
||||
fun `AutoconfigFetcher and AutoconfigParser should only be called when AutoDiscoveryRunnable is run`() = runTest {
|
||||
val emailAddress = "user@domain.example".toEmailAddress()
|
||||
val autoconfigUrl = "https://autoconfig.domain.invalid/mail/config-v1.1.xml".toHttpUrl()
|
||||
urlProvider.addResult(listOf(autoconfigUrl))
|
||||
fetcher.addSuccessResult("data")
|
||||
parser.addResult(MockAutoconfigParser.RESULT_ONE)
|
||||
autoconfigFetcher.addResult(RESULT_ONE)
|
||||
|
||||
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
|
||||
|
||||
assertThat(autoDiscoveryRunnables).hasSize(1)
|
||||
assertThat(urlProvider.callArguments).containsExactly("domain.example".toDomain() to emailAddress)
|
||||
assertThat(fetcher.callCount).isEqualTo(0)
|
||||
assertThat(parser.callCount).isEqualTo(0)
|
||||
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
|
||||
|
||||
val discoveryResult = autoDiscoveryRunnables.first().run()
|
||||
|
||||
assertThat(fetcher.callArguments).containsExactly(autoconfigUrl)
|
||||
assertThat(parser.callArguments).containsExactly("data" to emailAddress)
|
||||
assertThat(discoveryResult).isEqualTo(MockAutoconfigParser.RESULT_ONE)
|
||||
assertThat(autoconfigFetcher.callArguments).containsExactly(autoconfigUrl to emailAddress)
|
||||
assertThat(discoveryResult).isEqualTo(RESULT_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Two Autoconfig URLs should return two AutoDiscoveryRunnables`() = runTest {
|
||||
urlProvider.addResult(
|
||||
listOf(
|
||||
"https://autoconfig.domain1.invalid/mail/config-v1.1.xml".toHttpUrl(),
|
||||
"https://autoconfig.domain2.invalid/mail/config-v1.1.xml".toHttpUrl(),
|
||||
),
|
||||
)
|
||||
fetcher.apply {
|
||||
addSuccessResult("data1")
|
||||
addSuccessResult("data2")
|
||||
}
|
||||
parser.apply {
|
||||
addResult(MockAutoconfigParser.RESULT_ONE)
|
||||
addResult(MockAutoconfigParser.RESULT_TWO)
|
||||
val urlOne = "https://autoconfig.domain1.invalid/mail/config-v1.1.xml".toHttpUrl()
|
||||
val urlTwo = "https://autoconfig.domain2.invalid/mail/config-v1.1.xml".toHttpUrl()
|
||||
|
||||
urlProvider.addResult(listOf(urlOne, urlTwo))
|
||||
autoconfigFetcher.apply {
|
||||
addResult(RESULT_ONE)
|
||||
addResult(RESULT_TWO)
|
||||
}
|
||||
|
||||
val autoDiscoveryRunnables = discovery.initDiscovery(IRRELEVANT_EMAIL_ADDRESS)
|
||||
|
@ -64,14 +56,14 @@ class AutoconfigDiscoveryTest {
|
|||
|
||||
val discoveryResultOne = autoDiscoveryRunnables[0].run()
|
||||
|
||||
assertThat(parser.callArguments).extracting { it.first }.containsExactly("data1")
|
||||
assertThat(discoveryResultOne).isEqualTo(MockAutoconfigParser.RESULT_ONE)
|
||||
assertThat(autoconfigFetcher.callArguments).extracting { it.first }.containsExactly(urlOne)
|
||||
assertThat(discoveryResultOne).isEqualTo(RESULT_ONE)
|
||||
|
||||
parser.callArguments.clear()
|
||||
autoconfigFetcher.callArguments.clear()
|
||||
|
||||
val discoveryResultTwo = autoDiscoveryRunnables[1].run()
|
||||
|
||||
assertThat(parser.callArguments).extracting { it.first }.containsExactly("data2")
|
||||
assertThat(discoveryResultTwo).isEqualTo(MockAutoconfigParser.RESULT_TWO)
|
||||
assertThat(autoconfigFetcher.callArguments).extracting { it.first }.containsExactly(urlTwo)
|
||||
assertThat(discoveryResultTwo).isEqualTo(RESULT_TWO)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,10 @@ import app.k9mail.autodiscovery.api.SmtpServerSettings
|
|||
import app.k9mail.core.common.mail.EmailAddress
|
||||
import app.k9mail.core.common.net.toHostname
|
||||
import app.k9mail.core.common.net.toPort
|
||||
import java.io.InputStream
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
internal class MockAutoconfigParser : AutoconfigParser {
|
||||
val callArguments = mutableListOf<Pair<String, EmailAddress>>()
|
||||
internal class MockAutoconfigFetcher : AutoconfigFetcher {
|
||||
val callArguments = mutableListOf<Pair<HttpUrl, EmailAddress>>()
|
||||
|
||||
val callCount: Int
|
||||
get() = callArguments.size
|
||||
|
@ -24,11 +24,12 @@ internal class MockAutoconfigParser : AutoconfigParser {
|
|||
results.add(discoveryResult)
|
||||
}
|
||||
|
||||
override fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoDiscoveryResult? {
|
||||
val data = String(inputStream.readBytes())
|
||||
callArguments.add(data to email)
|
||||
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult? {
|
||||
callArguments.add(autoconfigUrl to email)
|
||||
|
||||
check(results.isNotEmpty()) { "parseSettings($data, $email) called but no result provided" }
|
||||
check(results.isNotEmpty()) {
|
||||
"MockAutoconfigFetcher.fetchAutoconfig($autoconfigUrl) called but no result provided"
|
||||
}
|
||||
return results.removeAt(0)
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
internal class MockHttpFetcher : HttpFetcher {
|
||||
val callArguments = mutableListOf<HttpUrl>()
|
||||
|
||||
val callCount: Int
|
||||
get() = callArguments.size
|
||||
|
||||
private val results = mutableListOf<HttpFetchResult>()
|
||||
|
||||
fun addSuccessResult(data: String) {
|
||||
val result = HttpFetchResult.SuccessResponse(
|
||||
inputStream = data.byteInputStream(),
|
||||
)
|
||||
|
||||
results.add(result)
|
||||
}
|
||||
|
||||
fun addErrorResult(code: Int) {
|
||||
results.add(HttpFetchResult.ErrorResponse(code))
|
||||
}
|
||||
|
||||
override suspend fun fetch(url: HttpUrl): HttpFetchResult {
|
||||
callArguments.add(url)
|
||||
|
||||
check(results.isNotEmpty()) { "MockHttpFetcher.fetch($url) called but no result provided" }
|
||||
return results.removeAt(0)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE
|
||||
import app.k9mail.core.common.mail.toEmailAddress
|
||||
import app.k9mail.core.common.net.toDomain
|
||||
import assertk.assertThat
|
||||
|
@ -16,15 +17,13 @@ class MxLookupAutoconfigDiscoveryTest {
|
|||
private val mxResolver = MockMxResolver()
|
||||
private val baseDomainExtractor = OkHttpBaseDomainExtractor()
|
||||
private val urlProvider = MockAutoconfigUrlProvider()
|
||||
private val fetcher = MockHttpFetcher()
|
||||
private val parser = MockAutoconfigParser()
|
||||
private val autoconfigFetcher = MockAutoconfigFetcher()
|
||||
private val discovery = MxLookupAutoconfigDiscovery(
|
||||
mxResolver = SuspendableMxResolver(mxResolver),
|
||||
baseDomainExtractor = baseDomainExtractor,
|
||||
subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor),
|
||||
urlProvider = urlProvider,
|
||||
fetcher = fetcher,
|
||||
parser = SuspendableAutoconfigParser(parser),
|
||||
autoconfigFetcher = autoconfigFetcher,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
@ -32,24 +31,21 @@ class MxLookupAutoconfigDiscoveryTest {
|
|||
val emailAddress = "user@company.example".toEmailAddress()
|
||||
mxResolver.addResult("mx.emailprovider.example".toDomain())
|
||||
urlProvider.addResult(listOf("https://ispdb.invalid/emailprovider.example".toHttpUrl()))
|
||||
fetcher.addSuccessResult("data")
|
||||
parser.addResult(MockAutoconfigParser.RESULT_ONE)
|
||||
autoconfigFetcher.addResult(RESULT_ONE)
|
||||
|
||||
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
|
||||
|
||||
assertThat(autoDiscoveryRunnables).hasSize(1)
|
||||
assertThat(mxResolver.callCount).isEqualTo(0)
|
||||
assertThat(fetcher.callCount).isEqualTo(0)
|
||||
assertThat(parser.callCount).isEqualTo(0)
|
||||
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
|
||||
|
||||
val discoveryResult = autoDiscoveryRunnables.first().run()
|
||||
|
||||
assertThat(mxResolver.callArguments).containsExactly("company.example".toDomain())
|
||||
assertThat(urlProvider.callArguments).extracting { it.first }
|
||||
.containsExactly("emailprovider.example".toDomain())
|
||||
assertThat(fetcher.callCount).isEqualTo(1)
|
||||
assertThat(parser.callCount).isEqualTo(1)
|
||||
assertThat(discoveryResult).isEqualTo(MockAutoconfigParser.RESULT_ONE)
|
||||
assertThat(autoconfigFetcher.callCount).isEqualTo(1)
|
||||
assertThat(discoveryResult).isEqualTo(RESULT_ONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -60,9 +56,9 @@ class MxLookupAutoconfigDiscoveryTest {
|
|||
addResult(listOf("https://ispdb.invalid/something.emailprovider.example".toHttpUrl()))
|
||||
addResult(listOf("https://ispdb.invalid/emailprovider.example".toHttpUrl()))
|
||||
}
|
||||
fetcher.apply {
|
||||
addErrorResult(404)
|
||||
addErrorResult(404)
|
||||
autoconfigFetcher.apply {
|
||||
addResult(null)
|
||||
addResult(null)
|
||||
}
|
||||
|
||||
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
|
||||
|
@ -72,8 +68,7 @@ class MxLookupAutoconfigDiscoveryTest {
|
|||
"something.emailprovider.example".toDomain(),
|
||||
"emailprovider.example".toDomain(),
|
||||
)
|
||||
assertThat(fetcher.callCount).isEqualTo(2)
|
||||
assertThat(parser.callCount).isEqualTo(0)
|
||||
assertThat(autoconfigFetcher.callCount).isEqualTo(2)
|
||||
assertThat(discoveryResult).isNull()
|
||||
}
|
||||
|
||||
|
@ -87,8 +82,7 @@ class MxLookupAutoconfigDiscoveryTest {
|
|||
|
||||
assertThat(mxResolver.callCount).isEqualTo(1)
|
||||
assertThat(urlProvider.callCount).isEqualTo(0)
|
||||
assertThat(fetcher.callCount).isEqualTo(0)
|
||||
assertThat(parser.callCount).isEqualTo(0)
|
||||
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
|
||||
assertThat(discoveryResult).isNull()
|
||||
}
|
||||
|
||||
|
@ -102,8 +96,7 @@ class MxLookupAutoconfigDiscoveryTest {
|
|||
|
||||
assertThat(mxResolver.callCount).isEqualTo(1)
|
||||
assertThat(urlProvider.callCount).isEqualTo(0)
|
||||
assertThat(fetcher.callCount).isEqualTo(0)
|
||||
assertThat(parser.callCount).isEqualTo(0)
|
||||
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
|
||||
assertThat(discoveryResult).isNull()
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue