Sign in with Classic

This commit is contained in:
Benoit Marty 2026-03-04 16:11:37 +01:00 committed by Benoit Marty
parent 683b1fe9d5
commit 8c5caabed4
62 changed files with 3120 additions and 728 deletions

View file

@ -252,7 +252,8 @@ class RootFlowNode(
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
when (navTarget) {
is NavTarget.SplashScreen,
is NavTarget.LoggedInFlow -> backstackFader
is NavTarget.LoggedInFlow,
is NavTarget.NotLoggedInFlow -> backstackFader
else -> backstackSlider
}
}

View file

@ -22,6 +22,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
@ -30,9 +31,11 @@ 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.classic.ElementClassicConnection
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
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
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
@ -63,9 +66,10 @@ class LoginFlowNode(
private val oidcActionFlow: OidcActionFlow,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val elementClassicConnection: ElementClassicConnection,
) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.OnBoarding,
initialElement = NavTarget.CheckClassicFlow,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@ -103,7 +107,12 @@ class LoginFlowNode(
sealed interface NavTarget : Parcelable {
@Parcelize
data object OnBoarding : NavTarget
data object CheckClassicFlow : NavTarget
@Parcelize
data class OnBoarding(
val showBackButton: Boolean,
) : NavTarget
@Parcelize
data object QrCode : NavTarget
@ -123,7 +132,9 @@ class LoginFlowNode(
data object SearchAccountProvider : NavTarget
@Parcelize
data object LoginPassword : NavTarget
data class LoginPassword(
val initialLogin: String = "",
) : NavTarget
@Parcelize
data class CreateAccount(val url: String) : NavTarget
@ -131,7 +142,31 @@ class LoginFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.OnBoarding -> {
NavTarget.CheckClassicFlow -> {
val callback = object : ClassicFlowNode.Callback {
override fun navigateToOnBoarding(allowBackNavigation: Boolean) {
if (allowBackNavigation) {
backstack.push(NavTarget.OnBoarding(showBackButton = true))
} else {
backstack.replace(NavTarget.OnBoarding(showBackButton = false))
}
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
override fun navigateToOidc(oidcDetails: OidcDetails) {
navigateToMas(oidcDetails)
}
override fun navigateToCreateAccount(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
}
createNode<ClassicFlowNode>(buildContext, listOf(callback))
}
is NavTarget.OnBoarding -> {
val callback = object : OnBoardingNode.Callback {
override fun navigateToSignUpFlow() {
backstack.push(
@ -166,17 +201,22 @@ class LoginFlowNode(
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
backstack.push(NavTarget.LoginPassword())
}
override fun onDone() {
if (navTarget.showBackButton) {
backstack.pop()
} else {
callback.onDone()
}
}
}
val params = inputs<Params>()
val inputs = OnBoardingNode.Params(
accountProvider = params.accountProvider,
loginHint = params.loginHint,
showBackButton = navTarget.showBackButton,
)
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
}
@ -191,7 +231,7 @@ class LoginFlowNode(
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
backstack.push(NavTarget.LoginPassword())
}
}
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
@ -218,7 +258,7 @@ class LoginFlowNode(
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
backstack.push(NavTarget.LoginPassword())
}
override fun navigateToChangeAccountProvider() {
@ -257,8 +297,11 @@ class LoginFlowNode(
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext)
is NavTarget.LoginPassword -> {
val inputs = LoginPasswordNode.Inputs(
initialLogin = navTarget.initialLogin,
)
createNode<LoginPasswordNode>(buildContext, plugins = listOf(inputs))
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
@ -280,6 +323,14 @@ class LoginFlowNode(
override fun View(modifier: Modifier) {
activity = requireNotNull(LocalActivity.current)
darkTheme = !ElementTheme.isLightTheme
DisposableEffect(Unit) {
elementClassicConnection.start()
onDispose {
elementClassicConnection.stop()
}
}
DisposableEffect(Unit) {
onDispose {
activity = null
@ -288,6 +339,6 @@ class LoginFlowNode(
}
}
}
BackstackView()
BackstackView(transitionHandler = rememberLoginFlowTransitionHandler())
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.Replace
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
/**
* A TransitionHandler that uses fade transition when OnBoarding is replacing the current screen,
* and slide transition for all other cases.
*/
private class LoginFlowTransitionHandler(
private val slider: ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>,
) : ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<LoginFlowNode.NavTarget, BackStack.State>
): Modifier {
val useFader = descriptor.element is LoginFlowNode.NavTarget.OnBoarding &&
descriptor.operation is Replace
val handler = if (useFader) fader else slider
return handler.createModifier(modifier, transition, descriptor)
}
}
@Composable
fun rememberLoginFlowTransitionHandler(): ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<LoginFlowNode.NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<LoginFlowNode.NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
LoginFlowTransitionHandler(slider, fader)
}
}

View file

@ -0,0 +1,382 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.classic
import android.content.ComponentName
import android.content.Context.BIND_AUTO_CREATE
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Message
import android.os.Messenger
import android.os.RemoteException
import androidx.annotation.VisibleForTesting
import androidx.core.os.BundleCompat
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.login.impl.BuildConfig
import io.element.android.libraries.androidutils.service.ServiceBinder
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
interface ElementClassicConnection {
fun start()
fun stop()
fun requestSession()
fun requestAvatar(userId: UserId)
val stateFlow: StateFlow<ElementClassicConnectionState>
}
sealed interface ElementClassicConnectionState {
object Idle : ElementClassicConnectionState
object ElementClassicNotFound : ElementClassicConnectionState
object ElementClassicReadyNoSession : ElementClassicConnectionState
data class ElementClassicReady(
val elementClassicSession: ElementClassicSession,
val displayName: String?,
val avatar: Bitmap?,
) : ElementClassicConnectionState
data class Error(val error: String) : ElementClassicConnectionState
}
private val loggerTag = LoggerTag("ECConnection")
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultElementClassicConnection(
private val serviceBinder: ServiceBinder,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
) : ElementClassicConnection {
// Messenger for communicating with the service.
private var messenger: Messenger? = null
// Target we publish for external service to send messages to IncomingHandler.
private val incomingMessenger: Messenger = Messenger(IncomingHandler())
// Flag indicating whether we have called bind on the service.
private var bound: Boolean = false
/**
* Class for interacting with the main interface of the service.
*/
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
Timber.tag(loggerTag.value).d("onServiceConnected")
// This is called when the connection with the service has been
// established, giving us the object we can use to
// interact with the service. We are communicating with the
// service using a Messenger, so here we get a client-side
// representation of that from the raw IBinder object.
messenger = Messenger(service)
bound = true
// Request the data as soon as possible
requestSession()
}
override fun onServiceDisconnected(className: ComponentName) {
Timber.tag(loggerTag.value).d("onServiceDisconnected")
// This is called when the connection with the service has been
// unexpectedly disconnected&mdash;that is, its process crashed.
messenger = null
bound = false
}
}
override fun start() {
Timber.tag(loggerTag.value).w("start()")
coroutineScope.launch {
// Establish a connection with the service. We use an explicit
// class name because there is no reason to be able to let other
// applications replace our component.
try {
val intentService = Intent()
intentService.setComponent(getElementClassicComponent())
if (serviceBinder.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
Timber.tag(loggerTag.value).d("Binding returned true")
} else {
// This happens when the app is not installed
Timber.tag(loggerTag.value).d("Binding returned false")
emitState(ElementClassicConnectionState.ElementClassicNotFound)
}
} catch (e: SecurityException) {
Timber.tag(loggerTag.value).e(e, "Can't bind to Service")
emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
}
}
}
override fun stop() {
Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)")
if (bound) {
// Detach our existing connection.
serviceBinder.unbindService(serviceConnection)
bound = false
}
coroutineScope.launch {
emitState(ElementClassicConnectionState.Idle)
}
}
override fun requestSession() {
Timber.tag(loggerTag.value).w("requestSession()")
coroutineScope.launch {
val finalMessenger = messenger
if (finalMessenger == null) {
Timber.tag(loggerTag.value).w("The messenger is null, can't request data")
// Do not emit error, else the regular on boarding flow will be displayed
// emitState(ElementClassicConnectionState.Error("The messenger is null, can't request data"))
} else {
try {
// Get the data
val msg = Message.obtain(null, MSG_GET_SESSION)
msg.replyTo = incomingMessenger
finalMessenger.send(msg)
} catch (e: RemoteException) {
// In this case the service has crashed before we could even
// do anything with it; we can count on soon being
// disconnected (and then reconnected if it can be restarted)
// so there is no need to do anything here.
Timber.tag(loggerTag.value).e(e, "RemoteException")
emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
}
}
}
}
override fun requestAvatar(userId: UserId) {
Timber.tag(loggerTag.value).w("requestAvatar()")
coroutineScope.launch {
val finalMessenger = messenger
if (finalMessenger == null) {
Timber.tag(loggerTag.value).w("The messenger is null, can't request extra data")
} else {
try {
// Get the data
val msg = Message.obtain(null, MSG_GET_AVATAR)
msg.data = Bundle().apply {
putString(KEY_USER_ID_STR, userId.value)
}
msg.replyTo = incomingMessenger
finalMessenger.send(msg)
} catch (e: RemoteException) {
// In this case the service has crashed before we could even
// do anything with it; we can count on soon being
// disconnected (and then reconnected if it can be restarted)
// so there is no need to do anything here.
Timber.tag(loggerTag.value).e(e, "RemoteException")
}
}
}
}
private val mutableStateFlow = MutableStateFlow<ElementClassicConnectionState>(ElementClassicConnectionState.Idle)
override val stateFlow = mutableStateFlow.asStateFlow()
/**
* Handler of incoming messages from service.
*/
@Suppress("DEPRECATION")
inner class IncomingHandler : Handler() {
override fun handleMessage(msg: Message) {
Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}")
when (msg.what) {
MSG_GET_SESSION -> onSessionReceived(msg.data)
MSG_GET_AVATAR -> onAvatarReceived(msg.data)
else -> {
Timber.tag(loggerTag.value).w("Received unknown message ${msg.what}")
super.handleMessage(msg)
}
}
}
}
@VisibleForTesting
fun onSessionReceived(data: Bundle) {
// The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied
val state = data.toElementClassicConnectionState()
coroutineScope.launch {
val updatedState = ensureHomeserverIsSupported(state)
emitState(updatedState)
}
}
@VisibleForTesting
fun onAvatarReceived(data: Bundle) {
val currentState = stateFlow.value
if (currentState is ElementClassicConnectionState.ElementClassicReady) {
// Check that the userId is still the same
val userId = data.getString(KEY_USER_ID_STR)
if (userId != currentState.elementClassicSession.userId.value) {
Timber.tag(loggerTag.value).w(
"Received profile data for userId $userId but current" +
" userId is ${currentState.elementClassicSession.userId}, ignoring"
)
} else {
val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java)
val updatedState = currentState.copy(
avatar = avatar,
)
coroutineScope.launch {
emitState(updatedState)
}
}
} else {
Timber.tag(loggerTag.value).w("Received profile data but current state is not ElementClassicReady: %s", currentState)
}
}
private suspend fun ensureHomeserverIsSupported(state: ElementClassicConnectionState): ElementClassicConnectionState {
return if (state is ElementClassicConnectionState.ElementClassicReady) {
val elementXCanConnect = setOfNotNull(
// Try with the domain name first
state.elementClassicSession.userId.domainName?.ensureProtocol(),
// Then try with the resolved homeserver URL, if provided and distinct
state.elementClassicSession.homeserverUrl,
).any { url ->
val isCompatible = homeServerLoginCompatibilityChecker.check(url)
.onFailure {
Timber.tag(loggerTag.value).w(it, "Failed to check compatibility with homeserver: $url")
}
.getOrNull() == true
if (isCompatible) {
Timber.tag(loggerTag.value).d("Found compatible homeserver URL: %s", url)
} else {
Timber.tag(loggerTag.value).w("Homeserver URL is not compatible: %s", url)
}
isCompatible
}
if (elementXCanConnect) {
state
} else {
Timber.tag(loggerTag.value).w("Cannot import session because the homeserver is not compatible with Element X")
ElementClassicConnectionState.Error("The homeserver is not compatible with Element X")
}
} else {
state
}
}
private suspend fun emitState(state: ElementClassicConnectionState) {
when (state) {
is ElementClassicConnectionState.Error -> {
Timber.tag(loggerTag.value).w("Error: %s", state.error)
}
is ElementClassicConnectionState.ElementClassicReady -> {
Timber.tag(loggerTag.value).d("Ready state for user: %s", state.elementClassicSession.userId)
}
ElementClassicConnectionState.ElementClassicReadyNoSession -> {
Timber.tag(loggerTag.value).d("No session from Element Classic")
}
ElementClassicConnectionState.ElementClassicNotFound -> {
Timber.tag(loggerTag.value).d("Element Classic not found")
}
ElementClassicConnectionState.Idle -> {
Timber.tag(loggerTag.value).d("Idle")
}
}
// Also give the Element Classic session info to the MatrixAuthenticationService
matrixAuthenticationService.setElementClassicSession(
session = (state as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession
)
mutableStateFlow.emit(state)
}
private fun getElementClassicComponent() = ComponentName(
BuildConfig.elementClassicPackage,
ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME,
)
private fun Bundle.toElementClassicConnectionState(): ElementClassicConnectionState {
val error = getString(KEY_ERROR_STR)
return if (error != null) {
ElementClassicConnectionState.Error(error)
} else {
val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId)
if (userId == null) {
ElementClassicConnectionState.ElementClassicReadyNoSession
} else {
val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() }
val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR)?.takeIf { it.isNotEmpty() }
val homeserverUrl = getString(KEY_HOMESERVER_URL_STR)?.takeIf { it.isNotEmpty() }
val displayName = getString(KEY_USER_DISPLAY_NAME_STR)?.takeIf { it.isNotEmpty() }
val doesContainBackupKey = secrets != null &&
roomKeysVersion != null &&
matrixAuthenticationService.doSecretsContainBackupKey(userId, secrets, roomKeysVersion)
ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession(
userId = userId,
homeserverUrl = homeserverUrl,
secrets = secrets,
roomKeysVersion = roomKeysVersion,
doesContainBackupKey = doesContainBackupKey,
),
displayName = displayName,
avatar = null,
)
}
}
}
// Everything in this companion object must match what is defined in Element Classic
companion object {
const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
// Command to the service to get the userId/displayName/secrets of a verified session.
const val MSG_GET_SESSION = 1
// Command to the service to get the avatar oor the session.
const val MSG_GET_AVATAR = 2
// Keys for the bundle returned from the service
const val KEY_ERROR_STR = "error"
const val KEY_USER_ID_STR = "userId"
const val KEY_HOMESERVER_URL_STR = "homeserverUrl"
const val KEY_USER_DISPLAY_NAME_STR = "displayName"
/**
* Key to extract the secrets from the bundle, as a Json string.
* Json will have this format:
* {
* "cross_signing" : {
* "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o",
* "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms",
* "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM"
* },
* "backup" : {
* "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2",
* "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc",
* "backup_version" : "1"
* }
* }
*/
const val KEY_SECRETS_STR = "secrets"
const val KEY_ROOM_KEYS_VERSION_STR = "roomKeysVersion"
// For the avatar
const val KEY_USER_AVATAR_PARCELABLE = "avatar"
}
}

