Merge pull request #6013 from element-hq/feature/bma/importSession

[POC] Signin with Element Classic
This commit is contained in:
Benoit Marty
2026-01-15 17:21:16 +01:00
committed by GitHub
16 changed files with 747 additions and 1 deletions

View File

@@ -15,4 +15,6 @@
</intent>
</queries>
<!-- Permission to read data from Element classic -->
<uses-permission android:name="im.vector.app.READ_DATA" />
</manifest>

View File

@@ -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<ChangeServerState>
@Binds
fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter<LoginWithClassicState>
}

View File

@@ -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<LoginWithClassicState>,
) : Presenter<OnBoardingState> {
@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,
)
}

View File

@@ -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<LoginMode>,
val loginWithClassicState: LoginWithClassicState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean

View File

@@ -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<LoginMode> = 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,
)

View File

@@ -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),

View File

@@ -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

View File

@@ -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<ElementClassicConnectionState>
}
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&mdash;that is, its process crashed.
messenger = null
bound = false
}
}
override fun start() {
Timber.tag(loggerTag.value).w("start()")
coroutineScope.launch {
// Establish a connection with the service. We use an explicit
// class name because there is no reason to be able to let other
// applications replace our component.
try {
val intentService = Intent()
intentService.setComponent(getElementClassicComponent(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>(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"
}
}

View File

@@ -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
}

View File

@@ -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<LoginWithClassicState> {
@Composable
override fun present(): LoginWithClassicState {
val coroutineScope = rememberCoroutineScope()
val isSignInWithClassicEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic)
}.collectAsState(initial = false)
if (isSignInWithClassicEnabled) {
DisposableEffect(Unit) {
elementClassicConnection.start()
onDispose {
elementClassicConnection.stop()
}
}
}
val state by elementClassicConnection.stateFlow.collectAsState()
val loginWithClassicAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val existingSession by remember {
sessionStore.sessionsFlow().toUserListFlow()
}.collectAsState(emptyList())
val canLoginWithClassic by remember {
derivedStateOf {
when (val finalState = state) {
is ElementClassicConnectionState.ElementClassicReady -> {
// Ensure there is no existing session with the same Id.
finalState.userId.value !in existingSession && isSignInWithClassicEnabled
}
else -> false
}
}
}
fun handleEvent(event: LoginWithClassicEvent) {
when (event) {
LoginWithClassicEvent.RefreshData -> {
elementClassicConnection.requestData()
}
LoginWithClassicEvent.StartLoginWithClassic -> {
val currentState = elementClassicConnection.stateFlow.value
if (currentState is ElementClassicConnectionState.ElementClassicReady) {
loginWithClassicAction.value = ConfirmingLoginWithElementClassic(
userId = currentState.userId,
)
} else {
loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready"))
}
}
LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch {
// TODO Implement real login logic here
loginWithClassicAction.value = AsyncAction.Loading
delay(1000)
loginWithClassicAction.value = AsyncAction.Success(Unit)
}
LoginWithClassicEvent.CloseDialog -> {
loginWithClassicAction.value = AsyncAction.Uninitialized
}
}
}
return LoginWithClassicState(
canLoginWithClassic = canLoginWithClassic,
loginWithClassicAction = loginWithClassicAction.value,
eventSink = ::handleEvent,
)
}
}

View File

@@ -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<Unit>,
val eventSink: (LoginWithClassicEvent) -> Unit,
)

View File

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

View File

@@ -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(

View File

@@ -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<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
suspend fun emitState(state: ElementClassicConnectionState) {
mutableStateFlow.emit(state)
}
}

View File

@@ -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<Unit> {}
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = startResult,
),
isFeatureEnabled = true,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithClassic).isFalse()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
val finalState = awaitItem()
assertThat(finalState.canLoginWithClassic).isFalse()
}
startResult.assertions().isCalledOnce()
}
@Test
fun `present - emit request data invokes the expected method`() = runTest {
val requestDataResult = lambdaRecorder<Unit> {}
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = {},
requestDataResult = requestDataResult,
),
isFeatureEnabled = true,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithClassic).isFalse()
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
val nextState = awaitItem()
assertThat(nextState.canLoginWithClassic).isFalse()
nextState.eventSink(LoginWithClassicEvent.RefreshData)
}
requestDataResult.assertions().isCalledOnce()
}
@Test
fun `present - start login with wrong state emits an error`() = runTest {
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
startResult = {},
),
isFeatureEnabled = true,
)
presenter.test {
skipItems(1)
val state = awaitItem()
state.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
val errorState = awaitItem()
assertThat(errorState.loginWithClassicAction.isFailure()).isTrue()
}
}
@Test
fun `present - start login with correct state - user cancel`() = runTest {
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
isFeatureEnabled = true,
)
presenter.test {
skipItems(2)
elementClassicConnection.emitState(
ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
)
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,
)

View File

@@ -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,
),
}