Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import io.element.android.annotations.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.di.AuthGraph
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.chooseaccountprovider.ChooseAccountProviderNode
Expand All @@ -44,6 +45,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcAction
Expand All @@ -62,19 +64,23 @@ class LoginFlowNode(
private val oidcActionFlow: OidcActionFlow,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val authGraphFactory: AuthGraph.Factory,
) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.OnBoarding,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
), DependencyInjectionGraphOwner {
data class Params(
val accountProvider: String?,
val loginHint: String?,
) : NodeInputs

override val graph: Any get() = currentAuthGraph
private var currentAuthGraph = authGraphFactory.createAuthGraph()

private val callback: LoginEntryPoint.Callback = callback()
private var activity: Activity? = null
private var darkTheme: Boolean = false
Expand Down Expand Up @@ -133,12 +139,14 @@ class LoginFlowNode(
NavTarget.OnBoarding -> {
val callback = object : OnBoardingNode.Callback {
override fun navigateToSignUpFlow() {
resetAuthGraph()
backstack.push(
NavTarget.ConfirmAccountProvider(isAccountCreation = true)
)
}

override fun navigateToSignInFlow(mustChooseAccountProvider: Boolean) {
resetAuthGraph()
backstack.push(
if (mustChooseAccountProvider) {
NavTarget.ChooseAccountProvider
Expand All @@ -149,6 +157,7 @@ class LoginFlowNode(
}

override fun navigateToQrCode() {
resetAuthGraph()
backstack.push(NavTarget.QrCode)
}

Expand All @@ -157,14 +166,17 @@ class LoginFlowNode(
}

override fun navigateToOidc(oidcDetails: OidcDetails) {
resetAuthGraph()
navigateToMas(oidcDetails)
}

override fun navigateToCreateAccount(url: String) {
resetAuthGraph()
backstack.push(NavTarget.CreateAccount(url))
}

override fun navigateToLoginPassword() {
resetAuthGraph()
backstack.push(NavTarget.LoginPassword)
}

Expand Down Expand Up @@ -284,4 +296,8 @@ class LoginFlowNode(
}
BackstackView()
}

private fun resetAuthGraph() {
currentAuthGraph = authGraphFactory.createAuthGraph()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.ContributesBinding
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.di.AuthScope
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
Expand All @@ -25,7 +26,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

@Inject
@ContributesBinding(AuthScope::class)
class ChangeServerPresenter(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 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.features.login.impl.di

import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.GraphExtension
import io.element.android.libraries.architecture.NodeFactoriesBindings

@GraphExtension(AuthScope::class)
interface AuthGraph : NodeFactoriesBindings {
@ContributesTo(AppScope::class)
@GraphExtension.Factory
fun interface Factory {
fun createAuthGraph(): AuthGraph
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2025 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.features.login.impl.di

abstract class AuthScope private constructor()

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.login.impl.di.AuthScope
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
Expand All @@ -28,92 +30,101 @@ import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow

/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* This class is responsible for managing the login and account creation flows, including handling OIDC actions and
* submitting login requests.
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* and [ChooseAccountProviderPresenter].
*/
@SingleIn(AuthScope::class)
@Inject
class LoginHelper(
class AuthenticationHelper(
private val oidcActionFlow: OidcActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) {
private val loginModeState: MutableState<AsyncData<LoginMode>> = mutableStateOf(AsyncData.Uninitialized)
private val authenticationModeState: MutableState<AsyncData<AuthenticationMode>> = mutableStateOf(AsyncData.Uninitialized)

// To remember if the current flow is account creation or login
private var isAccountCreation: Boolean = false

@Composable
fun collectLoginMode(): State<AsyncData<LoginMode>> {
fun collectAuthenticationMode(): State<AsyncData<AuthenticationMode>> {
LaunchedEffect(Unit) {
oidcActionFlow.collect { oidcAction ->
if (oidcAction != null) {
onOidcAction(oidcAction)
}
}
}
return loginModeState
return authenticationModeState
}

fun clearError() {
loginModeState.value = AsyncData.Uninitialized
authenticationModeState.value = AsyncData.Uninitialized
}

suspend fun submit(
isAccountCreation: Boolean,
homeserverUrl: String,
loginHint: String?,
) {
this.isAccountCreation = isAccountCreation

authenticationModeState.value = AsyncData.Loading()
suspend {
authenticationService.setHomeserver(homeserverUrl).map { matrixHomeServerDetails ->
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()
AuthenticationMode.Oidc(
authenticationService.getOidcUrl(prompt = oidcPrompt, loginHint = loginHint).getOrThrow(),
isAccountCreation,
)
} else if (isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginMode.AccountCreation(url)
AuthenticationMode.AccountCreation(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginMode.PasswordLogin
AuthenticationMode.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginModeState,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
authenticationModeState,
errorTransform = { exception ->
when (exception) {
is AccountCreationNotSupported -> exception
else -> ChangeServerError.from(exception)
}
}
)
}

private suspend fun onOidcAction(oidcAction: OidcAction) {
if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && authenticationModeState.value !is AsyncData.Loading) {
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
// This can happen if there is an error, for instance attempt to login again on the same account.
return
}
loginModeState.value = AsyncData.Loading()
authenticationModeState.value = AsyncData.Loading()
when (oidcAction) {
is OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized
authenticationModeState.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
authenticationModeState.value = AsyncData.Failure(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
authenticationService.loginWithOidc(callbackUrl = oidcAction.url, isAccountCreation = this.isAccountCreation)
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
authenticationModeState.value = AsyncData.Failure(failure)
}
}
}
isAccountCreation = false
oidcActionFlow.reset()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.features.login.impl.login

import io.element.android.libraries.matrix.api.auth.OidcDetails

/**
* Represents the different authentication modes available.
*/
sealed interface AuthenticationMode {
/**
* Password-based login mode. Added for backwards compatibility.
*/
data object PasswordLogin : AuthenticationMode

/**
* Account creation mode, using a non-OIDC web flow. It's the registration counterpart to [PasswordLogin].
*/
data class AccountCreation(val url: String) : AuthenticationMode

/**
* OIDC-based authentication mode.
* @param oidcDetails Details required for OIDC authentication.
* @param isAccountCreation Whether this mode is for account creation or login.
*/
data class Oidc(val oidcDetails: OidcDetails, val isAccountCreation: Boolean) : AuthenticationMode
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.ui.strings.CommonStrings

@Composable
fun LoginModeView(
loginMode: AsyncData<LoginMode>,
fun AuthenticationModeView(
authenticationMode: AsyncData<AuthenticationMode>,
onClearError: () -> Unit,
onLearnMoreClick: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit
) {
val context = LocalContext.current
when (loginMode) {
when (authenticationMode) {
is AsyncData.Failure -> {
when (val error = loginMode.error) {
when (val error = authenticationMode.error) {
is ChangeServerError -> {
when (error) {
ChangeServerError.InvalidServer ->
Expand Down Expand Up @@ -117,10 +117,10 @@ fun LoginModeView(
}
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)
when (val loginModeData = authenticationMode.data) {
is AuthenticationMode.Oidc -> onOidcDetails(loginModeData.oidcDetails)
AuthenticationMode.PasswordLogin -> onNeedLoginPassword()
is AuthenticationMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
}
// Also clear the data, to let the next screen be able to go back
onClearError()
Expand All @@ -131,10 +131,10 @@ fun LoginModeView(

@PreviewsDayNight
@Composable
internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) {
internal fun AuthenticationModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) {
ElementPreview {
LoginModeView(
loginMode = AsyncData.Failure(error),
AuthenticationModeView(
authenticationMode = AsyncData.Failure(error),
onClearError = {},
onLearnMoreClick = {},
onOidcDetails = {},
Expand Down
Loading
Loading