Add support for login link (#4752)

* Add support for login link

https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org

* Update screenshots

* Reduce code duplication

* Add test on OnBoardingPresenter

* Fix tool

* Ignore login parameter if user is not allowed to connect to the provided server.

* Improve tests.

* Cleanup

* Revert change on Project.xml.

* Add documentation

* Improve LoginHelper

* Rename LoginFlow to LoginMode

Move LoginFlow to package io.element.android.features.login.impl.login
Rename some implementation of LoginMode
Rename LoginFlowView to LoginModeView

* Change launchMode of MainActivity from `singleTop` to `singleTask`

Using launchMode singleTask to avoid multiple instances of the Activity when the app is already open. This is important for incoming share and for opening the application from a mobile.element.io link.

Closes #4074

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-05-21 18:19:42 +02:00
committed by GitHub
parent bc759e4129
commit f20caebdcb
52 changed files with 1092 additions and 363 deletions

View File

@@ -34,11 +34,17 @@
android:value='androidx.startup' />
</provider>
<!--
Using launchMode singleTask to avoid multiple instances of the Activity
when the app is already open. This is important for incoming share (see
https://github.com/element-hq/element-x-android/issues/4074) and for opening
the application from a mobile.element.io link.
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@@ -54,6 +60,9 @@
android:host="open"
android:scheme="elementx" />
</intent-filter>
<!--
Oidc redirection
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -80,6 +89,21 @@
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
<data android:host="staging.element.io" />
</intent-filter>
<!--
Element mobile links
Example: https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://mobile.element.io/.well-known/assetlinks.json -->
<data android:host="mobile.element.io" />
<data android:path="/element" />
</intent-filter>
<!--
matrix.to links
Note: On Android 12 and higher clicking a web link (that is not an Android App Link) always shows content in a web browser

View File

@@ -53,6 +53,7 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.impl)
testImplementation(projects.libraries.preferences.test)

View File

@@ -24,8 +24,11 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.di.AppScope
@@ -46,10 +49,16 @@ class NotLoggedInFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins,
) {
data class Params(
val loginParams: LoginParams?,
) : NodeInputs
interface Callback : Plugin {
fun onOpenBugReport()
}
private val inputs = inputs<Params>()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -74,6 +83,12 @@ class NotLoggedInFlowNode @AssistedInject constructor(
}
loginEntryPoint
.nodeBuilder(this, buildContext)
.params(
LoginEntryPoint.Params(
accountProvider = inputs.loginParams?.accountProvider,
loginHint = inputs.loginParams?.loginHint,
)
)
.callback(callback)
.build()
}

View File

