Extract code to retrieve .well-known files to its own modules.

This commit is contained in:
Benoit Marty 2025-08-08 12:21:36 +02:00
parent b1a48ec863
commit dbe5bb767b
26 changed files with 337 additions and 199 deletions

View file

@ -36,7 +36,6 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
@ -44,10 +43,9 @@ dependencies {
implementation(projects.libraries.qrcode) implementation(projects.libraries.qrcode)
implementation(projects.libraries.oidc.api) implementation(projects.libraries.oidc.api)
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
implementation(libs.network.retrofit)
implementation(libs.serialization.json) implementation(libs.serialization.json)
api(projects.features.login.api) api(projects.features.login.api)
@ -65,6 +63,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -13,12 +13,13 @@ import io.element.android.features.login.api.accesscontrol.AccountProviderAccess
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.wellknown.api.WellknownRetriever
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl @Inject constructor( class DefaultAccountProviderAccessControl @Inject constructor(
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
private val elementWellknownRetriever: ElementWellknownRetriever, private val wellknownRetriever: WellknownRetriever,
) : AccountProviderAccessControl { ) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider( assertIsAllowedToConnectToAccountProvider(
@ -37,8 +38,8 @@ class DefaultAccountProviderAccessControl @Inject constructor(
) { ) {
if (enterpriseService.isEnterpriseBuild.not()) { if (enterpriseService.isEnterpriseBuild.not()) {
// Ensure that Element Pro is not required for this account provider // Ensure that Element Pro is not required for this account provider
val wellKnown = elementWellknownRetriever.retrieve( val wellKnown = wellknownRetriever.getElementWellKnown(
accountProviderUrl = accountProviderUrl.ensureProtocol(), baseUrl = accountProviderUrl.ensureProtocol(),
) )
if (wellKnown?.enforceElementPro == true) { if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException( throw AccountProviderAccessException.NeedElementProException(

View file

@ -1,42 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.accesscontrol
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
import io.element.android.features.login.impl.resolver.network.WellknownAPI
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory
import timber.log.Timber
import javax.inject.Inject
interface ElementWellknownRetriever {
suspend fun retrieve(accountProviderUrl: String): ElementWellKnown?
}
@ContributesBinding(AppScope::class)
class DefaultElementWellknownRetriever @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) : ElementWellknownRetriever {
override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? {
val wellknownApi = try {
retrofitFactory.create(accountProviderUrl)
.create(WellknownAPI::class.java)
} catch (e: Exception) {
// If the base URL is not valid, we cannot retrieve the well-known data
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
return null
}
return try {
wellknownApi.getElementWellKnown()
} catch (e: Exception) {
Timber.e(e, "Failed to retrieve Element well-known data for $accountProviderUrl")
null
}
}
}

View file

@ -7,13 +7,14 @@
package io.element.android.features.login.impl.resolver package io.element.android.features.login.impl.resolver
import io.element.android.features.login.impl.resolver.network.WellknownRequest
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.parallelMap import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -27,7 +28,7 @@ import javax.inject.Inject
*/ */
class HomeserverResolver @Inject constructor( class HomeserverResolver @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val wellknownRequest: WellknownRequest, private val wellknownRetriever: WellknownRetriever,
) { ) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow { fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext() val flowContext = currentCoroutineContext()
@ -41,7 +42,7 @@ class HomeserverResolver @Inject constructor(
list.parallelMap { url -> list.parallelMap { url ->
val wellKnown = tryOrNull { val wellKnown = tryOrNull {
withTimeout(5000) { withTimeout(5000) {
wellknownRequest.execute(url) wellknownRetriever.getWellKnown(url)
} }
} }
val isValid = wellKnown?.isValid().orFalse() val isValid = wellKnown?.isValid().orFalse()
@ -86,3 +87,7 @@ class HomeserverResolver @Inject constructor(
} }
} }
} }
private fun WellKnown.isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}

View file

@ -1,27 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver.network
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultWellknownRequest @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) : WellknownRequest {
/**
* Return the WellKnown data, if found.
* @param baseUrl for instance https://matrix.org
*/
override suspend fun execute(baseUrl: String): WellKnown {
val wellknownApi = retrofitFactory.create(baseUrl)
.create(WellknownAPI::class.java)
return wellknownApi.getWellKnown()
}
}

View file

@ -1,15 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver.network
interface WellknownRequest {
/**
* Return the WellKnown data, or throw an error if not found.
* @param baseUrl for instance https://matrix.org
*/
suspend fun execute(baseUrl: String): WellKnown
}

View file

@ -10,12 +10,10 @@ package io.element.android.features.login.impl.web
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.resolver.network.WellknownAPI
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.wellknown.api.WellknownRetriever
import timber.log.Timber import timber.log.Timber
import java.net.HttpURLConnection
import javax.inject.Inject import javax.inject.Inject
interface WebClientUrlForAuthenticationRetriever { interface WebClientUrlForAuthenticationRetriever {
@ -24,24 +22,16 @@ interface WebClientUrlForAuthenticationRetriever {
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultWebClientUrlForAuthenticationRetriever @Inject constructor( class DefaultWebClientUrlForAuthenticationRetriever @Inject constructor(
private val retrofitFactory: RetrofitFactory, private val wellknownRetriever: WellknownRetriever,
) : WebClientUrlForAuthenticationRetriever { ) : WebClientUrlForAuthenticationRetriever {
override suspend fun retrieve(homeServerUrl: String): String { override suspend fun retrieve(homeServerUrl: String): String {
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) { if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
Timber.w("Temporary account creation flow is only supported on matrix.org") Timber.w("Temporary account creation flow is only supported on matrix.org")
throw AccountCreationNotSupported() throw AccountCreationNotSupported()
} }
val wellknownApi = retrofitFactory.create(homeServerUrl) val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl)
.create(WellknownAPI::class.java) ?: throw AccountCreationNotSupported()
val result = try { val registrationHelperUrl = wellknown.registrationHelperUrl
wellknownApi.getElementWellKnown()
} catch (e: retrofit2.HttpException) {
throw when {
e.code() == HttpURLConnection.HTTP_NOT_FOUND -> AccountCreationNotSupported()
else -> e
}
}
val registrationHelperUrl = result.registrationHelperUrl
return if (registrationHelperUrl != null) { return if (registrationHelperUrl != null) {
registrationHelperUrl.toUri() registrationHelperUrl.toUri()
.buildUpon() .buildUpon()

View file

@ -10,10 +10,11 @@ package io.element.android.features.login.impl.accesscontrol
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.login.impl.resolver.network.ElementWellKnown import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Test import org.junit.Test
@ -152,8 +153,8 @@ class DefaultAccountProviderAccessControlTest {
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders }, defaultHomeserverListResult = { allowedAccountProviders },
), ),
elementWellknownRetriever = FakeElementWellknownRetriever( wellknownRetriever = FakeWellknownRetriever(
retrieveResult = { elementWellKnown } getElementWellKnownResult = { elementWellKnown },
), ),
) )

View file

@ -1,19 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.accesscontrol
import io.element.android.features.login.impl.resolver.network.ElementWellKnown
import io.element.android.tests.testutils.simulateLongTask
class FakeElementWellknownRetriever(
private val retrieveResult: (String) -> ElementWellKnown? = { null },
) : ElementWellknownRetriever {
override suspend fun retrieve(accountProviderUrl: String): ElementWellKnown? = simulateLongTask {
retrieveResult(accountProviderUrl)
}
}

View file

@ -11,17 +11,17 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.resolver.network.ElementWellKnown import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -113,14 +113,14 @@ class ChangeServerPresenterTest {
@Test @Test
fun `present - change server element pro required error`() = runTest { fun `present - change server element pro required error`() = runTest {
val retrieveResult = lambdaRecorder<String, ElementWellKnown> { val getElementWellKnownResult = lambdaRecorder<String, ElementWellKnown> {
ElementWellKnown( ElementWellKnown(
enforceElementPro = true, enforceElementPro = true,
) )
} }
createPresenter( createPresenter(
elementWellknownRetriever = FakeElementWellknownRetriever( wellknownRetriever = FakeWellknownRetriever(
retrieveResult = retrieveResult, getElementWellKnownResult = getElementWellKnownResult,
), ),
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
@ -136,7 +136,7 @@ class ChangeServerPresenterTest {
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise") ).isEqualTo("io.element.enterprise")
retrieveResult.assertions() getElementWellKnownResult.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_HOMESERVER_URL.ensureProtocol())) .with(value(A_HOMESERVER_URL.ensureProtocol()))
} }
@ -146,13 +146,13 @@ class ChangeServerPresenterTest {
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(), wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = ChangeServerPresenter( ) = ChangeServerPresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever, wellknownRetriever = wellknownRetriever,
), ),
) )
} }

View file

@ -1,19 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.resolver.network
class FakeWellknownRequest : WellknownRequest {
private var resultMap: Map<String, WellKnown> = emptyMap()
fun givenResultMap(map: Map<String, WellKnown>) {
resultMap = map
}
override suspend fun execute(baseUrl: String): WellKnown {
return resultMap[baseUrl] ?: error("No result provided for $baseUrl")
}
}

View file

@ -13,11 +13,10 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.DefaultLoginUserStory import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -34,6 +33,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -238,7 +238,7 @@ private fun createPresenter(
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(), wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
rageshakeFeatureAvailability: () -> Boolean = { true }, rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(), loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter( ) = OnBoardingPresenter(
@ -248,7 +248,7 @@ private fun createPresenter(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever, wellknownRetriever = wellknownRetriever,
), ),
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper, loginHelper = loginHelper,

View file

@ -14,15 +14,15 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accesscontrol.ElementWellknownRetriever
import io.element.android.features.login.impl.accesscontrol.FakeElementWellknownRetriever
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -162,14 +162,14 @@ class QrCodeScanPresenterTest {
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(), wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = QrCodeScanPresenter( ) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager, qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever, wellknownRetriever = wellknownRetriever,
), ),
) )
} }

View file

@ -13,12 +13,14 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverResolver import io.element.android.features.login.impl.resolver.HomeserverResolver
import io.element.android.features.login.impl.resolver.network.FakeWellknownRequest import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.login.impl.resolver.network.WellKnown
import io.element.android.features.login.impl.resolver.network.WellKnownBaseConfig
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellKnownBaseConfig
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -30,9 +32,9 @@ class SearchAccountProviderPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest() val fakeWellknownRetriever = FakeWellknownRetriever()
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -46,9 +48,9 @@ class SearchAccountProviderPresenterTest {
@Test @Test
fun `present - enter text no result`() = runTest { fun `present - enter text no result`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest() val fakeWellknownRetriever = FakeWellknownRetriever()
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -66,9 +68,9 @@ class SearchAccountProviderPresenterTest {
@Test @Test
fun `present - enter valid url no wellknown`() = runTest { fun `present - enter valid url no wellknown`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest() val fakeWellknownRetriever = FakeWellknownRetriever()
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -92,14 +94,20 @@ class SearchAccountProviderPresenterTest {
@Test @Test
fun `present - enter text one result with wellknown`() = runTest { fun `present - enter text one result with wellknown`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest() val getWellKnownResult = lambdaRecorder<String, WellKnown> {
fakeWellknownRequest.givenResultMap( when (it) {
mapOf( "https://test.org" -> error("not found")
"https://test.io" to aWellKnown(), "https://test.com" -> error("not found")
) "https://test.io" -> aWellKnown()
"https://test" -> error("not found")
else -> error("should not happen")
}
}
val fakeWellknownRetriever = FakeWellknownRetriever(
getWellKnownResult = getWellKnownResult,
) )
val presenter = SearchAccountProviderPresenter( val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() } changeServerPresenter = { aChangeServerState() }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -118,6 +126,65 @@ class SearchAccountProviderPresenterTest {
) )
) )
) )
getWellKnownResult.assertions().isCalledExactly(4)
.withSequence(
listOf(value("https://test.org")),
listOf(value("https://test.com")),
listOf(value("https://test.io")),
listOf(value("https://test")),
)
}
}
@Test
fun `present - enter text two results with wellknown`() = runTest {
val getWellKnownResult = lambdaRecorder<String, WellKnown> {
when (it) {
"https://test.org" -> aWellKnown()
"https://test.com" -> error("not found")
"https://test.io" -> aWellKnown()
"https://test" -> error("not found")
else -> error("should not happen")
}
}
val fakeWellknownRetriever = FakeWellknownRetriever(
getWellKnownResult = getWellKnownResult,
)
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() }
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test"))
val withInputState = awaitItem()
assertThat(withInputState.userInput).isEqualTo("test")
assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized)
assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test.org"),
)
)
)
assertThat(awaitItem().userInputResult).isEqualTo(
AsyncData.Success(
listOf(
aHomeserverData(homeserverUrl = "https://test.org"),
aHomeserverData(homeserverUrl = "https://test.io"),
)
)
)
getWellKnownResult.assertions().isCalledExactly(4)
.withSequence(
listOf(value("https://test.org")),
listOf(value("https://test.com")),
listOf(value("https://test.io")),
listOf(value("https://test")),
)
} }
} }

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.wellknown.api"
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
data class ElementWellKnown(
val registrationHelperUrl: String? = null,
val enforceElementPro: Boolean? = null,
)

View file

@ -0,0 +1,17 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
data class WellKnown(
val homeServer: WellKnownBaseConfig? = null,
val identityServer: WellKnownBaseConfig? = null,
)
data class WellKnownBaseConfig(
val baseURL: String? = null
)

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
interface WellknownRetriever {
suspend fun getWellKnown(baseUrl: String): WellKnown?
suspend fun getElementWellKnown(baseUrl: String): ElementWellKnown?
}

View file

@ -0,0 +1,32 @@
import extension.setupAnvil
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.libraries.wellknown.impl"
}
setupAnvil()
dependencies {
implementation(libs.androidx.annotationjvm)
implementation(libs.coroutines.core)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.network)
implementation(projects.libraries.wellknown.api)
}

View file

@ -0,0 +1,69 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellKnownBaseConfig
import io.element.android.libraries.wellknown.api.WellknownRetriever
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultWellknownRetriever @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) : WellknownRetriever {
override suspend fun getWellKnown(baseUrl: String): WellKnown? {
val wellknownApi = buildWellknownApi(baseUrl) ?: return null
return try {
wellknownApi.getWellKnown().map()
} catch (e: Exception) {
Timber.e(e, "Failed to retrieve well-known data for $baseUrl")
null
}
}
override suspend fun getElementWellKnown(baseUrl: String): ElementWellKnown? {
val wellknownApi = buildWellknownApi(baseUrl) ?: return null
return try {
wellknownApi.getElementWellKnown().map()
} catch (e: Exception) {
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
null
}
}
private fun buildWellknownApi(accountProviderUrl: String): WellknownAPI? {
return try {
retrofitFactory.create(accountProviderUrl.ensureProtocol())
.create(WellknownAPI::class.java)
} catch (e: Exception) {
// If the base URL is not valid, we cannot retrieve the well-known data
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
null
}
}
}
private fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
)
private fun InternalWellKnown.map() = WellKnown(
homeServer = homeServer?.map(),
identityServer = identityServer?.map(),
)
private fun InternalWellKnownBaseConfig.map() = WellKnownBaseConfig(
baseURL = baseURL,
)