View file

@ -14,8 +14,6 @@ import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@ -23,7 +21,4 @@ import io.element.android.libraries.architecture.Presenter
interface LoginModule {
@Binds
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
@Binds
fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter<LoginWithClassicState>
}

View file

@ -60,10 +60,19 @@ class LoginHelper(
suspend fun submit(
isAccountCreation: Boolean,
homeserverUrl: String,
resolvedHomeserverUrl: String?,
loginHint: String?,
) {
suspend {
authenticationService.setHomeserver(homeserverUrl).map { matrixHomeServerDetails ->
authenticationService.setHomeserver(homeserverUrl).recoverCatching {
// No .well-known file?
// If the homeserver is not reachable, try using resolvedHomeserverUrl.
if (resolvedHomeserverUrl != null && resolvedHomeserverUrl != homeserverUrl) {
authenticationService.setHomeserver(resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login

View file

@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter(
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = it.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
}

View file

@ -0,0 +1,143 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.screens.classic.loginwithclassic.LoginWithClassicNode
import io.element.android.features.login.impl.screens.classic.missingkeybackup.MissingKeyBackupNode
import io.element.android.features.login.impl.screens.classic.root.RootNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberFaderOrSliderTransitionHandler
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@AssistedInject
class ClassicFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val classicFlowNodeHelper: ClassicFlowNodeHelper,
) : BaseFlowNode<ClassicFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
interface Callback : Plugin {
fun navigateToOnBoarding(allowBackNavigation: Boolean)
fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class LoginWithClassic(
val userId: UserId,
) : NavTarget
@Parcelize
data object MissingKeyBackup : NavTarget
}
private val callback: Callback = callback()
override fun onBuilt() {
super.onBuilt()
observeElementClassicConnection()
}
private fun observeElementClassicConnection() {
classicFlowNodeHelper.navigationEventFlow().onEach { navigationEvent ->
when (navigationEvent) {
is NavigationEvent.Idle -> Unit
is NavigationEvent.NavigateToOnBoarding -> callback.navigateToOnBoarding(allowBackNavigation = false)
is NavigationEvent.NavigateToLoginWithClassic -> backstack.newRoot(NavTarget.LoginWithClassic(navigationEvent.userId))
}
}.launchIn(lifecycleScope)
}
override fun resolve(
navTarget: NavTarget,
buildContext: BuildContext,
): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode<RootNode>(buildContext)
}
is NavTarget.LoginWithClassic -> {
val callback = object : LoginWithClassicNode.Callback {
override fun navigateToOtherOptions() {
callback.navigateToOnBoarding(allowBackNavigation = true)
}
override fun navigateToLoginPassword() {
callback.navigateToLoginPassword()
}
override fun navigateToOidc(oidcDetails: OidcDetails) {
callback.navigateToOidc(oidcDetails)
}
override fun navigateToCreateAccount(url: String) {
callback.navigateToCreateAccount(url)
}
override fun navigateToMissingKeyBackup() {
backstack.push(NavTarget.MissingKeyBackup)
}
}
val inputs = LoginWithClassicNode.Inputs(
userId = navTarget.userId,
)
createNode<LoginWithClassicNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.MissingKeyBackup -> {
val callback = object : MissingKeyBackupNode.Callback {
override fun navigateBack() {
backstack.pop()
}
}
createNode<MissingKeyBackupNode>(buildContext, listOf(callback))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(
modifier = modifier,
transitionHandler = rememberFaderOrSliderTransitionHandler(),
)
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.toUserListFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.take
@Inject
class ClassicFlowNodeHelper(
private val elementClassicConnection: ElementClassicConnection,
private val sessionStore: SessionStore,
) {
// Ensure user is not stuck on the loading screen.
// If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds.
private val timeoutFLow = flow {
emit(false)
delay(5_000)
emit(true)
}
fun navigationEventFlow(): Flow<NavigationEvent> {
return combine(
timeoutFLow,
elementClassicConnection.stateFlow
.distinctUntilChangedBy {
// Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar
if (it is ElementClassicConnectionState.ElementClassicReady) {
it.copy(avatar = null)
} else {
it
}
},
sessionStore.sessionsFlow().toUserListFlow()
// Take only 1 emission of the sessions, else when the user actually logged in it will trigger a navigation to OnBoarding.
.take(1),
) { timeout, elementClassicConnectionState, existingSessions ->
when (elementClassicConnectionState) {
ElementClassicConnectionState.Idle -> {
if (timeout) {
NavigationEvent.NavigateToOnBoarding
} else {
NavigationEvent.Idle
}
}
ElementClassicConnectionState.ElementClassicNotFound,
ElementClassicConnectionState.ElementClassicReadyNoSession,
is ElementClassicConnectionState.Error -> {
NavigationEvent.NavigateToOnBoarding
}
is ElementClassicConnectionState.ElementClassicReady -> {
if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) {
NavigationEvent.NavigateToOnBoarding
} else {
// 2 cases when this can be run:
// First time this screen will be displayed
// Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic),
// and the app is resuming.
NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId)
}
}
}
}
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic
import io.element.android.libraries.matrix.api.core.UserId
sealed interface NavigationEvent {
data object Idle : NavigationEvent
data object NavigateToOnBoarding : NavigationEvent
data class NavigateToLoginWithClassic(
val userId: UserId,
) : NavigationEvent
}

View file

@ -5,11 +5,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
package io.element.android.features.login.impl.screens.classic.loginwithclassic
sealed interface LoginWithClassicEvent {
data object RefreshData : LoginWithClassicEvent
data object StartLoginWithClassic : LoginWithClassicEvent
data object DoLoginWithClassic : LoginWithClassicEvent
data object CloseDialog : LoginWithClassicEvent
data object Submit : LoginWithClassicEvent
data object ClearError : LoginWithClassicEvent
}

View file

@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
interface LoginWithClassicNavigator {
fun navigateToMissingKeyBackup()
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
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
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.UserId
@ContributesNode(AppScope::class)
@AssistedInject
class LoginWithClassicNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: LoginWithClassicPresenter.Factory,
) : Node(buildContext, plugins = plugins),
LoginWithClassicNavigator {
interface Callback : Plugin {
fun navigateToOtherOptions()
fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String)
fun navigateToMissingKeyBackup()
}
data class Inputs(
val userId: UserId,
) : NodeInputs
private val inputs: Inputs = inputs()
val presenter = presenterFactory.create(inputs.userId, this)
private val callback: Callback = callback()
override fun navigateToMissingKeyBackup() {
callback.navigateToMissingKeyBackup()
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
val state = presenter.present()
LoginWithClassicView(
state = state,
modifier = modifier,
onOtherOptionsClick = callback::navigateToOtherOptions,
onOidcDetails = callback::navigateToOidc,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
)
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import androidx.compose.runtime.Composable
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 androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.launch
@AssistedInject
class LoginWithClassicPresenter(
@Assisted private val userId: UserId,
@Assisted private val navigator: LoginWithClassicNavigator,
private val loginHelper: LoginHelper,
private val elementClassicConnection: ElementClassicConnection,
private val accountProviderDataSource: AccountProviderDataSource,
private val buildMeta: BuildMeta,
) : Presenter<LoginWithClassicState> {
@AssistedFactory
interface Factory {
fun create(
userId: UserId,
navigator: LoginWithClassicNavigator,
): LoginWithClassicPresenter
}
@Composable
override fun present(): LoginWithClassicState {
val coroutineScope = rememberCoroutineScope()
var loginWithClassicAction by remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val loginMode by loginHelper.collectLoginMode()
val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState()
fun handleEvent(event: LoginWithClassicEvent) {
when (event) {
LoginWithClassicEvent.RefreshData -> {
// Request the avatar if not known yet
val currentState = elementClassicConnection.stateFlow.value
if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) {
elementClassicConnection.requestAvatar(userId)
}
}
LoginWithClassicEvent.Submit -> {
val currentState = elementClassicConnection.stateFlow.value
if (currentState is ElementClassicConnectionState.ElementClassicReady) {
if (currentState.elementClassicSession.secrets != null &&
!currentState.elementClassicSession.doesContainBackupKey) {
navigator.navigateToMissingKeyBackup()
} else {
coroutineScope.launch {
loginWithClassicAction = AsyncAction.Loading
// Ensure that the current account provider is set
val elementClassicUserId = currentState.elementClassicSession.userId
val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol()
accountProviderDataSource.setUrl(accountProvider)
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = accountProvider,
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
loginHint = "mxid:" + elementClassicUserId.value,
)
}
}
} else {
loginWithClassicAction = AsyncAction.Failure(IllegalStateException("Element Classic is not ready"))
}
}
LoginWithClassicEvent.ClearError -> {
loginWithClassicAction = AsyncAction.Uninitialized
loginHelper.clearError()
}
}
}
val elementClassicReady = elementClassicConnectionState as? ElementClassicConnectionState.ElementClassicReady
return LoginWithClassicState(
isElementPro = buildMeta.isEnterpriseBuild,
userId = userId,
displayName = elementClassicReady?.displayName,
avatar = elementClassicReady?.avatar,
loginMode = loginMode,
loginWithClassicAction = loginWithClassicAction,
eventSink = ::handleEvent,
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import android.graphics.Bitmap
import androidx.compose.runtime.Stable
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
@Stable
data class LoginWithClassicState(
val isElementPro: Boolean,
val userId: UserId,
val displayName: String?,
val avatar: Bitmap?,
val loginWithClassicAction: AsyncAction<Unit>,
val loginMode: AsyncData<LoginMode>,
val eventSink: (LoginWithClassicEvent) -> Unit,
)

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import android.graphics.Bitmap
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
open class LoginWithClassicStateProvider : PreviewParameterProvider<LoginWithClassicState> {
override val values: Sequence<LoginWithClassicState>
get() = sequenceOf(
aLoginWithClassicState(),
aLoginWithClassicState(isElementPro = true, displayName = "Alice"),
)
}
fun aLoginWithClassicState(
isElementPro: Boolean = false,
userId: UserId = UserId("@alice:matrix.org"),
displayName: String? = null,
avatar: Bitmap? = null,
loginWithClassicAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
eventSink: (LoginWithClassicEvent) -> Unit = {},
) = LoginWithClassicState(
isElementPro = isElementPro,
userId = userId,
displayName = displayName,
avatar = avatar,
loginWithClassicAction = loginWithClassicAction,
loginMode = loginMode,
eventSink = eventSink,
)

View file

@ -0,0 +1,226 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import io.element.android.compound.theme.ElementTheme
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.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.BitmapAvatar
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
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginWithClassicView(
state: LoginWithClassicState,
onOtherOptionsClick: () -> Unit,
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
state.eventSink(LoginWithClassicEvent.RefreshData)
}
val isLoading by remember(state.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
}
}
HeaderFooterPage(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
background = { OnboardingBackground() },
isScrollable = true,
header = {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(40.dp))
Box(
modifier = Modifier
.size(54.dp)
.shadow(elevation = 10.dp, shape = RoundedCornerShape(15.dp))
.background(ElementTheme.colors.bgCanvasDefault, shape = RoundedCornerShape(15.dp)),
contentAlignment = Alignment.Center,
) {
val resId = if (state.isElementPro) {
R.drawable.element_pro_logo
} else {
R.drawable.element_foss_logo
}
Image(
modifier = Modifier.size(37.5.dp),
painter = painterResource(id = resId),
contentDescription = null,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.screen_onboarding_welcome_title),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingMdBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
}
},
content = {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(40.dp))
BitmapAvatar(
avatarData = AvatarData(
id = state.userId.value,
name = state.displayName,
// Not used here
url = null,
size = AvatarSize.UserHeader,
),
bitmap = state.avatar,
)
Spacer(Modifier.height(24.dp))
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResource(R.string.screen_onboarding_welcome_back),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
// User display name
if (state.displayName != null) {
Text(
text = state.displayName,
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
}
// UserId
Text(
text = state.userId.value,
style = if (state.displayName == null) ElementTheme.typography.fontHeadingLgBold else ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
// Min spacing
Spacer(Modifier.height(45.dp))
ButtonColumnMolecule {
Button(
text = stringResource(CommonStrings.action_continue),
showProgress = isLoading,
onClick = {
state.eventSink(LoginWithClassicEvent.Submit)
},
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
OutlinedButton(
text = stringResource(CommonStrings.common_other_options),
onClick = onOtherOptionsClick,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
}
}
},
footer = {},
)
AsyncActionView(
async = state.loginWithClassicAction,
onErrorDismiss = {
state.eventSink(LoginWithClassicEvent.ClearError)
},
onSuccess = {
// noop, the view will be closed
},
progressDialog = {
// The button is showing the progress
}
)
LoginModeView(
loginMode = state.loginMode,
onClearError = {
state.eventSink(LoginWithClassicEvent.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
onOidcDetails = onOidcDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
}
@PreviewsDayNight
@Composable
internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicStateProvider::class) state: LoginWithClassicState) = ElementPreview {
LoginWithClassicView(
state = state,
onOtherOptionsClick = {},
onOidcDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onCreateAccountContinue = {},
)
}

View file

@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
sealed interface MissingKeyBackupEvent {
data object OnResume : MissingKeyBackupEvent
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
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
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.BuildConfig
import io.element.android.libraries.architecture.callback
import timber.log.Timber
@ContributesNode(AppScope::class)
@AssistedInject
class MissingKeyBackupNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: MissingKeyBackupPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateBack()
}
private val callback: Callback = callback()
/**
* Open Element Classic application.
*/
private fun openClassic(context: Context) {
context.packageManager.getLaunchIntentForPackage(
BuildConfig.elementClassicPackage,
)?.let { intent ->
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
// Should not happen, Element Classic must be installed for this screen to be displayed.
Timber.e(e, "Element Classic app not found, cannot open it.")
}
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
MissingKeyBackupView(
state = state,
onBackClick = callback::navigateBack,
onOpenClassicClick = {
openClassic(context)
},
modifier = modifier,
)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@Inject
class MissingKeyBackupPresenter(
private val buildMeta: BuildMeta,
private val elementClassicConnection: ElementClassicConnection,
) : Presenter<MissingKeyBackupState> {
@Composable
override fun present(): MissingKeyBackupState {
var resumeCounter by remember { mutableIntStateOf(0) }
fun handleEvent(event: MissingKeyBackupEvent) {
when (event) {
MissingKeyBackupEvent.OnResume -> {
resumeCounter++
if (resumeCounter > 1) {
// The user has returned to this screen, we can assume they have gone to the backup flow and are now back here
elementClassicConnection.requestSession()
}
}
}
}
return MissingKeyBackupState(
appName = buildMeta.applicationName,
eventSink = ::handleEvent,
)
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
data class MissingKeyBackupState(
val appName: String,
val eventSink: (MissingKeyBackupEvent) -> Unit
)

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class MissingKeyBackupStateProvider : PreviewParameterProvider<MissingKeyBackupState> {
override val values: Sequence<MissingKeyBackupState>
get() = sequenceOf(
aMissingKeyBackupState(),
// Add other state here
)
}
fun aMissingKeyBackupState(
appName: String = "AppName",
eventSink: (MissingKeyBackupEvent) -> Unit = {},
) = MissingKeyBackupState(
appName = appName,
eventSink = eventSink
)

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
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
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import kotlinx.collections.immutable.persistentListOf
@Composable
fun MissingKeyBackupView(
state: MissingKeyBackupState,
onBackClick: () -> Unit,
onOpenClassicClick: () -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink.invoke(MissingKeyBackupEvent.OnResume)
}
}
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
title = stringResource(id = R.string.screen_missing_key_backup_title, state.appName),
content = { Content(state) },
buttons = {
Buttons(
onOpenClassicClick = onOpenClassicClick,
)
}
)
}
@Composable
private fun Content(
state: MissingKeyBackupState,
) {
NumberedListOrganism(
modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp),
items = persistentListOf(
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_1)),
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_2_android)),
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_3_android)),
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_4)),
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_5, state.appName)),
),
)
}
@Composable
private fun ColumnScope.Buttons(
onOpenClassicClick: () -> Unit,
) {
Button(
text = stringResource(id = R.string.screen_missing_key_backup_open_element_classic),
modifier = Modifier.fillMaxWidth(),
onClick = onOpenClassicClick,
)
}
@PreviewsDayNight
@Composable
internal fun MissingKeyBackupViewPreview(@PreviewParameter(MissingKeyBackupStateProvider::class) state: MissingKeyBackupState) = ElementPreview {
MissingKeyBackupView(
state = state,
onBackClick = {},
onOpenClassicClick = {},
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.root
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
@ContributesNode(AppScope::class)
@AssistedInject
class RootNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
RootView(modifier)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.root
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun RootView(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
DelayedVisibility(
duration = 100.milliseconds,
) {
CircularProgressIndicator()
}
}
}
@PreviewsDayNight
@Composable
internal fun RootViewPreview() = ElementPreview {
RootView()
}

View file

@ -48,6 +48,7 @@ class ConfirmAccountProviderPresenter(
loginHelper.submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
}

View file

@ -17,14 +17,23 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@AssistedInject
class LoginPasswordNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LoginPasswordPresenter,
presenterFactory: LoginPasswordPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val initialLogin: String,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.initialLogin)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()

View file

@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -25,11 +27,18 @@ import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class LoginPasswordPresenter(
@Assisted
private val initialLogin: String,
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
) : Presenter<LoginPasswordState> {
@AssistedFactory
interface Factory {
fun create(initialLogin: String): LoginPasswordPresenter
}
@Composable
override fun present(): LoginPasswordState {
val localCoroutineScope = rememberCoroutineScope()
@ -38,7 +47,12 @@ class LoginPasswordPresenter(
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
mutableStateOf(
LoginFormState(
login = initialLogin,
password = "",
)
)
}
val accountProvider by accountProviderDataSource.flow.collectAsState()

View file

@ -48,6 +48,7 @@ class OnBoardingNode(
data class Params(
val accountProvider: String?,
val loginHint: String?,
val showBackButton: Boolean,
) : NodeInputs
private val callback: Callback = callback()
@ -61,6 +62,7 @@ class OnBoardingNode(
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
OnBoardingView(
state = state,
modifier = modifier,

View file

@ -26,7 +26,6 @@ import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@ -45,7 +44,6 @@ class OnBoardingPresenter(
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginWithClassicPresenter: Presenter<LoginWithClassicState>,
) : Presenter<OnBoardingState> {
@AssistedFactory
interface Factory {
@ -101,8 +99,6 @@ class OnBoardingPresenter(
val loginMode by loginHelper.collectLoginMode()
val loginWithClassicState = loginWithClassicPresenter.present()
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
@ -111,6 +107,7 @@ class OnBoardingPresenter(
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
}
@ -127,6 +124,7 @@ class OnBoardingPresenter(
return OnBoardingState(
isAddingAccount = isAddingAccount,
showBackButton = params.showBackButton,
productionApplicationName = buildMeta.productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
@ -136,7 +134,6 @@ class OnBoardingPresenter(
loginMode = loginMode,
version = buildMeta.versionName,
onBoardingLogoResId = onBoardingLogoResId,
loginWithClassicState = loginWithClassicState,
eventSink = ::handleEvent,
)
}

View file

@ -10,11 +10,11 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
val isAddingAccount: Boolean,
val showBackButton: Boolean,
val productionApplicationName: String,
val defaultAccountProvider: String?,
val mustChooseAccountProvider: Boolean,
@ -25,7 +25,6 @@ data class OnBoardingState(
@DrawableRes
val onBoardingLogoResId: Int?,
val loginMode: AsyncData<LoginMode>,
val loginWithClassicState: LoginWithClassicState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean

View file

@ -11,8 +11,6 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.R
@ -31,11 +29,15 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
canLoginWithQrCode = true,
canCreateAccount = true,
),
anOnBoardingState(
showBackButton = true,
),
)
}
fun anOnBoardingState(
isAddingAccount: Boolean = false,
showBackButton: Boolean = false,
productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
mustChooseAccountProvider: Boolean = false,
@ -46,10 +48,10 @@ fun anOnBoardingState(
@DrawableRes
customLogoResId: Int? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
isAddingAccount = isAddingAccount,
showBackButton = showBackButton,
productionApplicationName = productionApplicationName,
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
@ -59,6 +61,5 @@ fun anOnBoardingState(
version = version,
loginMode = loginMode,
onBoardingLogoResId = customLogoResId,
loginWithClassicState = loginWithClassicState,
eventSink = eventSink,
)

View file

@ -31,15 +31,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
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.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
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
@ -47,11 +42,11 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
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
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
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
@ -114,45 +109,9 @@ fun OnBoardingView(
state = state,
loginView = loginView,
buttons = buttons,
onBackClick = onBackClick,
)
}
LoginWithElementClassicView(
state = state.loginWithClassicState,
)
}
@Composable
private fun LoginWithElementClassicView(
state: LoginWithClassicState,
) {
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
state.eventSink(LoginWithClassicEvent.RefreshData)
}
AsyncActionView(
async = state.loginWithClassicAction,
confirmationDialog = { confirming ->
when (confirming) {
is ConfirmingLoginWithElementClassic -> {
// TODO i18n
ConfirmationDialog(
title = "Sign in with Element Classic",
content = "You are signing in as ${confirming.userId} on Element Classic." +
" Your existing session on Element Classic will not be signed out. Do you want to continue?",
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = { state.eventSink(LoginWithClassicEvent.DoLoginWithClassic) },
onDismiss = { state.eventSink(LoginWithClassicEvent.CloseDialog) },
)
}
}
},
onErrorDismiss = {
state.eventSink(LoginWithClassicEvent.CloseDialog)
},
onSuccess = {
// noop, the view will be closed
}
)
}
@Composable
@ -160,12 +119,16 @@ private fun AddFirstAccountScaffold(
state: OnBoardingState,
loginView: @Composable () -> Unit,
buttons: @Composable () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
OnBoardingPage(
modifier = modifier,
renderBackground = state.onBoardingLogoResId == null,
content = {
Box(
modifier = Modifier.fillMaxSize(),
) {
if (state.onBoardingLogoResId != null) {
OnBoardingLogo(
onBoardingLogoResId = state.onBoardingLogoResId,
@ -173,6 +136,20 @@ private fun AddFirstAccountScaffold(
} else {
OnBoardingContent(state = state)
}
if (state.showBackButton) {
// Add icon button to "navigate back"
IconButton(
onClick = onBackClick,
modifier = Modifier
.align(Alignment.TopEnd),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel),
)
}
}
}
loginView()
},
footer = {
@ -283,18 +260,6 @@ private fun OnBoardingButtons(
} else {
CommonStrings.action_continue
}
if (state.loginWithClassicState.canLoginWithClassic) {
Button(
text = "Sign in with Element Classic",
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
onClick = {
state.loginWithClassicState.eventSink(
LoginWithClassicEvent.StartLoginWithClassic
)
},
modifier = Modifier.fillMaxWidth(),
)
}
if (state.canLoginWithQrCode) {
Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),

View file

@ -1,260 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
import android.content.ComponentName
import android.content.Context
import android.content.Context.BIND_AUTO_CREATE
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Message
import android.os.Messenger
import android.os.RemoteException
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.login.impl.BuildConfig
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
interface ElementClassicConnection {
fun start()
fun stop()
fun requestData()
val stateFlow: StateFlow<ElementClassicConnectionState>
}
sealed interface ElementClassicConnectionState {
object Idle : ElementClassicConnectionState
object ElementClassicNotFound : ElementClassicConnectionState
object ElementClassicReadyNoSession : ElementClassicConnectionState
data class ElementClassicReady(
val userId: UserId,
val secrets: String,
) : ElementClassicConnectionState
data class Error(val error: String) : ElementClassicConnectionState
}
private val loggerTag = LoggerTag("ECConnection")
@ContributesBinding(AppScope::class)
class DefaultElementClassicConnection(
@ApplicationContext
private val context: Context,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
) : ElementClassicConnection {
// Messenger for communicating with the service.
private var messenger: Messenger? = null
// Target we publish for external service to send messages to IncomingHandler.
private val incomingMessenger: Messenger = Messenger(IncomingHandler())
// Flag indicating whether we have called bind on the service.
private var bound: Boolean = false
/**
* Class for interacting with the main interface of the service.
*/
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
Timber.tag(loggerTag.value).d("onServiceConnected")
// This is called when the connection with the service has been
// established, giving us the object we can use to
// interact with the service. We are communicating with the
// service using a Messenger, so here we get a client-side
// representation of that from the raw IBinder object.
messenger = Messenger(service)
bound = true
// Request the data as soon as possible
requestData()
}
override fun onServiceDisconnected(className: ComponentName) {
Timber.tag(loggerTag.value).d("onServiceDisconnected")
// This is called when the connection with the service has been
// unexpectedly disconnected&mdash;that is, its process crashed.
messenger = null
bound = false
}
}
override fun start() {
Timber.tag(loggerTag.value).w("start()")
coroutineScope.launch {
// Establish a connection with the service. We use an explicit
// class name because there is no reason to be able to let other
// applications replace our component.
try {
val intentService = Intent()
intentService.setComponent(getElementClassicComponent())
if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
Timber.tag(loggerTag.value).d("Binding returned true")
} else {
// This happens when the app is not installed
Timber.tag(loggerTag.value).d("Binding returned false")
mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound)
}
} catch (e: SecurityException) {
Timber.tag(loggerTag.value).e(e, "Can't bind to Service")
mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
}
}
}
override fun stop() {
Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)")
if (bound) {
// Detach our existing connection.
context.unbindService(serviceConnection)
bound = false
}
coroutineScope.launch {
mutableStateFlow.emit(ElementClassicConnectionState.Idle)
}
}
override fun requestData() {
Timber.tag(loggerTag.value).w("requestData()")
coroutineScope.launch {
val finalMessenger = messenger
if (finalMessenger == null) {
Timber.tag(loggerTag.value).w("The messenger is null, can't request data")
mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data"))
} else {
try {
// Get the data
val msg = Message.obtain(null, MSG_GET_DATA)
msg.replyTo = incomingMessenger
finalMessenger.send(msg)
} catch (e: RemoteException) {
// In this case the service has crashed before we could even
// do anything with it; we can count on soon being
// disconnected (and then reconnected if it can be restarted)
// so there is no need to do anything here.
Timber.tag(loggerTag.value).e(e, "RemoteException")
mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
}
}
}
}
private val mutableStateFlow = MutableStateFlow<ElementClassicConnectionState>(ElementClassicConnectionState.Idle)
override val stateFlow = mutableStateFlow.asStateFlow()
/**
* Handler of incoming messages from service.
*/
@Suppress("DEPRECATION")
inner class IncomingHandler : Handler() {
override fun handleMessage(msg: Message) {
Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}")
when (msg.what) {
MSG_GET_DATA -> {
// The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied
val state = msg.data.toElementClassicConnectionState()
emitElementClassicState(state)
}
else -> {
super.handleMessage(msg)
}
}
}
}
private fun emitElementClassicState(state: ElementClassicConnectionState) = coroutineScope.launch {
when (state) {
is ElementClassicConnectionState.Error -> {
Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error)
mutableStateFlow.emit(state)
}
is ElementClassicConnectionState.ElementClassicReady -> {
Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId)
mutableStateFlow.emit(state)
}
ElementClassicConnectionState.ElementClassicReadyNoSession -> {
Timber.tag(loggerTag.value).d("Received no session from Element Classic")
mutableStateFlow.emit(state)
}
else -> {
// Should not happen
Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state)
mutableStateFlow.emit(ElementClassicConnectionState.Idle)
}
}
}
private fun getElementClassicComponent() = ComponentName(
BuildConfig.elementClassicPackage,
ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME,
)
private fun Bundle?.toElementClassicConnectionState(): ElementClassicConnectionState {
return if (this == null) {
ElementClassicConnectionState.Error("No data received from Element Classic")
} else {
val error = getString(KEY_ERROR_STR)
if (error != null) {
ElementClassicConnectionState.Error(error)
} else {
val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId)
if (userId != null) {
val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() }
if (secrets == null) {
ElementClassicConnectionState.Error("No secrets received from Element Classic")
} else {
ElementClassicConnectionState.ElementClassicReady(userId, secrets)
}
} else {
ElementClassicConnectionState.ElementClassicReadyNoSession
}
}
}
}
// Everything in this companion object must match what is defined in Element Classic
private companion object {
const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
// Command to the service to get the data.
const val MSG_GET_DATA = 1
// Keys for the bundle returned from the service
const val KEY_ERROR_STR = "error"
const val KEY_USER_ID_STR = "userId"
/**
* Key to extract the secrets from the bundle, as a Json string.
* Json will have this format:
* {
* "cross_signing" : {
* "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o",
* "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms",
* "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM"
* },
* "backup" : {
* "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2",
* "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc",
* "backup_version" : "1"
* }
* }
*/
const val KEY_SECRETS_STR = "secrets"
}
}