@@ -33,6 +33,8 @@ import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
@@ -40,6 +42,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope
@@ -61,6 +64,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService,
private val enterpriseService: EnterpriseService,
private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter,
@@ -99,14 +103,14 @@ class RootFlowNode @AssistedInject constructor(
if (navState.loggedInState.isTokenValid) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow() }
onFailure = { switchToNotLoggedInFlow(null) }
)
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow()
switchToNotLoggedInFlow(null)
}
}
}
@@ -117,9 +121,9 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
private fun switchToNotLoggedInFlow() {
private fun switchToNotLoggedInFlow(params: LoginParams?) {
matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow)
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
@@ -175,7 +179,9 @@ class RootFlowNode @AssistedInject constructor(
data object SplashScreen : NavTarget
@Parcelize
data object NotLoggedInFlow : NavTarget
data class NotLoggedInFlow(
val params: LoginParams?
) : NavTarget
@Parcelize
data class LoggedInFlow(
@@ -211,13 +217,16 @@ class RootFlowNode @AssistedInject constructor(
}
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.NotLoggedInFlow -> {
is NavTarget.NotLoggedInFlow -> {
val callback = object : NotLoggedInFlowNode.Callback {
override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
}
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(callback))
val params = NotLoggedInFlowNode.Params(
loginParams = navTarget.params,
)
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
}
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext)
@@ -272,18 +281,36 @@ class RootFlowNode @AssistedInject constructor(
val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
}
}
private suspend fun onLoginLink(params: LoginParams) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
if (enterpriseService.isAllowedToConnectToHomeserver(params.accountProvider.ensureProtocol())) {
switchToNotLoggedInFlow(params)
} else {
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
switchToNotLoggedInFlow(null)
}
} else {
// Just ignore the login link if we already have a session
Timber.w("Login link ignored, we already have a session")
}
}
private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow()
switchToNotLoggedInFlow(null)
} else {
attachSession(latestSessionId)
.attachIncomingShare(intent)

View File

@@ -8,6 +8,8 @@
package io.element.android.appnav.intent
import android.content.Intent
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -21,11 +23,13 @@ sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent
}
class IntentResolver @Inject constructor(
private val deeplinkParser: DeeplinkParser,
private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser,
) {
@@ -40,10 +44,17 @@ class IntentResolver @Inject constructor(
val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
// External link clicked? (matrix.to, element.io, etc.)
val permalinkData = intent
val actionViewData = intent
.takeIf { it.action == Intent.ACTION_VIEW }
?.dataString
// Mobile configuration link clicked? (mobile.element.io)
val mobileLoginData = actionViewData
?.let { loginIntentResolver.parse(it) }
if (mobileLoginData != null) return ResolvedIntent.Login(mobileLoginData)
// External link clicked? (matrix.to, element.io, etc.)
val permalinkData = actionViewData
?.let { permalinkParser.parse(it) }
?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)

View File

@@ -12,6 +12,8 @@ import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
@@ -165,6 +167,7 @@ class IntentResolverTest {
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
loginIntentResolverResult = { null },
permalinkParserResult = { permalinkData }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@@ -182,7 +185,8 @@ class IntentResolverTest {
@Test
fun `test resolve external permalink, FallbackLink should be ignored`() {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -231,7 +235,8 @@ class IntentResolverTest {
@Test
fun `test resolve invalid`() {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -241,11 +246,29 @@ class IntentResolverTest {
assertThat(result).isNull()
}
@Test
fun `test resolve login param`() {
val aLoginParams = LoginParams("accountProvider", null)
val sut = createIntentResolver(
loginIntentResolverResult = { aLoginParams },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.Login(aLoginParams))
}
private fun createIntentResolver(
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
): IntentResolver {
return IntentResolver(
deeplinkParser = DeeplinkParser(),
loginIntentResolver = FakeLoginIntentResolver(
parseResult = loginIntentResolverResult,
),
oidcIntentResolver = DefaultOidcIntentResolver(
oidcUrlParser = DefaultOidcUrlParser(
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),

View File

@@ -13,6 +13,11 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface LoginEntryPoint : FeatureEntryPoint {
data class Params(
val accountProvider: String?,
val loginHint: String?,
)
interface Callback : Plugin {
fun onReportProblem()
}
@@ -20,6 +25,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.api
interface LoginIntentResolver {
fun parse(uriString: String): LoginParams?
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.api
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Parameters to start the login flow, when the application is opened
* from a mobile.element.io link.
*/
@Parcelize
data class LoginParams(
val accountProvider: String,
val loginHint: String?
) : Parcelable

View File

@@ -58,6 +58,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)

View File

@@ -22,6 +22,14 @@ class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
val plugins = ArrayList<Plugin>()
return object : LoginEntryPoint.NodeBuilder {
override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder {
plugins += LoginFlowNode.Params(
accountProvider = params.accountProvider,
loginHint = params.loginHint,
)
return this
}
override fun callback(callback: LoginEntryPoint.Callback): LoginEntryPoint.NodeBuilder {
plugins += callback
return this

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLoginIntentResolver @Inject constructor() : LoginIntentResolver {
override fun parse(uriString: String): LoginParams? {
val uri = uriString.toUri()
if (uri.host != "mobile.element.io") return null
if (uri.path?.startsWith("/element")?.not() == true) return null
val accountProvider = uri.getQueryParameter("account_provider") ?: return null
val loginHint = uri.getQueryParameter("login_hint")
return LoginParams(
accountProvider = accountProvider,
loginHint = loginHint,
)
}
}

View File

@@ -28,16 +28,18 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.onboarding.OnBoardingNode
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcAction
@@ -63,6 +65,11 @@ class LoginFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins,
) {
data class Params(
val accountProvider: String?,
val loginHint: String?,
) : NodeInputs
private var activity: Activity? = null
private var darkTheme: Boolean = false
@@ -139,8 +146,25 @@ class LoginFlowNode @AssistedInject constructor(
override fun onReportProblem() {
plugins<LoginEntryPoint.Callback>().forEach { it.onReportProblem() }
}
override fun onOidcDetails(oidcDetails: OidcDetails) {
navigateToMas(oidcDetails)
}
override fun onCreateAccountContinue(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPassword)
}
}
createNode<OnBoardingNode>(buildContext, listOf(callback))
val params = inputs<Params>()
val inputs = OnBoardingNode.Params(
accountProvider = params.accountProvider,
loginHint = params.loginHint,
)
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
}
NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext)
@@ -151,16 +175,7 @@ class LoginFlowNode @AssistedInject constructor(
)
val callback = object : ConfirmAccountProviderNode.Callback {
override fun onOidcDetails(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
}
navigateToMas(oidcDetails)
}
override fun onCreateAccountContinue(url: String) {
@@ -222,6 +237,19 @@ class LoginFlowNode @AssistedInject constructor(
}
}
private fun navigateToMas(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
}
}
@Composable
override fun View(modifier: Modifier) {
activity = requireNotNull(LocalActivity.current)

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcPrompt
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests.
* It's an helper to avoid code duplication. It is used by [OnBoardingPresenter] and [ConfirmAccountProviderPresenter].
*/
class LoginHelper @Inject constructor(
private val oidcActionFlow: OidcActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) {
private val loginModeState: MutableState<AsyncData<LoginMode>> = mutableStateOf(AsyncData.Uninitialized)
@Composable
fun collectLoginMode(): State<AsyncData<LoginMode>> {
LaunchedEffect(Unit) {
oidcActionFlow.collect { oidcAction ->
if (oidcAction != null) {
onOidcAction(oidcAction)
}
}
}
return loginModeState
}
fun clearError() {
loginModeState.value = AsyncData.Uninitialized
}
fun submit(
coroutineScope: CoroutineScope,
isAccountCreation: Boolean,
homeserverUrl: String,
loginHint: String?,
) = coroutineScope.launch {
suspend {
authenticationService.setHomeserver(homeserverUrl).map {
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
LoginMode.Oidc(
authenticationService.getOidcUrl(prompt = oidcPrompt, loginHint = loginHint).getOrThrow()
)
} else if (isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginMode.AccountCreation(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginMode.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginModeState,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOidcAction(oidcAction: OidcAction) {
loginModeState.value = AsyncData.Loading()
when (oidcAction) {
OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
.onSuccess { _ ->
defaultLoginUserStory.setLoginFlowIsDone(true)
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
}
oidcActionFlow.reset()
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
import io.element.android.libraries.matrix.api.auth.OidcDetails
sealed interface LoginMode {
data object PasswordLogin : LoginMode
data class Oidc(val oidcDetails: OidcDetails) : LoginMode
data class AccountCreation(val url: String) : LoginMode
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.login
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoginModeView(
loginMode: AsyncData<LoginMode>,
onClearError: () -> Unit,
onLearnMoreClick: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit
) {
when (loginMode) {
is AsyncData.Failure -> {
when (val error = loginMode.error) {
is ChangeServerError -> {
when (error) {
is ChangeServerError.Error -> {
ErrorDialog(
content = error.message(),
onSubmit = onClearError,
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(
onLearnMoreClick = {
onLearnMoreClick()
onClearError()
},
onDismiss = onClearError,
)
}
is ChangeServerError.UnauthorizedAccountProvider -> {
ErrorDialog(
content = stringResource(
id = R.string.screen_change_server_error_unauthorized_homeserver,
LocalBuildMeta.current.applicationName,
error.unauthorisedAccountProviderTitle,
),
onSubmit = onClearError,
)
}
}
}
is AccountCreationNotSupported -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_creation_not_possible),
onSubmit = onClearError,
)
}
else -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = onClearError,
)
}
}
}
is AsyncData.Loading -> Unit // The Continue button shows the loading state
is AsyncData.Success -> {
when (val loginModeData = loginMode.data) {
is LoginMode.Oidc -> onOidcDetails(loginModeData.oidcDetails)
LoginMode.PasswordLogin -> onNeedLoginPassword()
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
}
}
AsyncData.Uninitialized -> Unit
}
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import javax.inject.Inject
/**
* Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold.
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
*/
class OnBoardingPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<OnBoardingState> {
@Composable
override fun present(): OnBoardingState {
val canLoginWithQrCode by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
)
}
}

View File

@@ -1,15 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
data class OnBoardingState(
val productionApplicationName: String,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
val canReportBug: Boolean,
)

View File

@@ -8,38 +8,20 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory
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.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcPrompt
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val authenticationService: MatrixAuthenticationService,
private val oidcActionFlow: OidcActionFlow,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
private val loginHelper: LoginHelper,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@@ -55,91 +37,27 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
LaunchedEffect(Unit) {
oidcActionFlow.collect { oidcAction ->
if (oidcAction != null) {
onOidcAction(oidcAction, loginFlowAction)
}
}
}
val loginMode by loginHelper.collectLoginMode()
fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> {
localCoroutineScope.submit(accountProvider.url, loginFlowAction)
loginHelper.submit(
coroutineScope = localCoroutineScope,
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
loginHint = null,
)
}
ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = AsyncData.Uninitialized
ConfirmAccountProviderEvents.ClearError -> loginHelper.clearError()
}
}
return ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = params.isAccountCreation,
loginFlow = loginFlowAction.value,
loginMode = loginMode,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(
homeserverUrl: String,
loginFlowAction: MutableState<AsyncData<LoginFlow>>,
) = launch {
suspend {
authenticationService.setHomeserver(homeserverUrl).map {
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
val oidcPrompt = if (params.isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
LoginFlow.OidcFlow(authenticationService.getOidcUrl(oidcPrompt).getOrThrow())
} else if (params.isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginFlow.AccountCreationFlow(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginFlowAction,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOidcAction(
oidcAction: OidcAction,
loginFlowAction: MutableState<AsyncData<LoginFlow>>,
) {
loginFlowAction.value = AsyncData.Loading()
when (oidcAction) {
OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loginFlowAction.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginFlowAction.value = AsyncData.Failure(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
.onSuccess { _ ->
defaultLoginUserStory.setLoginFlowIsDone(true)
}
.onFailure { failure ->
loginFlowAction.value = AsyncData.Failure(failure)
}
}
}
oidcActionFlow.reset()
}
}

View File

@@ -8,21 +8,15 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails
// Do not use default value, so no member get forgotten in the presenters.
data class ConfirmAccountProviderState(
val accountProvider: AccountProvider,
val isAccountCreation: Boolean,
val loginFlow: AsyncData<LoginFlow>,
val loginMode: AsyncData<LoginMode>,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginFlow is AsyncData.Uninitialized || loginFlow is AsyncData.Loading)
}
sealed interface LoginFlow {
data object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
data class AccountCreationFlow(val url: String) : LoginFlow
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
}

View File

@@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
@@ -22,7 +23,7 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
),
aConfirmAccountProviderState(
isAccountCreation = true,
loginFlow = AsyncData.Failure(AccountCreationNotSupported())
loginMode = AsyncData.Failure(AccountCreationNotSupported())
),
)
}
@@ -30,11 +31,11 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(),
isAccountCreation: Boolean = false,
loginFlow: AsyncData<LoginFlow> = AsyncData.Uninitialized,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = isAccountCreation,
loginFlow = loginFlow,
loginMode = loginMode,
eventSink = eventSink
)

View File

@@ -19,15 +19,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -47,9 +44,9 @@ fun ConfirmAccountProviderView(
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginFlow) {
val isLoading by remember(state.loginMode) {
derivedStateOf {
state.loginFlow is AsyncData.Loading
state.loginMode is AsyncData.Loading
}
}
val eventSink = state.eventSink
@@ -99,48 +96,16 @@ fun ConfirmAccountProviderView(
}
}
) {
when (state.loginFlow) {
is AsyncData.Failure -> {
when (val error = state.loginFlow.error) {
is ChangeServerError.Error -> {
ErrorDialog(
content = error.message(),
onSubmit = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(
onLearnMoreClick = {
onLearnMoreClick()
eventSink(ConfirmAccountProviderEvents.ClearError)
},
onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
}
)
}
is AccountCreationNotSupported -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_creation_not_possible),
onSubmit = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
}
}
is AsyncData.Loading -> Unit // The Continue button shows the loading state
is AsyncData.Success -> {
when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onNeedLoginPassword()
is LoginFlow.AccountCreationFlow -> onCreateAccountContinue(loginFlowState.url)
}
}
AsyncData.Uninitialized -> Unit
}
LoginModeView(
loginMode = state.loginMode,
onClearError = {
eventSink(ConfirmAccountProviderEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding
sealed interface OnBoardingEvents {
data class OnSignIn(
val defaultAccountProvider: String
) : OnBoardingEvents
data object ClearError : OnBoardingEvents
}

View File

@@ -5,10 +5,11 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -16,13 +17,17 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class OnBoardingNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: OnBoardingPresenter,
presenterFactory: OnBoardingPresenter.Factory,
) : Node(
buildContext = buildContext,
plugins = plugins
@@ -32,8 +37,22 @@ class OnBoardingNode @AssistedInject constructor(
fun onSignIn()
fun onSignInWithQrCode()
fun onReportProblem()
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onCreateAccountContinue(url: String)
}
data class Params(
val accountProvider: String?,
val loginHint: String?,
) : NodeInputs
private val params = inputs<Params>()
private val presenter = presenterFactory.create(
params = params,
)
private fun onSignIn() {
plugins<Callback>().forEach { it.onSignIn() }
}
@@ -50,9 +69,22 @@ class OnBoardingNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onReportProblem() }
}
private fun onOidcDetails(data: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(data) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onCreateAccountContinue(url: String) {
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
OnBoardingView(
state = state,
modifier = modifier,
@@ -60,6 +92,10 @@ class OnBoardingNode @AssistedInject constructor(
onCreateAccount = ::onSignUp,
onSignInWithQrCode = ::onSignInWithQrCode,
onReportProblem = ::onReportProblem,
onOidcDetails = ::onOidcDetails,
onNeedLoginPassword = ::onLoginPasswordNeeded,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = ::onCreateAccountContinue,
)
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
class OnBoardingPresenter @AssistedInject constructor(
@Assisted private val params: OnBoardingNode.Params,
private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
) : Presenter<OnBoardingState> {
@AssistedFactory
interface Factory {
fun create(
params: OnBoardingNode.Params,
): OnBoardingPresenter
}
private val defaultAccountProvider = params.accountProvider
private val loginHint = params.loginHint
@Composable
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val canLoginWithQrCode by produceState(initialValue = false) {
value = defaultAccountProvider == null &&
featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
}
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val loginMode by loginHelper.collectLoginMode()
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> loginHelper.submit(
coroutineScope = localCoroutineScope,
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
loginHint = loginHint,
)
OnBoardingEvents.ClearError -> loginHelper.clearError()
}
}
return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
loginMode = loginMode,
eventSink = ::handleEvent,
)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
val productionApplicationName: String,
val defaultAccountProvider: String?,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
val canReportBug: Boolean,
val loginMode: AsyncData<LoginMode>,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = defaultAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
}

View File

@@ -5,9 +5,11 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
override val values: Sequence<OnBoardingState>
@@ -17,17 +19,24 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
)
}
fun anOnBoardingState(
productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false,
canReportBug: Boolean = false,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount,
canReportBug = canReportBug,
loginMode = loginMode,
eventSink = eventSink,
)

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
package io.element.android.features.login.impl.screens.onboarding
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
@@ -27,6 +30,8 @@ import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@@ -37,6 +42,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -52,6 +58,10 @@ fun OnBoardingView(
onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit,
onCreateAccount: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
onReportProblem: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -59,6 +69,16 @@ fun OnBoardingView(
modifier = modifier,
content = {
OnBoardingContent(state = state)
LoginModeView(
loginMode = state.loginMode,
onClearError = {
state.eventSink(OnBoardingEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
},
footer = {
OnBoardingButtons(
@@ -127,6 +147,12 @@ private fun OnBoardingButtons(
onCreateAccount: () -> Unit,
onReportProblem: () -> Unit,
) {
val isLoading by remember(state.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
}
}
ButtonColumnMolecule {
val signInButtonStringRes = if (state.canLoginWithQrCode || state.canCreateAccount) {
R.string.screen_onboarding_sign_in_manually
@@ -141,13 +167,27 @@ private fun OnBoardingButtons(
modifier = Modifier.fillMaxWidth()
)
}
Button(
text = stringResource(id = signInButtonStringRes),
onClick = onSignIn,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)
)
val defaultAccountProvider = state.defaultAccountProvider
if (defaultAccountProvider == null) {
Button(
text = stringResource(id = signInButtonStringRes),
onClick = onSignIn,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)
)
} else {
Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider),
showProgress = isLoading,
onClick = {
state.eventSink(OnBoardingEvents.OnSignIn(defaultAccountProvider))
},
enabled = state.submitEnabled || isLoading,
modifier = Modifier
.fillMaxWidth()
)
}
if (state.canCreateAccount) {
TextButton(
text = stringResource(id = R.string.screen_onboarding_sign_up),
@@ -181,5 +221,9 @@ internal fun OnBoardingViewPreview(
onSignIn = {},
onCreateAccount = {},
onReportProblem = {},
onOidcDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onCreateAccountContinue = {},
)
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultLoginIntentResolverTest {
@Test
fun `nominal case`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = "mxid:@alice:example.org",
)
)
}
@Test
fun `extra unknown param`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org&extra=uknown"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = "mxid:@alice:example.org",
)
)
}
@Test
fun `no account provider`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `no path`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `wrong path`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/wrong?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `wrong host`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://wrong.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `no login_hint param`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element?account_provider=example.org"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = null,
)
)
}
}

View File

@@ -1,68 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OnBoardingPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta(
applicationName = "A",
productionApplicationName = "B",
desktopApplicationName = "C",
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
buildMeta = buildMeta,
)
val presenter = OnBoardingPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
@Test
fun `present - rageshake not available`() = runTest {
val presenter = OnBoardingPresenter(
buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(),
rageshakeFeatureAvailability = { false },
)
presenter.test {
skipItems(1)
assertThat(awaitItem().canReportBug).isFalse()
}
}
}

View File

@@ -15,7 +15,9 @@ import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
@@ -25,6 +27,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
@@ -46,7 +49,7 @@ class ConfirmAccountProviderPresenterTest {
assertThat(initialState.isAccountCreation).isFalse()
assertThat(initialState.submitEnabled).isTrue()
assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL)
assertThat(initialState.loginFlow).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -64,11 +67,11 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.PasswordLogin)
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin)
}
}
@@ -86,11 +89,11 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
}
}
@@ -110,15 +113,15 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenOidcCancelError(A_THROWABLE)
defaultOidcActionFlow.post(OidcAction.GoBack)
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginFlow).isInstanceOf(AsyncData.Failure::class.java)
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@@ -138,14 +141,14 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
defaultOidcActionFlow.post(OidcAction.GoBack)
val cancelFinalState = awaitItem()
assertThat(cancelFinalState.loginFlow).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
}
}
@@ -165,17 +168,17 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
authenticationService.givenLoginError(A_THROWABLE)
defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginFlow).isInstanceOf(AsyncData.Failure::class.java)
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@@ -199,15 +202,15 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
val successSuccessState = awaitItem()
assertThat(successSuccessState.loginFlow).isInstanceOf(AsyncData.Loading::class.java)
assertThat(successSuccessState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
waitForPredicate { defaultLoginUserStory.loginFlowIsDone.value }
}
}
@@ -227,7 +230,7 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading
val failureState = awaitItem()
assertThat(failureState.submitEnabled).isFalse()
assertThat(failureState.loginFlow).isInstanceOf(AsyncData.Failure::class.java)
assertThat(failureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@@ -250,12 +253,12 @@ class ConfirmAccountProviderPresenterTest {
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Failure::class.java)
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginFlow).isEqualTo(AsyncData.Uninitialized)
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -278,11 +281,11 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginFlow.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
assertThat(submittedState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
// Assert the error is then cleared
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginFlow).isEqualTo(AsyncData.Uninitialized)
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
@@ -301,8 +304,8 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
}
}
@@ -323,8 +326,8 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
}
}
@@ -345,7 +348,7 @@ class ConfirmAccountProviderPresenterTest {
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.AccountCreationFlow(aUrl))
assertThat(submittedState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl))
}
}
@@ -353,15 +356,17 @@ class ConfirmAccountProviderPresenterTest {
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(),
defaultOidcActionFlow: OidcActionFlow = DefaultOidcActionFlow(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
authenticationService = matrixAuthenticationService,
oidcActionFlow = defaultOidcActionFlow,
defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever
loginHelper = createLoginHelper(
authenticationService = matrixAuthenticationService,
oidcActionFlow = defaultOidcActionFlow,
defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
),
)
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.login.impl.DefaultLoginUserStory
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OnBoardingPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val buildMeta = aBuildMeta(
applicationName = "A",
productionApplicationName = "B",
desktopApplicationName = "C",
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.QrCodeLogin.key to true),
buildMeta = buildMeta,
)
val presenter = createPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = { true },
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue()
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}
}
@Test
fun `present - rageshake not available`() = runTest {
val presenter = createPresenter(
rageshakeFeatureAvailability = { false },
)
presenter.test {
skipItems(1)
assertThat(awaitItem().canReportBug).isFalse()
}
}
@Test
fun `present - default account provider`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
loginHint = null,
),
)
presenter.test {
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
assertThat(it.canLoginWithQrCode).isFalse()
assertThat(it.canCreateAccount).isFalse()
}
}
}
@Test
fun `present - default account provider - login and clear error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
loginHint = A_LOGIN_HINT,
),
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
awaitItem().also {
assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL)
authenticationService.givenChangeServerError(A_THROWABLE)
it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL))
skipItems(1) // Loading
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
// Assert the error is then cleared
submittedState.eventSink(OnBoardingEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized)
}
}
}
}
private fun createPresenter(
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
buildMeta: BuildMeta = aBuildMeta(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
rageshakeFeatureAvailability: () -> Boolean = { true },
loginHelper: LoginHelper = createLoginHelper(),
) = OnBoardingPresenter(
params = params,
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginHelper = loginHelper,
)
fun createLoginHelper(
oidcActionFlow: OidcActionFlow = DefaultOidcActionFlow(),
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
): LoginHelper = LoginHelper(
oidcActionFlow = oidcActionFlow,
authenticationService = authenticationService,
defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
)

View File

@@ -5,16 +5,22 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.onboarding
package io.element.android.features.login.impl.screens.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
@@ -74,6 +80,34 @@ class OnboardingViewTest {
}
}
@Test
fun `when sign in to pre defined account provider - clicking on button emits the expected event`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
rule.setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
eventSink = eventSink,
),
)
val buttonText = rule.activity.getString(R.string.screen_onboarding_sign_in_to, "element.io")
rule.onNodeWithText(buttonText).performClick()
eventSink.assertSingle(OnBoardingEvents.OnSignIn("element.io"))
}
@Test
fun `when error is displayed - closing the dialog emits the expected event`() {
val eventSink = EventsRecorder<OnBoardingEvents>()
rule.setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink,
),
)
rule.clickOn(CommonStrings.action_ok)
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
@Test
fun `clicking on report a problem calls the sign in callback`() {
ensureCalledOnce { callback ->
@@ -106,6 +140,10 @@ class OnboardingViewTest {
onSignIn: () -> Unit = EnsureNeverCalled(),
onCreateAccount: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(),
onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
OnBoardingView(
@@ -114,6 +152,10 @@ class OnboardingViewTest {
onSignIn = onSignIn,
onCreateAccount = onCreateAccount,
onReportProblem = onReportProblem,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}

View File

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

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.test
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLoginIntentResolver(
private val parseResult: (String) -> LoginParams? = { lambdaError() }
) : LoginIntentResolver {
override fun parse(uriString: String): LoginParams? {
return parseResult(uriString)
}
}

View File

@@ -43,7 +43,10 @@ interface MatrixAuthenticationService {
/**
* Get the Oidc url to display to the user.
*/
suspend fun getOidcUrl(prompt: OidcPrompt): Result<OidcDetails>
suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails>
/**
* Cancel Oidc login sequence.

View File

@@ -187,14 +187,17 @@ class RustMatrixAuthenticationService @Inject constructor(
private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null
override suspend fun getOidcUrl(prompt: OidcPrompt): Result<OidcDetails> {
override suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails> {
return withContext(coroutineDispatchers.io) {
runCatching {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val oAuthAuthorizationData = client.urlForOidc(
oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(),
loginHint = null,
loginHint = loginHint,
)
val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthorizationData

View File

@@ -19,7 +19,6 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
const val A_USER_NAME = "alice"
const val A_USER_NAME_2 = "Bob"
@@ -73,7 +72,6 @@ const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE
val A_ROOM_NOTIFICATION_SETTINGS = RoomNotificationSettings(mode = A_ROOM_NOTIFICATION_MODE, isDefault = false)
const val AN_AVATAR_URL = "mxc://data"
@@ -88,3 +86,5 @@ val A_SERVER_LIST = listOf("server1", "server2")
const val A_TIMESTAMP = 567L
const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM"
const val A_LOGIN_HINT = "mxid:@alice:example.org"

View File

@@ -87,7 +87,10 @@ class FakeMatrixAuthenticationService(
return importCreatedSessionLambda(externalSession)
}
override suspend fun getOidcUrl(prompt: OidcPrompt): Result<OidcDetails> = simulateLongTask {
override suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails> = simulateLongTask {
oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
}

12
tools/adb/deeplink_mobile.sh Executable file
View File

@@ -0,0 +1,12 @@
#! /bin/bash
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
# Format is:
# https://mobile.element.io/element?account_provider=example.org&login_hint=mxid:@alice:example.org
adb shell am start -a android.intent.action.VIEW \
-d "https://mobile.element.io/element?account_provider=element.io\\&login_hint=mxid:@alice:element.io"