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..c983ea04ba
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt
@@ -0,0 +1,249 @@
+/*
+ * 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 stateFlow: 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")
+ 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(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..ef352794cb
--- /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.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
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..2c41d2ed0f
--- /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 mutableStateFlow = MutableStateFlow(initialState)
+ override val stateFlow: StateFlow = mutableStateFlow.asStateFlow()
+ suspend fun emitState(state: ElementClassicConnectionState) {
+ mutableStateFlow.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,
+ ),
}