View file

@ -1,103 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.toUserListFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Inject
class LoginWithClassicPresenter(
private val elementClassicConnection: ElementClassicConnection,
private val sessionStore: SessionStore,
private val featureFlagService: FeatureFlagService,
) : Presenter<LoginWithClassicState> {
@Composable
override fun present(): LoginWithClassicState {
val coroutineScope = rememberCoroutineScope()
val isSignInWithClassicEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic)
}.collectAsState(initial = false)
if (isSignInWithClassicEnabled) {
DisposableEffect(Unit) {
elementClassicConnection.start()
onDispose {
elementClassicConnection.stop()
}
}
}
val state by elementClassicConnection.stateFlow.collectAsState()
val loginWithClassicAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val existingSession by remember {
sessionStore.sessionsFlow().toUserListFlow()
}.collectAsState(emptyList())
val canLoginWithClassic by remember {
derivedStateOf {
when (val finalState = state) {
is ElementClassicConnectionState.ElementClassicReady -> {
// Ensure there is no existing session with the same Id.
finalState.userId.value !in existingSession && isSignInWithClassicEnabled
}
else -> false
}
}
}
fun handleEvent(event: LoginWithClassicEvent) {
when (event) {
LoginWithClassicEvent.RefreshData -> {
elementClassicConnection.requestData()
}
LoginWithClassicEvent.StartLoginWithClassic -> {
val currentState = elementClassicConnection.stateFlow.value
if (currentState is ElementClassicConnectionState.ElementClassicReady) {
loginWithClassicAction.value = ConfirmingLoginWithElementClassic(
userId = currentState.userId,
)
} else {
loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready"))
}
}
LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch {
// TODO Implement real login logic here
loginWithClassicAction.value = AsyncAction.Loading
delay(1000)
loginWithClassicAction.value = AsyncAction.Success(Unit)
}
LoginWithClassicEvent.CloseDialog -> {
loginWithClassicAction.value = AsyncAction.Uninitialized
}
}
}
return LoginWithClassicState(
canLoginWithClassic = canLoginWithClassic,
loginWithClassicAction = loginWithClassicAction.value,
eventSink = ::handleEvent,
)
}
}

