Merge pull request #6013 from element-hq/feature/bma/importSession
[POC] Signin with Element Classic
This commit is contained in:
commit
f62b2b7ae6
16 changed files with 747 additions and 1 deletions
|
|
@ -15,4 +15,6 @@
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
|
<!-- Permission to read data from Element classic -->
|
||||||
|
<uses-permission android:name="im.vector.app.READ_DATA" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds
|
||||||
import dev.zacsweers.metro.ContributesTo
|
import dev.zacsweers.metro.ContributesTo
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
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.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
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
|
|
@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter
|
||||||
interface LoginModule {
|
interface LoginModule {
|
||||||
@Binds
|
@Binds
|
||||||
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
|
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter<LoginWithClassicState>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
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.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.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
|
@ -44,6 +45,7 @@ class OnBoardingPresenter(
|
||||||
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
|
private val loginWithClassicPresenter: Presenter<LoginWithClassicState>,
|
||||||
) : Presenter<OnBoardingState> {
|
) : Presenter<OnBoardingState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -99,6 +101,8 @@ class OnBoardingPresenter(
|
||||||
|
|
||||||
val loginMode by loginHelper.collectLoginMode()
|
val loginMode by loginHelper.collectLoginMode()
|
||||||
|
|
||||||
|
val loginWithClassicState = loginWithClassicPresenter.present()
|
||||||
|
|
||||||
fun handleEvent(event: OnBoardingEvents) {
|
fun handleEvent(event: OnBoardingEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
|
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
|
||||||
|
|
@ -132,6 +136,7 @@ class OnBoardingPresenter(
|
||||||
loginMode = loginMode,
|
loginMode = loginMode,
|
||||||
version = buildMeta.versionName,
|
version = buildMeta.versionName,
|
||||||
onBoardingLogoResId = onBoardingLogoResId,
|
onBoardingLogoResId = onBoardingLogoResId,
|
||||||
|
loginWithClassicState = loginWithClassicState,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.onboarding
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import io.element.android.features.login.impl.login.LoginMode
|
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
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
data class OnBoardingState(
|
data class OnBoardingState(
|
||||||
|
|
@ -24,6 +25,7 @@ data class OnBoardingState(
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
val onBoardingLogoResId: Int?,
|
val onBoardingLogoResId: Int?,
|
||||||
val loginMode: AsyncData<LoginMode>,
|
val loginMode: AsyncData<LoginMode>,
|
||||||
|
val loginWithClassicState: LoginWithClassicState,
|
||||||
val eventSink: (OnBoardingEvents) -> Unit,
|
val eventSink: (OnBoardingEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val submitEnabled: Boolean
|
val submitEnabled: Boolean
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ package io.element.android.features.login.impl.screens.onboarding
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.login.impl.login.LoginMode
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.R
|
import io.element.android.libraries.designsystem.R
|
||||||
|
|
||||||
|
|
@ -44,6 +46,7 @@ fun anOnBoardingState(
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
customLogoResId: Int? = null,
|
customLogoResId: Int? = null,
|
||||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||||
|
loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
|
||||||
eventSink: (OnBoardingEvents) -> Unit = {},
|
eventSink: (OnBoardingEvents) -> Unit = {},
|
||||||
) = OnBoardingState(
|
) = OnBoardingState(
|
||||||
isAddingAccount = isAddingAccount,
|
isAddingAccount = isAddingAccount,
|
||||||
|
|
@ -56,5 +59,6 @@ fun anOnBoardingState(
|
||||||
version = version,
|
version = version,
|
||||||
loginMode = loginMode,
|
loginMode = loginMode,
|
||||||
onBoardingLogoResId = customLogoResId,
|
onBoardingLogoResId = customLogoResId,
|
||||||
|
loginWithClassicState = loginWithClassicState,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,15 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.login.LoginModeView
|
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.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
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.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
|
@ -109,6 +116,43 @@ fun OnBoardingView(
|
||||||
buttons = buttons,
|
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
|
@Composable
|
||||||
|
|
@ -239,6 +283,18 @@ private fun OnBoardingButtons(
|
||||||
} else {
|
} else {
|
||||||
CommonStrings.action_continue
|
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) {
|
if (state.canLoginWithQrCode) {
|
||||||
Button(
|
Button(
|
||||||
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
|
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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—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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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.accesscontrol.DefaultAccountProviderAccessControl
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
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.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.FakeWebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||||
|
|
@ -88,7 +89,10 @@ class OnBoardingPresenterTest {
|
||||||
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
||||||
assertThat(initialState.canReportBug).isFalse()
|
assertThat(initialState.canReportBug).isFalse()
|
||||||
assertThat(initialState.isAddingAccount).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,
|
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
|
loginWithClassicPresenter = { aLoginWithClassicState() },
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createLoginHelper(
|
fun createLoginHelper(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -133,4 +133,11 @@ enum class FeatureFlags(
|
||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = 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,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue