Merge pull request #6013 from element-hq/feature/bma/importSession
[POC] Signin with Element Classic
This commit is contained in:
@@ -15,4 +15,6 @@
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<!-- Permission to read data from Element classic -->
|
||||
<uses-permission android:name="im.vector.app.READ_DATA" />
|
||||
</manifest>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.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(
|
||||
|
||||
@@ -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 },
|
||||
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,
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user