View file

@ -1,16 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
import io.element.android.libraries.architecture.AsyncAction
data class LoginWithClassicState(
val canLoginWithClassic: Boolean,
val loginWithClassicAction: AsyncAction<Unit>,
val eventSink: (LoginWithClassicEvent) -> Unit,
)

View file

@ -1,20 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
import io.element.android.libraries.architecture.AsyncAction
fun aLoginWithClassicState(
canLoginWithClassic: Boolean = false,
loginWithClassicAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LoginWithClassicEvent) -> Unit = {},
) = LoginWithClassicState(
canLoginWithClassic = canLoginWithClassic,
loginWithClassicAction = loginWithClassicAction,
eventSink = eventSink,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -37,11 +37,19 @@
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Open Element Classic"</string>
<string name="screen_missing_key_backup_step_1">"Open Element Classic on your device"</string>
<string name="screen_missing_key_backup_step_2_android">"Go to Settings &gt; Security &amp; Privacy"</string>
<string name="screen_missing_key_backup_step_3_android">"In Cryptography keys management, select Encrypted message recovery"</string>
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
<string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
<string name="screen_onboarding_sign_up">"Create account"</string>
<string name="screen_onboarding_welcome_back">"Welcome back"</string>
<string name="screen_onboarding_welcome_message">"Welcome to the fastest %1$s ever. Supercharged for speed and simplicity."</string>
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
<string name="screen_onboarding_welcome_title">"Be in your element"</string>

View file

@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
@ -39,6 +40,7 @@ class DefaultLoginEntryPointTest {
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
oidcActionFlow = FakeOidcActionFlow(),
appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(),
)
}
val callback = object : LoginEntryPoint.Callback {

View file

@ -0,0 +1,429 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.impl.classic
import android.graphics.Bitmap
import android.os.Bundle
import androidx.core.graphics.createBitmap
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.service.ServiceBinder
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultElementClassicConnectionTest {
@Test
fun `connection can be started Element Classic service can be bound`() = runTest {
val connection = createDefaultElementClassicConnection(
serviceBinder = FakeServiceBinder(
bindServiceResult = {
// Element Classic is found
true
},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
connection.start()
runCurrent()
expectNoEvents()
}
}
@Test
fun `connection can be started Element Classic service cannot be bound`() = runTest {
val setElementClassicSessionResult = lambdaRecorder<ElementClassicSession?, Unit> { }
val connection = createDefaultElementClassicConnection(
serviceBinder = FakeServiceBinder(
bindServiceResult = {
// Element Classic not found
false
},
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = setElementClassicSessionResult,
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
connection.start()
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicNotFound)
setElementClassicSessionResult.assertions().isCalledOnce().with(value(null))
}
}
@Test
fun `connection cannot be started in case of security error`() = runTest {
val setElementClassicSessionResult = lambdaRecorder<ElementClassicSession?, Unit> { }
val connection = createDefaultElementClassicConnection(
serviceBinder = FakeServiceBinder(
bindServiceResult = { throw SecurityException(A_FAILURE_REASON) },
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = setElementClassicSessionResult,
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
connection.start()
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
setElementClassicSessionResult.assertions().isCalledOnce().with(value(null))
}
}
@Test
fun `requestSession when messenger is not ready has no effect`() = runTest {
val connection = createDefaultElementClassicConnection()
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
connection.requestSession()
runCurrent()
expectNoEvents()
}
}
@Test
fun `when an error is received, an error is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_ERROR_STR, A_FAILURE_REASON)
}
)
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
}
}
@Test
fun `when there is no Element Classic session, ElementClassicReadyNoSession is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving no session from Element Classic
connection.onSessionReceived(Bundle())
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicReadyNoSession)
}
}
@Test
fun `when there is Element Classic session with empty userId, ElementClassicReadyNoSession is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving empty userId from Element Classic
connection.onSessionReceived(Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, "")
})
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicReadyNoSession)
}
}
@Test
fun `when session is received, but homeserver is not supported, an error is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(false) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
}
)
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
}
}
@Test
fun `when session is received without secrets, and homeserver is supported, ElementClassicReady is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
}
)
assertThat(awaitItem()).isEqualTo(
ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession(
userId = A_USER_ID,
homeserverUrl = null,
secrets = null,
roomKeysVersion = null,
doesContainBackupKey = false,
),
displayName = null,
avatar = null,
)
)
}
}
@Test
fun `when session is received with all data including key backup, and homeserver is supported, ElementClassicReady is emitted`() {
`when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`(
withKeyBackup = true,
)
}
@Test
fun `when session is received with all data without key backup, and homeserver is supported, ElementClassicReady is emitted - backup key is missing`() {
`when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`(
withKeyBackup = false,
)
}
private fun `when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`(
withKeyBackup: Boolean,
) = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
doSecretsContainBackupKeyResult = { _, _, _ -> withKeyBackup },
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, A_HOMESERVER_URL)
putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET)
putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, ROOM_KEYS_VERSION)
putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, A_USER_NAME)
}
)
assertThat(awaitItem()).isEqualTo(
ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession(
userId = A_USER_ID,
homeserverUrl = A_HOMESERVER_URL,
secrets = A_SECRET,
roomKeysVersion = ROOM_KEYS_VERSION,
doesContainBackupKey = withKeyBackup,
),
displayName = A_USER_NAME,
avatar = null,
)
)
}
}
@Test
fun `when session is received with empty data, and homeserver is supported, ElementClassicReady is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, "")
putString(DefaultElementClassicConnection.KEY_SECRETS_STR, "")
putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, "")
}
)
assertThat(awaitItem()).isEqualTo(
ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession(
userId = A_USER_ID,
homeserverUrl = null,
secrets = null,
roomKeysVersion = null,
doesContainBackupKey = false,
),
displayName = null,
avatar = null,
)
)
}
}
@Test
fun `when avatar is received when the state is not ElementClassicReady, nothing happen`() = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving an avatar from Element Classic
connection.onAvatarReceived(Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888))
})
runCurrent()
expectNoEvents()
}
}
@Test
fun `when avatar is received when the state is ElementClassicReady with a different user, nothing happen`() = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
}
)
assertThat(awaitItem()).isEqualTo(
ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession(
userId = A_USER_ID,
homeserverUrl = null,
secrets = null,
roomKeysVersion = null,
doesContainBackupKey = false,
),
displayName = null,
avatar = null,
)
)
// Simulate receiving an avatar for another user from Element Classic
connection.onAvatarReceived(Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID_2.value)
putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888))
})
runCurrent()
expectNoEvents()
}
}
@Test
fun `when avatar is received, the state is updated`() = runTest {
val connection = createDefaultElementClassicConnection(
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
// Simulate receiving a session from Element Classic
connection.onSessionReceived(
Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
}
)
assertThat(awaitItem()).isEqualTo(
ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession(
userId = A_USER_ID,
homeserverUrl = null,
secrets = null,
roomKeysVersion = null,
doesContainBackupKey = false,
),
displayName = null,
avatar = null,
)
)
// Simulate receiving an avatar from Element Classic
connection.onAvatarReceived(Bundle().apply {
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888))
})
assertThat((awaitItem() as? ElementClassicConnectionState.ElementClassicReady)?.avatar).isNotNull()
}
}
private fun TestScope.createDefaultElementClassicConnection(
serviceBinder: ServiceBinder = FakeServiceBinder(
bindServiceResult = { true },
unbindServiceResult = { },
),
coroutineScope: CoroutineScope = backgroundScope,
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
) = DefaultElementClassicConnection(
serviceBinder = serviceBinder,
coroutineScope = coroutineScope,
matrixAuthenticationService = matrixAuthenticationService,
homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker,
)
}