View file

@ -1,11 +1,11 @@
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.resolver.network package io.element.android.libraries.wellknown.impl
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -20,7 +20,7 @@ import kotlinx.serialization.Serializable
* . * .
*/ */
@Serializable @Serializable
data class ElementWellKnown( data class InternalElementWellKnown(
@SerialName("registration_helper_url") @SerialName("registration_helper_url")
val registrationHelperUrl: String? = null, val registrationHelperUrl: String? = null,
@SerialName("enforce_element_pro") @SerialName("enforce_element_pro")

View file

@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.resolver.network package io.element.android.libraries.wellknown.impl
import io.element.android.libraries.core.bool.orFalse
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -26,13 +25,9 @@ import kotlinx.serialization.Serializable
* . * .
*/ */
@Serializable @Serializable
data class WellKnown( data class InternalWellKnown(
@SerialName("m.homeserver") @SerialName("m.homeserver")
val homeServer: WellKnownBaseConfig? = null, val homeServer: InternalWellKnownBaseConfig? = null,
@SerialName("m.identity_server") @SerialName("m.identity_server")
val identityServer: WellKnownBaseConfig? = null, val identityServer: InternalWellKnownBaseConfig? = null,
) { )
fun isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.resolver.network package io.element.android.libraries.wellknown.impl
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -20,7 +20,7 @@ import kotlinx.serialization.Serializable
* . * .
*/ */
@Serializable @Serializable
data class WellKnownBaseConfig( data class InternalWellKnownBaseConfig(
@SerialName("base_url") @SerialName("base_url")
val baseURL: String? = null val baseURL: String? = null
) )

View file

@ -5,14 +5,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.login.impl.resolver.network package io.element.android.libraries.wellknown.impl
import retrofit2.http.GET import retrofit2.http.GET
internal interface WellknownAPI { internal interface WellknownAPI {
@GET(".well-known/matrix/client") @GET(".well-known/matrix/client")
suspend fun getWellKnown(): WellKnown suspend fun getWellKnown(): InternalWellKnown
@GET(".well-known/element/element.json") @GET(".well-known/element/element.json")
suspend fun getElementWellKnown(): ElementWellKnown suspend fun getElementWellKnown(): InternalElementWellKnown
} }

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.wellknown.test"
}
dependencies {
implementation(projects.libraries.wellknown.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.simulateLongTask
class FakeWellknownRetriever(
private val getWellKnownResult: (String) -> WellKnown? = { null },
private val getElementWellKnownResult: (String) -> ElementWellKnown? = { null },
) : WellknownRetriever {
override suspend fun getWellKnown(baseUrl: String): WellKnown? = simulateLongTask {
getWellKnownResult(baseUrl)
}
override suspend fun getElementWellKnown(baseUrl: String): ElementWellKnown? = simulateLongTask {
getElementWellKnownResult(baseUrl)
}
}