Merge pull request #5139 from element-hq/feature/bma/rageshakeConfigStep2

Let enterprise build be able to override (or disable) the bug report URL.
This commit is contained in:
Benoit Marty
2025-08-12 09:41:28 +02:00
committed by GitHub
67 changed files with 1028 additions and 280 deletions

View File

@@ -9,8 +9,6 @@ package io.element.android.x
import android.app.Application
import androidx.startup.AppInitializer
import io.element.android.appconfig.RageshakeConfig
import io.element.android.appconfig.isEnabled
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
@@ -25,9 +23,7 @@ class ElementXApplication : Application(), DaggerComponentOwner {
override fun onCreate() {
super.onCreate()
AppInitializer.getInstance(this).apply {
if (RageshakeConfig.isEnabled) {
initializeComponent(CrashInitializer::class.java)
}
initializeComponent(CrashInitializer::class.java)
initializeComponent(PlatformInitializer::class.java)
initializeComponent(CacheCleanerInitializer::class.java)
}

View File

@@ -25,9 +25,3 @@ object RageshakeConfig {
*/
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
}
/**
* Whether the rageshake feature is enabled.
*/
val RageshakeConfig.isEnabled: Boolean
get() = BUG_REPORT_URL.isNotEmpty() && BUG_REPORT_APP_NAME.isNotEmpty()

View File

@@ -47,6 +47,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
@@ -127,6 +128,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
private val networkMonitor: NetworkMonitor,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
@@ -185,7 +187,9 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
lifecycleScope.launch {
sessionEnterpriseService.init()
}
lifecycle.subscribe(
onCreate = {
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)

View File

@@ -0,0 +1,16 @@
/*
* 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.enterprise.api
sealed interface BugReportUrl {
data object UseDefault : BugReportUrl
data object Disabled : BugReportUrl
data class Custom(
val url: String,
) : BugReportUrl
}

View File

@@ -9,6 +9,7 @@ package io.element.android.features.enterprise.api
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
val isEnterpriseBuild: Boolean
@@ -22,6 +23,8 @@ interface EnterpriseService {
fun firebasePushGateway(): String?
fun unifiedPushDefaultPushGateway(): String?
val bugReportUrlFlow: Flow<BugReportUrl>
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}

View File

@@ -9,4 +9,6 @@ package io.element.android.features.enterprise.api
interface SessionEnterpriseService {
suspend fun isElementCallAvailable(): Boolean
suspend fun init()
}

View File

@@ -11,9 +11,11 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.compoundColorsDark
import io.element.android.compound.tokens.generated.compoundColorsLight
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@@ -31,4 +33,6 @@ class DefaultEnterpriseService @Inject constructor() : EnterpriseService {
override fun firebasePushGateway(): String? = null
override fun unifiedPushDefaultPushGateway(): String? = null
override val bugReportUrlFlow = flowOf(BugReportUrl.UseDefault)
}

View File

@@ -14,5 +14,6 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultSessionEnterpriseService @Inject constructor() : SessionEnterpriseService {
override suspend fun init() = Unit
override suspend fun isElementCallAvailable(): Boolean = true
}

View File

@@ -8,10 +8,14 @@
package io.element.android.features.enterprise.test
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeEnterpriseService(
override val isEnterpriseBuild: Boolean = false,
@@ -51,7 +55,6 @@ class FakeEnterpriseService(
return unifiedPushDefaultPushGatewayResult()
}
companion object {
const val A_FAKE_HOMESERVER = "a_fake_homeserver"
}
val bugReportUrlMutableFlow = MutableStateFlow<BugReportUrl>(BugReportUrl.UseDefault)
override val bugReportUrlFlow: Flow<BugReportUrl> = bugReportUrlMutableFlow.asStateFlow()
}

View File

@@ -14,6 +14,9 @@ import io.element.android.tests.testutils.simulateLongTask
class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}

View File

@@ -43,7 +43,7 @@ class HomePresenter @Inject constructor(
override fun present(): HomeState {
val matrixUser = client.userProfile.collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val isSpaceFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)

View File

@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -49,7 +50,7 @@ class HomePresenterTest {
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
val presenter = createHomePresenter(
client = matrixClient,
rageshakeFeatureAvailability = { false },
rageshakeFeatureAvailability = { flowOf(false) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -66,6 +67,21 @@ class HomePresenterTest {
}
}
@Test
fun `present - can report bug`() = runTest {
val presenter = createHomePresenter(
rageshakeFeatureAvailability = { flowOf(true) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.canReportBug).isFalse()
val finalState = awaitItem()
assertThat(finalState.canReportBug).isTrue()
}
}
@Test
fun `present - space feature enabled`() = runTest {
val presenter = createHomePresenter(
@@ -132,7 +148,7 @@ class HomePresenterTest {
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
) = HomePresenter(

View File

@@ -36,7 +36,6 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
@@ -44,10 +43,9 @@ dependencies {
implementation(projects.libraries.qrcode)
implementation(projects.libraries.oidc.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.androidx.webkit)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.login.api)
@@ -65,6 +63,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.tests.testutils)
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.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.wellknown.api.WellknownRetriever
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl @Inject constructor(
private val enterpriseService: EnterpriseService,
private val elementWellknownRetriever: ElementWellknownRetriever,
private val wellknownRetriever: WellknownRetriever,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider(
@@ -37,8 +38,8 @@ class DefaultAccountProviderAccessControl @Inject constructor(
) {
if (enterpriseService.isEnterpriseBuild.not()) {
// Ensure that Element Pro is not required for this account provider
val wellKnown = elementWellknownRetriever.retrieve(
accountProviderUrl = accountProviderUrl.ensureProtocol(),
val wellKnown = wellknownRetriever.getElementWellKnown(
baseUrl = accountProviderUrl.ensureProtocol(),
)
if (wellKnown?.enforceElementPro == true) {
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
import io.element.android.features.login.impl.resolver.network.WellknownRequest
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.uri.ensureProtocol
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.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -27,7 +28,7 @@ import javax.inject.Inject
*/
class HomeserverResolver @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val wellknownRequest: WellknownRequest,
private val wellknownRetriever: WellknownRetriever,
) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
@@ -41,7 +42,7 @@ class HomeserverResolver @Inject constructor(
list.parallelMap { url ->
val wellKnown = tryOrNull {
withTimeout(5000) {
wellknownRequest.execute(url)
wellknownRetriever.getWellKnown(url)
}
}
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

@@ -8,6 +8,7 @@
package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -82,7 +83,7 @@ class OnBoardingPresenter @AssistedInject constructor(
value = linkAccountProvider == null &&
featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
var showReportBug by rememberSaveable { mutableStateOf(false) }
val loginMode by loginHelper.collectLoginMode()

View File

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

View File

@@ -10,10 +10,12 @@ package io.element.android.features.login.impl.accesscontrol
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
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.features.wellknown.test.anElementWellKnown
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_URL
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -24,7 +26,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
),
)
@@ -37,7 +39,7 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = false,
// false here.
isAllowedToConnectToHomeserver = false,
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
),
)
@@ -49,7 +51,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
),
)
@@ -61,7 +63,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = null,
),
)
@@ -84,7 +86,7 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
),
)
@@ -96,7 +98,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true,
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
),
)
@@ -108,7 +110,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true,
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
),
)
@@ -121,7 +123,7 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
),
)
@@ -134,7 +136,7 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
elementWellKnown = ElementWellKnown(
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
),
)
@@ -152,8 +154,8 @@ class DefaultAccountProviderAccessControlTest {
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders },
),
elementWellknownRetriever = FakeElementWellknownRetriever(
retrieveResult = { elementWellKnown }
wellknownRetriever = FakeWellknownRetriever(
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,18 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
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.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.AccountProviderDataSource
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.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData
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_URL
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.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -113,14 +114,14 @@ class ChangeServerPresenterTest {
@Test
fun `present - change server element pro required error`() = runTest {
val retrieveResult = lambdaRecorder<String, ElementWellKnown> {
ElementWellKnown(
val getElementWellKnownResult = lambdaRecorder<String, ElementWellKnown> {
anElementWellKnown(
enforceElementPro = true,
)
}
createPresenter(
elementWellknownRetriever = FakeElementWellknownRetriever(
retrieveResult = retrieveResult,
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = getElementWellKnownResult,
),
).test {
val initialState = awaitItem()
@@ -136,7 +137,7 @@ class ChangeServerPresenterTest {
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise")
retrieveResult.assertions()
getElementWellKnownResult.assertions()
.isCalledOnce()
.with(value(A_HOMESERVER_URL.ensureProtocol()))
}
@@ -146,13 +147,13 @@ class ChangeServerPresenterTest {
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = ChangeServerPresenter(
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
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.login.impl.DefaultLoginUserStory
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.web.FakeWebClientUrlForAuthenticationRetriever
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.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -34,8 +33,11 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oidc.api.OidcActionFlow
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.test
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -78,7 +80,6 @@ class OnBoardingPresenterTest {
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
),
rageshakeFeatureAvailability = { true },
)
presenter.test {
val initialState = awaitItem()
@@ -94,7 +95,7 @@ class OnBoardingPresenterTest {
@Test
fun `present - clicking on version 7 times has no effect if rageshake not available`() = runTest {
val presenter = createPresenter(
rageshakeFeatureAvailability = { false },
rageshakeFeatureAvailability = { flowOf(false) },
)
presenter.test {
skipItems(1)
@@ -238,8 +239,8 @@ private fun createPresenter(
buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
rageshakeFeatureAvailability: () -> Boolean = { true },
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter(
params = params,
@@ -248,7 +249,7 @@ private fun createPresenter(
enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
elementWellknownRetriever = elementWellknownRetriever,
wellknownRetriever = wellknownRetriever,
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
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.test.FakeEnterpriseService
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.qrcode.FakeQrCodeLoginManager
import io.element.android.features.wellknown.test.FakeWellknownRetriever
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.QrLoginException
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.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -162,14 +162,14 @@ class QrCodeScanPresenterTest {
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
elementWellknownRetriever: ElementWellknownRetriever = FakeElementWellknownRetriever(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) = QrCodeScanPresenter(
qrCodeLoginDataFactory = qrCodeLoginDataFactory,
qrCodeLoginManager = qrCodeLoginManager,
coroutineDispatchers = coroutineDispatchers,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
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 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.network.FakeWellknownRequest
import io.element.android.features.login.impl.resolver.network.WellKnown
import io.element.android.features.login.impl.resolver.network.WellKnownBaseConfig
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.libraries.architecture.AsyncData
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.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -30,9 +32,9 @@ class SearchAccountProviderPresenterTest {
@Test
fun `present - initial state`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()
val fakeWellknownRetriever = FakeWellknownRetriever()
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() }
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -46,9 +48,9 @@ class SearchAccountProviderPresenterTest {
@Test
fun `present - enter text no result`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()
val fakeWellknownRetriever = FakeWellknownRetriever()
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() }
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -66,9 +68,9 @@ class SearchAccountProviderPresenterTest {
@Test
fun `present - enter valid url no wellknown`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()
val fakeWellknownRetriever = FakeWellknownRetriever()
val presenter = SearchAccountProviderPresenter(
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() }
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -92,14 +94,20 @@ class SearchAccountProviderPresenterTest {
@Test
fun `present - enter text one result with wellknown`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()
fakeWellknownRequest.givenResultMap(
mapOf(
"https://test.io" to aWellKnown(),
)
val getWellKnownResult = lambdaRecorder<String, WellKnown> {
when (it) {
"https://test.org" -> error("not found")
"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(), fakeWellknownRequest),
homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever),
changeServerPresenter = { aChangeServerState() }
)
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

@@ -83,7 +83,7 @@ class PreferencesRootPresenter @Inject constructor(
var canDeactivateAccount by remember {
mutableStateOf(false)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
LaunchedEffect(Unit) {
canDeactivateAccount = matrixClient.canDeactivateAccount()
}

View File

@@ -32,6 +32,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -102,7 +103,7 @@ class PreferencesRootPresenterTest {
)
createPresenter(
matrixClient = matrixClient,
rageshakeFeatureAvailability = { false },
rageshakeFeatureAvailability = { flowOf(false) },
).test {
val initialState = awaitItem()
assertThat(initialState.canReportBug).isFalse()
@@ -119,7 +120,7 @@ class PreferencesRootPresenterTest {
val indicatorService = FakeIndicatorService()
createPresenter(
matrixClient = matrixClient,
rageshakeFeatureAvailability = { false },
rageshakeFeatureAvailability = { flowOf(false) },
indicatorService = indicatorService,
).test {
skipItems(1)
@@ -185,7 +186,7 @@ class PreferencesRootPresenterTest {
matrixClient: FakeMatrixClient = FakeMatrixClient(),
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) },
indicatorService: IndicatorService = FakeIndicatorService(),
) = PreferencesRootPresenter(
matrixClient = matrixClient,

View File

@@ -7,6 +7,8 @@
package io.element.android.features.rageshake.api
import kotlinx.coroutines.flow.Flow
fun interface RageshakeFeatureAvailability {
fun isAvailable(): Boolean
fun isAvailable(): Flow<Boolean>
}

View File

@@ -26,6 +26,7 @@ setupAnvil()
dependencies {
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.services.toolbox.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
@@ -50,6 +51,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.implMemory)
testImplementation(projects.libraries.sessionStorage.test)

View File

@@ -8,15 +8,19 @@
package io.element.android.features.rageshake.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.appconfig.isEnabled
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.impl.reporter.BugReporterUrlProvider
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRageshakeFeatureAvailability @Inject constructor() : RageshakeFeatureAvailability {
override fun isAvailable(): Boolean {
return RageshakeConfig.isEnabled
class DefaultRageshakeFeatureAvailability @Inject constructor(
private val bugReporterUrlProvider: BugReporterUrlProvider,
) : RageshakeFeatureAvailability {
override fun isAvailable(): Flow<Boolean> {
return bugReporterUrlProvider.provide()
.map { it != null }
}
}

View File

@@ -5,10 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.impl.crash
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding
@@ -19,6 +22,8 @@ import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -32,12 +37,15 @@ class DefaultCrashDetectionPresenter @Inject constructor(
@Composable
override fun present(): CrashDetectionState {
val localCoroutineScope = rememberCoroutineScope()
val crashDetected = remember {
if (rageshakeFeatureAvailability.isAvailable()) {
crashDataStore.appHasCrashed()
} else {
flowOf(false)
}
val crashDetected by remember {
rageshakeFeatureAvailability.isAvailable()
.flatMapLatest { isAvailable ->
if (isAvailable) {
crashDataStore.appHasCrashed()
} else {
flowOf(false)
}
}
}.collectAsState(false)
fun handleEvents(event: CrashDetectionEvents) {
@@ -49,7 +57,7 @@ class DefaultCrashDetectionPresenter @Inject constructor(
return CrashDetectionState(
appName = buildMeta.applicationName,
crashDetected = crashDetected.value,
crashDetected = crashDetected,
eventSink = ::handleEvents
)
}

View File

@@ -39,7 +39,7 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
val isSupported: MutableState<Boolean> = rememberSaveable {
mutableStateOf(rageshake.isAvailable())
}
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
val isFeatureAvailable by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val isEnabled by remember {
rageshakeDataStore.isEnabled()
}.collectAsState(initial = false)

View File

@@ -0,0 +1,22 @@
/*
* 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.rageshake.impl.reporter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
fun interface BugReportAppNameProvider {
fun provide(): String
}
@ContributesBinding(AppScope::class)
class DefaultBugReportAppNameProvider @Inject constructor() : BugReportAppNameProvider {
override fun provide(): String = RageshakeConfig.BUG_REPORT_APP_NAME
}

View File

@@ -7,8 +7,9 @@
package io.element.android.features.rageshake.impl.reporter
import kotlinx.coroutines.flow.Flow
import okhttp3.HttpUrl
fun interface BugReporterUrlProvider {
fun provide(): HttpUrl
fun provide(): Flow<HttpUrl?>
}

View File

@@ -114,6 +114,12 @@ class DefaultBugReporter @Inject constructor(
canContact: Boolean,
listener: BugReporterListener,
) {
val url = bugReporterUrlProvider.provide().first()
if (url == null) {
// It should not happen, but if the URL is null, we cannot proceed
Timber.e("## sendBugReport() : bug report URL is null")
error("Bug report URL is null, cannot send bug report")
}
// enumerate files to delete
val bugReportFiles: MutableList<File> = ArrayList()
var response: Response? = null
@@ -243,7 +249,7 @@ class DefaultBugReporter @Inject constructor(
}
// build the request
val request = Request.Builder()
.url(bugReporterUrlProvider.provide())
.url(url)
.post(requestBody)
.build()
var errorMessage: String? = null

View File

@@ -9,14 +9,31 @@ package io.element.android.features.rageshake.impl.reporter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultBugReporterUrlProvider @Inject constructor() : BugReporterUrlProvider {
override fun provide(): HttpUrl {
return RageshakeConfig.BUG_REPORT_URL.toHttpUrl()
class DefaultBugReporterUrlProvider @Inject constructor(
private val bugReportAppNameProvider: BugReportAppNameProvider,
private val enterpriseService: EnterpriseService,
) : BugReporterUrlProvider {
override fun provide(): Flow<HttpUrl?> {
if (bugReportAppNameProvider.provide().isEmpty()) return flowOf(null)
return enterpriseService.bugReportUrlFlow
.map { bugReportUrl ->
when (bugReportUrl) {
is BugReportUrl.Custom -> bugReportUrl.url
BugReportUrl.Disabled -> null
BugReportUrl.UseDefault -> RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() }
}
}
.map { it?.toHttpUrl() }
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.rageshake.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test
class DefaultRageshakeFeatureAvailabilityTest {
@Test
fun `test isAvailable returns true when bug reporter URL is provided`() = runTest {
val flow = MutableStateFlow<HttpUrl?>(null)
val sut = DefaultRageshakeFeatureAvailability(
bugReporterUrlProvider = { flow },
)
sut.isAvailable().test {
assertThat(awaitItem()).isFalse()
flow.value = "https://example.com/bugreport".toHttpUrl()
assertThat(awaitItem()).isTrue()
flow.value = null
assertThat(awaitItem()).isFalse()
}
}
}

View File

@@ -21,6 +21,7 @@ class FakeCrashDataStore(
override fun setCrashData(crashData: String) {
crashDataFlow.value = crashData
appHasCrashedFlow.value = true
}
override suspend fun resetAppHasCrashed() {

View File

@@ -18,6 +18,9 @@ import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -55,7 +58,7 @@ class CrashDetectionPresenterTest {
fun `present - initial state crash is ignored if the feature is not available`() = runTest {
val presenter = createPresenter(
FakeCrashDataStore(appHasCrashed = true),
isFeatureAvailable = false,
isFeatureAvailableFlow = flowOf(false),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -97,13 +100,42 @@ class CrashDetectionPresenterTest {
}
}
@Test
fun `present - crashDetected is false if the feature is not available`() = runTest {
val isFeatureAvailableFlow = MutableStateFlow(false)
val crashDataStore = FakeCrashDataStore(appHasCrashed = false)
val presenter = createPresenter(
crashDataStore = crashDataStore,
isFeatureAvailableFlow = isFeatureAvailableFlow,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse()
crashDataStore.setCrashData("Some crash data")
// No new state
crashDataStore.resetAppHasCrashed()
// No new state
isFeatureAvailableFlow.value = true
crashDataStore.setCrashData("Some crash data")
assertThat(awaitItem().crashDetected).isTrue()
crashDataStore.resetAppHasCrashed()
assertThat(awaitItem().crashDetected).isFalse()
crashDataStore.setCrashData("Some crash data")
assertThat(awaitItem().crashDetected).isTrue()
isFeatureAvailableFlow.value = false
assertThat(awaitItem().crashDetected).isFalse()
}
}
private fun createPresenter(
crashDataStore: FakeCrashDataStore = FakeCrashDataStore(),
buildMeta: BuildMeta = aBuildMeta(),
isFeatureAvailable: Boolean = true,
isFeatureAvailableFlow: Flow<Boolean> = flowOf(true),
) = DefaultCrashDetectionPresenter(
buildMeta = buildMeta,
crashDataStore = crashDataStore,
rageshakeFeatureAvailability = { isFeatureAvailable },
rageshakeFeatureAvailability = { isFeatureAvailableFlow },
)
}

View File

@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
import org.junit.Rule
@@ -52,7 +53,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -77,7 +78,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -103,7 +104,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -138,7 +139,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -173,7 +174,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
)
moleculeFlow(RecompositionMode.Immediate) {

View File

@@ -16,6 +16,7 @@ import io.element.android.features.rageshake.impl.rageshake.A_SENSITIVITY
import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -29,7 +30,7 @@ class RageshakePreferencesPresenterTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -46,7 +47,7 @@ class RageshakePreferencesPresenterTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = false),
FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -63,7 +64,7 @@ class RageshakePreferencesPresenterTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -83,7 +84,7 @@ class RageshakePreferencesPresenterTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
rageshakeFeatureAvailability = { flowOf(true) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()

View File

@@ -31,6 +31,7 @@ import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionSt
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import okhttp3.MultipartReader
@@ -464,7 +465,7 @@ class DefaultBugReporterTest {
userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")),
sessionStore = sessionStore,
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") },
bugReporterUrlProvider = { flowOf(server.url("/")) },
sdkMetadata = FakeSdkMetadata("123456789"),
matrixClientProvider = matrixClientProvider,
tracingService = tracingService,

View File

@@ -7,18 +7,44 @@
package io.element.android.features.rageshake.impl.reporter
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.test.FakeEnterpriseService
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test
class DefaultBugReporterUrlProviderTest {
@Test
fun `test DefaultBugReporterUrlProvider`() {
val sut = DefaultBugReporterUrlProvider()
if (RageshakeConfig.BUG_REPORT_URL.isNotEmpty()) {
val result = sut.provide()
assertThat(result).isEqualTo(RageshakeConfig.BUG_REPORT_URL.toHttpUrl())
fun `provide return values when there is an rageshake app name`() = runTest {
val enterpriseService = FakeEnterpriseService()
val sut = DefaultBugReporterUrlProvider(
bugReportAppNameProvider = { "rageshakeAppName" },
enterpriseService = enterpriseService,
)
sut.provide().test {
assertThat(awaitItem()).isEqualTo(
RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() }?.toHttpUrl()
)
enterpriseService.bugReportUrlMutableFlow.emit(BugReportUrl.Disabled)
assertThat(awaitItem()).isNull()
enterpriseService.bugReportUrlMutableFlow.emit(BugReportUrl.Custom("https://aURL.org"))
assertThat(awaitItem()).isEqualTo("https://aURL.org".toHttpUrl())
}
}
@Test
fun `provide return null when there is no rageshake app name`() = runTest {
val enterpriseService = FakeEnterpriseService()
val sut = DefaultBugReporterUrlProvider(
bugReportAppNameProvider = { "" },
enterpriseService = enterpriseService,
)
sut.provide().test {
assertThat(awaitItem()).isNull()
awaitComplete()
}
}
}

View File

@@ -10,13 +10,6 @@ package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
/**
* Returns the first element of the flow that is an instance of [T], waiting for it if necessary.
*/

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,14 @@
/*
* 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?,
val enforceElementPro: Boolean?,
val rageshakeUrl: String?,
)

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 SessionWellknownRetriever {
suspend fun getWellKnown(): WellKnown?
suspend fun getElementWellKnown(): ElementWellKnown?
}

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?,
val identityServer: WellKnownBaseConfig?,
)
data class WellKnownBaseConfig(
val baseURL: String?
)

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,42 @@
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 {
api(projects.libraries.wellknown.api)
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.matrix.api)
implementation(projects.libraries.network)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -0,0 +1,53 @@
/*
* 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.extensions.mapCatchingExceptions
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellKnown
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultSessionWellknownRetriever @Inject constructor(
private val matrixClient: MatrixClient,
private val parser: Json,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
override suspend fun getWellKnown(): WellKnown? {
val url = "https://$domain/.well-known/matrix/client"
return matrixClient
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
parser.decodeFromString(InternalWellKnown.serializer(), data)
}
.onFailure { Timber.e(it, "Failed to retrieve .well-known from $domain") }
.map { it.map() }
.getOrNull()
}
override suspend fun getElementWellKnown(): ElementWellKnown? {
val url = "https://$domain/.well-known/element/element.json"
return matrixClient
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
parser.decodeFromString(InternalElementWellKnown.serializer(), data)
}
.onFailure { Timber.e(it, "Failed to retrieve Element .well-known from $domain") }
.map { it.map() }
.getOrNull()
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.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
}
}
}

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
* 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.Serializable
@@ -20,9 +20,11 @@ import kotlinx.serialization.Serializable
* .
*/
@Serializable
data class ElementWellKnown(
data class InternalElementWellKnown(
@SerialName("registration_helper_url")
val registrationHelperUrl: String? = null,
@SerialName("enforce_element_pro")
val enforceElementPro: Boolean? = null,
@SerialName("rageshake_url")
val rageshakeUrl: String? = null,
)

View File

@@ -5,9 +5,8 @@
* 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.Serializable
@@ -26,13 +25,9 @@ import kotlinx.serialization.Serializable
* .
*/
@Serializable
data class WellKnown(
data class InternalWellKnown(
@SerialName("m.homeserver")
val homeServer: WellKnownBaseConfig? = null,
val homeServer: InternalWellKnownBaseConfig? = null,
@SerialName("m.identity_server")
val identityServer: WellKnownBaseConfig? = null,
) {
fun isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}
}
val identityServer: InternalWellKnownBaseConfig? = null,
)

View File

@@ -5,7 +5,7 @@
* 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.Serializable
@@ -20,7 +20,7 @@ import kotlinx.serialization.Serializable
* .
*/
@Serializable
data class WellKnownBaseConfig(
data class InternalWellKnownBaseConfig(
@SerialName("base_url")
val baseURL: String? = null
)

View File

@@ -0,0 +1,27 @@
/*
* 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 io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.libraries.wellknown.api.WellKnownBaseConfig
internal fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
rageshakeUrl = rageshakeUrl,
)
internal fun InternalWellKnown.map() = WellKnown(
homeServer = homeServer?.map(),
identityServer = identityServer?.map(),
)
internal fun InternalWellKnownBaseConfig.map() = WellKnownBaseConfig(
baseURL = baseURL,
)

View File

@@ -5,14 +5,14 @@
* 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
internal interface WellknownAPI {
@GET(".well-known/matrix/client")
suspend fun getWellKnown(): WellKnown
suspend fun getWellKnown(): InternalWellKnown
@GET(".well-known/element/element.json")
suspend fun getElementWellKnown(): ElementWellKnown
suspend fun getElementWellKnown(): InternalElementWellKnown
}

View File

@@ -0,0 +1,249 @@
/*
* 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.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
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.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
class DefaultSessionWellknownRetrieverTest {
@Test
fun `get empty wellknown`() = runTest {
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
Result.success("{}".toByteArray())
}
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getUrlLambda,
)
assertThat(sut.getWellKnown()).isEqualTo(
WellKnown(
homeServer = null,
identityServer = null,
)
)
getUrlLambda.assertions().isCalledOnce()
.with(value("https://user.domain.org/.well-known/matrix/client"))
}
@Test
fun `get wellknown with full content`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
"m.identity_server": {
"base_url": "https://identity.example.org"
}
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isEqualTo(
WellKnown(
homeServer = WellKnownBaseConfig(
baseURL = "https://example.org",
),
identityServer = WellKnownBaseConfig(
baseURL = "https://identity.example.org",
),
)
)
}
@Test
fun `get wellknown with full content empty base_url`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
"m.identity_server": {}
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isEqualTo(
WellKnown(
homeServer = WellKnownBaseConfig(
baseURL = "https://example.org",
),
identityServer = WellKnownBaseConfig(
baseURL = null,
),
)
)
}
@Test
fun `get wellknown with unknown key`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
"m.identity_server": {
"base_url": "https://identity.example.org"
},
"other": true
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isEqualTo(
WellKnown(
homeServer = WellKnownBaseConfig(
baseURL = "https://example.org",
),
identityServer = WellKnownBaseConfig(
baseURL = "https://identity.example.org",
),
)
)
}
@Test
fun `get wellknown json error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"m.homeserver": {
"base_url": "https://example.org"
},
error
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getWellKnown()).isNull()
}
@Test
fun `get wellknown network error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(AN_EXCEPTION)
}
)
assertThat(sut.getWellKnown()).isNull()
}
@Test
fun `get empty element wellknown`() = runTest {
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
Result.success("{}".toByteArray())
}
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getUrlLambda,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
ElementWellKnown(
registrationHelperUrl = null,
enforceElementPro = null,
rageshakeUrl = null,
)
)
getUrlLambda.assertions().isCalledOnce()
.with(value("https://user.domain.org/.well-known/element/element.json"))
}
@Test
fun `get element wellknown with full content`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url"
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isEqualTo(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
)
)
}
@Test
fun `get element wellknown with unknown key`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
"other": true
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isEqualTo(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
)
)
}
@Test
fun `get element wellknown json error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"registration_helper_url" = "a_registration_url",
error
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isNull()
}
@Test
fun `get element wellknown network error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(AN_EXCEPTION)
}
)
assertThat(sut.getElementWellKnown()).isNull()
}
private fun createDefaultSessionWellknownRetriever(
getUrlLambda: (String) -> Result<ByteArray>,
) = DefaultSessionWellknownRetriever(
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
parser = Json { ignoreUnknownKeys = true }
)
}

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.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellKnown
import io.element.android.tests.testutils.simulateLongTask
class FakeSessionWellknownRetriever(
private val getWellKnownResult: () -> WellKnown? = { null },
private val getElementWellKnownResult: () -> ElementWellKnown? = { null },
) : SessionWellknownRetriever {
override suspend fun getWellKnown(): WellKnown? = simulateLongTask {
getWellKnownResult()
}
override suspend fun getElementWellKnown(): ElementWellKnown? = simulateLongTask {
getElementWellKnownResult()
}
}

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)
}
}

View File

@@ -0,0 +1,20 @@
/*
* 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
fun anElementWellKnown(
registrationHelperUrl: String? = null,
enforceElementPro: Boolean? = null,
rageshakeUrl: String? = null,
) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
rageshakeUrl = rageshakeUrl,
)

View File

@@ -89,6 +89,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediaviewer:impl"))
implementation(project(":libraries:troubleshoot:impl"))
implementation(project(":libraries:fullscreenintent:impl"))
implementation(project(":libraries:wellknown:impl"))
implementation(project(":libraries:oidc:impl"))
}

View File

@@ -68,4 +68,30 @@ class KonsistLicenseTest {
enterpriseLicense.containsMatchIn(it.text)
}
}
@Test
fun `assert that files do not have double license header`() {
Konsist
.scopeFromProject()
.files
.filter {
it.nameWithExtension != "locales.kt" &&
it.nameWithExtension != "KonsistLicenseTest.kt" &&
it.name.startsWith("Template ").not()
}
.assertTrue {
it.text.count("New Vector") == 1
}
}
}
private fun String.count(subString: String): Int {
var count = 0
var index = 0
while (true) {
index = indexOf(subString, index)
if (index == -1) return count
count++
index += subString.length
}
}