View file

@ -5,8 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
package io.element.android.features.login.impl.classic
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -15,12 +16,14 @@ import kotlinx.coroutines.flow.asStateFlow
class FakeElementClassicConnection(
private val startResult: () -> Unit = { lambdaError() },
private val stopResult: () -> Unit = { lambdaError() },
private val requestDataResult: () -> Unit = { lambdaError() },
private val requestSessionResult: () -> Unit = { lambdaError() },
private val requestAvatarResult: (UserId) -> Unit = { lambdaError() },
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
) : ElementClassicConnection {
override fun start() = startResult()
override fun stop() = stopResult()
override fun requestData() = requestDataResult()
override fun requestSession() = requestSessionResult()
override fun requestAvatar(userId: UserId) = requestAvatarResult(userId)
private val mutableStateFlow = MutableStateFlow(initialState)
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
suspend fun emitState(state: ElementClassicConnectionState) {

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.classic
import android.content.Intent
import android.content.ServiceConnection
import io.element.android.libraries.androidutils.service.ServiceBinder
import io.element.android.tests.testutils.lambda.lambdaError
class FakeServiceBinder(
private val bindServiceResult: () -> Boolean = { lambdaError() },
private val unbindServiceResult: () -> Unit = { lambdaError() },
) : ServiceBinder {
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
return bindServiceResult()
}
override fun unbindService(conn: ServiceConnection) {
unbindServiceResult()
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.classic
import android.graphics.Bitmap
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_USER_ID
internal const val ROOM_KEYS_VERSION = "roomKeysVersion as Json data"
fun anElementClassicReady(
elementClassicSession: ElementClassicSession = anElementClassicSession(),
displayName: String? = null,
avatar: Bitmap? = null,
) = ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = elementClassicSession,
displayName = displayName,
avatar = avatar,
)
fun anElementClassicSession(
userId: UserId = A_USER_ID,
homeserverUrl: String? = null,
secrets: String? = null,
roomKeysVersion: String? = null,
doesContainBackupKey: Boolean = false,
) = ElementClassicSession(
userId = userId,
homeserverUrl = homeserverUrl,
secrets = secrets,
roomKeysVersion = roomKeysVersion,
doesContainBackupKey = doesContainBackupKey,
)

View file

@ -0,0 +1,278 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.impl.screens.classic
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.login.impl.classic.anElementClassicReady
import io.element.android.features.login.impl.classic.anElementClassicSession
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
// Use AndroidJUnit4 for the test with the Bitmap.
@RunWith(AndroidJUnit4::class)
class ClassicFlowNodeHelperTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `initial state`() = runTest {
createHelper()
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
}
}
@Test
fun `after a few seconds in Idle, NavigateToOnBoarding is emitted`() = runTest {
createHelper()
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
}
}
@Test
fun `navigate to onboarding if a session with the same account already exists`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
)
)
),
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
anElementClassicReady()
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
}
}
@Test
fun `navigate to onboarding if Element Classic is not found`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicNotFound
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
}
}
@Test
fun `navigate to onboarding if Element Classic has no session`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReadyNoSession
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
}
}
@Test
fun `navigate to onboarding if there has been an error`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
ElementClassicConnectionState.Error(A_FAILURE_REASON)
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
}
}
@Test
fun `navigate to login with classic when the session can be retrieved`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
anElementClassicReady()
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
}
}
@Test
fun `navigate to login with classic when the session can be retrieved - ignore avatar update`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
anElementClassicReady()
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
// When the avatar is retrieved, no new event is emitted
elementClassicConnection.emitState(
anElementClassicReady(
avatar = createBitmap(1, 1)
)
)
expectNoEvents()
}
}
@Test
fun `navigate to login with classic when the session can be retrieved and navigate again once the session is verified`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
secrets = A_SECRET,
)
)
)
val readyState = awaitItem()
assertThat(readyState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
// When the secret with the key backup is retrieved, NavigateToLoginWithClassic is emitted again
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
secrets = A_SECRET + A_SECRET,
)
)
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
}
}
@Test
fun `navigate to login with classic if a session with another account already exists`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
createHelper(
elementClassicConnection = elementClassicConnection,
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID_2.value,
)
)
),
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
anElementClassicReady()
)
val finalState = awaitItem()
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
}
}
@Test
fun `navigate to login with classic but do not navigate to OnBoarding once the user is logged in`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
val sessionStore = InMemorySessionStore(
initialList = listOf()
)
createHelper(
elementClassicConnection = elementClassicConnection,
sessionStore = sessionStore,
)
.navigationEventFlow()
.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
elementClassicConnection.emitState(
anElementClassicReady()
)
val navigateToLoginWithClassicState = awaitItem()
assertThat(navigateToLoginWithClassicState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
// User actually logs in
sessionStore.addSession(
aSessionData(
sessionId = A_USER_ID.value,
)
)
expectNoEvents()
}
}
}
private fun createHelper(
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
sessionStore: SessionStore = InMemorySessionStore(),
) = ClassicFlowNodeHelper(
elementClassicConnection = elementClassicConnection,
sessionStore = sessionStore,
)

