From 017894201c979b792d4375076a35b7cd2d8a0172 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 2 Jan 2026 16:03:31 +0100 Subject: [PATCH 1/2] Signin with Element Classic WIP --- .../login/impl/src/main/AndroidManifest.xml | 2 + .../features/login/impl/di/LoginModule.kt | 5 + .../screens/onboarding/OnBoardingPresenter.kt | 5 + .../screens/onboarding/OnBoardingState.kt | 2 + .../onboarding/OnBoardingStateProvider.kt | 4 + .../impl/screens/onboarding/OnBoardingView.kt | 56 ++++ .../ConfirmingLoginWithElementClassic.kt | 15 ++ .../classic/ElementClassicConnection.kt | 251 ++++++++++++++++++ .../classic/LoginWithClassicEvent.kt | 15 ++ .../classic/LoginWithClassicPresenter.kt | 103 +++++++ .../classic/LoginWithClassicState.kt | 16 ++ .../classic/LoginWithClassicStateProvider.kt | 20 ++ .../onboarding/OnBoardingPresenterTest.kt | 7 +- .../classic/FakeElementClassicConnection.kt | 29 ++ .../classic/LoginWithClassicPresenterTest.kt | 213 +++++++++++++++ .../libraries/featureflag/api/FeatureFlags.kt | 7 + 16 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt create 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/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml index 453cf05132..f2d84131a7 100644 --- a/features/login/impl/src/main/AndroidManifest.xml +++ b/features/login/impl/src/main/AndroidManifest.xml @@ -15,4 +15,6 @@ + + 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 4523e6f45e..12b9106b71 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,6 +14,8 @@ 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) @@ -21,4 +23,7 @@ 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/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 4d83c45a44..741f65234e 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,6 +26,7 @@ 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 @@ -44,6 +45,7 @@ class OnBoardingPresenter( private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider, private val sessionStore: SessionStore, private val accountProviderDataSource: AccountProviderDataSource, + private val loginWithClassicPresenter: Presenter, ) : Presenter { @AssistedFactory interface Factory { @@ -99,6 +101,8 @@ class OnBoardingPresenter( val loginMode by loginHelper.collectLoginMode() + val loginWithClassicState = loginWithClassicPresenter.present() + fun handleEvent(event: OnBoardingEvents) { when (event) { is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch { @@ -132,6 +136,7 @@ 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 db6c3573f9..703120b260 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,6 +10,7 @@ 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( @@ -24,6 +25,7 @@ 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 d7db27ca0b..76f8eb3513 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,6 +11,8 @@ 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 @@ -44,6 +46,7 @@ fun anOnBoardingState( @DrawableRes customLogoResId: Int? = null, loginMode: AsyncData = AsyncData.Uninitialized, + loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(), eventSink: (OnBoardingEvents) -> Unit = {}, ) = OnBoardingState( isAddingAccount = isAddingAccount, @@ -56,5 +59,6 @@ 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 977c6de71c..d590f1fec8 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,10 +31,15 @@ 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 @@ -42,6 +47,8 @@ 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 @@ -109,6 +116,43 @@ fun OnBoardingView( buttons = buttons, ) } + + 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 @@ -239,6 +283,18 @@ 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/ConfirmingLoginWithElementClassic.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt new file mode 100644 index 0000000000..5fae0afdd5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt @@ -0,0 +1,15 @@ +/* + * 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 +import io.element.android.libraries.matrix.api.core.UserId + +class ConfirmingLoginWithElementClassic( + val userId: UserId, +) : AsyncAction.Confirming 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 new file mode 100644 index 0000000000..29a4f9b3fc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt @@ -0,0 +1,251 @@ +/* + * 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.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +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 state: StateFlow +} + +sealed interface ElementClassicConnectionState { + object Idle : ElementClassicConnectionState + object ElementClassicNotFound : ElementClassicConnectionState + object ElementClassicReadyNoSession : ElementClassicConnectionState + data class ElementClassicReady(val userId: UserId) : 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, + private val buildMeta: BuildMeta, +) : 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(buildMeta)) + if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { + Timber.tag(loggerTag.value).d("Binding returned true") + } else { + // This happen when the app is not installed + Timber.tag(loggerTag.value).d("Binding returned false") + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) + } + } catch (e: SecurityException) { + Timber.tag(loggerTag.value).e(e, "Can't bind to Service") + elementClassicConnectionStateFlow.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 { + elementClassicConnectionStateFlow.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") + elementClassicConnectionStateFlow.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") + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + } + + private val elementClassicConnectionStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + + override val state: StateFlow + get() = elementClassicConnectionStateFlow.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) + elementClassicConnectionStateFlow.emit(state) + } + is ElementClassicConnectionState.ElementClassicReady -> { + Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId) + elementClassicConnectionStateFlow.emit(state) + } + ElementClassicConnectionState.ElementClassicReadyNoSession -> { + Timber.tag(loggerTag.value).d("Received no session from Element Classic") + elementClassicConnectionStateFlow.emit(state) + } + else -> { + // Should not happen + Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state) + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + } + } + } + + private fun getElementClassicComponent(buildMeta: BuildMeta) = ComponentName( + buildString { + append(ELEMENT_CLASSIC_APP_ID) + append( + when (buildMeta.buildType) { + BuildType.DEBUG -> ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX + BuildType.NIGHTLY -> ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX + BuildType.RELEASE -> ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX + } + ) + }, + 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)?.let(::UserId) + if (userId != null) { + ElementClassicConnectionState.ElementClassicReady(userId) + } else { + ElementClassicConnectionState.ElementClassicReadyNoSession + } + } + } + } + + // Everything in this companion object must match what is defined in Element Classic + private companion object { + // Command to the service to get the data. + const val MSG_GET_DATA = 1 + + const val ELEMENT_CLASSIC_APP_ID = "im.vector.app" + const val ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX = ".debug" + const val ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX = ".nightly" + const val ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX = "" + + const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService" + + // Keys for the bundle returned from the service + const val KEY_ERROR_STR = "error" + const val KEY_USER_ID_STR = "userId" + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt new file mode 100644 index 0000000000..75a9496a02 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding.classic + +sealed interface LoginWithClassicEvent { + data object RefreshData : LoginWithClassicEvent + data object StartLoginWithClassic : LoginWithClassicEvent + data object DoLoginWithClassic : LoginWithClassicEvent + data object CloseDialog : LoginWithClassicEvent +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt new file mode 100644 index 0000000000..d962c5978a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt @@ -0,0 +1,103 @@ +/* + * 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.state.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.state.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 new file mode 100644 index 0000000000..d2706fc24a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt @@ -0,0 +1,16 @@ +/* + * 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 new file mode 100644 index 0000000000..73f68e5d61 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.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.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/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 1d434997ca..1e971ef265 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,6 +16,7 @@ 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 @@ -88,7 +89,10 @@ class OnBoardingPresenterTest { assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canReportBug).isFalse() assertThat(initialState.isAddingAccount).isFalse() - assertThat(awaitItem().canLoginWithQrCode).isTrue() + assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse() + val finalState = awaitItem() + assertThat(finalState.canLoginWithQrCode).isTrue() + assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse() } } @@ -283,6 +287,7 @@ 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/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt new file mode 100644 index 0000000000..9f2e76e75d --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt @@ -0,0 +1,29 @@ +/* + * 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.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeElementClassicConnection( + private val startResult: () -> Unit = { lambdaError() }, + private val stopResult: () -> Unit = { lambdaError() }, + private val requestDataResult: () -> Unit = { lambdaError() }, + initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle +) : ElementClassicConnection { + override fun start() = startResult() + override fun stop() = stopResult() + override fun requestData() = requestDataResult() + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + suspend fun emitState(state: ElementClassicConnectionState) { + _state.emit(state) + } +} 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 new file mode 100644 index 0000000000..8a8e4985c9 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt @@ -0,0 +1,213 @@ +/* + * 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_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) + ) + 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) + ) + 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) + ) + // 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) + ) + // 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/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index a77e09711f..4422330924 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -133,4 +133,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + SignInWithClassic( + key = "feature.signin_with_classic", + title = "Sign in with Element Classic", + description = "Allow the application to sign in to the current Element Classic account.", + defaultValue = { false }, + isFinished = false, + ), } From caf5ab10855dce3f96dd6f7db317f10c4cc7edc5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Jan 2026 15:20:48 +0100 Subject: [PATCH 2/2] Rename the state flow. Also let `stateFlow` be a real `val`. --- .../classic/ElementClassicConnection.kt | 26 +++++++++---------- .../classic/LoginWithClassicPresenter.kt | 4 +-- .../classic/FakeElementClassicConnection.kt | 6 ++--- 3 files changed, 17 insertions(+), 19 deletions(-) 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 29a4f9b3fc..c983ea04ba 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 @@ -37,7 +37,7 @@ interface ElementClassicConnection { fun start() fun stop() fun requestData() - val state: StateFlow + val stateFlow: StateFlow } sealed interface ElementClassicConnectionState { @@ -107,11 +107,11 @@ class DefaultElementClassicConnection( } else { // This happen when the app is not installed Timber.tag(loggerTag.value).d("Binding returned false") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) + mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) } } catch (e: SecurityException) { Timber.tag(loggerTag.value).e(e, "Can't bind to Service") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) } } } @@ -124,7 +124,7 @@ class DefaultElementClassicConnection( bound = false } coroutineScope.launch { - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + mutableStateFlow.emit(ElementClassicConnectionState.Idle) } } @@ -134,7 +134,7 @@ class DefaultElementClassicConnection( val finalMessenger = messenger if (finalMessenger == null) { Timber.tag(loggerTag.value).w("The messenger is null, can't request data") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error("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 @@ -147,16 +147,14 @@ class DefaultElementClassicConnection( // 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") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) } } } } - private val elementClassicConnectionStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) - - override val state: StateFlow - get() = elementClassicConnectionStateFlow.asStateFlow() + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() /** * Handler of incoming messages from service. @@ -182,20 +180,20 @@ class DefaultElementClassicConnection( when (state) { is ElementClassicConnectionState.Error -> { Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error) - elementClassicConnectionStateFlow.emit(state) + mutableStateFlow.emit(state) } is ElementClassicConnectionState.ElementClassicReady -> { Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId) - elementClassicConnectionStateFlow.emit(state) + mutableStateFlow.emit(state) } ElementClassicConnectionState.ElementClassicReadyNoSession -> { Timber.tag(loggerTag.value).d("Received no session from Element Classic") - elementClassicConnectionStateFlow.emit(state) + mutableStateFlow.emit(state) } else -> { // Should not happen Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state) - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + mutableStateFlow.emit(ElementClassicConnectionState.Idle) } } } 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 index d962c5978a..ef352794cb 100644 --- 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 @@ -48,7 +48,7 @@ class LoginWithClassicPresenter( } } - val state by elementClassicConnection.state.collectAsState() + val state by elementClassicConnection.stateFlow.collectAsState() val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val existingSession by remember { @@ -73,7 +73,7 @@ class LoginWithClassicPresenter( elementClassicConnection.requestData() } LoginWithClassicEvent.StartLoginWithClassic -> { - val currentState = elementClassicConnection.state.value + val currentState = elementClassicConnection.stateFlow.value if (currentState is ElementClassicConnectionState.ElementClassicReady) { loginWithClassicAction.value = ConfirmingLoginWithElementClassic( userId = currentState.userId, 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/screens/onboarding/classic/FakeElementClassicConnection.kt index 9f2e76e75d..2c41d2ed0f 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/screens/onboarding/classic/FakeElementClassicConnection.kt @@ -21,9 +21,9 @@ class FakeElementClassicConnection( override fun start() = startResult() override fun stop() = stopResult() override fun requestData() = requestDataResult() - private val _state = MutableStateFlow(initialState) - override val state: StateFlow = _state.asStateFlow() + private val mutableStateFlow = MutableStateFlow(initialState) + override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() suspend fun emitState(state: ElementClassicConnectionState) { - _state.emit(state) + mutableStateFlow.emit(state) } }