From 683b1fe9d576a4f8fe5ba6c9d8683d438d79f877 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 24 Feb 2026 16:37:03 +0100 Subject: [PATCH 1/9] Fix typo --- .../impl/screens/onboarding/classic/ElementClassicConnection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f895dd781e1..d0ebbb336f4 100644 --- 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 @@ -107,7 +107,7 @@ class DefaultElementClassicConnection( 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 + // This happens when the app is not installed Timber.tag(loggerTag.value).d("Binding returned false") mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) } From 8c5caabed488e28ae725d515a185c1bce49d702b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 4 Mar 2026 16:11:37 +0100 Subject: [PATCH 2/9] Sign in with Classic --- .../io/element/android/appnav/RootFlowNode.kt | 3 +- .../features/login/impl/LoginFlowNode.kt | 73 ++- .../login/impl/LoginFlowTransitionHandler.kt | 54 +++ .../impl/classic/ElementClassicConnection.kt | 382 ++++++++++++++++ .../features/login/impl/di/LoginModule.kt | 5 - .../features/login/impl/login/LoginHelper.kt | 11 +- .../ChooseAccountProviderPresenter.kt | 1 + .../impl/screens/classic/ClassicFlowNode.kt | 143 ++++++ .../screens/classic/ClassicFlowNodeHelper.kt | 78 ++++ .../impl/screens/classic/NavigationEvent.kt | 18 + .../LoginWithClassicEvent.kt | 7 +- .../LoginWithClassicNavigator.kt | 12 + .../loginwithclassic/LoginWithClassicNode.kt | 69 +++ .../LoginWithClassicPresenter.kt | 109 +++++ .../loginwithclassic/LoginWithClassicState.kt | 26 ++ .../LoginWithClassicStateProvider.kt | 41 ++ .../loginwithclassic/LoginWithClassicView.kt | 226 +++++++++ .../missingkeybackup/MissingKeyBackupEvent.kt | 12 + .../missingkeybackup/MissingKeyBackupNode.kt | 70 +++ .../MissingKeyBackupPresenter.kt | 45 ++ .../missingkeybackup/MissingKeyBackupState.kt | 13 + .../MissingKeyBackupStateProvider.kt | 26 ++ .../missingkeybackup/MissingKeyBackupView.kt | 92 ++++ .../impl/screens/classic/root/RootNode.kt | 30 ++ .../impl/screens/classic/root/RootView.kt | 41 ++ .../ConfirmAccountProviderPresenter.kt | 1 + .../loginpassword/LoginPasswordNode.kt | 11 +- .../loginpassword/LoginPasswordPresenter.kt | 20 +- .../impl/screens/onboarding/OnBoardingNode.kt | 2 + .../screens/onboarding/OnBoardingPresenter.kt | 7 +- .../screens/onboarding/OnBoardingState.kt | 3 +- .../onboarding/OnBoardingStateProvider.kt | 9 +- .../impl/screens/onboarding/OnBoardingView.kt | 89 ++-- .../classic/ElementClassicConnection.kt | 260 ----------- .../classic/LoginWithClassicPresenter.kt | 103 ----- .../classic/LoginWithClassicState.kt | 16 - .../classic/LoginWithClassicStateProvider.kt | 20 - .../res/drawable-xxhdpi/element_foss_logo.png | Bin 0 -> 13888 bytes .../res/drawable-xxhdpi/element_pro_logo.png | Bin 0 -> 26428 bytes .../impl/src/main/res/values/localazy.xml | 8 + .../login/impl/DefaultLoginEntryPointTest.kt | 2 + .../DefaultElementClassicConnectionTest.kt | 429 ++++++++++++++++++ .../classic/FakeElementClassicConnection.kt | 9 +- .../login/impl/classic/FakeServiceBinder.kt | 26 ++ .../features/login/impl/classic/Fixtures.kt | 39 ++ .../classic/ClassicFlowNodeHelperTest.kt | 278 ++++++++++++ .../FakeLoginWithClassicNavigator.kt | 18 + .../LoginWithClassicPresenterTest.kt | 293 ++++++++++++ .../MissingKeyBackupPresenterTest.kt | 55 +++ .../LoginPasswordPresenterTest.kt | 17 + .../onboarding/OnBoardingPresenterTest.kt | 31 +- .../classic/LoginWithClassicPresenterTest.kt | 214 --------- .../androidutils/service/ServiceBinder.kt | 33 ++ .../appyx/FaderOrSliderTransitionHandler.kt | 54 +++ .../components/avatar/BitmapAvatar.kt | 79 ++++ .../matrix/api/auth/ElementClassicSession.kt | 11 +- .../api/auth/MatrixAuthenticationService.kt | 15 + .../libraries/matrix/api/core/UserIdTest.kt | 20 + .../auth/RustMatrixAuthenticationService.kt | 72 ++- .../auth/FakeMatrixAuthenticationService.kt | 12 + tools/localazy/checkForbiddenTerms.py | 3 + tools/localazy/config.json | 2 + 62 files changed, 3120 insertions(+), 728 deletions(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowTransitionHandler.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/NavigationEvent.kt rename features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/{onboarding/classic => classic/loginwithclassic}/LoginWithClassicEvent.kt (54%) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNavigator.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicState.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootNode.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/root/RootView.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt create mode 100644 features/login/impl/src/main/res/drawable-xxhdpi/element_foss_logo.png create mode 100644 features/login/impl/src/main/res/drawable-xxhdpi/element_pro_logo.png create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt rename features/login/impl/src/test/kotlin/io/element/android/features/login/impl/{screens/onboarding => }/classic/FakeElementClassicConnection.kt (72%) create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeServiceBinder.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/Fixtures.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/FakeLoginWithClassicNavigator.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt delete mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/service/ServiceBinder.kt create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/FaderOrSliderTransitionHandler.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/BitmapAvatar.kt rename features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt => libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/ElementClassicSession.kt (56%) create mode 100644 libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/UserIdTest.kt 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..5e838094d39 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -0,0 +1,382 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.classic + +import android.content.ComponentName +import android.content.Context.BIND_AUTO_CREATE +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.Bitmap +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import androidx.annotation.VisibleForTesting +import androidx.core.os.BundleCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.login.impl.BuildConfig +import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.auth.ElementClassicSession +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +interface ElementClassicConnection { + fun start() + fun stop() + fun requestSession() + fun requestAvatar(userId: UserId) + val stateFlow: StateFlow +} + +sealed interface ElementClassicConnectionState { + object Idle : ElementClassicConnectionState + object ElementClassicNotFound : ElementClassicConnectionState + object ElementClassicReadyNoSession : ElementClassicConnectionState + data class ElementClassicReady( + val elementClassicSession: ElementClassicSession, + val displayName: String?, + val avatar: Bitmap?, + ) : ElementClassicConnectionState + + data class Error(val error: String) : ElementClassicConnectionState +} + +private val loggerTag = LoggerTag("ECConnection") + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultElementClassicConnection( + private val serviceBinder: ServiceBinder, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val matrixAuthenticationService: MatrixAuthenticationService, + private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, +) : ElementClassicConnection { + // Messenger for communicating with the service. + private var messenger: Messenger? = null + + // Target we publish for external service to send messages to IncomingHandler. + private val incomingMessenger: Messenger = Messenger(IncomingHandler()) + + // Flag indicating whether we have called bind on the service. + private var bound: Boolean = false + + /** + * Class for interacting with the main interface of the service. + */ + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + Timber.tag(loggerTag.value).d("onServiceConnected") + // This is called when the connection with the service has been + // established, giving us the object we can use to + // interact with the service. We are communicating with the + // service using a Messenger, so here we get a client-side + // representation of that from the raw IBinder object. + messenger = Messenger(service) + bound = true + // Request the data as soon as possible + requestSession() + } + + override fun onServiceDisconnected(className: ComponentName) { + Timber.tag(loggerTag.value).d("onServiceDisconnected") + // This is called when the connection with the service has been + // unexpectedly disconnected—that is, its process crashed. + messenger = null + bound = false + } + } + + override fun start() { + Timber.tag(loggerTag.value).w("start()") + coroutineScope.launch { + // Establish a connection with the service. We use an explicit + // class name because there is no reason to be able to let other + // applications replace our component. + try { + val intentService = Intent() + intentService.setComponent(getElementClassicComponent()) + if (serviceBinder.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { + Timber.tag(loggerTag.value).d("Binding returned true") + } else { + // This happens when the app is not installed + Timber.tag(loggerTag.value).d("Binding returned false") + emitState(ElementClassicConnectionState.ElementClassicNotFound) + } + } catch (e: SecurityException) { + Timber.tag(loggerTag.value).e(e, "Can't bind to Service") + emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + + override fun stop() { + Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") + if (bound) { + // Detach our existing connection. + serviceBinder.unbindService(serviceConnection) + bound = false + } + coroutineScope.launch { + emitState(ElementClassicConnectionState.Idle) + } + } + + override fun requestSession() { + Timber.tag(loggerTag.value).w("requestSession()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).w("The messenger is null, can't request data") + // Do not emit error, else the regular on boarding flow will be displayed + // emitState(ElementClassicConnectionState.Error("The messenger is null, can't request data")) + } else { + try { + // Get the data + val msg = Message.obtain(null, MSG_GET_SESSION) + msg.replyTo = incomingMessenger + finalMessenger.send(msg) + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + Timber.tag(loggerTag.value).e(e, "RemoteException") + emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + } + + override fun requestAvatar(userId: UserId) { + Timber.tag(loggerTag.value).w("requestAvatar()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).w("The messenger is null, can't request extra data") + } else { + try { + // Get the data + val msg = Message.obtain(null, MSG_GET_AVATAR) + msg.data = Bundle().apply { + putString(KEY_USER_ID_STR, userId.value) + } + msg.replyTo = incomingMessenger + finalMessenger.send(msg) + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + Timber.tag(loggerTag.value).e(e, "RemoteException") + } + } + } + } + + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() + + /** + * Handler of incoming messages from service. + */ + @Suppress("DEPRECATION") + inner class IncomingHandler : Handler() { + override fun handleMessage(msg: Message) { + Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}") + when (msg.what) { + MSG_GET_SESSION -> onSessionReceived(msg.data) + MSG_GET_AVATAR -> onAvatarReceived(msg.data) + else -> { + Timber.tag(loggerTag.value).w("Received unknown message ${msg.what}") + super.handleMessage(msg) + } + } + } + } + + @VisibleForTesting + fun onSessionReceived(data: Bundle) { + // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied + val state = data.toElementClassicConnectionState() + coroutineScope.launch { + val updatedState = ensureHomeserverIsSupported(state) + emitState(updatedState) + } + } + + @VisibleForTesting + fun onAvatarReceived(data: Bundle) { + val currentState = stateFlow.value + if (currentState is ElementClassicConnectionState.ElementClassicReady) { + // Check that the userId is still the same + val userId = data.getString(KEY_USER_ID_STR) + if (userId != currentState.elementClassicSession.userId.value) { + Timber.tag(loggerTag.value).w( + "Received profile data for userId $userId but current" + + " userId is ${currentState.elementClassicSession.userId}, ignoring" + ) + } else { + val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java) + val updatedState = currentState.copy( + avatar = avatar, + ) + coroutineScope.launch { + emitState(updatedState) + } + } + } else { + Timber.tag(loggerTag.value).w("Received profile data but current state is not ElementClassicReady: %s", currentState) + } + } + + private suspend fun ensureHomeserverIsSupported(state: ElementClassicConnectionState): ElementClassicConnectionState { + return if (state is ElementClassicConnectionState.ElementClassicReady) { + val elementXCanConnect = setOfNotNull( + // Try with the domain name first + state.elementClassicSession.userId.domainName?.ensureProtocol(), + // Then try with the resolved homeserver URL, if provided and distinct + state.elementClassicSession.homeserverUrl, + ).any { url -> + val isCompatible = homeServerLoginCompatibilityChecker.check(url) + .onFailure { + Timber.tag(loggerTag.value).w(it, "Failed to check compatibility with homeserver: $url") + } + .getOrNull() == true + if (isCompatible) { + Timber.tag(loggerTag.value).d("Found compatible homeserver URL: %s", url) + } else { + Timber.tag(loggerTag.value).w("Homeserver URL is not compatible: %s", url) + } + isCompatible + } + if (elementXCanConnect) { + state + } else { + Timber.tag(loggerTag.value).w("Cannot import session because the homeserver is not compatible with Element X") + ElementClassicConnectionState.Error("The homeserver is not compatible with Element X") + } + } else { + state + } + } + + private suspend fun emitState(state: ElementClassicConnectionState) { + when (state) { + is ElementClassicConnectionState.Error -> { + Timber.tag(loggerTag.value).w("Error: %s", state.error) + } + is ElementClassicConnectionState.ElementClassicReady -> { + Timber.tag(loggerTag.value).d("Ready state for user: %s", state.elementClassicSession.userId) + } + ElementClassicConnectionState.ElementClassicReadyNoSession -> { + Timber.tag(loggerTag.value).d("No session from Element Classic") + } + ElementClassicConnectionState.ElementClassicNotFound -> { + Timber.tag(loggerTag.value).d("Element Classic not found") + } + ElementClassicConnectionState.Idle -> { + Timber.tag(loggerTag.value).d("Idle") + } + } + // Also give the Element Classic session info to the MatrixAuthenticationService + matrixAuthenticationService.setElementClassicSession( + session = (state as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession + ) + mutableStateFlow.emit(state) + } + + private fun getElementClassicComponent() = ComponentName( + BuildConfig.elementClassicPackage, + ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, + ) + + private fun Bundle.toElementClassicConnectionState(): ElementClassicConnectionState { + val error = getString(KEY_ERROR_STR) + return if (error != null) { + ElementClassicConnectionState.Error(error) + } else { + val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId) + if (userId == null) { + ElementClassicConnectionState.ElementClassicReadyNoSession + } else { + val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } + val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR)?.takeIf { it.isNotEmpty() } + val homeserverUrl = getString(KEY_HOMESERVER_URL_STR)?.takeIf { it.isNotEmpty() } + val displayName = getString(KEY_USER_DISPLAY_NAME_STR)?.takeIf { it.isNotEmpty() } + val doesContainBackupKey = secrets != null && + roomKeysVersion != null && + matrixAuthenticationService.doSecretsContainBackupKey(userId, secrets, roomKeysVersion) + ElementClassicConnectionState.ElementClassicReady( + elementClassicSession = ElementClassicSession( + userId = userId, + homeserverUrl = homeserverUrl, + secrets = secrets, + roomKeysVersion = roomKeysVersion, + doesContainBackupKey = doesContainBackupKey, + ), + displayName = displayName, + avatar = null, + ) + } + } + } + + // Everything in this companion object must match what is defined in Element Classic + companion object { + const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService" + + // Command to the service to get the userId/displayName/secrets of a verified session. + const val MSG_GET_SESSION = 1 + + // Command to the service to get the avatar oor the session. + const val MSG_GET_AVATAR = 2 + + // Keys for the bundle returned from the service + const val KEY_ERROR_STR = "error" + const val KEY_USER_ID_STR = "userId" + const val KEY_HOMESERVER_URL_STR = "homeserverUrl" + const val KEY_USER_DISPLAY_NAME_STR = "displayName" + + /** + * Key to extract the secrets from the bundle, as a Json string. + * Json will have this format: + * { + * "cross_signing" : { + * "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o", + * "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms", + * "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM" + * }, + * "backup" : { + * "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2", + * "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc", + * "backup_version" : "1" + * } + * } + */ + const val KEY_SECRETS_STR = "secrets" + const val KEY_ROOM_KEYS_VERSION_STR = "roomKeysVersion" + + // For the avatar + const val KEY_USER_AVATAR_PARCELABLE = "avatar" + } +} 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..8d79453318d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.screens.classic.loginwithclassic.LoginWithClassicNode +import io.element.android.features.login.impl.screens.classic.missingkeybackup.MissingKeyBackupNode +import io.element.android.features.login.impl.screens.classic.root.RootNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.rememberFaderOrSliderTransitionHandler +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class ClassicFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + 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() + } + + 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..f719fe50832 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelper.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic + +import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.take + +@Inject +class ClassicFlowNodeHelper( + private val elementClassicConnection: ElementClassicConnection, + private val sessionStore: SessionStore, +) { + // Ensure user is not stuck on the loading screen. + // If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds. + private val timeoutFLow = flow { + emit(false) + delay(5_000) + emit(true) + } + + fun navigationEventFlow(): Flow { + return combine( + timeoutFLow, + elementClassicConnection.stateFlow + .distinctUntilChangedBy { + // Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar + if (it is ElementClassicConnectionState.ElementClassicReady) { + it.copy(avatar = null) + } else { + it + } + }, + sessionStore.sessionsFlow().toUserListFlow() + // Take only 1 emission of the sessions, else when the user actually logged in it will trigger a navigation to OnBoarding. + .take(1), + ) { timeout, elementClassicConnectionState, existingSessions -> + when (elementClassicConnectionState) { + ElementClassicConnectionState.Idle -> { + if (timeout) { + NavigationEvent.NavigateToOnBoarding + } else { + NavigationEvent.Idle + } + } + ElementClassicConnectionState.ElementClassicNotFound, + ElementClassicConnectionState.ElementClassicReadyNoSession, + is ElementClassicConnectionState.Error -> { + NavigationEvent.NavigateToOnBoarding + } + is ElementClassicConnectionState.ElementClassicReady -> { + if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) { + NavigationEvent.NavigateToOnBoarding + } else { + // 2 cases when this can be run: + // First time this screen will be displayed + // Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic), + // and the app is resuming. + NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId) + } + } + } + } + } +} 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/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt similarity index 54% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicEvent.kt index 75a9496a027..e3c6ed782dd 100644 --- 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/classic/loginwithclassic/LoginWithClassicEvent.kt @@ -5,11 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.login.impl.screens.onboarding.classic +package io.element.android.features.login.impl.screens.classic.loginwithclassic sealed interface LoginWithClassicEvent { data object RefreshData : LoginWithClassicEvent - data object StartLoginWithClassic : LoginWithClassicEvent - data object DoLoginWithClassic : LoginWithClassicEvent - data object CloseDialog : LoginWithClassicEvent + data object Submit : LoginWithClassicEvent + data object ClearError : LoginWithClassicEvent } 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..6494ee741e0 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.loginwithclassic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.launch + +@AssistedInject +class LoginWithClassicPresenter( + @Assisted private val userId: UserId, + @Assisted private val navigator: LoginWithClassicNavigator, + private val loginHelper: LoginHelper, + private val elementClassicConnection: ElementClassicConnection, + private val accountProviderDataSource: AccountProviderDataSource, + private val buildMeta: BuildMeta, +) : Presenter { + @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.RefreshData -> { + // Request the avatar if not known yet + val currentState = elementClassicConnection.stateFlow.value + if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) { + elementClassicConnection.requestAvatar(userId) + } + } + LoginWithClassicEvent.Submit -> { + val currentState = elementClassicConnection.stateFlow.value + if (currentState is ElementClassicConnectionState.ElementClassicReady) { + if (currentState.elementClassicSession.secrets != null && + !currentState.elementClassicSession.doesContainBackupKey) { + navigator.navigateToMissingKeyBackup() + } else { + coroutineScope.launch { + loginWithClassicAction = AsyncAction.Loading + // Ensure that the current account provider is set + val elementClassicUserId = currentState.elementClassicSession.userId + val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol() + accountProviderDataSource.setUrl(accountProvider) + loginHelper.submit( + isAccountCreation = false, + homeserverUrl = accountProvider, + resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl, + loginHint = "mxid:" + elementClassicUserId.value, + ) + } + } + } else { + loginWithClassicAction = AsyncAction.Failure(IllegalStateException("Element Classic is not ready")) + } + } + LoginWithClassicEvent.ClearError -> { + loginWithClassicAction = AsyncAction.Uninitialized + loginHelper.clearError() + } + } + } + + val elementClassicReady = elementClassicConnectionState as? ElementClassicConnectionState.ElementClassicReady + return LoginWithClassicState( + isElementPro = buildMeta.isEnterpriseBuild, + userId = userId, + displayName = elementClassicReady?.displayName, + avatar = elementClassicReady?.avatar, + loginMode = loginMode, + loginWithClassicAction = loginWithClassicAction, + eventSink = ::handleEvent, + ) + } +} 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..aeb61946ff5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.loginwithclassic + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.login.LoginModeView +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.background.OnboardingBackground +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.BitmapAvatar +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginWithClassicView( + state: LoginWithClassicState, + onOtherOptionsClick: () -> Unit, + onOidcDetails: (OidcDetails) -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, + onCreateAccountContinue: (url: String) -> Unit, + modifier: Modifier = Modifier, +) { + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + state.eventSink(LoginWithClassicEvent.RefreshData) + } + + val isLoading by remember(state.loginMode) { + derivedStateOf { + state.loginMode is AsyncData.Loading + } + } + + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + background = { OnboardingBackground() }, + isScrollable = true, + header = { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(40.dp)) + Box( + modifier = Modifier + .size(54.dp) + .shadow(elevation = 10.dp, shape = RoundedCornerShape(15.dp)) + .background(ElementTheme.colors.bgCanvasDefault, shape = RoundedCornerShape(15.dp)), + contentAlignment = Alignment.Center, + ) { + val resId = if (state.isElementPro) { + R.drawable.element_pro_logo + } else { + R.drawable.element_foss_logo + } + Image( + modifier = Modifier.size(37.5.dp), + painter = painterResource(id = resId), + contentDescription = null, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.screen_onboarding_welcome_title), + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontHeadingMdBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + } + }, + content = { + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(40.dp)) + BitmapAvatar( + avatarData = AvatarData( + id = state.userId.value, + name = state.displayName, + // Not used here + url = null, + size = AvatarSize.UserHeader, + ), + bitmap = state.avatar, + ) + Spacer(Modifier.height(24.dp)) + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResource(R.string.screen_onboarding_welcome_back), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + // User display name + if (state.displayName != null) { + Text( + text = state.displayName, + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + } + // UserId + Text( + text = state.userId.value, + style = if (state.displayName == null) ElementTheme.typography.fontHeadingLgBold else ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + // Min spacing + Spacer(Modifier.height(45.dp)) + ButtonColumnMolecule { + Button( + text = stringResource(CommonStrings.action_continue), + showProgress = isLoading, + onClick = { + state.eventSink(LoginWithClassicEvent.Submit) + }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + OutlinedButton( + text = stringResource(CommonStrings.common_other_options), + onClick = onOtherOptionsClick, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + } + } + }, + footer = {}, + ) + + AsyncActionView( + async = state.loginWithClassicAction, + onErrorDismiss = { + state.eventSink(LoginWithClassicEvent.ClearError) + }, + onSuccess = { + // noop, the view will be closed + }, + progressDialog = { + // The button is showing the progress + } + ) + LoginModeView( + loginMode = state.loginMode, + onClearError = { + state.eventSink(LoginWithClassicEvent.ClearError) + }, + onLearnMoreClick = onLearnMoreClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) +} + +@PreviewsDayNight +@Composable +internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicStateProvider::class) state: LoginWithClassicState) = ElementPreview { + LoginWithClassicView( + state = state, + onOtherOptionsClick = {}, + onOidcDetails = {}, + onNeedLoginPassword = {}, + onLearnMoreClick = {}, + onCreateAccountContinue = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt new file mode 100644 index 00000000000..a8b86ec1bff --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.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 + +sealed interface MissingKeyBackupEvent { + data object OnResume : MissingKeyBackupEvent +} 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..7b8ea7e6330 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.missingkeybackup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta + +@Inject +class MissingKeyBackupPresenter( + private val buildMeta: BuildMeta, + private val elementClassicConnection: ElementClassicConnection, +) : Presenter { + @Composable + override fun present(): MissingKeyBackupState { + var resumeCounter by remember { mutableIntStateOf(0) } + fun handleEvent(event: MissingKeyBackupEvent) { + when (event) { + MissingKeyBackupEvent.OnResume -> { + resumeCounter++ + if (resumeCounter > 1) { + // The user has returned to this screen, we can assume they have gone to the backup flow and are now back here + elementClassicConnection.requestSession() + } + } + } + } + + return MissingKeyBackupState( + appName = buildMeta.applicationName, + eventSink = ::handleEvent, + ) + } +} 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..78d3d81c72c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupState.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.missingkeybackup + +data class MissingKeyBackupState( + val appName: String, + val eventSink: (MissingKeyBackupEvent) -> Unit +) 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..85d1042985b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupStateProvider.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.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", + eventSink: (MissingKeyBackupEvent) -> Unit = {}, +) = MissingKeyBackupState( + appName = appName, + eventSink = eventSink +) 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..67865ffcb86 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupView.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.missingkeybackup + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun MissingKeyBackupView( + state: MissingKeyBackupState, + onBackClick: () -> Unit, + onOpenClassicClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + state.eventSink.invoke(MissingKeyBackupEvent.OnResume) + } + } + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + title = stringResource(id = R.string.screen_missing_key_backup_title, state.appName), + content = { Content(state) }, + buttons = { + Buttons( + onOpenClassicClick = onOpenClassicClick, + ) + } + ) +} + +@Composable +private fun Content( + state: MissingKeyBackupState, +) { + NumberedListOrganism( + modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_1)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_2_android)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_3_android)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_4)), + AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_5, state.appName)), + ), + ) +} + +@Composable +private fun ColumnScope.Buttons( + onOpenClassicClick: () -> Unit, +) { + Button( + text = stringResource(id = R.string.screen_missing_key_backup_open_element_classic), + modifier = Modifier.fillMaxWidth(), + onClick = onOpenClassicClick, + ) +} + +@PreviewsDayNight +@Composable +internal fun MissingKeyBackupViewPreview(@PreviewParameter(MissingKeyBackupStateProvider::class) state: MissingKeyBackupState) = ElementPreview { + MissingKeyBackupView( + state = state, + onBackClick = {}, + onOpenClassicClick = {}, + ) +} 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 d0ebbb336f4..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 happens when the app is not installed - Timber.tag(loggerTag.value).d("Binding returned false") - mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) - } - } catch (e: SecurityException) { - Timber.tag(loggerTag.value).e(e, "Can't bind to Service") - mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) - } - } - } - - override fun stop() { - Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") - if (bound) { - // Detach our existing connection. - context.unbindService(serviceConnection) - bound = false - } - coroutineScope.launch { - mutableStateFlow.emit(ElementClassicConnectionState.Idle) - } - } - - override fun requestData() { - Timber.tag(loggerTag.value).w("requestData()") - coroutineScope.launch { - val finalMessenger = messenger - if (finalMessenger == null) { - Timber.tag(loggerTag.value).w("The messenger is null, can't request data") - mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data")) - } else { - try { - // Get the data - val msg = Message.obtain(null, MSG_GET_DATA) - msg.replyTo = incomingMessenger - finalMessenger.send(msg) - } catch (e: RemoteException) { - // In this case the service has crashed before we could even - // do anything with it; we can count on soon being - // disconnected (and then reconnected if it can be restarted) - // so there is no need to do anything here. - Timber.tag(loggerTag.value).e(e, "RemoteException") - mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) - } - } - } - } - - private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.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/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 0000000000000000000000000000000000000000..67684ee9449ce2261c34af5ecd2bf8f91c61077d GIT binary patch literal 13888 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"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..1c76a93650f --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -0,0 +1,429 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.login.impl.classic + +import android.graphics.Bitmap +import android.os.Bundle +import androidx.core.graphics.createBitmap +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.libraries.matrix.api.auth.ElementClassicSession +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultElementClassicConnectionTest { + @Test + fun `connection can be started Element Classic service can be bound`() = runTest { + val connection = createDefaultElementClassicConnection( + serviceBinder = FakeServiceBinder( + bindServiceResult = { + // Element Classic is found + true + }, + ), + ) + connection.stateFlow.test { + assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle) + connection.start() + runCurrent() + expectNoEvents() + } + } + + @Test + fun `connection can be started Element Classic service cannot be bound`() = runTest { + val setElementClassicSessionResult = lambdaRecorder { } + 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 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 72% 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..6b601543cee 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,8 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.login.impl.screens.onboarding.classic +package io.element.android.features.login.impl.classic +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.tests.testutils.lambda.lambdaError import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,12 +16,14 @@ import kotlinx.coroutines.flow.asStateFlow class FakeElementClassicConnection( private val startResult: () -> Unit = { lambdaError() }, private val stopResult: () -> Unit = { lambdaError() }, - private val requestDataResult: () -> Unit = { lambdaError() }, + private val requestSessionResult: () -> Unit = { lambdaError() }, + private val requestAvatarResult: (UserId) -> Unit = { lambdaError() }, initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle ) : ElementClassicConnection { override fun start() = startResult() override fun stop() = stopResult() - override fun requestData() = requestDataResult() + override fun requestSession() = requestSessionResult() + override fun requestAvatar(userId: UserId) = requestAvatarResult(userId) private val mutableStateFlow = MutableStateFlow(initialState) override val stateFlow: StateFlow = 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..1184bb91af2 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.login.impl.screens.classic + +import androidx.core.graphics.createBitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.features.login.impl.classic.FakeElementClassicConnection +import io.element.android.features.login.impl.classic.anElementClassicReady +import io.element.android.features.login.impl.classic.anElementClassicSession +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +// Use AndroidJUnit4 for the test with the Bitmap. +@RunWith(AndroidJUnit4::class) +class ClassicFlowNodeHelperTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `initial state`() = runTest { + createHelper() + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + } + } + + @Test + fun `after a few seconds in Idle, NavigateToOnBoarding is emitted`() = runTest { + createHelper() + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if a session with the same account already exists`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ) + ) + ), + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if Element Classic is not found`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicNotFound + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if Element Classic has no session`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReadyNoSession + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to onboarding if there has been an error`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + ElementClassicConnectionState.Error(A_FAILURE_REASON) + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + } + } + + @Test + fun `navigate to login with classic when the session can be retrieved`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + } + } + + @Test + fun `navigate to login with classic when the session can be retrieved - ignore avatar update`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + // When the avatar is retrieved, no new event is emitted + elementClassicConnection.emitState( + anElementClassicReady( + avatar = createBitmap(1, 1) + ) + ) + expectNoEvents() + } + } + + @Test + fun `navigate to login with classic when the session can be retrieved and navigate again once the session is verified`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + secrets = A_SECRET, + ) + ) + ) + val readyState = awaitItem() + assertThat(readyState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + // When the secret with the key backup is retrieved, NavigateToLoginWithClassic is emitted again + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + secrets = A_SECRET + A_SECRET, + ) + ) + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + } + } + + @Test + fun `navigate to login with classic if a session with another account already exists`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + createHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID_2.value, + ) + ) + ), + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val finalState = awaitItem() + assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + } + } + + @Test + fun `navigate to login with classic but do not navigate to OnBoarding once the user is logged in`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + val sessionStore = InMemorySessionStore( + initialList = listOf() + ) + createHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, + ) + .navigationEventFlow() + .test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(NavigationEvent.Idle) + elementClassicConnection.emitState( + anElementClassicReady() + ) + val navigateToLoginWithClassicState = awaitItem() + assertThat(navigateToLoginWithClassicState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + // User actually logs in + sessionStore.addSession( + aSessionData( + sessionId = A_USER_ID.value, + ) + ) + expectNoEvents() + } + } +} + +private fun createHelper( + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), + sessionStore: SessionStore = InMemorySessionStore(), +) = ClassicFlowNodeHelper( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, +) 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..3bd2dd0cf86 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.loginwithclassic + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.ElementClassicConnectionState +import io.element.android.features.login.impl.classic.FakeElementClassicConnection +import io.element.android.features.login.impl.classic.ROOM_KEYS_VERSION +import io.element.android.features.login.impl.classic.anElementClassicReady +import io.element.android.features.login.impl.classic.anElementClassicSession +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.createLoginHelper +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoginWithClassicPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.isElementPro).isFalse() + assertThat(initialState.userId).isEqualTo(A_USER_ID) + assertThat(initialState.displayName).isNull() + assertThat(initialState.avatar).isNull() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + assertThat(initialState.loginMode.isUninitialized()).isTrue() + } + } + + @Test + fun `present - initial state - element Pro`() = runTest { + val presenter = createPresenter( + isEnterpriseBuild = true, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.isElementPro).isTrue() + } + } + + @Test + fun `present - refresh data invokes the expected methods`() = runTest { + val requestAvatarResult = lambdaRecorder { } + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + requestAvatarResult = requestAvatarResult, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = A_SECRET, + roomKeysVersion = ROOM_KEYS_VERSION, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.RefreshData) + requestAvatarResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - start login with correct state - user can login`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = A_SECRET, + roomKeysVersion = ROOM_KEYS_VERSION, + doesContainBackupKey = true, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.Submit) + val loadingState = awaitItem() + assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() + skipItems(1) + } + } + + @Test + fun `present - start login with no secrets - user can login and will have to verify manually`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + skipItems(1) + elementClassicConnection.emitState( + anElementClassicReady( + elementClassicSession = anElementClassicSession( + userId = A_USER_ID, + secrets = null, + roomKeysVersion = null, + ), + displayName = A_USER_NAME, + ) + ) + val readyState = awaitItem() + assertThat(readyState.userId).isEqualTo(A_USER_ID) + assertThat(readyState.displayName).isEqualTo(A_USER_NAME) + readyState.eventSink(LoginWithClassicEvent.Submit) + val loadingState = awaitItem() + assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() + skipItems(1) + } + } + + @Test + fun `present - start login with secrets and without key backup - user will see the screen to enable key backup`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val navigateToMissingKeyBackupResult = lambdaRecorder { } + 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..2a4af8bf5a8 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupPresenterTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.classic.missingkeybackup + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.classic.ElementClassicConnection +import io.element.android.features.login.impl.classic.FakeElementClassicConnection +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MissingKeyBackupPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) + } + } + + @Test + fun `present - when the screen is resumed twice, the start over method is called`() = runTest { + val requestSessionResult = lambdaRecorder { } + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + requestSessionResult = requestSessionResult, + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MissingKeyBackupEvent.OnResume) + expectNoEvents() + initialState.eventSink(MissingKeyBackupEvent.OnResume) + requestSessionResult.assertions().isCalledOnce() + } + } +} + +private fun createPresenter( + buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), +) = MissingKeyBackupPresenter( + buildMeta = buildMeta, + elementClassicConnection = elementClassicConnection, +) 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/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_.*", From 62f2251adbf003471045214c80a8eaee3439b808 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 9 Apr 2026 13:15:51 +0000 Subject: [PATCH 3/9] Update screenshots --- ....classic.loginwithclassic_LoginWithClassicView_Day_0_en.png | 3 +++ ....classic.loginwithclassic_LoginWithClassicView_Day_1_en.png | 3 +++ ...lassic.loginwithclassic_LoginWithClassicView_Night_0_en.png | 3 +++ ...lassic.loginwithclassic_LoginWithClassicView_Night_1_en.png | 3 +++ ....classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png | 3 +++ ...lassic.missingkeybackup_MissingKeyBackupView_Night_0_en.png | 3 +++ ...tures.login.impl.screens.classic.root_RootView_Day_0_en.png | 3 +++ ...res.login.impl.screens.classic.root_RootView_Night_0_en.png | 3 +++ ...s.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png | 3 +++ ...login.impl.screens.onboarding_OnBoardingView_Night_8_en.png | 3 +++ 10 files changed, 30 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.loginwithclassic_LoginWithClassicView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.missingkeybackup_MissingKeyBackupView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.classic.root_RootView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_8_en.png 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 From 73e1a092d22d348af8091f851c5744a34ab8b7c5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Apr 2026 11:15:42 +0200 Subject: [PATCH 4/9] Ignore secrets when the bundle does not contain the room keys version. --- .../impl/classic/ElementClassicConnection.kt | 12 ++- .../DefaultElementClassicConnectionTest.kt | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) 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 index 5e838094d39..e360aff3767 100644 --- 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 @@ -319,8 +319,16 @@ class DefaultElementClassicConnection( if (userId == null) { ElementClassicConnectionState.ElementClassicReadyNoSession } else { - val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() } - val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR)?.takeIf { it.isNotEmpty() } + 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 && 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 index 1c76a93650f..8ea1b2e3d39 100644 --- 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 @@ -270,6 +270,82 @@ class DefaultElementClassicConnectionTest { } } + @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( From f5e1cbef38df6c151acabbc7096a17990b6a25ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 13 Apr 2026 15:25:37 +0200 Subject: [PATCH 5/9] Fix navigation issue. Ensure that the timeout has effect only in Idle state. --- .../screens/classic/ClassicFlowNodeHelper.kt | 85 +++++++++---------- .../classic/ClassicFlowNodeHelperTest.kt | 29 ++++--- 2 files changed, 58 insertions(+), 56 deletions(-) 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 index f719fe50832..cae4f834d05 100644 --- 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 @@ -12,67 +12,60 @@ 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.combine import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.flowOf @Inject class ClassicFlowNodeHelper( private val elementClassicConnection: ElementClassicConnection, private val sessionStore: SessionStore, ) { - // Ensure user is not stuck on the loading screen. - // If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds. - private val timeoutFLow = flow { - emit(false) - delay(5_000) - emit(true) - } - + @OptIn(ExperimentalCoroutinesApi::class) fun navigationEventFlow(): Flow { - return combine( - timeoutFLow, - elementClassicConnection.stateFlow - .distinctUntilChangedBy { - // Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar - if (it is ElementClassicConnectionState.ElementClassicReady) { - it.copy(avatar = null) - } else { - it + 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) + } } - }, - sessionStore.sessionsFlow().toUserListFlow() - // Take only 1 emission of the sessions, else when the user actually logged in it will trigger a navigation to OnBoarding. - .take(1), - ) { timeout, elementClassicConnectionState, existingSessions -> - when (elementClassicConnectionState) { - ElementClassicConnectionState.Idle -> { - if (timeout) { - NavigationEvent.NavigateToOnBoarding - } else { - NavigationEvent.Idle + ElementClassicConnectionState.ElementClassicNotFound, + ElementClassicConnectionState.ElementClassicReadyNoSession, + is ElementClassicConnectionState.Error -> { + flowOf(NavigationEvent.NavigateToOnBoarding) } - } - ElementClassicConnectionState.ElementClassicNotFound, - ElementClassicConnectionState.ElementClassicReadyNoSession, - is ElementClassicConnectionState.Error -> { - NavigationEvent.NavigateToOnBoarding - } - is ElementClassicConnectionState.ElementClassicReady -> { - if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) { - NavigationEvent.NavigateToOnBoarding - } else { - // 2 cases when this can be run: - // First time this screen will be displayed - // Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic), - // and the app is resuming. - NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId) + 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/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 index 1184bb91af2..017fd1b633c 100644 --- 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 @@ -27,6 +27,7 @@ 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 @@ -38,16 +39,6 @@ class ClassicFlowNodeHelperTest { @get:Rule val warmUpRule = WarmUpRule() - @Test - fun `initial state`() = runTest { - createHelper() - .navigationEventFlow() - .test { - val initialState = awaitItem() - assertThat(initialState).isEqualTo(NavigationEvent.Idle) - } - } - @Test fun `after a few seconds in Idle, NavigateToOnBoarding is emitted`() = runTest { createHelper() @@ -57,6 +48,8 @@ class ClassicFlowNodeHelperTest { assertThat(initialState).isEqualTo(NavigationEvent.Idle) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -82,6 +75,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -100,6 +95,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -118,6 +115,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -136,6 +135,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -154,6 +155,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -178,6 +181,7 @@ class ClassicFlowNodeHelperTest { avatar = createBitmap(1, 1) ) ) + advanceTimeBy(10_000) expectNoEvents() } } @@ -211,6 +215,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -236,6 +242,8 @@ class ClassicFlowNodeHelperTest { ) val finalState = awaitItem() assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID)) + advanceTimeBy(10_000) + expectNoEvents() } } @@ -264,6 +272,7 @@ class ClassicFlowNodeHelperTest { sessionId = A_USER_ID.value, ) ) + advanceTimeBy(10_000) expectNoEvents() } } From 1962b965fde31fa629e3ebdb9303a9cd0c29a175 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 10:12:03 +0200 Subject: [PATCH 6/9] Improve log and reduce severity. --- .../impl/classic/ElementClassicConnection.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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 index e360aff3767..393762fed2b 100644 --- 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 @@ -107,7 +107,7 @@ class DefaultElementClassicConnection( } override fun start() { - Timber.tag(loggerTag.value).w("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 @@ -130,7 +130,7 @@ class DefaultElementClassicConnection( } override fun stop() { - Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") + Timber.tag(loggerTag.value).d("stop(): Unbinding (bound=$bound)") if (bound) { // Detach our existing connection. serviceBinder.unbindService(serviceConnection) @@ -142,13 +142,12 @@ class DefaultElementClassicConnection( } override fun requestSession() { - Timber.tag(loggerTag.value).w("requestSession()") + Timber.tag(loggerTag.value).d("requestSession()") coroutineScope.launch { val finalMessenger = messenger if (finalMessenger == null) { - Timber.tag(loggerTag.value).w("The messenger is null, can't request data") + 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 - // emitState(ElementClassicConnectionState.Error("The messenger is null, can't request data")) } else { try { // Get the data @@ -168,7 +167,7 @@ class DefaultElementClassicConnection( } override fun requestAvatar(userId: UserId) { - Timber.tag(loggerTag.value).w("requestAvatar()") + Timber.tag(loggerTag.value).d("requestAvatar()") coroutineScope.launch { val finalMessenger = messenger if (finalMessenger == null) { @@ -265,7 +264,7 @@ class DefaultElementClassicConnection( if (isCompatible) { Timber.tag(loggerTag.value).d("Found compatible homeserver URL: %s", url) } else { - Timber.tag(loggerTag.value).w("Homeserver URL is not compatible: %s", url) + Timber.tag(loggerTag.value).d("Homeserver URL is not compatible: %s", url) } isCompatible } @@ -334,6 +333,16 @@ class DefaultElementClassicConnection( 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) + } + ) ElementClassicConnectionState.ElementClassicReady( elementClassicSession = ElementClassicSession( userId = userId, From 53c20f3f25a870d4d63fdad6b5c69702788dbc7b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 10:22:13 +0200 Subject: [PATCH 7/9] Make elementClassicComponent a val. --- .../features/login/impl/classic/ElementClassicConnection.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 393762fed2b..55bc5e1fd65 100644 --- 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 @@ -114,7 +114,7 @@ class DefaultElementClassicConnection( // applications replace our component. try { val intentService = Intent() - intentService.setComponent(getElementClassicComponent()) + intentService.setComponent(elementClassicComponent) if (serviceBinder.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { Timber.tag(loggerTag.value).d("Binding returned true") } else { @@ -304,7 +304,7 @@ class DefaultElementClassicConnection( mutableStateFlow.emit(state) } - private fun getElementClassicComponent() = ComponentName( + private val elementClassicComponent = ComponentName( BuildConfig.elementClassicPackage, ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, ) From 76de9db94e14cc76ab53d2c42b85e0023e60b712 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 12:21:44 +0200 Subject: [PATCH 8/9] Move `val`s at the top of the class. --- .../impl/classic/ElementClassicConnection.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 55bc5e1fd65..3c4dd4de3ce 100644 --- 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 @@ -80,6 +80,14 @@ class DefaultElementClassicConnection( // 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. */ @@ -192,9 +200,6 @@ class DefaultElementClassicConnection( } } - private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) - override val stateFlow = mutableStateFlow.asStateFlow() - /** * Handler of incoming messages from service. */ @@ -304,11 +309,6 @@ class DefaultElementClassicConnection( mutableStateFlow.emit(state) } - private val elementClassicComponent = ComponentName( - BuildConfig.elementClassicPackage, - ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, - ) - private fun Bundle.toElementClassicConnectionState(): ElementClassicConnectionState { val error = getString(KEY_ERROR_STR) return if (error != null) { From fd3c4c2b2b8eff400541550ba237b25cbb93a54b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Apr 2026 16:51:00 +0200 Subject: [PATCH 9/9] Refresh Element Classic state each time ClassicFlowNode is resumed. This ensure that Element X is always up to date regarding Element Classic state. --- .../impl/classic/ElementClassicConnection.kt | 28 ++++++++++++----- .../impl/screens/classic/ClassicFlowNode.kt | 6 ++++ .../screens/classic/ClassicFlowNodeHelper.kt | 4 +++ .../loginwithclassic/LoginWithClassicEvent.kt | 1 - .../LoginWithClassicPresenter.kt | 7 ----- .../loginwithclassic/LoginWithClassicView.kt | 6 ---- .../missingkeybackup/MissingKeyBackupEvent.kt | 12 -------- .../MissingKeyBackupPresenter.kt | 20 ------------- .../missingkeybackup/MissingKeyBackupState.kt | 1 - .../MissingKeyBackupStateProvider.kt | 2 -- .../missingkeybackup/MissingKeyBackupView.kt | 7 ----- .../classic/FakeElementClassicConnection.kt | 3 -- .../LoginWithClassicPresenterTest.kt | 30 ------------------- .../MissingKeyBackupPresenterTest.kt | 22 -------------- 14 files changed, 30 insertions(+), 119 deletions(-) delete mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt 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 index 3c4dd4de3ce..dfddd1d496f 100644 --- 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 @@ -43,7 +43,6 @@ interface ElementClassicConnection { fun start() fun stop() fun requestSession() - fun requestAvatar(userId: UserId) val stateFlow: StateFlow } @@ -174,7 +173,7 @@ class DefaultElementClassicConnection( } } - override fun requestAvatar(userId: UserId) { + private fun requestAvatar(userId: UserId) { Timber.tag(loggerTag.value).d("requestAvatar()") coroutineScope.launch { val finalMessenger = messenger @@ -225,6 +224,11 @@ class DefaultElementClassicConnection( 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) + } } } @@ -241,11 +245,15 @@ class DefaultElementClassicConnection( ) } else { val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java) - val updatedState = currentState.copy( - avatar = avatar, - ) - coroutineScope.launch { - emitState(updatedState) + // 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 { @@ -343,6 +351,10 @@ class DefaultElementClassicConnection( 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, @@ -352,7 +364,7 @@ class DefaultElementClassicConnection( doesContainBackupKey = doesContainBackupKey, ), displayName = displayName, - avatar = null, + avatar = currentAvatar, ) } } 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 index 8d79453318d..f2ff998652c 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -75,6 +76,11 @@ class ClassicFlowNode( override fun onBuilt() { super.onBuilt() observeElementClassicConnection() + lifecycle.subscribe( + onResume = { + classicFlowNodeHelper.onResume() + }, + ) } private fun observeElementClassicConnection() { 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 index cae4f834d05..a5bc74c5e41 100644 --- 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 @@ -26,6 +26,10 @@ class ClassicFlowNodeHelper( private val elementClassicConnection: ElementClassicConnection, private val sessionStore: SessionStore, ) { + fun onResume() { + elementClassicConnection.requestSession() + } + @OptIn(ExperimentalCoroutinesApi::class) fun navigationEventFlow(): Flow { return elementClassicConnection.stateFlow 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 index e3c6ed782dd..6ba9b2142ad 100644 --- 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 @@ -8,7 +8,6 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic sealed interface LoginWithClassicEvent { - data object RefreshData : 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/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenter.kt index 6494ee741e0..90a528c3ae2 100644 --- 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 @@ -56,13 +56,6 @@ class LoginWithClassicPresenter( fun handleEvent(event: LoginWithClassicEvent) { when (event) { - LoginWithClassicEvent.RefreshData -> { - // Request the avatar if not known yet - val currentState = elementClassicConnection.stateFlow.value - if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) { - elementClassicConnection.requestAvatar(userId) - } - } LoginWithClassicEvent.Submit -> { val currentState = elementClassicConnection.stateFlow.value if (currentState is ElementClassicConnectionState.ElementClassicReady) { 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 index aeb61946ff5..6b5c48f1ec5 100644 --- 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 @@ -33,8 +33,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import io.element.android.compound.theme.ElementTheme import io.element.android.features.login.impl.R import io.element.android.features.login.impl.login.LoginModeView @@ -67,10 +65,6 @@ fun LoginWithClassicView( onCreateAccountContinue: (url: String) -> Unit, modifier: Modifier = Modifier, ) { - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - state.eventSink(LoginWithClassicEvent.RefreshData) - } - val isLoading by remember(state.loginMode) { derivedStateOf { state.loginMode is AsyncData.Loading diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt deleted file mode 100644 index a8b86ec1bff..00000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/missingkeybackup/MissingKeyBackupEvent.kt +++ /dev/null @@ -1,12 +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.classic.missingkeybackup - -sealed interface MissingKeyBackupEvent { - data object OnResume : MissingKeyBackupEvent -} 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 index 7b8ea7e6330..593c50dcb55 100644 --- 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 @@ -8,38 +8,18 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta @Inject class MissingKeyBackupPresenter( private val buildMeta: BuildMeta, - private val elementClassicConnection: ElementClassicConnection, ) : Presenter { @Composable override fun present(): MissingKeyBackupState { - var resumeCounter by remember { mutableIntStateOf(0) } - fun handleEvent(event: MissingKeyBackupEvent) { - when (event) { - MissingKeyBackupEvent.OnResume -> { - resumeCounter++ - if (resumeCounter > 1) { - // The user has returned to this screen, we can assume they have gone to the backup flow and are now back here - elementClassicConnection.requestSession() - } - } - } - } - return MissingKeyBackupState( appName = buildMeta.applicationName, - eventSink = ::handleEvent, ) } } 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 index 78d3d81c72c..31eaf015a0f 100644 --- 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 @@ -9,5 +9,4 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup data class MissingKeyBackupState( val appName: String, - val eventSink: (MissingKeyBackupEvent) -> Unit ) 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 index 85d1042985b..2c6a09b3edf 100644 --- 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 @@ -19,8 +19,6 @@ open class MissingKeyBackupStateProvider : PreviewParameterProvider Unit = {}, ) = MissingKeyBackupState( appName = appName, - eventSink = eventSink ) 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 index 67865ffcb86..c4c9c5f2864 100644 --- 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 @@ -16,7 +16,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.R import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism @@ -25,7 +24,6 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import kotlinx.collections.immutable.persistentListOf @Composable @@ -35,11 +33,6 @@ fun MissingKeyBackupView( onOpenClassicClick: () -> Unit, modifier: Modifier = Modifier, ) { - OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - state.eventSink.invoke(MissingKeyBackupEvent.OnResume) - } - } FlowStepPage( modifier = modifier, onBackClick = onBackClick, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt index 6b601543cee..227aa514b3b 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/FakeElementClassicConnection.kt @@ -7,7 +7,6 @@ package io.element.android.features.login.impl.classic -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.tests.testutils.lambda.lambdaError import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,13 +16,11 @@ class FakeElementClassicConnection( private val startResult: () -> Unit = { lambdaError() }, private val stopResult: () -> Unit = { lambdaError() }, private val requestSessionResult: () -> Unit = { lambdaError() }, - private val requestAvatarResult: (UserId) -> Unit = { lambdaError() }, initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle ) : ElementClassicConnection { override fun start() = startResult() override fun stop() = stopResult() override fun requestSession() = requestSessionResult() - override fun requestAvatar(userId: UserId) = requestAvatarResult(userId) 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/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicPresenterTest.kt index 3bd2dd0cf86..6b2a4fb0e1e 100644 --- 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 @@ -57,36 +57,6 @@ class LoginWithClassicPresenterTest { } } - @Test - fun `present - refresh data invokes the expected methods`() = runTest { - val requestAvatarResult = lambdaRecorder { } - val elementClassicConnection = FakeElementClassicConnection( - startResult = {}, - requestAvatarResult = requestAvatarResult, - ) - val presenter = createPresenter( - elementClassicConnection = elementClassicConnection, - ) - presenter.test { - skipItems(1) - elementClassicConnection.emitState( - anElementClassicReady( - elementClassicSession = anElementClassicSession( - userId = A_USER_ID, - secrets = A_SECRET, - roomKeysVersion = ROOM_KEYS_VERSION, - ), - displayName = A_USER_NAME, - ) - ) - val readyState = awaitItem() - assertThat(readyState.userId).isEqualTo(A_USER_ID) - assertThat(readyState.displayName).isEqualTo(A_USER_NAME) - readyState.eventSink(LoginWithClassicEvent.RefreshData) - requestAvatarResult.assertions().isCalledOnce() - } - } - @Test fun `present - start login with correct state - user can login`() = runTest { val authenticationService = FakeMatrixAuthenticationService( 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 index 2a4af8bf5a8..447b0ba77b3 100644 --- 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 @@ -8,12 +8,9 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.impl.classic.ElementClassicConnection -import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME import io.element.android.libraries.matrix.test.core.aBuildMeta -import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,29 +24,10 @@ class MissingKeyBackupPresenterTest { assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) } } - - @Test - fun `present - when the screen is resumed twice, the start over method is called`() = runTest { - val requestSessionResult = lambdaRecorder { } - val presenter = createPresenter( - elementClassicConnection = FakeElementClassicConnection( - requestSessionResult = requestSessionResult, - ), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(MissingKeyBackupEvent.OnResume) - expectNoEvents() - initialState.eventSink(MissingKeyBackupEvent.OnResume) - requestSessionResult.assertions().isCalledOnce() - } - } } private fun createPresenter( buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), - elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), ) = MissingKeyBackupPresenter( buildMeta = buildMeta, - elementClassicConnection = elementClassicConnection, )