View file

@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLoginWithClassicNavigator(
private val navigateToMissingKeyBackupResult: () -> Unit = { lambdaError() },
) : LoginWithClassicNavigator {
override fun navigateToMissingKeyBackup() {
navigateToMissingKeyBackupResult()
}
}

View file

@ -0,0 +1,293 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.loginwithclassic
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.login.impl.classic.ROOM_KEYS_VERSION
import io.element.android.features.login.impl.classic.anElementClassicReady
import io.element.android.features.login.impl.classic.anElementClassicSession
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoginWithClassicPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.isElementPro).isFalse()
assertThat(initialState.userId).isEqualTo(A_USER_ID)
assertThat(initialState.displayName).isNull()
assertThat(initialState.avatar).isNull()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
assertThat(initialState.loginMode.isUninitialized()).isTrue()
}
}
@Test
fun `present - initial state - element Pro`() = runTest {
val presenter = createPresenter(
isEnterpriseBuild = true,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.isElementPro).isTrue()
}
}
@Test
fun `present - refresh data invokes the expected methods`() = runTest {
val requestAvatarResult = lambdaRecorder<UserId, Unit> { }
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
requestAvatarResult = requestAvatarResult,
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
userId = A_USER_ID,
secrets = A_SECRET,
roomKeysVersion = ROOM_KEYS_VERSION,
),
displayName = A_USER_NAME,
)
)
val readyState = awaitItem()
assertThat(readyState.userId).isEqualTo(A_USER_ID)
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
readyState.eventSink(LoginWithClassicEvent.RefreshData)
requestAvatarResult.assertions().isCalledOnce()
}
}
@Test
fun `present - start login with correct state - user can login`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
Result.failure(AN_EXCEPTION)
},
)
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
userId = A_USER_ID,
secrets = A_SECRET,
roomKeysVersion = ROOM_KEYS_VERSION,
doesContainBackupKey = true,
),
displayName = A_USER_NAME,
)
)
val readyState = awaitItem()
assertThat(readyState.userId).isEqualTo(A_USER_ID)
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
readyState.eventSink(LoginWithClassicEvent.Submit)
val loadingState = awaitItem()
assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
skipItems(1)
}
}
@Test
fun `present - start login with no secrets - user can login and will have to verify manually`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
Result.failure(AN_EXCEPTION)
},
)
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
userId = A_USER_ID,
secrets = null,
roomKeysVersion = null,
),
displayName = A_USER_NAME,
)
)
val readyState = awaitItem()
assertThat(readyState.userId).isEqualTo(A_USER_ID)
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
readyState.eventSink(LoginWithClassicEvent.Submit)
val loadingState = awaitItem()
assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
skipItems(1)
}
}
@Test
fun `present - start login with secrets and without key backup - user will see the screen to enable key backup`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
Result.failure(AN_EXCEPTION)
},
)
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
navigator = FakeLoginWithClassicNavigator(
navigateToMissingKeyBackupResult = navigateToMissingKeyBackupResult,
),
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
userId = A_USER_ID,
secrets = A_SECRET,
roomKeysVersion = null,
doesContainBackupKey = false,
),
displayName = A_USER_NAME,
)
)
val readyState = awaitItem()
assertThat(readyState.userId).isEqualTo(A_USER_ID)
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
readyState.eventSink(LoginWithClassicEvent.Submit)
navigateToMissingKeyBackupResult.assertions().isCalledOnce()
}
}
@Test
fun `present - start login with secrets and with invalid key backup - user will see the screen to enable key backup`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
Result.failure(AN_EXCEPTION)
},
)
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
loginHelper = createLoginHelper(
authenticationService = authenticationService,
),
navigator = FakeLoginWithClassicNavigator(
navigateToMissingKeyBackupResult = navigateToMissingKeyBackupResult,
),
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
userId = A_USER_ID,
secrets = A_SECRET,
roomKeysVersion = ROOM_KEYS_VERSION,
// false here
doesContainBackupKey = false,
),
displayName = A_USER_NAME,
)
)
val readyState = awaitItem()
assertThat(readyState.userId).isEqualTo(A_USER_ID)
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
readyState.eventSink(LoginWithClassicEvent.Submit)
navigateToMissingKeyBackupResult.assertions().isCalledOnce()
}
}
@Test
fun `present - submit in wrong state and clear error`() = runTest {
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
ElementClassicConnectionState.Error(
error = A_FAILURE_REASON,
)
)
val initialState = awaitItem()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
initialState.eventSink(LoginWithClassicEvent.Submit)
val errorState = awaitItem()
assertThat(errorState.loginWithClassicAction.isFailure()).isTrue()
errorState.eventSink(LoginWithClassicEvent.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loginWithClassicAction.isUninitialized()).isTrue()
}
}
}
private fun createPresenter(
userId: UserId = A_USER_ID,
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
loginHelper: LoginHelper = createLoginHelper(),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
isEnterpriseBuild: Boolean = false,
) = LoginWithClassicPresenter(
userId = userId,
navigator = navigator,
loginHelper = loginHelper,
elementClassicConnection = elementClassicConnection,
accountProviderDataSource = accountProviderDataSource,
buildMeta = aBuildMeta(
isEnterpriseBuild = isEnterpriseBuild,
),
)

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MissingKeyBackupPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME)
}
}
@Test
fun `present - when the screen is resumed twice, the start over method is called`() = runTest {
val requestSessionResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
requestSessionResult = requestSessionResult,
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MissingKeyBackupEvent.OnResume)
expectNoEvents()
initialState.eventSink(MissingKeyBackupEvent.OnResume)
requestSessionResult.assertions().isCalledOnce()
}
}
}
private fun createPresenter(
buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
) = MissingKeyBackupPresenter(
buildMeta = buildMeta,
elementClassicConnection = elementClassicConnection,
)

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.A_USER_NAME_2
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.tests.testutils.WarmUpRule
@ -41,6 +42,20 @@ class LoginPasswordPresenterTest {
}
}
@Test
fun `present - initial login is in the first state and can be modified`() = runTest {
createLoginPasswordPresenter(
initialLogin = A_USER_NAME,
).test {
val initialState = awaitItem()
assertThat(initialState.formState.login).isEqualTo(A_USER_NAME)
// Login can be changed
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME_2))
val loginChangedState = awaitItem()
assertThat(loginChangedState.formState.login).isEqualTo(A_USER_NAME_2)
}
}
@Test
fun `present - enter login and password`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
@ -140,9 +155,11 @@ class LoginPasswordPresenterTest {
}
private fun createLoginPasswordPresenter(
initialLogin: String = "",
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
): LoginPasswordPresenter = LoginPasswordPresenter(
initialLogin = initialLogin,
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
)

View file

@ -16,7 +16,6 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
@ -83,16 +82,31 @@ class OnBoardingPresenterTest {
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.showBackButton).isFalse()
assertThat(initialState.defaultAccountProvider).isNull()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isFalse()
assertThat(initialState.isAddingAccount).isFalse()
assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse()
val finalState = awaitItem()
assertThat(finalState.canLoginWithQrCode).isTrue()
assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse()
}
}
@Test
fun `present - initial state with back button`() = runTest {
val presenter = createPresenter(
params = OnBoardingNode.Params(
accountProvider = null,
loginHint = null,
showBackButton = true,
),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.showBackButton).isTrue()
skipItems(1)
}
}
@ -162,6 +176,7 @@ class OnBoardingPresenterTest {
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
showBackButton = false,
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
@ -184,6 +199,7 @@ class OnBoardingPresenterTest {
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
showBackButton = false,
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
@ -206,6 +222,7 @@ class OnBoardingPresenterTest {
params = OnBoardingNode.Params(
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
loginHint = null,
showBackButton = false,
),
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
@ -233,6 +250,7 @@ class OnBoardingPresenterTest {
params = OnBoardingNode.Params(
accountProvider = A_HOMESERVER_URL,
loginHint = A_LOGIN_HINT,
showBackButton = false,
),
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
@ -265,7 +283,11 @@ class OnBoardingPresenterTest {
}
private fun createPresenter(
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
params: OnBoardingNode.Params = OnBoardingNode.Params(
accountProvider = null,
loginHint = null,
showBackButton = false,
),
buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
@ -287,7 +309,6 @@ private fun createPresenter(
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
sessionStore = sessionStore,
accountProviderDataSource = accountProviderDataSource,
loginWithClassicPresenter = { aLoginWithClassicState() },
)
fun createLoginHelper(

View file

@ -1,214 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.impl.screens.onboarding.classic
import com.google.common.truth.Truth.assertThat
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.test.A_SECRET
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LoginWithClassicPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state - feature disabled - start is not invoked`() = runTest {
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = {
error("start should not be invoked when feature is disabled")
},
)
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithClassic).isFalse()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - feature enabled - start is invoked`() = runTest {
val startResult = lambdaRecorder<Unit> {}
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = startResult,
),
isFeatureEnabled = true,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithClassic).isFalse()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
val finalState = awaitItem()
assertThat(finalState.canLoginWithClassic).isFalse()
}
startResult.assertions().isCalledOnce()
}
@Test
fun `present - emit request data invokes the expected method`() = runTest {
val requestDataResult = lambdaRecorder<Unit> {}
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = {},
requestDataResult = requestDataResult,
),
isFeatureEnabled = true,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithClassic).isFalse()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
val nextState = awaitItem()
assertThat(nextState.canLoginWithClassic).isFalse()
nextState.eventSink(LoginWithClassicEvent.RefreshData)
}
requestDataResult.assertions().isCalledOnce()
}
@Test
fun `present - start login with wrong state emits an error`() = runTest {
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = {},
),
isFeatureEnabled = true,
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
val errorState = awaitItem()
assertThat(errorState.loginWithClassicAction.isFailure()).isTrue()
}
}
@Test
fun `present - start login with correct state - user cancel`() = runTest {
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
isFeatureEnabled = true,
)
presenter.test {
skipItems(2)
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
)
val readyState = awaitItem()
assertThat(readyState.canLoginWithClassic).isTrue()
readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
val confirmingState = awaitItem()
assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue()
assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID)
confirmingState.eventSink(LoginWithClassicEvent.CloseDialog)
val finalState = awaitItem()
assertThat(finalState.loginWithClassicAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - start login with correct state - user confirms`() = runTest {
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
isFeatureEnabled = true,
)
presenter.test {
skipItems(2)
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
)
val readyState = awaitItem()
assertThat(readyState.canLoginWithClassic).isTrue()
readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
val confirmingState = awaitItem()
assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue()
assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID)
confirmingState.eventSink(LoginWithClassicEvent.DoLoginWithClassic)
val loadingState = awaitItem()
assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.loginWithClassicAction.isSuccess()).isTrue()
}
}
@Test
fun `present - cannot sign in if a session with the same account already exists`() = runTest {
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
isFeatureEnabled = true,
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
)
)
),
)
presenter.test {
skipItems(2)
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
)
// No new item, because canLoginWithClassic is still false
}
}
@Test
fun `present - cannot sign in if the feature is disabled`() = runTest {
val elementClassicConnection = FakeElementClassicConnection()
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
isFeatureEnabled = false,
)
presenter.test {
skipItems(1)
// Note: it should not happen IRL
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID, secrets = A_SECRET)
)
// No new item, because canLoginWithClassic is still false
}
}
}
private fun createPresenter(
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
sessionStore: SessionStore = InMemorySessionStore(),
isFeatureEnabled: Boolean = false,
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.SignInWithClassic.key to isFeatureEnabled)
),
) = LoginWithClassicPresenter(
elementClassicConnection = elementClassicConnection,
sessionStore = sessionStore,
featureFlagService = featureFlagService,
)

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
interface ServiceBinder {
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
fun unbindService(conn: ServiceConnection)
}
@ContributesBinding(AppScope::class)
class DefaultServiceBinder(
@ApplicationContext private val context: Context,
) : ServiceBinder {
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
return context.bindService(service, conn, flags)
}
override fun unbindService(conn: ServiceConnection) {
context.unbindService(conn)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.architecture.appyx
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Replace
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
/**
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
* and slide transition for all other cases.
*/
private class FaderOrSliderTransitionHandler<NavTarget>(
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
): Modifier {
val operation = descriptor.operation
val useFader = operation is Replace || operation is NewRoot
val handler = if (useFader) fader else slider
return handler.createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
FaderOrSliderTransitionHandler(slider, fader)
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.avatar
import android.graphics.Bitmap
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
import timber.log.Timber
// For user avatar only.
@Composable
fun BitmapAvatar(
avatarData: AvatarData,
bitmap: Bitmap?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val avatarShape = AvatarType.User.avatarShape()
when {
bitmap == null -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
else -> {
val size = avatarData.size.dp
SubcomposeAsyncImage(
model = bitmap,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
}
}
}
}

View file

@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.onboarding.classic
package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
class ConfirmingLoginWithElementClassic(
data class ElementClassicSession(
val userId: UserId,
) : AsyncAction.Confirming
val homeserverUrl: String?,
val secrets: String?,
val roomKeysVersion: String?,
val doesContainBackupKey: Boolean,
)

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
interface MatrixAuthenticationService {
/**
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Set the existing data about Element Classic session, if any.
*/
fun setElementClassicSession(session: ElementClassicSession?)
/**
* Check if the provided secrets from Element Classic session contain a key backup.
*/
fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.core
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UserIdTest {
@Test
fun `valid user id`() {
val userId = UserId("@alice:example.org")
assertThat(userId.extractedDisplayName).isEqualTo("alice")
assertThat(userId.domainName).isEqualTo("example.org")
}
}

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
import timber.log.Timber
import uniffi.matrix_sdk.OAuthAuthorizationData
import kotlin.time.Duration.Companion.seconds
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
private val passphraseGenerator: PassphraseGenerator,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
client.login(
username = username,
password = password,
initialDeviceName = "Element X Android",
deviceId = null,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
elementClassicSession
?.takeIf {
// Note: the SDK will also do this check
it.userId.value == client.userId()
}
?.let {
val secrets = it.secrets
val roomKeysVersion = it.roomKeysVersion
if (secrets == null || roomKeysVersion == null) {
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
} else {
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
runCatchingExceptions {
SecretsBundleWithUserId.fromStr(
userId = it.userId.value,
bundle = secrets,
backupInfo = roomKeysVersion,
).use { secretsBundle ->
client.encryption().importSecretsBundle(secretsBundle)
}
}.onFailure { failure ->
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
}
}
}
}
override fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean {
return try {
SecretsBundleWithUserId.fromStr(
userId = userId.value,
bundle = secrets,
backupInfo = backupInfo,
).use { secretsBundle ->
secretsBundle.containsBackupKey()
}
} catch (failure: Exception) {
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
false
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
}
}
override fun setElementClassicSession(session: ElementClassicSession?) {
elementClassicSession = session
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(callbackUrl)
client.loginWithOidcCallback(
callbackUrl = callbackUrl,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
) : MatrixAuthenticationService {
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
fun givenMatrixClient(matrixClient: MatrixClient) {
this.matrixClient = matrixClient
}
override fun setElementClassicSession(session: ElementClassicSession?) {
setElementClassicSessionResult(session)
}
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
}
}

View file

@ -31,6 +31,9 @@ forbiddenTerms = {
# We explicitly want to mention Element Pro in these 2:
"screen_change_server_error_element_pro_required_title",
"screen_change_server_error_element_pro_required_message",
# Contains "Element Classic"
"screen_missing_key_backup_open_element_classic",
"screen_missing_key_backup_step_1",
]
}

View file

@ -170,6 +170,8 @@
"name" : ":features:login:impl",
"includeRegex" : [
"screen_onboarding_.*",
"screen\\.onboarding\\..*",
"screen\\.missing_key_backup\\..*",
"screen_login_.*",
"screen_server_confirmation_.*",
"screen_change_server_.*",