Merge branch 'develop' into bma/fixLeak

This commit is contained in:
Benoit Marty
2026-09-16 09:42:36 +02:00
committed by GitHub
184 changed files with 940 additions and 750 deletions
+3
View File
@@ -78,6 +78,9 @@ jobs:
- name: ⚙️ Run screenshot tests - name: ⚙️ Run screenshot tests
run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Run Compound screenshot tests
run: ./gradlew :libraries:compound:verifyRoborazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Generate kover coverage report - name: ⚙️ Generate kover coverage report
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
@@ -9,15 +9,8 @@
package io.element.android.appconfig package io.element.android.appconfig
object AuthenticationConfig { object AuthenticationConfig {
const val MATRIX_ORG_URL = "https://matrix.org"
/** /**
* URL with some docs that explain what's sliding sync and how to add it to your home server. * URL with some docs that explain what's sliding sync and how to add it to your home server.
*/ */
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
/**
* Force a sliding sync proxy url, if not null, the proxy url in the .well-known file will be ignored.
*/
val SLIDING_SYNC_PROXY_URL: String? = null
} }
@@ -52,11 +52,11 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
@@ -410,7 +410,8 @@ class RootFlowNode(
} }
private suspend fun onLoginLink(params: LoginParams) { private suspend fun onLoginLink(params: LoginParams) {
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { val accountProvider = AccountProvider.Generic(params.accountProvider)
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(accountProvider)) {
// Is there a session already? // Is there a session already?
val sessions = sessionStore.getAllSessions() val sessions = sessionStore.getAllSessions()
if (sessions.isNotEmpty()) { if (sessions.isNotEmpty()) {
+16
View File
@@ -153,7 +153,10 @@ allprojects {
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
val isRoborazziTest = project.gradle.startParameter.taskNames.any { it.contains("roborazzi", ignoreCase = true) }
if (isScreenshotTest) { if (isScreenshotTest) {
// Paparazzi tests benefit from parallelisation, so we can use half the available cores to run them in parallel.
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
// Increase heap size for screenshot tests // Increase heap size for screenshot tests
maxHeapSize = "2g" maxHeapSize = "2g"
// Record all the languages? // Record all the languages?
@@ -165,9 +168,22 @@ allprojects {
exclude("translations/*.class") exclude("translations/*.class")
} }
} else { } else {
// Robolectric and Compose pay a 5 to 30 seconds bootstrap cost per test JVM (instrumenting
// android-all, then warming up the Compose runtime). That cost is paid once per JVM and then
// amortised over every test class the JVM runs, so splitting a module across several forks
// re-pays it for each fork instead of saving time. Keep a single fork per module and let
// Gradle parallelise by running many modules' test tasks concurrently instead.
maxParallelForks = 1
// Disable screenshot tests by default // Disable screenshot tests by default
exclude("ui/*.class") exclude("ui/*.class")
exclude("translations/*.class") exclude("translations/*.class")
if (isRoborazziTest.not()) {
// Roborazzi screenshot tests (:libraries:compound) live in a `screenshot` package, which
// the two patterns above do not match, so they used to run on every plain unit test run.
// They are verified by the dedicated `verifyRoborazziDebug` task instead.
exclude("**/screenshot/**")
}
} }
} }
} }
@@ -11,6 +11,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -36,18 +37,21 @@ interface EnterpriseService {
suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
/** /**
* Returns the list of homeservers the user is allowed to sign in to. * Returns the list of account provider the user is allowed to sign in to.
*
* If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver.
*/ */
fun homeserverAllowList(): List<String> fun accountProviderAllowList(): List<AccountProvider>
/** /**
* Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList]. * Whether the user is allowed to sign in to any account provider.
*
* @param homeserverUrl the server the user is trying to use.
*/ */
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean fun canConnectToAnyAccountProvider(): Boolean
/**
* Whether the user is allowed to sign in to a given homeserver, according to [accountProviderAllowList].
*
* @param accountProvider the account provider the user is trying to use.
*/
suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean
/** /**
* Whether the given homeserver enforces the use of Element Pro or a derived app. * Whether the given homeserver enforces the use of Element Pro or a derived app.
@@ -97,14 +101,4 @@ interface EnterpriseService {
* @param sessionId the session whose channel is requested. * @param sessionId the session whose channel is requested.
*/ */
fun getNoisyNotificationChannelId(sessionId: SessionId): String? fun getNoisyNotificationChannelId(sessionId: SessionId): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
return homeserverAllowList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
} }
@@ -19,6 +19,7 @@ import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -33,8 +34,9 @@ class DefaultEnterpriseService(
) : EnterpriseService { ) : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List<String> = emptyList() override fun accountProviderAllowList(): List<AccountProvider> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true override fun canConnectToAnyAccountProvider(): Boolean = true
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true
override suspend fun isElementProEnforced(serverName: String): Boolean { override suspend fun isElementProEnforced(serverName: String): Boolean {
val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false } val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false }
return temporaryMatrixClient.use { client -> return temporaryMatrixClient.use { client ->
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderGeneric
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
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -26,15 +27,21 @@ import org.junit.Test
class DefaultEnterpriseServiceTest { class DefaultEnterpriseServiceTest {
@Test @Test
fun homeserverWhitelist() { fun accountProviderAllowList() {
val defaultEnterpriseService = createDefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty()
} }
@Test @Test
fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest { fun `canConnectToAnyAccountProvider is always true`() {
val defaultEnterpriseService = createDefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue() assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue()
}
@Test
fun `isAllowedToConnectToAccountProvider is true for all account providers`() = runTest {
val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(anAccountProviderGeneric())).isTrue()
} }
@Test @Test
@@ -13,6 +13,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
@@ -22,8 +23,9 @@ import kotlinx.coroutines.flow.asStateFlow
class FakeEnterpriseService( class FakeEnterpriseService(
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() }, private val accountProviderAllowListResult: () -> List<AccountProvider> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, private val canConnectToAnyAccountProviderResult: () -> Boolean = { true },
private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() },
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
initialBrandColor: Color? = null, initialBrandColor: Color? = null,
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
@@ -44,12 +46,16 @@ class FakeEnterpriseService(
tweakMasUrlResult(url, urlContentFetcher) tweakMasUrlResult(url, urlContentFetcher)
} }
override fun homeserverAllowList(): List<String> { override fun accountProviderAllowList(): List<AccountProvider> {
return defaultHomeserverListResult() return accountProviderAllowListResult()
} }
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask { override fun canConnectToAnyAccountProvider(): Boolean {
isAllowedToConnectToHomeserverResult(homeserverUrl) return canConnectToAnyAccountProviderResult()
}
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean = simulateLongTask {
isAllowedToConnectToAccountProviderResult(accountProvider)
} }
override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask { override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
@@ -91,7 +91,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class RoomListPresenterTest { class RoomListPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -59,7 +59,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
internal class DefaultInvitePeoplePresenterTest { internal class DefaultInvitePeoplePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -70,7 +70,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Optional import java.util.Optional
@Suppress("LargeClass")
class JoinRoomPresenterTest { class JoinRoomPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
+1
View File
@@ -16,4 +16,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
} }
@@ -8,6 +8,8 @@
package io.element.android.features.login.api.accesscontrol package io.element.android.features.login.api.accesscontrol
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
/** /**
* Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to. * Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to.
*/ */
@@ -15,7 +17,7 @@ interface AccountProviderAccessControl {
/** /**
* Whether sign-in to this provider is permitted; `true` on a build with no such restriction. * Whether sign-in to this provider is permitted; `true` on a build with no such restriction.
* *
* @param accountProviderUrl the server the user is trying to use. * @param accountProvider the account provider the user is trying to use.
*/ */
suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean
} }
@@ -14,16 +14,16 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl( class DefaultAccountProviderAccessControl(
private val isEnterpriseBuild: IsEnterpriseBuild, private val isEnterpriseBuild: IsEnterpriseBuild,
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
) : AccountProviderAccessControl { ) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = try {
assertIsAllowedToConnectToAccountProvider( assertIsAllowedToConnectToAccountProvider(
title = accountProviderUrl, accountProvider = accountProvider,
accountProviderUrl = accountProviderUrl,
) )
true true
} catch (_: AccountProviderAccessException) { } catch (_: AccountProviderAccessException) {
@@ -32,22 +32,22 @@ class DefaultAccountProviderAccessControl(
@Throws(AccountProviderAccessException::class) @Throws(AccountProviderAccessException::class)
suspend fun assertIsAllowedToConnectToAccountProvider( suspend fun assertIsAllowedToConnectToAccountProvider(
title: String, accountProvider: AccountProvider,
accountProviderUrl: String,
) { ) {
if (isEnterpriseBuild().not()) { if (isEnterpriseBuild().not()) {
// Ensure that Element Pro is not required for this account provider // Ensure that Element Pro is not required for this account provider
if (enterpriseService.isElementProEnforced(accountProviderUrl)) { if (enterpriseService.isElementProEnforced(accountProvider.serverNameOrBaseUrl())) {
throw AccountProviderAccessException.NeedElementProException( throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title, unauthorisedAccountProviderTitle = accountProvider.friendlyServerName(),
applicationId = ELEMENT_PRO_APPLICATION_ID, applicationId = ELEMENT_PRO_APPLICATION_ID,
) )
} }
} }
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { if (enterpriseService.isAllowedToConnectToAccountProvider(accountProvider).not()) {
throw AccountProviderAccessException.UnauthorizedAccountProviderException( throw AccountProviderAccessException.UnauthorizedAccountProviderException(
unauthorisedAccountProviderTitle = title, unauthorisedAccountProviderTitle = accountProvider.friendlyServerName(),
authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(), authorisedAccountProviderTitles = enterpriseService.accountProviderAllowList()
.map { it.friendlyServerName() },
) )
} }
} }
@@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.accountprovider
data class AccountProvider(
val url: String,
val title: String = url.removePrefix("https://").removePrefix("http://"),
val subtitle: String? = null,
val isPublic: Boolean = false,
val isMatrixOrg: Boolean = false,
)
@@ -11,10 +11,10 @@ package io.element.android.features.login.impl.accountprovider
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,11 +33,9 @@ class AccountProviderDataSource(
) { ) {
// The provider used when the user has not selected one: an enterprise/MDM-configured provider, // The provider used when the user has not selected one: an enterprise/MDM-configured provider,
// else matrix.org. The most recently used provider (from history) can override it, see init. // else matrix.org. The most recently used provider (from history) can override it, see init.
private val configuredAccountProvider = createAccountProvider( private val configuredAccountProvider = enterpriseService.accountProviderAllowList()
url = enterpriseService.homeserverAllowList() .firstOrNull()
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } ?: matrixOrgAccountProvider
?: AuthenticationConfig.MATRIX_ORG_URL
)
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider) private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider)
@@ -46,7 +44,7 @@ class AccountProviderDataSource(
// The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]). // The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
// Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a // Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
// successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip). // successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
var lastSelectedAccountProviderUrl: String? = null var lastSelectedAccountProvider: AccountProvider? = null
private set private set
init { init {
@@ -66,28 +64,19 @@ class AccountProviderDataSource(
* connect to any provider, otherwise the enterprise/MDM-configured provider. * connect to any provider, otherwise the enterprise/MDM-configured provider.
*/ */
private suspend fun defaultAccountProvider(): AccountProvider { private suspend fun defaultAccountProvider(): AccountProvider {
if (!enterpriseService.canConnectToAnyHomeserver()) { if (!enterpriseService.canConnectToAnyAccountProvider()) {
return configuredAccountProvider return configuredAccountProvider
} }
val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull() val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider return lastUsedProvider?.let { AccountProvider.Generic(it) } ?: configuredAccountProvider
} }
suspend fun setUrl(url: String) { suspend fun setUrl(url: String) {
setAccountProvider(createAccountProvider(url)) setAccountProvider(AccountProvider.Generic(url))
} }
suspend fun setAccountProvider(data: AccountProvider) { suspend fun setAccountProvider(data: AccountProvider) {
lastSelectedAccountProviderUrl = data.url lastSelectedAccountProvider = data
accountProvider.emit(data) accountProvider.emit(data)
} }
private fun createAccountProvider(url: String): AccountProvider {
return AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
} }
@@ -9,27 +9,27 @@
package io.element.android.features.login.impl.accountprovider package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.appconfig.AuthenticationConfig import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
open class AccountProviderPreviewParam : PreviewParameterProvider<AccountProvider> { open class AccountProviderPreviewParam : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider> override val values: Sequence<AccountProvider>
get() = sequenceOf( get() = sequenceOf(
anAccountProvider(), anAccountProviderManaged(),
anAccountProvider().copy(subtitle = null),
anAccountProvider().copy(subtitle = null, title = "invalid"),
anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false),
// Add other state here
) )
} }
fun anAccountProvider( fun anAccountProviderManaged(
url: String = AuthenticationConfig.MATRIX_ORG_URL, serverName: String = "matrix.org",
subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.", baseUrl: String = "https://matrix-client.matrix.org",
isPublic: Boolean = true, canUseServerName: Boolean = true,
isMatrixOrg: Boolean = true, ) = AccountProvider.Managed(
) = AccountProvider( serverName = serverName,
url = url, baseUrl = baseUrl,
subtitle = subtitle, canUseServerName = canUseServerName,
isPublic = isPublic, )
isMatrixOrg = isMatrixOrg,
fun anAccountProviderGeneric(
serverName: String = "matrix.org",
) = AccountProvider.Generic(
serverName = serverName,
) )
@@ -14,17 +14,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -32,6 +29,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
/** /**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
@@ -60,37 +58,19 @@ fun AccountProviderView(
.heightIn(min = 44.dp), .heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.isMatrixOrg) { RoundedIconAtom(
RoundedIconAtom( size = RoundedIconAtomSize.Medium,
size = RoundedIconAtomSize.Medium, imageVector = CompoundIcons.Host(),
resourceId = R.drawable.ic_matrix, tint = ElementTheme.colors.iconPrimary,
tint = Color.Unspecified, )
)
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Host(),
tint = ElementTheme.colors.iconPrimary,
)
}
Text( Text(
modifier = Modifier modifier = Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.weight(1f), .weight(1f),
text = item.title, text = item.friendlyServerName(),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
) )
if (item.isPublic) {
Icon(
modifier = Modifier
.padding(start = 10.dp)
.size(16.dp),
imageVector = CompoundIcons.Public(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
}
if (selected) { if (selected) {
Icon( Icon(
modifier = Modifier modifier = Modifier
@@ -101,15 +81,6 @@ fun AccountProviderView(
) )
} }
} }
if (item.subtitle != null) {
Text(
modifier = Modifier
.padding(start = 46.dp, bottom = 12.dp, end = 26.dp),
text = item.subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
} }
} }
} }
@@ -25,8 +25,11 @@ class SaveAccountProviderToHistory(
suspend operator fun invoke() { suspend operator fun invoke() {
// Persist the provider the user actually selected, not the currently-exposed default: the latter can be // Persist the provider the user actually selected, not the currently-exposed default: the latter can be
// reset back to the history default while the login flow is torn down across an OAuth round-trip. // reset back to the history default while the login flow is torn down across an OAuth round-trip.
val url = accountProviderDataSource.lastSelectedAccountProviderUrl val accountProvider = accountProviderDataSource.lastSelectedAccountProvider
?: accountProviderDataSource.flow.value.url ?: accountProviderDataSource.flow.value
appPreferencesStore.addHomeserverToHistory(url) // Persist the sanitized name the user knows the provider by rather than its base url, which can be an
// internal client API url (e.g. matrix.org resolves to matrix-client.matrix.org) the user would not
// recognise, and so that the same provider is not stored twice with and without its scheme.
appPreferencesStore.addHomeserverToHistory(accountProvider.serverNameOrBaseUrl())
} }
} }
@@ -8,7 +8,7 @@
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.changeserver
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
sealed interface ChangeServerEvent { sealed interface ChangeServerEvent {
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvent data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvent
@@ -14,13 +14,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
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.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.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
@@ -38,7 +38,7 @@ class ChangeServerPresenter(
} }
val gateState = localNetworkPermissionGate.present<AccountProvider>( val gateState = localNetworkPermissionGate.present<AccountProvider>(
urlOf = { provider -> provider.url }, urlOf = { provider -> provider.serverNameOrBaseUrl() },
onProceed = { provider -> changeServer(provider, changeServerAction) }, onProceed = { provider -> changeServer(provider, changeServerAction) },
) )
@@ -63,11 +63,8 @@ class ChangeServerPresenter(
changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>, changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>,
) { ) {
suspend { suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(data)
title = data.title, val details = authenticationService.setHomeserver(data.serverNameOrBaseUrl()).getOrThrow()
accountProviderUrl = data.url,
)
val details = authenticationService.setHomeserver(data.url).getOrThrow()
if (!details.isSupported) { if (!details.isSupported) {
throw ChangeServerError.UnsupportedServer throw ChangeServerError.UnsupportedServer
} }
@@ -8,7 +8,7 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider package io.element.android.features.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
sealed interface ChooseAccountProviderEvent { sealed interface ChooseAccountProviderEvent {
data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvent data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvent
@@ -14,14 +14,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Inject @Inject
@@ -41,7 +39,7 @@ class ChooseAccountProviderPresenter(
loginModeState.eventSink( loginModeState.eventSink(
LoginModeEvent.Submit( LoginModeEvent.Submit(
isAccountCreation = false, isAccountCreation = false,
homeserverUrl = provider.url, homeserverUrl = provider.serverNameOrBaseUrl(),
resolvedHomeserverUrl = null, resolvedHomeserverUrl = null,
loginHint = null, loginHint = null,
) )
@@ -59,18 +57,8 @@ class ChooseAccountProviderPresenter(
} }
val staticAccountProviderList = remember { val staticAccountProviderList = remember {
// The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point // The list cannot be empty at this point
enterpriseService.homeserverAllowList() enterpriseService.accountProviderAllowList().toImmutableList()
.map { it.ensureProtocol() }
.map { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
.toImmutableList()
} }
return ChooseAccountProviderState( return ChooseAccountProviderState(
@@ -8,9 +8,9 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider package io.element.android.features.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ChooseAccountProviderState( data class ChooseAccountProviderState(
@@ -9,31 +9,22 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider<ChooseAccountProviderState> { open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider<ChooseAccountProviderState> {
private val server1 = anAccountProvider( private val server1 = anAccountProviderManaged(
url = "https://server1.io", baseUrl = "https://server1.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
) )
private val server2 = anAccountProvider( private val server2 = anAccountProviderManaged(
url = "https://server2.io", baseUrl = "https://server2.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
) )
private val server3 = anAccountProvider( private val server3 = anAccountProviderManaged(
url = "https://server3.io", baseUrl = "https://server3.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
) )
override val values: Sequence<ChooseAccountProviderState> override val values: Sequence<ChooseAccountProviderState>
get() = sequenceOf( get() = sequenceOf(
@@ -61,13 +52,12 @@ open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider<Cho
selectedAccountProvider = server2, selectedAccountProvider = server2,
loginModeState = aLoginModeState(loginMode = AsyncData.Loading()), loginModeState = aLoginModeState(loginMode = AsyncData.Loading()),
), ),
// Add other state here
) )
} }
fun aChooseAccountProviderState( fun aChooseAccountProviderState(
accountProviders: List<AccountProvider> = listOf( accountProviders: List<AccountProvider> = listOf(
anAccountProvider() anAccountProviderManaged()
), ),
selectedAccountProvider: AccountProvider? = null, selectedAccountProvider: AccountProvider? = null,
loginModeState: LoginModeState = aLoginModeState(), loginModeState: LoginModeState = aLoginModeState(),
@@ -96,16 +96,8 @@ fun ChooseAccountProviderView(
) )
state.accountProviders.forEach { item -> state.accountProviders.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
AccountProviderView( AccountProviderView(
item = alteredItem, item = item,
selected = item == state.selectedAccountProvider, selected = item == state.selectedAccountProvider,
onClick = { onClick = {
state.eventSink(ChooseAccountProviderEvent.SelectAccountProvider(item)) state.eventSink(ChooseAccountProviderEvent.SelectAccountProvider(item))
@@ -20,9 +20,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
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.changeserver.ChangeServerEvent import io.element.android.features.login.impl.changeserver.ChangeServerEvent
import io.element.android.features.login.impl.changeserver.ChangeServerState import io.element.android.features.login.impl.changeserver.ChangeServerState
@@ -30,8 +28,11 @@ import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.map
@AssistedInject @AssistedInject
class ConfirmAccountProviderPresenter( class ConfirmAccountProviderPresenter(
@@ -56,23 +57,25 @@ class ConfirmAccountProviderPresenter(
val accountProvider by accountProviderDataSource.flow.collectAsState() val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.present() val loginModeState = loginModePresenter.present()
val changeServerState = changeServerPresenter.present() val changeServerState = changeServerPresenter.present()
val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList()) val homeserverHistory by remember {
appPreferencesStore.getHomeserverHistoryFlow().map { history ->
history.map { AccountProvider.Generic(it) }
}
}.collectAsState(emptyList())
// The account providers offered for autocomplete: previously-used ones first, then the // The account providers offered for autocomplete: previously-used ones first, then the
// enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in). // enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in).
// The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is // Everything is rendered without the https:// scheme (added back at connection time).
// rendered without the https:// scheme (added back at connection time).
val autocompleteCandidates = remember(homeserverHistory) { val autocompleteCandidates = remember(homeserverHistory) {
(homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL) (homeserverHistory + enterpriseService.accountProviderAllowList() + matrixOrgAccountProvider)
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } .map { it.friendlyServerName() }
.map { it.withoutScheme() }
.distinct() .distinct()
} }
// Editable input, seeded from the current (history-defaulted) account provider until the user edits it. // Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
// Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org". // Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org".
var userInput by rememberSaveable { mutableStateOf<String?>(null) } var userInput by rememberSaveable { mutableStateOf<String?>(null) }
val accountProviderInput = userInput ?: accountProvider.url.withoutScheme() val accountProviderInput = userInput ?: accountProvider.friendlyServerName()
// Offer the first candidate that the current input is a (case-insensitive) prefix of. // Offer the first candidate that the current input is a (case-insensitive) prefix of.
val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) { val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) {
@@ -134,7 +137,7 @@ class ConfirmAccountProviderPresenter(
val accountProviderUrl = host.ensureProtocol() val accountProviderUrl = host.ensureProtocol()
submittedAccountProvider = accountProviderUrl submittedAccountProvider = accountProviderUrl
changeServerState.eventSink( changeServerState.eventSink(
ChangeServerEvent.ChangeServer(AccountProvider(url = accountProviderUrl)) ChangeServerEvent.ChangeServer(AccountProvider.Generic(host))
) )
} }
ConfirmAccountProviderEvent.ClearError -> { ConfirmAccountProviderEvent.ClearError -> {
@@ -154,9 +157,3 @@ class ConfirmAccountProviderPresenter(
) )
} }
} }
/**
* The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed.
* Returns the input unchanged when it has no scheme (e.g. `matrix.org`).
*/
private fun String.withoutScheme(): String = substringAfter("://")
@@ -9,8 +9,8 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import android.os.Parcelable import android.os.Parcelable
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -9,9 +9,9 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
open class LoginPasswordStatePreviewParam : PreviewParameterProvider<LoginPasswordState> { open class LoginPasswordStatePreviewParam : PreviewParameterProvider<LoginPasswordState> {
@@ -26,7 +26,7 @@ open class LoginPasswordStatePreviewParam : PreviewParameterProvider<LoginPasswo
} }
fun aLoginPasswordState( fun aLoginPasswordState(
accountProvider: AccountProvider = anAccountProvider(), accountProvider: AccountProvider = anAccountProviderManaged(),
formState: LoginFormState = LoginFormState.Default, formState: LoginFormState = LoginFormState.Default,
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized, loginAction: AsyncData<SessionId> = AsyncData.Uninitialized,
eventSink: (LoginPasswordEvent) -> Unit = {}, eventSink: (LoginPasswordEvent) -> Unit = {},
@@ -131,7 +131,7 @@ fun LoginPasswordView(
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource( title = stringResource(
id = R.string.screen_account_provider_signin_title, id = R.string.screen_account_provider_signin_title,
state.accountProvider.title state.accountProvider.friendlyServerName()
), ),
subTitle = stringResource(id = R.string.screen_login_subtitle) subTitle = stringResource(id = R.string.screen_login_subtitle)
) )
@@ -8,9 +8,11 @@
package io.element.android.features.login.impl.screens.onboarding package io.element.android.features.login.impl.screens.onboarding
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
sealed interface OnBoardingEvent { sealed interface OnBoardingEvent {
data class OnSignIn( data class OnSignIn(
val defaultAccountProvider: String val defaultAccountProvider: AccountProvider
) : OnBoardingEvent ) : OnBoardingEvent
data object OnVersionClick : OnBoardingEvent data object OnVersionClick : OnBoardingEvent
@@ -22,7 +22,6 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
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.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginModeEvent import io.element.android.features.login.impl.login.LoginModeEvent
@@ -31,9 +30,11 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.utils.MultipleTapToUnlock import io.element.android.libraries.ui.utils.MultipleTapToUnlock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject @AssistedInject
class OnBoardingPresenter( class OnBoardingPresenter(
@@ -59,27 +60,35 @@ class OnBoardingPresenter(
@Composable @Composable
override fun present(): OnBoardingState { override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val forcedAccountProvider = remember { val canConnectToAnyAccountProvider = remember {
// If homeserverAllowList() returns a singleton list, this is the default account provider. enterpriseService.canConnectToAnyAccountProvider()
// In this case, the user can sign in using this homeserver, or use QrCode login
enterpriseService.homeserverAllowList().singleOrNull()
} }
val canConnectToAnyHomeserver = remember { val forcedAccountProvider = remember {
enterpriseService.canConnectToAnyHomeserver() // If accountProviderAllowList() returns a singleton list, and the user is not free to use another
// account provider, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
if (canConnectToAnyAccountProvider) {
null
} else {
enterpriseService.accountProviderAllowList().singleOrNull()
}
} }
val mustChooseAccountProvider = remember { val mustChooseAccountProvider = remember {
!canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1 !canConnectToAnyAccountProvider && enterpriseService.accountProviderAllowList().size > 1
} }
val linkAccountProvider by produceState<String?>(initialValue = null) { val linkAccountProvider by produceState<AccountProvider?>(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service // Account provider from the link, if allowed by the enterprise service
value = params.accountProvider?.takeIf { value = params.accountProvider
try { ?.let { AccountProvider.Generic(it) }
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it) ?.takeIf {
true try {
} catch (_: Exception) { defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it)
false true
} catch (e: Exception) {
Timber.e(e, "Failed to check account provider from link, assuming not allowed")
false
}
} }
}
} }
val defaultAccountProvider = remember(linkAccountProvider) { val defaultAccountProvider = remember(linkAccountProvider) {
// If there is a forced account provider, this is the default account provider // If there is a forced account provider, this is the default account provider
@@ -105,11 +114,11 @@ class OnBoardingPresenter(
when (event) { when (event) {
is OnBoardingEvent.OnSignIn -> localCoroutineScope.launch { is OnBoardingEvent.OnSignIn -> localCoroutineScope.launch {
// Ensure that the current account provider is set // Ensure that the current account provider is set
accountProviderDataSource.setUrl(event.defaultAccountProvider) accountProviderDataSource.setAccountProvider(event.defaultAccountProvider)
loginModeState.eventSink( loginModeState.eventSink(
LoginModeEvent.Submit( LoginModeEvent.Submit(
isAccountCreation = false, isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider, homeserverUrl = event.defaultAccountProvider.serverNameOrBaseUrl(),
resolvedHomeserverUrl = null, resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null }, loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
) )
@@ -134,7 +143,7 @@ class OnBoardingPresenter(
defaultAccountProvider = defaultAccountProvider, defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT, canCreateAccount = defaultAccountProvider == null && canConnectToAnyAccountProvider && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug && showReportBug, canReportBug = canReportBug && showReportBug,
loginModeState = loginModeState, loginModeState = loginModeState,
version = buildMeta.versionName, version = buildMeta.versionName,
@@ -11,13 +11,14 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
data class OnBoardingState( data class OnBoardingState(
val isAddingAccount: Boolean, val isAddingAccount: Boolean,
val showBackButton: Boolean, val showBackButton: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val productionApplicationName: String, val productionApplicationName: String,
val defaultAccountProvider: String?, val defaultAccountProvider: AccountProvider?,
val mustChooseAccountProvider: Boolean, val mustChooseAccountProvider: Boolean,
val canLoginWithQrCode: Boolean, val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean, val canCreateAccount: Boolean,
@@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginModeState import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.R
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
open class OnBoardingStatePreviewParam : PreviewParameterProvider<OnBoardingState> { open class OnBoardingStatePreviewParam : PreviewParameterProvider<OnBoardingState> {
override val values: Sequence<OnBoardingState> override val values: Sequence<OnBoardingState>
@@ -22,7 +23,11 @@ open class OnBoardingStatePreviewParam : PreviewParameterProvider<OnBoardingStat
anOnBoardingState(canCreateAccount = true), anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true), anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true), anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true), anOnBoardingState(
defaultAccountProvider = AccountProvider.Generic("element.io"),
canCreateAccount = false,
canReportBug = true
),
anOnBoardingState(customLogoResId = R.drawable.sample_background), anOnBoardingState(customLogoResId = R.drawable.sample_background),
anOnBoardingState( anOnBoardingState(
isAddingAccount = true, isAddingAccount = true,
@@ -41,7 +46,7 @@ fun anOnBoardingState(
showBackButton: Boolean = false, showBackButton: Boolean = false,
showDeveloperSettings: Boolean = false, showDeveloperSettings: Boolean = false,
productionApplicationName: String = "Element", productionApplicationName: String = "Element",
defaultAccountProvider: String? = null, defaultAccountProvider: AccountProvider? = null,
mustChooseAccountProvider: Boolean = false, mustChooseAccountProvider: Boolean = false,
canLoginWithQrCode: Boolean = false, canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false, canCreateAccount: Boolean = false,
@@ -303,7 +303,7 @@ private fun OnBoardingButtons(
) )
} else { } else {
Button( Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider), text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider.friendlyServerName()),
showProgress = isLoading, showProgress = isLoading,
onClick = { onClick = {
state.eventSink(OnBoardingEvent.OnSignIn(defaultAccountProvider)) state.eventSink(OnBoardingEvent.OnSignIn(defaultAccountProvider))
@@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,0L8,0A8,8 0,0 1,16 8L16,8A8,8 0,0 1,8 16L8,16A8,8 0,0 1,0 8L0,8A8,8 0,0 1,8 0z"
android:fillColor="#101317"/>
<path
android:pathData="M5.355,5.141V5.85H5.375C5.564,5.579 5.793,5.37 6.059,5.223C6.324,5.073 6.632,5 6.976,5C7.307,5 7.609,5.065 7.883,5.192C8.157,5.319 8.363,5.548 8.507,5.87C8.662,5.641 8.874,5.438 9.139,5.263C9.405,5.088 9.721,5 10.085,5C10.362,5 10.619,5.034 10.856,5.102C11.094,5.169 11.294,5.277 11.464,5.426C11.633,5.576 11.763,5.768 11.859,6.008C11.952,6.248 12,6.536 12,6.875V10.38H10.563V7.412C10.563,7.236 10.557,7.07 10.543,6.915C10.529,6.759 10.492,6.624 10.433,6.511C10.371,6.395 10.283,6.305 10.165,6.237C10.046,6.169 9.885,6.135 9.684,6.135C9.481,6.135 9.317,6.175 9.193,6.251C9.069,6.33 8.97,6.429 8.899,6.556C8.829,6.68 8.781,6.821 8.758,6.982C8.736,7.14 8.722,7.301 8.722,7.462V10.38H7.284V7.443C7.284,7.287 7.281,7.135 7.273,6.982C7.267,6.83 7.236,6.691 7.185,6.562C7.134,6.435 7.05,6.33 6.931,6.254C6.813,6.178 6.64,6.138 6.409,6.138C6.341,6.138 6.251,6.152 6.14,6.183C6.03,6.214 5.92,6.271 5.816,6.355C5.711,6.44 5.621,6.562 5.547,6.72C5.474,6.878 5.437,7.087 5.437,7.344V10.382H4V5.141H5.355Z"
android:fillColor="#EBEEF2"/>
</vector>
@@ -11,19 +11,30 @@ 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.libraries.matrix.api.accountprovider.AccountProvider
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.matrix.test.accountprovider.anAccountProviderManaged
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
class DefaultAccountProviderAccessControlTest { class DefaultAccountProviderAccessControlTest {
/**
* The account provider the assertions are run against: its server name is the title reported by the exceptions,
* its base url is what the enterprise service is queried with.
*/
private fun anAccountProviderUnderTest() = anAccountProviderManaged(
serverName = AN_ACCOUNT_PROVIDER,
baseUrl = AN_ACCOUNT_PROVIDER_URL,
)
@Test @Test
fun `foss build should not allow using account provider that enforce enterprise build`() { fun `foss build should not allow using account provider that enforce enterprise build`() {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false, isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true, isAllowedToConnectToAccountProvider = true,
enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }),
) )
accessControl.expectNeedElementProException() accessControl.expectNeedElementProException()
@@ -34,7 +45,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false, isEnterpriseBuild = false,
// false here. // false here.
isAllowedToConnectToHomeserver = false, isAllowedToConnectToAccountProvider = false,
enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }), enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }),
) )
accessControl.expectNeedElementProException() accessControl.expectNeedElementProException()
@@ -44,9 +55,9 @@ class DefaultAccountProviderAccessControlTest {
fun `foss build should allow using account provider that does not enforce enterprise build`() = runTest { fun `foss build should allow using account provider that does not enforce enterprise build`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false, isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true, isAllowedToConnectToAccountProvider = true,
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false } isElementProEnforcedResult = { false }
), ),
) )
@@ -57,7 +68,7 @@ class DefaultAccountProviderAccessControlTest {
fun `foss build should allow using account provider twith missing key in wellknown`() = runTest { fun `foss build should allow using account provider twith missing key in wellknown`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false, isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true, isAllowedToConnectToAccountProvider = true,
) )
accessControl.expectAllowed() accessControl.expectAllowed()
} }
@@ -66,7 +77,7 @@ class DefaultAccountProviderAccessControlTest {
fun `foss build should allow using account provider with missing wellknown`() = runTest { fun `foss build should allow using account provider with missing wellknown`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false, isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true, isAllowedToConnectToAccountProvider = true,
) )
accessControl.expectAllowed() accessControl.expectAllowed()
} }
@@ -75,12 +86,12 @@ class DefaultAccountProviderAccessControlTest {
fun `foss build should not allow using account provider that do not enforce enterprise build but is not allowed`() { fun `foss build should not allow using account provider that do not enforce enterprise build but is not allowed`() {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false, isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = false, isAllowedToConnectToAccountProvider = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), allowedAccountProviders = listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false }, isAllowedToConnectToAccountProviderResult = { false },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)) },
), ),
) )
accessControl.expectUnauthorizedAccountProviderException() accessControl.expectUnauthorizedAccountProviderException()
@@ -90,9 +101,9 @@ class DefaultAccountProviderAccessControlTest {
fun `enterprise build should allow using account provider that enforce enterprise build`() = runTest { fun `enterprise build should allow using account provider that enforce enterprise build`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true, isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true, isAllowedToConnectToAccountProvider = true,
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { true }, isElementProEnforcedResult = { true },
), ),
) )
@@ -103,9 +114,9 @@ class DefaultAccountProviderAccessControlTest {
fun `enterprise build should allow using account provider that do not enforce enterprise build`() = runTest { fun `enterprise build should allow using account provider that do not enforce enterprise build`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true, isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true, isAllowedToConnectToAccountProvider = true,
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
) )
@@ -116,12 +127,12 @@ class DefaultAccountProviderAccessControlTest {
fun `enterprise build should not allow using account provider that enforce enterprise build but is not allowed`() = runTest { fun `enterprise build should not allow using account provider that enforce enterprise build but is not allowed`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true, isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false, isAllowedToConnectToAccountProvider = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), allowedAccountProviders = listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false }, isAllowedToConnectToAccountProviderResult = { false },
isElementProEnforcedResult = { true }, isElementProEnforcedResult = { true },
defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)) },
), ),
) )
accessControl.expectUnauthorizedAccountProviderException() accessControl.expectUnauthorizedAccountProviderException()
@@ -131,12 +142,12 @@ class DefaultAccountProviderAccessControlTest {
fun `enterprise build should not allow using account provider that do not enforce enterprise build but is not allowed`() = runTest { fun `enterprise build should not allow using account provider that do not enforce enterprise build but is not allowed`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl( val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true, isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false, isAllowedToConnectToAccountProvider = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), allowedAccountProviders = listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false }, isAllowedToConnectToAccountProviderResult = { false },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = AN_ACCOUNT_PROVIDER_2)) },
), ),
) )
accessControl.expectUnauthorizedAccountProviderException() accessControl.expectUnauthorizedAccountProviderException()
@@ -144,11 +155,11 @@ class DefaultAccountProviderAccessControlTest {
private fun createDefaultAccountProviderAccessControl( private fun createDefaultAccountProviderAccessControl(
isEnterpriseBuild: Boolean = false, isEnterpriseBuild: Boolean = false,
isAllowedToConnectToHomeserver: Boolean = false, isAllowedToConnectToAccountProvider: Boolean = false,
allowedAccountProviders: List<String> = emptyList(), allowedAccountProviders: List<AccountProvider> = emptyList(),
enterpriseService: FakeEnterpriseService = FakeEnterpriseService( enterpriseService: FakeEnterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, isAllowedToConnectToAccountProviderResult = { isAllowedToConnectToAccountProvider },
defaultHomeserverListResult = { allowedAccountProviders }, accountProviderAllowListResult = { allowedAccountProviders },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
) )
) = DefaultAccountProviderAccessControl( ) = DefaultAccountProviderAccessControl(
@@ -159,19 +170,14 @@ class DefaultAccountProviderAccessControlTest {
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
val exception = assertThrows(AccountProviderAccessException.NeedElementProException::class.java) { val exception = assertThrows(AccountProviderAccessException.NeedElementProException::class.java) {
runTest { runTest {
assertIsAllowedToConnectToAccountProvider( assertIsAllowedToConnectToAccountProvider(anAccountProviderUnderTest())
title = AN_ACCOUNT_PROVIDER,
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
)
} }
} }
assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER)
assertThat(exception.applicationId).isEqualTo("io.element.enterprise") assertThat(exception.applicationId).isEqualTo("io.element.enterprise")
runTest { runTest {
assertThat( assertThat(
isAllowedToConnectToAccountProvider( isAllowedToConnectToAccountProvider(anAccountProviderUnderTest())
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
)
).isFalse() ).isFalse()
} }
} }
@@ -179,34 +185,24 @@ class DefaultAccountProviderAccessControlTest {
private fun DefaultAccountProviderAccessControl.expectUnauthorizedAccountProviderException() { private fun DefaultAccountProviderAccessControl.expectUnauthorizedAccountProviderException() {
val exception = assertThrows(AccountProviderAccessException.UnauthorizedAccountProviderException::class.java) { val exception = assertThrows(AccountProviderAccessException.UnauthorizedAccountProviderException::class.java) {
runTest { runTest {
assertIsAllowedToConnectToAccountProvider( assertIsAllowedToConnectToAccountProvider(anAccountProviderUnderTest())
title = AN_ACCOUNT_PROVIDER,
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
)
} }
} }
assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER)
assertThat(exception.authorisedAccountProviderTitles).containsExactly(AN_ACCOUNT_PROVIDER_2) assertThat(exception.authorisedAccountProviderTitles).containsExactly(AN_ACCOUNT_PROVIDER_2)
runTest { runTest {
assertThat( assertThat(
isAllowedToConnectToAccountProvider( isAllowedToConnectToAccountProvider(anAccountProviderUnderTest())
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
)
).isFalse() ).isFalse()
} }
} }
private suspend fun DefaultAccountProviderAccessControl.expectAllowed() { private suspend fun DefaultAccountProviderAccessControl.expectAllowed() {
// If no exception is thrown, the test passes // If no exception is thrown, the test passes
assertIsAllowedToConnectToAccountProvider( assertIsAllowedToConnectToAccountProvider(anAccountProviderUnderTest())
title = AN_ACCOUNT_PROVIDER,
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
)
runTest { runTest {
assertThat( assertThat(
isAllowedToConnectToAccountProvider( isAllowedToConnectToAccountProvider(anAccountProviderUnderTest())
accountProviderUrl = AN_ACCOUNT_PROVIDER_URL,
)
).isTrue() ).isTrue()
} }
} }
@@ -10,9 +10,10 @@ package io.element.android.features.login.impl.accountprovider
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
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.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -24,61 +25,24 @@ class AccountProviderDataSourceTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state - matrix org is the default when nothing is configured`() = runTest {
val sut = anAccountProviderDataSource() val sut = anAccountProviderDataSource()
sut.flow.test { sut.flow.test {
val initialState = awaitItem() assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider)
assertThat(initialState).isEqualTo(
AccountProvider(
url = AuthenticationConfig.MATRIX_ORG_URL,
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
)
} }
} }
@Test @Test
fun `present - initial state - matrix org`() = runTest { fun `present - initial state - the first configured account provider is the default`() = runTest {
val sut = anAccountProviderDataSource( val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) } accountProviderAllowListResult = {
listOf(anAccountProviderManaged(serverName = "first.org"), anAccountProviderManaged(serverName = "second.org"))
}
), ),
) )
sut.flow.test { sut.flow.test {
val initialState = awaitItem() assertThat(awaitItem()).isEqualTo(anAccountProviderManaged(serverName = "first.org"))
assertThat(initialState).isEqualTo(
AccountProvider(
url = AuthenticationConfig.MATRIX_ORG_URL,
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
)
}
}
@Test
fun `present - ensure that default homeserver is not star char`() = runTest {
val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) }
),
)
sut.flow.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
AccountProvider(
url = AuthenticationConfig.MATRIX_ORG_URL,
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
)
} }
} }
@@ -86,22 +50,11 @@ class AccountProviderDataSourceTest {
fun `present - user change and reset`() = runTest { fun `present - user change and reset`() = runTest {
val sut = anAccountProviderDataSource() val sut = anAccountProviderDataSource()
sut.flow.test { sut.flow.test {
val initialState = awaitItem() assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider)
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) sut.setAccountProvider(AccountProvider.Generic("https://example.com"))
sut.setAccountProvider(AccountProvider(url = "https://example.com")) assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com"))
val changedState = awaitItem()
assertThat(changedState).isEqualTo(
AccountProvider(
url = "https://example.com",
title = "example.com",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
)
sut.reset() sut.reset()
val resetState = awaitItem() assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider)
assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
} }
} }
@@ -109,22 +62,23 @@ class AccountProviderDataSourceTest {
fun `present - set url and reset`() = runTest { fun `present - set url and reset`() = runTest {
val sut = anAccountProviderDataSource() val sut = anAccountProviderDataSource()
sut.flow.test { sut.flow.test {
val initialState = awaitItem() assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider)
assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
sut.setUrl(url = "https://example.com") sut.setUrl(url = "https://example.com")
val changedState = awaitItem() assertThat(awaitItem()).isEqualTo(
assertThat(changedState).isEqualTo( AccountProvider.Generic("https://example.com")
AccountProvider(
url = "https://example.com",
title = "example.com",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
) )
sut.reset() sut.reset()
val resetState = awaitItem() assertThat(awaitItem()).isEqualTo(matrixOrgAccountProvider)
assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) }
}
@Test
fun `present - a provider set by the user is stored as they input it`() = runTest {
val sut = anAccountProviderDataSource()
sut.flow.test {
skipItems(1)
sut.setUrl(url = "example.com")
assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("example.com"))
} }
} }
@@ -136,7 +90,7 @@ class AccountProviderDataSourceTest {
), ),
) )
sut.flow.test { sut.flow.test {
assertThat(awaitItem().url).isEqualTo("https://example.com") assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com"))
} }
} }
@@ -144,14 +98,31 @@ class AccountProviderDataSourceTest {
fun `present - history is ignored when the account provider is enforced`() = runTest { fun `present - history is ignored when the account provider is enforced`() = runTest {
val sut = anAccountProviderDataSource( val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf("https://enforced.org") } accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "enforced.org")) },
canConnectToAnyAccountProviderResult = { false },
), ),
appPreferencesStore = InMemoryAppPreferencesStore( appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://example.com"), homeserverHistory = listOf("https://example.com"),
), ),
) )
sut.flow.test { sut.flow.test {
assertThat(awaitItem().url).isEqualTo("https://enforced.org") assertThat(awaitItem()).isEqualTo(anAccountProviderManaged(serverName = "enforced.org"))
}
}
@Test
fun `present - history wins over the configured provider when other providers are allowed`() = runTest {
val sut = anAccountProviderDataSource(
enterpriseService = FakeEnterpriseService(
accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "preferred.org")) },
canConnectToAnyAccountProviderResult = { true },
),
appPreferencesStore = InMemoryAppPreferencesStore(
homeserverHistory = listOf("https://example.com"),
),
)
sut.flow.test {
assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com"))
} }
} }
@@ -163,11 +134,11 @@ class AccountProviderDataSourceTest {
), ),
) )
sut.flow.test { sut.flow.test {
assertThat(awaitItem().url).isEqualTo("https://example.com") assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com"))
sut.setUrl("https://other.com") sut.setUrl("https://other.com")
assertThat(awaitItem().url).isEqualTo("https://other.com") assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://other.com"))
sut.reset() sut.reset()
assertThat(awaitItem().url).isEqualTo("https://example.com") assertThat(awaitItem()).isEqualTo(AccountProvider.Generic("https://example.com"))
} }
} }
} }
@@ -20,7 +20,7 @@ class SaveAccountProviderToHistoryTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `invoke saves the current account provider url to history`() = runTest { fun `invoke saves the current account provider server name to history`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore() val appPreferencesStore = InMemoryAppPreferencesStore()
val accountProviderDataSource = anAccountProviderDataSource() val accountProviderDataSource = anAccountProviderDataSource()
accountProviderDataSource.setUrl("https://example.com") accountProviderDataSource.setUrl("https://example.com")
@@ -29,7 +29,7 @@ class SaveAccountProviderToHistoryTest {
sut() sut()
assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com") .containsExactly("example.com")
} }
@Test @Test
@@ -45,6 +45,6 @@ class SaveAccountProviderToHistoryTest {
sut() sut()
assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
.containsExactly("https://example.com") .containsExactly("example.com")
} }
} }
@@ -12,14 +12,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.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.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
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.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
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.accountprovider.anAccountProviderManaged
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
@@ -57,13 +58,13 @@ class ChangeServerPresenterTest {
createPresenter( createPresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL)))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
@@ -80,14 +81,14 @@ class ChangeServerPresenterTest {
) )
createPresenter( createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
authenticationService = authenticationService, authenticationService = authenticationService,
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL)))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem() val failureState = awaitItem()
@@ -108,14 +109,14 @@ class ChangeServerPresenterTest {
) )
createPresenter( createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
authenticationService = authenticationService, authenticationService = authenticationService,
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL)))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem() val failureState = awaitItem()
@@ -128,30 +129,30 @@ class ChangeServerPresenterTest {
@Test @Test
fun `present - change server not allowed error`() = runTest { fun `present - change server not allowed error`() = runTest {
val isAllowedToConnectToHomeserverResult = lambdaRecorder<String, Boolean> { false } val isAllowedToConnectToAccountProviderResult = lambdaRecorder<AccountProvider, Boolean> { false }
createPresenter( createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult, isAllowedToConnectToAccountProviderResult = isAllowedToConnectToAccountProviderResult,
defaultHomeserverListResult = { listOf("element.io") }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "element.io")) },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) val anAccountProvider = AccountProvider.Generic(A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(anAccountProvider)) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(anAccountProvider))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem() val failureState = awaitItem()
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).unauthorisedAccountProviderTitle (failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).unauthorisedAccountProviderTitle
).isEqualTo(anAccountProvider.title) ).isEqualTo(anAccountProvider.serverNameOrBaseUrl())
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).authorisedAccountProviderTitles (failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).authorisedAccountProviderTitles
).containsExactly("element.io") ).containsExactly("element.io")
isAllowedToConnectToHomeserverResult.assertions() isAllowedToConnectToAccountProviderResult.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_HOMESERVER_URL)) .with(value(AccountProvider.Generic(A_HOMESERVER_URL)))
} }
} }
@@ -166,14 +167,14 @@ class ChangeServerPresenterTest {
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) val anAccountProvider = AccountProvider.Generic(A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(anAccountProvider)) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(anAccountProvider))
// Skip loading state // Skip loading state
skipItems(1) skipItems(1)
val failureState = awaitItem() val failureState = awaitItem()
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
).isEqualTo(anAccountProvider.title) ).isEqualTo(anAccountProvider.serverNameOrBaseUrl())
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise") ).isEqualTo("io.element.enterprise")
@@ -188,13 +189,13 @@ class ChangeServerPresenterTest {
) )
createPresenter( createPresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }), enterpriseService = FakeEnterpriseService(isAllowedToConnectToAccountProviderResult = { true }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = FakePermissionsPresenter(), permissionsPresenter = FakePermissionsPresenter(),
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None) assertThat(initialState.localNetworkPermissionDialog).isEqualTo(LocalNetworkPermissionDialog.None)
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL)))
val promptState = expectMostRecentItem() val promptState = expectMostRecentItem()
// Dialog is shown, permission has not been requested yet, setHomeserver has not been called. // Dialog is shown, permission has not been requested yet, setHomeserver has not been called.
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None) assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
@@ -208,12 +209,12 @@ class ChangeServerPresenterTest {
authenticationService = FakeMatrixAuthenticationService( authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) }, setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) },
), ),
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }), enterpriseService = FakeEnterpriseService(isAllowedToConnectToAccountProviderResult = { true }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = FakePermissionsPresenter(), permissionsPresenter = FakePermissionsPresenter(),
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL)))
val promptState = expectMostRecentItem() val promptState = expectMostRecentItem()
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None) assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
promptState.eventSink.invoke(ChangeServerEvent.DismissLocalNetworkPermission) promptState.eventSink.invoke(ChangeServerEvent.DismissLocalNetworkPermission)
@@ -231,12 +232,12 @@ class ChangeServerPresenterTest {
val permissionsPresenter = FakePermissionsPresenter() val permissionsPresenter = FakePermissionsPresenter()
createPresenter( createPresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }, isElementProEnforcedResult = { false }), enterpriseService = FakeEnterpriseService(isAllowedToConnectToAccountProviderResult = { true }, isElementProEnforcedResult = { false }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = permissionsPresenter, permissionsPresenter = permissionsPresenter,
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvent.ChangeServer(AccountProvider.Generic(A_HOMESERVER_URL)))
val promptState = expectMostRecentItem() val promptState = expectMostRecentItem()
assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None) assertThat(promptState.localNetworkPermissionDialog).isNotEqualTo(LocalNetworkPermissionDialog.None)
permissionsPresenter.setPermissionGranted() permissionsPresenter.setPermissionGranted()
@@ -11,14 +11,13 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider
import com.google.common.truth.Truth.assertThat 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.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginModePresenter import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter
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.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_3 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
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
@@ -33,18 +32,8 @@ class ChooseAccountProviderPresenterTest {
companion object { companion object {
private const val ACCOUNT_PROVIDER_FROM_CONFIG_1 = AN_ACCOUNT_PROVIDER_2 private const val ACCOUNT_PROVIDER_FROM_CONFIG_1 = AN_ACCOUNT_PROVIDER_2
private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3 private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3
val accountProvider1 = AccountProvider( val accountProvider1 = anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_1)
url = ACCOUNT_PROVIDER_FROM_CONFIG_1.ensureProtocol(), val accountProvider2 = anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_2)
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
val accountProvider2 = AccountProvider(
url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(),
subtitle = null,
isPublic = false,
isMatrixOrg = false,
)
} }
@Test @Test
@@ -61,7 +50,7 @@ class ChooseAccountProviderPresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) },
), ),
) )
presenter.test { presenter.test {
@@ -79,7 +68,7 @@ class ChooseAccountProviderPresenterTest {
val authenticationService = FakeMatrixAuthenticationService() val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter( val presenter = createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) },
), ),
loginModePresenter = createLoginModePresenter( loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
@@ -103,7 +92,7 @@ class ChooseAccountProviderPresenterTest {
) )
val presenter = createPresenter( val presenter = createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) },
), ),
loginModePresenter = createLoginModePresenter( loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
@@ -136,7 +125,7 @@ class ChooseAccountProviderPresenterTest {
val authenticationService = FakeMatrixAuthenticationService() val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter( val presenter = createPresenter(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, accountProviderAllowListResult = { listOf(accountProvider1, accountProvider2) },
), ),
loginModePresenter = createLoginModePresenter( loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService, authenticationService = authenticationService,
@@ -9,7 +9,7 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider package io.element.android.features.login.impl.screens.chooseaccountprovider
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.accountprovider.anAccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged
import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@@ -27,7 +27,7 @@ class ChooseAccountProviderStateTest {
@Test @Test
fun `submitEnabled returns true when there is a selectedAccountProvider`() { fun `submitEnabled returns true when there is a selectedAccountProvider`() {
val sut = aChooseAccountProviderState( val sut = aChooseAccountProviderState(
selectedAccountProvider = anAccountProvider(), selectedAccountProvider = anAccountProviderManaged(),
) )
assertThat(sut.submitEnabled).isTrue() assertThat(sut.submitEnabled).isTrue()
} }
@@ -35,7 +35,7 @@ class ChooseAccountProviderStateTest {
@Test @Test
fun `submitEnabled returns false when there is a selectedAccountProvider but there is an error`() { fun `submitEnabled returns false when there is a selectedAccountProvider but there is an error`() {
val sut = aChooseAccountProviderState( val sut = aChooseAccountProviderState(
selectedAccountProvider = anAccountProvider(), selectedAccountProvider = anAccountProviderManaged(),
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(Throwable("Error"))), loginModeState = aLoginModeState(loginMode = AsyncData.Failure(Throwable("Error"))),
) )
assertThat(sut.submitEnabled).isFalse() assertThat(sut.submitEnabled).isFalse()
@@ -44,7 +44,7 @@ class ChooseAccountProviderStateTest {
@Test @Test
fun `submitEnabled returns false when there is a selectedAccountProvider but the result is successful`() { fun `submitEnabled returns false when there is a selectedAccountProvider but the result is successful`() {
val sut = aChooseAccountProviderState( val sut = aChooseAccountProviderState(
selectedAccountProvider = anAccountProvider(), selectedAccountProvider = anAccountProviderManaged(),
loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)), loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.PasswordLogin)),
) )
assertThat(sut.submitEnabled).isFalse() assertThat(sut.submitEnabled).isFalse()
@@ -16,7 +16,7 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.login.impl.accountprovider.anAccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged
import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.matrix.api.auth.OAuthDetails
@@ -57,11 +57,11 @@ class ChooseAccountProviderViewTest : RobolectricTest() {
ChooseAccountProviderPresenterTest.accountProvider1, ChooseAccountProviderPresenterTest.accountProvider1,
ChooseAccountProviderPresenterTest.accountProvider2, ChooseAccountProviderPresenterTest.accountProvider2,
), ),
selectedAccountProvider = anAccountProvider(), selectedAccountProvider = anAccountProviderManaged(),
eventSink = eventSink, eventSink = eventSink,
), ),
) )
onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick() onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.friendlyServerName()).performClick()
eventSink.assertSingle(ChooseAccountProviderEvent.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1)) eventSink.assertSingle(ChooseAccountProviderEvent.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1))
} }
@@ -10,7 +10,6 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
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
@@ -24,6 +23,7 @@ import io.element.android.features.login.impl.screens.onboarding.createLoginMode
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthAction
@@ -44,6 +44,11 @@ class ConfirmAccountProviderPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
companion object {
private const val MATRIX_ORG_SERVER_NAME = "matrix.org"
private const val MATRIX_ORG_URL = "https://matrix.org"
}
@Test @Test
fun `present - initial test`() = runTest { fun `present - initial test`() = runTest {
val presenter = createConfirmAccountProviderPresenter() val presenter = createConfirmAccountProviderPresenter()
@@ -68,7 +73,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) assertThat(successState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -87,7 +92,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -108,7 +113,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenOAuthCancelError(AN_EXCEPTION) authenticationService.givenOAuthCancelError(AN_EXCEPTION)
@@ -133,7 +138,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOAuthActionFlow.post(OAuthAction.GoBack()) defaultOAuthActionFlow.post(OAuthAction.GoBack())
@@ -157,7 +162,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
awaitLoginMode { it is AsyncData.Loading } awaitLoginMode { it is AsyncData.Loading }
defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true)) defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized } val cancelFinalState = awaitLoginMode { it is AsyncData.Uninitialized }
@@ -180,7 +185,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION) authenticationService.givenLoginError(AN_EXCEPTION)
@@ -205,7 +210,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val successState = awaitLoginMode { it is AsyncData.Success } val successState = awaitLoginMode { it is AsyncData.Success }
assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(successState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
defaultOidcActionFlow.post(OAuthAction.Success("aUrl")) defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
@@ -227,7 +232,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink.invoke(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
// The account provider validation fails, so the login is never attempted. // The account provider validation fails, so the login is never attempted.
val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } val failureState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized) assertThat(failureState.loginModeState.loginMode).isEqualTo(AsyncData.Uninitialized)
@@ -249,7 +254,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
// Submit will return an error while validating the account provider // Submit will return an error while validating the account provider
initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
// Check an error was returned // Check an error was returned
val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure } val submittedState = awaitState { it.changeServerState.changeServerAction is AsyncData.Failure }
@@ -274,7 +279,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
// Check an error was returned // Check an error was returned
val submittedState = awaitLoginMode { it is AsyncData.Failure } val submittedState = awaitLoginMode { it is AsyncData.Failure }
assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) assertThat(submittedState.loginModeState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
@@ -299,7 +304,7 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
val submittedState = awaitLoginMode { it is AsyncData.Success } val submittedState = awaitLoginMode { it is AsyncData.Success }
assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@@ -344,7 +349,7 @@ class ConfirmAccountProviderPresenterTest {
val suggestionState = awaitState { it.accountProviderSuggestion != null } val suggestionState = awaitState { it.accountProviderSuggestion != null }
suggestionState.eventSink(ConfirmAccountProviderEvent.Continue("randomcommunity.org")) suggestionState.eventSink(ConfirmAccountProviderEvent.Continue("randomcommunity.org"))
awaitLoginMode { it is AsyncData.Success } awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo("https://randomcommunity.org") assertThat(submittedUrls.first()).isEqualTo("randomcommunity.org")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -375,8 +380,7 @@ class ConfirmAccountProviderPresenterTest {
val presenter = createConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()), appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = emptyList()),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
// Preferred servers plus the "*" (any) wildcard: the wildcard is filtered out, the rest complete. accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = "element.io")) },
defaultHomeserverListResult = { listOf("https://element.io", EnterpriseService.ANY_ACCOUNT_PROVIDER) },
), ),
) )
presenter.test { presenter.test {
@@ -387,6 +391,19 @@ class ConfirmAccountProviderPresenterTest {
} }
} }
@Test
fun `present - offers the previously used servers without the scheme as autocomplete suggestions`() = runTest {
val presenter = createConfirmAccountProviderPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(homeserverHistory = listOf("https://previous.org")),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.UserInputChanged("prev"))
val suggestionState = awaitState { it.accountProviderInput == "prev" }
assertThat(suggestionState.accountProviderSuggestion).isEqualTo("previous.org")
}
}
@Test @Test
fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest { fun `present - offers matrix_org without the scheme as an autocomplete suggestion`() = runTest {
val presenter = createConfirmAccountProviderPresenter( val presenter = createConfirmAccountProviderPresenter(
@@ -432,7 +449,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.Continue("matrix.org")) initialState.eventSink(ConfirmAccountProviderEvent.Continue("matrix.org"))
awaitLoginMode { it is AsyncData.Success } awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(submittedUrls.first()).isEqualTo(MATRIX_ORG_SERVER_NAME)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -453,9 +470,9 @@ class ConfirmAccountProviderPresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.Continue(AuthenticationConfig.MATRIX_ORG_URL)) initialState.eventSink(ConfirmAccountProviderEvent.Continue(MATRIX_ORG_URL))
awaitLoginMode { it is AsyncData.Success } awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls).containsExactly(AuthenticationConfig.MATRIX_ORG_URL) assertThat(submittedUrls).containsExactly(MATRIX_ORG_SERVER_NAME)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -474,7 +491,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.Continue("@alice:example.org")) initialState.eventSink(ConfirmAccountProviderEvent.Continue("@alice:example.org"))
awaitLoginMode { it is AsyncData.Success } awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo("https://example.org") assertThat(submittedUrls.first()).isEqualTo("example.org")
assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org") assertThat(authenticationService.getOAuthUrlLoginHint).isEqualTo("mxid:@alice:example.org")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@@ -494,7 +511,7 @@ class ConfirmAccountProviderPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfirmAccountProviderEvent.Continue("@alice:example.org:8448")) initialState.eventSink(ConfirmAccountProviderEvent.Continue("@alice:example.org:8448"))
awaitLoginMode { it is AsyncData.Success } awaitLoginMode { it is AsyncData.Success }
assertThat(submittedUrls.first()).isEqualTo("https://example.org:8448") assertThat(submittedUrls.first()).isEqualTo("example.org:8448")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -570,7 +587,7 @@ class ConfirmAccountProviderPresenterTest {
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
isEnterpriseBuild = { false }, isEnterpriseBuild = { false },
@@ -9,11 +9,11 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
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.accountprovider.SaveAccountProviderToHistory import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_PASSWORD
@@ -39,7 +39,7 @@ class LoginPasswordPresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
createLoginPasswordPresenter().test { createLoginPasswordPresenter().test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(initialState.accountProvider).isEqualTo(matrixOrgAccountProvider)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default) assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.submitEnabled).isFalse() assertThat(initialState.submitEnabled).isFalse()
@@ -127,7 +127,7 @@ class LoginPasswordPresenterTest {
val loggedInState = awaitItem() val loggedInState = awaitItem()
assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID)) assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID))
assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) assertThat(appPreferencesStore.getHomeserverHistoryFlow().first())
.containsExactly(AuthenticationConfig.MATRIX_ORG_URL) .containsExactly(matrixOrgAccountProvider.serverName)
} }
} }
@@ -9,7 +9,6 @@
package io.element.android.features.login.impl.screens.onboarding package io.element.android.features.login.impl.screens.onboarding
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.IsEnterpriseBuild import io.element.android.features.enterprise.api.IsEnterpriseBuild
@@ -22,6 +21,8 @@ import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissio
import io.element.android.features.login.impl.login.LoginModePresenter import io.element.android.features.login.impl.login.LoginModePresenter
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.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
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
@@ -30,6 +31,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
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.A_HOMESERVER_URL_2 import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_LOGIN_HINT import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderManaged
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.api.OAuthActionFlow
@@ -82,7 +84,8 @@ class OnBoardingPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
buildMeta = buildMeta, buildMeta = buildMeta,
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) },
canConnectToAnyAccountProviderResult = { true },
), ),
) )
presenter.test { presenter.test {
@@ -184,15 +187,16 @@ class OnBoardingPresenterTest {
showBackButton = false, showBackButton = false,
), ),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) },
isAllowedToConnectToHomeserverResult = { true }, canConnectToAnyAccountProviderResult = { true },
isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
) )
presenter.test { presenter.test {
skipItems(3) skipItems(3)
awaitItem().also { awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_LINK) assertThat(it.defaultAccountProvider).isEqualTo(AccountProvider.Generic(ACCOUNT_PROVIDER_FROM_LINK))
assertThat(it.canLoginWithQrCode).isFalse() assertThat(it.canLoginWithQrCode).isFalse()
assertThat(it.canCreateAccount).isFalse() assertThat(it.canCreateAccount).isFalse()
} }
@@ -208,8 +212,14 @@ class OnBoardingPresenterTest {
showBackButton = false, showBackButton = false,
), ),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, accountProviderAllowListResult = {
isAllowedToConnectToHomeserverResult = { false }, listOf(
anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG),
anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_2)
)
},
canConnectToAnyAccountProviderResult = { false },
isAllowedToConnectToAccountProviderResult = { false },
), ),
) )
presenter.test { presenter.test {
@@ -231,19 +241,60 @@ class OnBoardingPresenterTest {
showBackButton = false, showBackButton = false,
), ),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) }, accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) },
canConnectToAnyAccountProviderResult = { false },
) )
) )
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_CONFIG) assertThat(it.defaultAccountProvider).isEqualTo(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG))
assertThat(it.canLoginWithQrCode).isTrue() assertThat(it.canLoginWithQrCode).isTrue()
assertThat(it.canCreateAccount).isFalse() assertThat(it.canCreateAccount).isFalse()
} }
} }
} }
@Test
fun `present - a single configured account provider is not forced when the user may use another one`() = runTest {
// A singleton allow list only forces the account provider when the user is not free to use another one.
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
accountProviderAllowListResult = { listOf(anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG)) },
canConnectToAnyAccountProviderResult = { true },
),
)
presenter.test {
awaitItem().also {
assertThat(it.defaultAccountProvider).isNull()
assertThat(it.mustChooseAccountProvider).isFalse()
}
skipItems(1)
}
}
@Test
fun `present - the user must choose when several account providers are configured and enforced`() = runTest {
val presenter = createPresenter(
enterpriseService = FakeEnterpriseService(
accountProviderAllowListResult = {
listOf(
anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG),
anAccountProviderManaged(serverName = ACCOUNT_PROVIDER_FROM_CONFIG_2)
)
},
canConnectToAnyAccountProviderResult = { false },
),
)
presenter.test {
awaitItem().also {
assertThat(it.defaultAccountProvider).isNull()
assertThat(it.mustChooseAccountProvider).isTrue()
}
skipItems(1)
}
}
@Test @Test
fun `present - default account provider - login and clear error`() = runTest { fun `present - default account provider - login and clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService( val authenticationService = FakeMatrixAuthenticationService(
@@ -259,7 +310,7 @@ class OnBoardingPresenterTest {
showBackButton = false, showBackButton = false,
), ),
enterpriseService = FakeEnterpriseService( enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true }, isAllowedToConnectToAccountProviderResult = { true },
isElementProEnforcedResult = { false }, isElementProEnforcedResult = { false },
), ),
loginModePresenter = createLoginModePresenter( loginModePresenter = createLoginModePresenter(
@@ -270,12 +321,12 @@ class OnBoardingPresenterTest {
presenter.test { presenter.test {
skipItems(3) skipItems(3)
awaitItem().also { awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL) assertThat(it.defaultAccountProvider).isEqualTo(AccountProvider.Generic(A_HOMESERVER_URL))
assertThat(accountProviderDataSource.flow.first().url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) assertThat(accountProviderDataSource.flow.first()).isEqualTo(matrixOrgAccountProvider)
it.eventSink(OnBoardingEvent.OnSignIn(A_HOMESERVER_URL_2)) it.eventSink(OnBoardingEvent.OnSignIn(AccountProvider.Generic(A_HOMESERVER_URL_2)))
skipItems(1) // Loading skipItems(1) // Loading
// Account data source has been updated // Account data source has been updated
assertThat(accountProviderDataSource.flow.first().url).isEqualTo(A_HOMESERVER_URL_2) assertThat(accountProviderDataSource.flow.first()).isEqualTo(AccountProvider.Generic(A_HOMESERVER_URL_2))
// Check an error was returned // Check an error was returned
val submittedState = awaitItem() val submittedState = awaitItem()
assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java) assertThat(submittedState.loginModeState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
@@ -23,6 +23,7 @@ import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.aLoginModeState import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -134,13 +135,13 @@ class OnboardingViewTest : RobolectricTestParameter() {
val eventSink = EventsRecorder<OnBoardingEvent>() val eventSink = EventsRecorder<OnBoardingEvent>()
setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
defaultAccountProvider = "element.io", defaultAccountProvider = AccountProvider.Generic("element.io"),
eventSink = eventSink, eventSink = eventSink,
), ),
) )
val buttonText = activity!!.getString(R.string.screen_onboarding_sign_in_to, "element.io") val buttonText = activity!!.getString(R.string.screen_onboarding_sign_in_to, "element.io")
onNodeWithText(buttonText).performClick() onNodeWithText(buttonText).performClick()
eventSink.assertSingle(OnBoardingEvent.OnSignIn("element.io")) eventSink.assertSingle(OnBoardingEvent.OnSignIn(AccountProvider.Generic("element.io")))
} }
@Test @Test
@@ -148,7 +149,7 @@ class OnboardingViewTest : RobolectricTestParameter() {
val eventSink = EventsRecorder<OnBoardingEvent>() val eventSink = EventsRecorder<OnBoardingEvent>()
setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
defaultAccountProvider = "element.io", defaultAccountProvider = AccountProvider.Generic("element.io"),
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)), loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AN_EXCEPTION)),
eventSink = eventSink, eventSink = eventSink,
), ),
+1
View File
@@ -16,6 +16,7 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.features.login.api) implementation(projects.features.login.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
} }
@@ -8,12 +8,13 @@
package io.element.android.features.login.test.accesscontrol package io.element.android.features.login.test.accesscontrol
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeAccountProviderAccessControl( class FakeAccountProviderAccessControl(
private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() }, private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() },
) : AccountProviderAccessControl { ) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean { override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean {
return isAllowedToConnectToAccountProviderResult(accountProviderUrl) return isAllowedToConnectToAccountProviderResult(accountProvider)
} }
} }
@@ -51,6 +51,7 @@ import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
@@ -103,6 +104,7 @@ class MessagesNode(
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache, private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val dispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val focusedEventId: EventId?, val focusedEventId: EventId?,
@@ -111,7 +113,12 @@ class MessagesNode(
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callback: Callback = callback() private val callback: Callback = callback()
private val timelineController = TimelineController(room, room.liveTimeline) private val timelineController = TimelineController(
room = room,
liveTimeline = room.liveTimeline,
roomCoroutineScope = room.roomCoroutineScope,
dispatchers = dispatchers,
)
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
navigator = this, navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null), composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null),
@@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -95,6 +96,7 @@ class ThreadedMessagesNode(
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val dispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val threadRootEventId: ThreadId, val threadRootEventId: ThreadId,
@@ -113,7 +115,12 @@ class ThreadedMessagesNode(
*/ */
private suspend fun createPresenter(): Presenter<MessagesState> { private suspend fun createPresenter(): Presenter<MessagesState> {
val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow()
val timelineController = TimelineController(room, threadedTimeline) val timelineController = TimelineController(
room,
liveTimeline = threadedTimeline,
roomCoroutineScope = room.roomCoroutineScope,
dispatchers = dispatchers,
)
this.timelineController = timelineController this.timelineController = timelineController
return presenterFactory.create( return presenterFactory.create(
navigator = this, navigator = this,
@@ -12,7 +12,10 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding import dev.zacsweers.metro.binding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.CreateTimelineParams
@@ -23,7 +26,6 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,8 +49,10 @@ import java.util.Optional
class TimelineController( class TimelineController(
private val room: JoinedRoom, private val room: JoinedRoom,
@LiveTimeline private val liveTimeline: Timeline, @LiveTimeline private val liveTimeline: Timeline,
@RoomCoroutineScope private val roomCoroutineScope: CoroutineScope,
dispatchers: CoroutineDispatchers,
) : Closeable, TimelineProvider { ) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob()) private val coroutineScope = roomCoroutineScope.childScope(dispatchers.computation, "TimelineController")
private val liveTimelineFlow = flowOf(liveTimeline) private val liveTimelineFlow = flowOf(liveTimeline)
private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty()) private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@@ -125,7 +125,6 @@ import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class MessagesPresenterTest { class MessagesPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -1484,7 +1483,12 @@ class MessagesPresenterTest {
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
timelineController = TimelineController(joinedRoom, timeline), timelineController = TimelineController(
room = joinedRoom,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = coroutineDispatchers,
),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, encryptionService = encryptionService,
@@ -90,7 +90,6 @@ import org.junit.Test
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
class MessagesViewTest : RobolectricTest() { class MessagesViewTest : RobolectricTest() {
@Test @Test
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest { fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
@@ -54,7 +54,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
class ActionListPresenterTest { class ActionListPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -82,7 +82,6 @@ import org.junit.Test
import java.io.File import java.io.File
import kotlin.io.path.createTempFile import kotlin.io.path.createTempFile
@Suppress("LargeClass")
class AttachmentsPreviewPresenterTest : RobolectricTest() { class AttachmentsPreviewPresenterTest : RobolectricTest() {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -65,6 +65,7 @@ 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
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -307,7 +308,12 @@ class MessageComposerPresenterSlashCommandTest {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline), timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,
@@ -113,6 +113,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
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.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -126,7 +127,6 @@ import org.junit.Test
import uniffi.wysiwyg_composer.MentionsState import uniffi.wysiwyg_composer.MentionsState
import java.io.File import java.io.File
@Suppress("LargeClass")
class MessageComposerPresenterTest : RobolectricTest() { class MessageComposerPresenterTest : RobolectricTest() {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -1707,7 +1707,12 @@ class MessageComposerPresenterTest : RobolectricTest() {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline), timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, pillificationHelper = textPillificationHelper,
@@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -35,7 +37,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@@ -74,7 +79,10 @@ class TimelineControllerTest {
} }
} }
) )
val sut = TimelineController(joinedRoom, liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@@ -102,7 +110,10 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline liveTimeline = liveTimeline
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
@@ -121,7 +132,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
@@ -149,7 +163,10 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline liveTimeline = liveTimeline
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
assertThat(sut.timelineItems().first()).hasSize(1) assertThat(sut.timelineItems().first()).hasSize(1)
} }
@@ -171,7 +188,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID, null) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
@@ -196,7 +216,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@@ -220,12 +243,14 @@ class TimelineControllerTest {
} }
} }
internal fun createTimelineController( internal fun TestScope.createTimelineController(
room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()), room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()),
liveTimeline: Timeline = FakeTimeline(name = "live"), liveTimeline: Timeline = FakeTimeline(name = "live"),
): TimelineController { ): TimelineController {
return TimelineController( return TimelineController(
room = room, room = room,
liveTimeline = liveTimeline liveTimeline = liveTimeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
) )
} }
@@ -103,7 +103,6 @@ import java.util.Date
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class TimelinePresenterTest { class TimelinePresenterTest {
@get:Rule @get:Rule
@@ -1079,7 +1078,11 @@ class TimelinePresenterTest {
val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last() val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
assertThat(focusedState.isLive).isFalse() assertThat(focusedState.isLive).isFalse()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS))
advanceUntilIdle()
// Give some time for any potential pagination to be called, but it should not be called because the focused event is not rendered yet.
// Also, given enough time, a new state should be emitted with isLive = false, because we switched to a focused event timeline.
runCurrent()
assert(paginateLambda).isNeverCalled() assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse() assertThat(expectMostRecentItem().isLive).isFalse()
} }
@@ -1906,7 +1909,12 @@ class TimelinePresenterTest {
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer, timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room, timeline), timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
@@ -83,7 +83,6 @@ import org.junit.Test
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
class TimelineItemContentMessageFactoryTest : RobolectricTest() { class TimelineItemContentMessageFactoryTest : RobolectricTest() {
@Test @Test
fun `test create OtherMessageType`() = runTest { fun `test create OtherMessageType`() = runTest {
@@ -30,7 +30,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
class TypingNotificationPresenterTest { class TypingNotificationPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -63,7 +63,6 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class DefaultVoiceMessageComposerPresenterTest { class DefaultVoiceMessageComposerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -45,7 +45,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
class NotificationSettingsPresenterTest { class NotificationSettingsPresenterTest {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
@@ -68,7 +68,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Suppress("LargeClass")
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomDetailsPresenterTest { class RoomDetailsPresenterTest {
@get:Rule @get:Rule
@@ -50,7 +50,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@Suppress("LargeClass")
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomDetailsEditPresenterTest { class RoomDetailsEditPresenterTest {
@get:Rule @get:Rule
@@ -43,7 +43,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@Suppress("LargeClass")
class SecurityAndPrivacyPresenterTest { class SecurityAndPrivacyPresenterTest {
@Test @Test
fun `present - initial states`() = runTest { fun `present - initial states`() = runTest {
+4 -4
View File
@@ -6,7 +6,7 @@
android_gradle_plugin = "9.3.2" android_gradle_plugin = "9.3.2"
# When updating this, please also update the version in the file ./idea/kotlinc.xml # When updating this, please also update the version in the file ./idea/kotlinc.xml
kotlin = "2.4.10" kotlin = "2.4.10"
kotlinpoet = "2.3.0" kotlinpoet = "2.4.0"
ksp = "2.3.11" ksp = "2.3.11"
firebaseAppDistribution = "5.3.0" firebaseAppDistribution = "5.3.0"
@@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test # Test
test_core = "1.7.0" test_core = "1.7.0"
roborazzi = "1.73.0" roborazzi = "1.74.0"
# Jetbrain # Jetbrain
datetime = "0.8.0" datetime = "0.8.0"
@@ -238,7 +238,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call # Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.25.0" element_call_embedded = "io.element.android:element-call-embedded:0.26.0"
# Auto services # Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
@@ -272,7 +272,7 @@ paparazzi = "app.cash.paparazzi:2.0.0-alpha05"
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
sonarqube = "org.sonarqube:7.4.0.8496" sonarqube = "org.sonarqube:7.5.0.8588"
licensee = "app.cash.licensee:1.14.1" licensee = "app.cash.licensee:1.14.1"
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" } gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" }
@@ -57,7 +57,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() { class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
private lateinit var context: Context private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -58,7 +58,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultRoomLatestEventFormatterTest : RobolectricTest() { class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
private lateinit var context: Context private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -21,7 +21,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@Suppress("LargeClass")
class RtcNotificationContentFormatterTest : RobolectricTest() { class RtcNotificationContentFormatterTest : RobolectricTest() {
private lateinit var context: Context private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakeMatrixClient: FakeMatrixClient
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Element Creations 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.matrix.api.accountprovider
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AccountProvider {
/**
* An account provider that was input by the user.
*/
data class Generic(
val serverName: String,
) : AccountProvider
/**
* An account provider that is provided by the app.
*/
data class Managed(
val serverName: String,
val baseUrl: String,
val canUseServerName: Boolean,
) : AccountProvider
fun serverNameOrBaseUrl(): String {
return when (this) {
is Generic -> serverName
is Managed -> if (canUseServerName) serverName else baseUrl
}.sanitized()
}
/**
* Return the server name, to be used in the UI.
*/
fun friendlyServerName(): String {
return when (this) {
is Generic -> serverName
is Managed -> serverName
}.sanitized()
}
/**
* The user may reach an allowed account provider by either of its identifiers: the server name it is known by
* (what the user types, e.g. `element.io`) or the base url its client API lives at (e.g. `https://element.ems.host`).
*/
fun matches(other: AccountProvider): Boolean {
return serverNameOrBaseUrl() == other.serverNameOrBaseUrl()
}
/**
* Sanitizes the string with the following rules:
* - Trim any whitespace.
* - Lowercase the string.
* - Removes a https scheme (treating it as implicit).
* - Remove any trailing slashes.
*/
private fun String.sanitized(): String {
return trim()
.lowercase()
// Intentionally continue to show http:// as an indicator (and for history).
.removePrefix("https://")
.removeSuffix("/")
}
}
val matrixOrgAccountProvider = AccountProvider.Generic(
serverName = "matrix.org",
)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Element Creations 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.matrix.api.accountprovider
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class AccountProviderTest {
@Test
fun `serverNameOrBaseUrl of a generic account provider is the server name the user input`() {
assertThat(AccountProvider.Generic("matrix.org").serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl of a managed account provider is its server name when it can be used`() {
val accountProvider = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = true,
)
assertThat(accountProvider.serverNameOrBaseUrl()).isEqualTo("element.io")
}
@Test
fun `serverNameOrBaseUrl of a managed account provider is its base url when the server name cannot be used`() {
val accountProvider = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = false,
)
assertThat(accountProvider.serverNameOrBaseUrl()).isEqualTo("element.ems.host")
}
@Test
fun `matrixOrgAccountProvider is known by its server name`() {
assertThat(matrixOrgAccountProvider.serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl trims, lowercases and drops the implicit https scheme and a trailing slash`() {
assertThat(AccountProvider.Generic(" MATRIX.org ").serverNameOrBaseUrl()).isEqualTo("matrix.org")
assertThat(AccountProvider.Generic("https://matrix.org").serverNameOrBaseUrl()).isEqualTo("matrix.org")
assertThat(AccountProvider.Generic("https://matrix.org/").serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl keeps an explicit http scheme as an indicator`() {
assertThat(AccountProvider.Generic("http://localhost:8080").serverNameOrBaseUrl()).isEqualTo("http://localhost:8080")
}
@Test
fun `matches ignores the scheme, the case and a trailing slash`() {
val typed = AccountProvider.Generic("Element.io/")
assertThat(typed.matches(AccountProvider.Generic("https://element.io"))).isTrue()
assertThat(
typed.matches(
AccountProvider.Managed(serverName = "element.io", baseUrl = "https://element.ems.host", canUseServerName = true)
)
).isTrue()
}
@Test
fun `matches a managed provider that cannot use its server name only through its base url`() {
val managed = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = false,
)
assertThat(AccountProvider.Generic("element.ems.host").matches(managed)).isTrue()
assertThat(AccountProvider.Generic("element.io").matches(managed)).isFalse()
}
@Test
fun `matches is false for a different account provider`() {
val typed = AccountProvider.Generic("element.io")
assertThat(typed.matches(AccountProvider.Generic("matrix.org"))).isFalse()
// A managed provider that can use its server name is not reached through its base url.
assertThat(
typed.matches(
AccountProvider.Managed(serverName = "other.io", baseUrl = "https://element.io", canUseServerName = true)
)
).isFalse()
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations 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.matrix.test.accountprovider
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
fun anAccountProviderGeneric(
serverName: String = AN_ACCOUNT_PROVIDER,
) = AccountProvider.Generic(
serverName = serverName,
)
fun anAccountProviderManaged(
baseUrl: String = AN_ACCOUNT_PROVIDER_URL,
serverName: String = AN_ACCOUNT_PROVIDER,
canUseServerName: Boolean = true,
) = AccountProvider.Managed(
serverName = serverName,
baseUrl = baseUrl,
canUseServerName = canUseServerName,
)
@@ -54,7 +54,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@Suppress("LargeClass")
class InReplyToMetadataKtTest : RobolectricTest() { class InReplyToMetadataKtTest : RobolectricTest() {
@Test @Test
fun `any message content`() = runTest { fun `any message content`() = runTest {
@@ -62,7 +62,6 @@ import kotlinx.collections.immutable.toImmutableList
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class EventItemFactoryTest { class EventItemFactoryTest {
@Test @Test
fun `create check all null cases`() { fun `create check all null cases`() {
@@ -64,7 +64,6 @@ private val TESTED_MEDIA_INFO = anApkMediaInfo(
senderId = A_USER_ID, senderId = A_USER_ID,
) )
@Suppress("LargeClass")
class MediaViewerPresenterTest { class MediaViewerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -69,7 +69,6 @@ import org.junit.Test
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@Suppress("LargeClass")
class DefaultNotifiableEventResolverTest : RobolectricTest() { class DefaultNotifiableEventResolverTest : RobolectricTest() {
@Test @Test
fun `resolve event no session`() = runTest { fun `resolve event no session`() = runTest {
@@ -52,7 +52,6 @@ import kotlin.time.Duration.Companion.milliseconds
private const val A_PUSHER_INFO = "info" private const val A_PUSHER_INFO = "info"
@Suppress("LargeClass")
class DefaultPushHandlerTest { class DefaultPushHandlerTest {
@Test @Test
fun `check handleInvalid behavior`() = runTest { fun `check handleInvalid behavior`() = runTest {

Some files were not shown because too many files have changed in this diff Show More