diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 745ab390b24..0e458d3b9cf 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -252,7 +252,8 @@ class RootFlowNode( val transitionHandler = rememberDelegateTransitionHandler { navTarget -> when (navTarget) { is NavTarget.SplashScreen, - is NavTarget.LoggedInFlow -> backstackFader + is NavTarget.LoggedInFlow, + is NavTarget.NotLoggedInFlow -> backstackFader else -> backstackSlider } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 928d98c2446..bc928a936ff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -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( 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(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() { - callback.onDone() + if (navTarget.showBackButton) { + backstack.pop() + } else { + callback.onDone() + } } } val params = inputs() val inputs = OnBoardingNode.Params( accountProvider = params.accountProvider, loginHint = params.loginHint, + showBackButton = navTarget.showBackButton, ) createNode(buildContext, listOf(callback, inputs)) } @@ -191,7 +231,7 @@ class LoginFlowNode( } override fun navigateToLoginPassword() { - backstack.push(NavTarget.LoginPassword) + backstack.push(NavTarget.LoginPassword()) } } createNode(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(buildContext, plugins = listOf(callback)) } - NavTarget.LoginPassword -> { - createNode(buildContext) + is NavTarget.LoginPassword -> { + val inputs = LoginPasswordNode.Inputs( + initialLogin = navTarget.initialLogin, + ) + createNode(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()) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt new file mode 100644 index 00000000000..5486619e5d1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt @@ -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, + private val fader: ModifierTransitionHandler, +) : ModifierTransitionHandler() { + override fun createModifier( + modifier: Modifier, + transition: Transition, + descriptor: TransitionDescriptor + ): 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 { + val slider = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val fader = rememberBackstackFader( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + return rememberDelegateTransitionHandler { + LoginFlowTransitionHandler(slider, fader) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt new file mode 100644 index 00000000000..dfddd1d496f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -0,0 +1,411 @@ +/* + * 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() + val stateFlow: StateFlow +} + +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 + + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() + + private val elementClassicComponent = ComponentName( + BuildConfig.elementClassicPackage, + ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, + ) + + /** + * 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—that is, its process crashed. + messenger = null + bound = false + } + } + + override fun start() { + Timber.tag(loggerTag.value).d("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(elementClassicComponent) + 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).d("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).d("requestSession()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).d("The messenger is null, can't request data") + // Do not emit error, else the regular on boarding flow will be displayed + } 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())) + } + } + } + } + + private fun requestAvatar(userId: UserId) { + Timber.tag(loggerTag.value).d("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") + } + } + } + } + + /** + * 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) + val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId + if (userId != null) { + // Step 2, request the avatar + requestAvatar(userId) + } + } + } + + @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) + // If the avatar is identical to the current one, do not emit a new state to avoid unnecessary recompositions + // and blink on the avatar image + if (avatar == null || !avatar.sameAs(currentState.avatar)) { + 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).d("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 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 { + var secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } + val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR) + .also { + if (secrets != null && it == null) { + Timber.tag(loggerTag.value).w("Room keys version is null, outdated version of Element Classic, ignore secrets") + // In this case, just ignore the secrets, the SDK will not accept them anyway + secrets = null + } + } + ?.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) + Timber.tag(loggerTag.value).d( + buildString { + append("Receiving session $userId ($displayName) from Element Classic, with secrets: ") + append(secrets != null) + append(", with roomKeysVersion: ") + append(roomKeysVersion != null) + append(", with valid backup key: ") + append(doesContainBackupKey) + } + ) + // Ensure avatar is not lost when refreshing the data + val currentAvatar = (stateFlow.value as? ElementClassicConnectionState.ElementClassicReady) + ?.takeIf { it.elementClassicSession.userId == userId } + ?.avatar + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = userId, + homeserverUrl = homeserverUrl, + secrets = secrets, + roomKeysVersion = roomKeysVersion, + doesContainBackupKey = doesContainBackupKey, + ), + displayName = displayName, + avatar = currentAvatar, + ) + } + } + } + + // 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" + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt index 12b9106b711..4523e6f45e8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt @@ -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 - - @Binds - fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt index a62919e7054..78be770bfc5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt @@ -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 diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt index 87010a4a305..c6d6d76486f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter( loginHelper.submit( isAccountCreation = false, homeserverUrl = it.url, + resolvedHomeserverUrl = null, loginHint = null, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt new file mode 100644 index 00000000000..f2ff998652c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt @@ -0,0 +1,149 @@ +/* + * 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.lifecycle.subscribe +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, + private val classicFlowNodeHelper: ClassicFlowNodeHelper, +) : BaseFlowNode( + 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() + lifecycle.subscribe( + onResume = { + classicFlowNodeHelper.onResume() + }, + ) + } + + 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(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(buildContext, plugins = listOf(inputs, callback)) + } + NavTarget.MissingKeyBackup -> { + val callback = object : MissingKeyBackupNode.Callback { + override fun navigateBack() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView( + modifier = modifier, + transitionHandler = rememberFaderOrSliderTransitionHandler(), + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt new file mode 100644 index 00000000000..a5bc74c5e41 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt @@ -0,0 +1,75 @@ +/* + * 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf + +@Inject +class ClassicFlowNodeHelper( + private val elementClassicConnection: ElementClassicConnection, + private val sessionStore: SessionStore, +) { + fun onResume() { + elementClassicConnection.requestSession() + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun navigationEventFlow(): Flow { + return elementClassicConnection.stateFlow + .distinctUntilChangedBy { + // Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar + if (it is ElementClassicConnectionState.ElementClassicReady) { + it.copy(avatar = null) + } else { + it + } + } + .flatMapLatest { elementClassicConnectionState -> + when (elementClassicConnectionState) { + ElementClassicConnectionState.Idle -> { + // 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. + flow { + emit(NavigationEvent.Idle) + delay(5_000) + emit(NavigationEvent.NavigateToOnBoarding) + } + } + ElementClassicConnectionState.ElementClassicNotFound, + ElementClassicConnectionState.ElementClassicReadyNoSession, + is ElementClassicConnectionState.Error -> { + flowOf(NavigationEvent.NavigateToOnBoarding) + } + is ElementClassicConnectionState.ElementClassicReady -> { + val existingSessions = sessionStore.sessionsFlow().toUserListFlow().first() + if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) { + flowOf(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. + flowOf(NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId)) + } + } + } + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt new file mode 100644 index 00000000000..cddca0015b8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt @@ -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 +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt new file mode 100644 index 00000000000..6ba9b2142ad --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt @@ -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.loginwithclassic + +sealed interface LoginWithClassicEvent { + data object Submit : LoginWithClassicEvent + data object ClearError : LoginWithClassicEvent +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt new file mode 100644 index 00000000000..55716c2cf7a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt @@ -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() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt new file mode 100644 index 00000000000..c42248a3f8a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt @@ -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, + 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, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt new file mode 100644 index 00000000000..90a528c3ae2 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt @@ -0,0 +1,102 @@ +/* + * 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 { + @AssistedFactory + interface Factory { + fun create( + userId: UserId, + navigator: LoginWithClassicNavigator, + ): LoginWithClassicPresenter + } + + @Composable + override fun present(): LoginWithClassicState { + val coroutineScope = rememberCoroutineScope() + var loginWithClassicAction by remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + val loginMode by loginHelper.collectLoginMode() + val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState() + + fun handleEvent(event: LoginWithClassicEvent) { + when (event) { + 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, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt new file mode 100644 index 00000000000..275a444768f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt @@ -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, + val loginMode: AsyncData, + val eventSink: (LoginWithClassicEvent) -> Unit, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt new file mode 100644 index 00000000000..d8dcfeb072f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt @@ -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 { + override val values: Sequence + 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 = AsyncAction.Uninitialized, + loginMode: AsyncData = AsyncData.Uninitialized, + eventSink: (LoginWithClassicEvent) -> Unit = {}, +) = LoginWithClassicState( + isElementPro = isElementPro, + userId = userId, + displayName = displayName, + avatar = avatar, + loginWithClassicAction = loginWithClassicAction, + loginMode = loginMode, + eventSink = eventSink, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt new file mode 100644 index 00000000000..6b5c48f1ec5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt @@ -0,0 +1,220 @@ +/* + * 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 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, +) { + 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 = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt new file mode 100644 index 00000000000..45c16e7cde3 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt @@ -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, + 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, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt new file mode 100644 index 00000000000..593c50dcb55 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt @@ -0,0 +1,25 @@ +/* + * 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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta + +@Inject +class MissingKeyBackupPresenter( + private val buildMeta: BuildMeta, +) : Presenter { + @Composable + override fun present(): MissingKeyBackupState { + return MissingKeyBackupState( + appName = buildMeta.applicationName, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt new file mode 100644 index 00000000000..31eaf015a0f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt @@ -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 + +data class MissingKeyBackupState( + val appName: String, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt new file mode 100644 index 00000000000..2c6a09b3edf --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt @@ -0,0 +1,24 @@ +/* + * 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 { + override val values: Sequence + get() = sequenceOf( + aMissingKeyBackupState(), + // Add other state here + ) +} + +fun aMissingKeyBackupState( + appName: String = "AppName", +) = MissingKeyBackupState( + appName = appName, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt new file mode 100644 index 00000000000..c4c9c5f2864 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt @@ -0,0 +1,85 @@ +/* + * 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 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 kotlinx.collections.immutable.persistentListOf + +@Composable +fun MissingKeyBackupView( + state: MissingKeyBackupState, + onBackClick: () -> Unit, + onOpenClassicClick: () -> Unit, + modifier: Modifier = Modifier, +) { + 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 = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt new file mode 100644 index 00000000000..adb8c2d7282 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt @@ -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, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + RootView(modifier) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt new file mode 100644 index 00000000000..f1ca4b048a8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt @@ -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() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index c38da7b11c0..bf06613830f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -48,6 +48,7 @@ class ConfirmAccountProviderPresenter( loginHelper.submit( isAccountCreation = params.isAccountCreation, homeserverUrl = accountProvider.url, + resolvedHomeserverUrl = null, loginHint = null, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt index c6ce16141dc..853b8a74231 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -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, - 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() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt index b1ddc6e5b80..f26f342a426 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -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 { + @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() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 1ded677c136..030d65eae9a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -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, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 741f65234ec..60fa34f4d02 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -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, ) : Presenter { @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, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index 703120b260e..a1c49e0d452 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -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, - val loginWithClassicState: LoginWithClassicState, val eventSink: (OnBoardingEvents) -> Unit, ) { val submitEnabled: Boolean diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index 76f8eb35135..ec764046862 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -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 { 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 = 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, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index d590f1fec81..6549e21e411 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -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,18 +119,36 @@ private fun AddFirstAccountScaffold( state: OnBoardingState, loginView: @Composable () -> Unit, buttons: @Composable () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { OnBoardingPage( modifier = modifier, renderBackground = state.onBoardingLogoResId == null, content = { - if (state.onBoardingLogoResId != null) { - OnBoardingLogo( - onBoardingLogoResId = state.onBoardingLogoResId, - ) - } else { - OnBoardingContent(state = state) + Box( + modifier = Modifier.fillMaxSize(), + ) { + if (state.onBoardingLogoResId != null) { + OnBoardingLogo( + onBoardingLogoResId = state.onBoardingLogoResId, + ) + } 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() }, @@ -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), diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt deleted file mode 100644 index f895dd781e1..00000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt +++ /dev/null @@ -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 -} - -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—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 happen 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.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" - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt deleted file mode 100644 index 75a9496a027..00000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt +++ /dev/null @@ -1,15 +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 - -sealed interface LoginWithClassicEvent { - data object RefreshData : LoginWithClassicEvent - data object StartLoginWithClassic : LoginWithClassicEvent - data object DoLoginWithClassic : LoginWithClassicEvent - data object CloseDialog : LoginWithClassicEvent -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt deleted file mode 100644 index ef352794cbd..00000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt +++ /dev/null @@ -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 { - @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.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, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt deleted file mode 100644 index d2706fc24a5..00000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt +++ /dev/null @@ -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, - val eventSink: (LoginWithClassicEvent) -> Unit, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt deleted file mode 100644 index 73f68e5d61b..00000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt +++ /dev/null @@ -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 = AsyncAction.Uninitialized, - eventSink: (LoginWithClassicEvent) -> Unit = {}, -) = LoginWithClassicState( - canLoginWithClassic = canLoginWithClassic, - loginWithClassicAction = loginWithClassicAction, - eventSink = eventSink, -) diff --git a/features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png b/features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png new file mode 100644 index 00000000000..67684ee9449 Binary files /dev/null and b/features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png differ diff --git a/features/login/impl/src/main/res/drawable-xxhdpi/element_pro_logo.png b/features/login/impl/src/main/res/drawable-xxhdpi/element_pro_logo.png new file mode 100644 index 00000000000..45d11b5f2e7 Binary files /dev/null and b/features/login/impl/src/main/res/drawable-xxhdpi/element_pro_logo.png differ diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 832c3b7f71d..b4dee327216 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -37,11 +37,19 @@ "Matrix is an open network for secure, decentralised communication." "Welcome back!" "Sign in to %1$s" + "Open Element Classic" + "Open Element Classic on your device" + "Go to Settings > Security & Privacy" + "In Cryptography keys management, select Encrypted message recovery" + "Follow the instructions to enable your key storage" + "Come back to %1$s" + "Enable your key storage before proceeding to %1$s" "Version %1$s" "Sign in manually" "Sign in to %1$s" "Sign in with QR code" "Create account" + "Welcome back" "Welcome to the fastest %1$s ever. Supercharged for speed and simplicity." "Welcome to %1$s. Supercharged, for speed and simplicity." "Be in your element" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index 953693b40d9..1d24d775bec 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -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 { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt new file mode 100644 index 00000000000..8ea1b2e3d39 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -0,0 +1,505 @@ +/* + * 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 { } + 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 { } + 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 secret but without room keys version Element Classic is outdated and the secret is ignored`() = 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, A_HOMESERVER_URL) + putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET) + putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, null) + 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 = null, + roomKeysVersion = null, + doesContainBackupKey = false, + ), + displayName = A_USER_NAME, + avatar = null, + ) + ) + } + } + + @Test + fun `when session is received with secret but with empty room keys version, doesContainBackupKey is false`() = 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, A_HOMESERVER_URL) + putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET) + putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, "") + 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 = null, + doesContainBackupKey = false, + ), + 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, + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt similarity index 84% rename from features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt rename to features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt index 2c41d2ed0f5..227aa514b3b 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt @@ -5,7 +5,7 @@ * 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.tests.testutils.lambda.lambdaError import kotlinx.coroutines.flow.MutableStateFlow @@ -15,12 +15,12 @@ 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() }, initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle ) : ElementClassicConnection { override fun start() = startResult() override fun stop() = stopResult() - override fun requestData() = requestDataResult() + override fun requestSession() = requestSessionResult() private val mutableStateFlow = MutableStateFlow(initialState) override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() suspend fun emitState(state: ElementClassicConnectionState) { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt new file mode 100644 index 00000000000..0a24f13fa78 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt @@ -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() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt new file mode 100644 index 00000000000..9039743fd5c --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt @@ -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, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt new file mode 100644 index 00000000000..017fd1b633c --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt @@ -0,0 +1,287 @@ +/* + * 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.advanceTimeBy +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 `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) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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)) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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) + ) + ) + advanceTimeBy(10_000) + 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)) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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)) + advanceTimeBy(10_000) + expectNoEvents() + } + } + + @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, + ) + ) + advanceTimeBy(10_000) + expectNoEvents() + } + } +} + +private fun createHelper( + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), + sessionStore: SessionStore = InMemorySessionStore(), +) = ClassicFlowNodeHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt new file mode 100644 index 00000000000..e5ff91aa91e --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt @@ -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() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt new file mode 100644 index 00000000000..6b2a4fb0e1e --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt @@ -0,0 +1,263 @@ +/* + * 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 - 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 { } + 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 { } + 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, + ), +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt new file mode 100644 index 00000000000..447b0ba77b3 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt @@ -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.features.login.impl.screens.classic.missingkeybackup + +import com.google.common.truth.Truth.assertThat +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.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) + } + } +} + +private fun createPresenter( + buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), +) = MissingKeyBackupPresenter( + buildMeta = buildMeta, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 92099180ec8..31a835cb8c0 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -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, ) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 1e971ef2656..1fdfb7e0701 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -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( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt deleted file mode 100644 index 437e65f21d3..00000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt +++ /dev/null @@ -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 {} - 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 {} - 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, -) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt new file mode 100644 index 00000000000..ba71ca131f9 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt @@ -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) + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt new file mode 100644 index 00000000000..c8da7439a5f --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt @@ -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( + private val slider: ModifierTransitionHandler, + private val fader: ModifierTransitionHandler, +) : ModifierTransitionHandler() { + override fun createModifier( + modifier: Modifier, + transition: Transition, + descriptor: TransitionDescriptor + ): 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 rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler { + val slider = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val fader = rememberBackstackFader( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + return rememberDelegateTransitionHandler { + FaderOrSliderTransitionHandler(slider, fader) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt new file mode 100644 index 00000000000..f25174b7b0c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt @@ -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, + ) + } + } + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt similarity index 56% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt index 5fae0afdd59..d094019db29 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt @@ -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, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index 1c574ad467f..7c826682423 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -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 + /** + * 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. */ diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt new file mode 100644 index 00000000000..eb97ce0d8c7 --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt @@ -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") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 7cd9fbedf53..9fe7c7cd1f9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -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 = 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, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index c4acccb55cd..238ad2663d7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -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 Unit, Result> { _, _ -> Result.success(A_SESSION_ID) }, private val importCreatedSessionLambda: (ExternalSession) -> Result = { lambdaError() }, private val setHomeserverResult: (String) -> Result = { 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) + } } diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png new file mode 100644 index 00000000000..adbcd16ec1c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d861dd7397c0e15091e022d956c1955d86529fa0cc39e08c3c645d91e5023e0e +size 77677 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png new file mode 100644 index 00000000000..df7c95510d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0acaebca757642346f3381601f044a55d02749575150364e232f772ba0167e1 +size 73811 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png new file mode 100644 index 00000000000..7bed006a975 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54c841ffefb3d053bb74e6f88a9aa7cff5d39b4854c92de3b507552a9a4226bc +size 68693 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png new file mode 100644 index 00000000000..94a17495172 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba1f4395b4c32e7edead84b1e22957cc8f973121207d7000419a8f4c314f5b8 +size 65936 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png new file mode 100644 index 00000000000..4a5a13a36a6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef35fd3f5346870f11120a37c9db969453b7594bf9a0ccc71fe43e7fdade488 +size 62532 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png new file mode 100644 index 00000000000..1b69f8f6a1a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1501c2591f7df68404285770b1dad67360dddce074d4ce1c71223ea0baa0d1e4 +size 60873 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png new file mode 100644 index 00000000000..e3e5480addd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72e73b036458ee32e207f711cf6656fe7646b23d3d9e096e62932c828dd53189 +size 5244 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png new file mode 100644 index 00000000000..65822643839 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518818c549548b6304d2960242ce7251bb609fa439928539a7556c33223ca8ba +size 5251 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png new file mode 100644 index 00000000000..b5f0eb7fcfc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5015f504040a0141d40bc14bf8a3a3be43c9c95a3702a6dc53bb253746e5a3aa +size 311553 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png new file mode 100644 index 00000000000..4669c0b9723 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8972af96ba5624f92c826ef0e20595b6193a44b6b0a0ea03cb133c516a93a90e +size 391678 diff --git a/tools/localazy/checkForbiddenTerms.py b/tools/localazy/checkForbiddenTerms.py index e190fcea689..123246ffd05 100755 --- a/tools/localazy/checkForbiddenTerms.py +++ b/tools/localazy/checkForbiddenTerms.py @@ -31,6 +31,9 @@ # 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", ] } diff --git a/tools/localazy/config.json b/tools/localazy/config.json index d3e44b7c088..b38268e5f79 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -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_.*",