Sign in with Classic
This commit is contained in:
parent
683b1fe9d5
commit
8c5caabed4
62 changed files with 3120 additions and 728 deletions
|
|
@ -252,7 +252,8 @@ class RootFlowNode(
|
||||||
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
|
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
|
||||||
when (navTarget) {
|
when (navTarget) {
|
||||||
is NavTarget.SplashScreen,
|
is NavTarget.SplashScreen,
|
||||||
is NavTarget.LoggedInFlow -> backstackFader
|
is NavTarget.LoggedInFlow,
|
||||||
|
is NavTarget.NotLoggedInFlow -> backstackFader
|
||||||
else -> backstackSlider
|
else -> backstackSlider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
|
|
@ -30,9 +31,11 @@ import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.login.api.LoginEntryPoint
|
import io.element.android.features.login.api.LoginEntryPoint
|
||||||
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.classic.ElementClassicConnection
|
||||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
||||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
|
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
|
||||||
|
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
|
||||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
|
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||||
|
|
@ -63,9 +66,10 @@ class LoginFlowNode(
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val elementClassicConnection: ElementClassicConnection,
|
||||||
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.OnBoarding,
|
initialElement = NavTarget.CheckClassicFlow,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
|
@ -103,7 +107,12 @@ class LoginFlowNode(
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object OnBoarding : NavTarget
|
data object CheckClassicFlow : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class OnBoarding(
|
||||||
|
val showBackButton: Boolean,
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object QrCode : NavTarget
|
data object QrCode : NavTarget
|
||||||
|
|
@ -123,7 +132,9 @@ class LoginFlowNode(
|
||||||
data object SearchAccountProvider : NavTarget
|
data object SearchAccountProvider : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object LoginPassword : NavTarget
|
data class LoginPassword(
|
||||||
|
val initialLogin: String = "",
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class CreateAccount(val url: String) : NavTarget
|
data class CreateAccount(val url: String) : NavTarget
|
||||||
|
|
@ -131,7 +142,31 @@ class LoginFlowNode(
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.OnBoarding -> {
|
NavTarget.CheckClassicFlow -> {
|
||||||
|
val callback = object : ClassicFlowNode.Callback {
|
||||||
|
override fun navigateToOnBoarding(allowBackNavigation: Boolean) {
|
||||||
|
if (allowBackNavigation) {
|
||||||
|
backstack.push(NavTarget.OnBoarding(showBackButton = true))
|
||||||
|
} else {
|
||||||
|
backstack.replace(NavTarget.OnBoarding(showBackButton = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToLoginPassword() {
|
||||||
|
backstack.push(NavTarget.LoginPassword())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToOidc(oidcDetails: OidcDetails) {
|
||||||
|
navigateToMas(oidcDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToCreateAccount(url: String) {
|
||||||
|
backstack.push(NavTarget.CreateAccount(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<ClassicFlowNode>(buildContext, listOf(callback))
|
||||||
|
}
|
||||||
|
is NavTarget.OnBoarding -> {
|
||||||
val callback = object : OnBoardingNode.Callback {
|
val callback = object : OnBoardingNode.Callback {
|
||||||
override fun navigateToSignUpFlow() {
|
override fun navigateToSignUpFlow() {
|
||||||
backstack.push(
|
backstack.push(
|
||||||
|
|
@ -166,17 +201,22 @@ class LoginFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
|
if (navTarget.showBackButton) {
|
||||||
|
backstack.pop()
|
||||||
|
} else {
|
||||||
callback.onDone()
|
callback.onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
val params = inputs<Params>()
|
val params = inputs<Params>()
|
||||||
val inputs = OnBoardingNode.Params(
|
val inputs = OnBoardingNode.Params(
|
||||||
accountProvider = params.accountProvider,
|
accountProvider = params.accountProvider,
|
||||||
loginHint = params.loginHint,
|
loginHint = params.loginHint,
|
||||||
|
showBackButton = navTarget.showBackButton,
|
||||||
)
|
)
|
||||||
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
|
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +231,7 @@ class LoginFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
|
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
|
||||||
|
|
@ -218,7 +258,7 @@ class LoginFlowNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun navigateToChangeAccountProvider() {
|
override fun navigateToChangeAccountProvider() {
|
||||||
|
|
@ -257,8 +297,11 @@ class LoginFlowNode(
|
||||||
|
|
||||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.LoginPassword -> {
|
is NavTarget.LoginPassword -> {
|
||||||
createNode<LoginPasswordNode>(buildContext)
|
val inputs = LoginPasswordNode.Inputs(
|
||||||
|
initialLogin = navTarget.initialLogin,
|
||||||
|
)
|
||||||
|
createNode<LoginPasswordNode>(buildContext, plugins = listOf(inputs))
|
||||||
}
|
}
|
||||||
is NavTarget.CreateAccount -> {
|
is NavTarget.CreateAccount -> {
|
||||||
val inputs = CreateAccountNode.Inputs(
|
val inputs = CreateAccountNode.Inputs(
|
||||||
|
|
@ -280,6 +323,14 @@ class LoginFlowNode(
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
activity = requireNotNull(LocalActivity.current)
|
activity = requireNotNull(LocalActivity.current)
|
||||||
darkTheme = !ElementTheme.isLightTheme
|
darkTheme = !ElementTheme.isLightTheme
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
elementClassicConnection.start()
|
||||||
|
onDispose {
|
||||||
|
elementClassicConnection.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
activity = null
|
activity = null
|
||||||
|
|
@ -288,6 +339,6 @@ class LoginFlowNode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackstackView()
|
BackstackView(transitionHandler = rememberLoginFlowTransitionHandler())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.Transition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
|
||||||
|
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.Replace
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
|
||||||
|
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TransitionHandler that uses fade transition when OnBoarding is replacing the current screen,
|
||||||
|
* and slide transition for all other cases.
|
||||||
|
*/
|
||||||
|
private class LoginFlowTransitionHandler(
|
||||||
|
private val slider: ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>,
|
||||||
|
private val fader: ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>,
|
||||||
|
) : ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State>() {
|
||||||
|
override fun createModifier(
|
||||||
|
modifier: Modifier,
|
||||||
|
transition: Transition<BackStack.State>,
|
||||||
|
descriptor: TransitionDescriptor<LoginFlowNode.NavTarget, BackStack.State>
|
||||||
|
): Modifier {
|
||||||
|
val useFader = descriptor.element is LoginFlowNode.NavTarget.OnBoarding &&
|
||||||
|
descriptor.operation is Replace
|
||||||
|
val handler = if (useFader) fader else slider
|
||||||
|
return handler.createModifier(modifier, transition, descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberLoginFlowTransitionHandler(): ModifierTransitionHandler<LoginFlowNode.NavTarget, BackStack.State> {
|
||||||
|
val slider = rememberBackstackSlider<LoginFlowNode.NavTarget>(
|
||||||
|
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||||
|
)
|
||||||
|
val fader = rememberBackstackFader<LoginFlowNode.NavTarget>(
|
||||||
|
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||||
|
)
|
||||||
|
return rememberDelegateTransitionHandler {
|
||||||
|
LoginFlowTransitionHandler(slider, fader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context.BIND_AUTO_CREATE
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
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 androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.features.login.impl.BuildConfig
|
||||||
|
import io.element.android.libraries.androidutils.service.ServiceBinder
|
||||||
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||||
|
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
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 requestSession()
|
||||||
|
fun requestAvatar(userId: UserId)
|
||||||
|
val stateFlow: StateFlow<ElementClassicConnectionState>
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ElementClassicConnectionState {
|
||||||
|
object Idle : ElementClassicConnectionState
|
||||||
|
object ElementClassicNotFound : ElementClassicConnectionState
|
||||||
|
object ElementClassicReadyNoSession : ElementClassicConnectionState
|
||||||
|
data class ElementClassicReady(
|
||||||
|
val elementClassicSession: ElementClassicSession,
|
||||||
|
val displayName: String?,
|
||||||
|
val avatar: Bitmap?,
|
||||||
|
) : ElementClassicConnectionState
|
||||||
|
|
||||||
|
data class Error(val error: String) : ElementClassicConnectionState
|
||||||
|
}
|
||||||
|
|
||||||
|
private val loggerTag = LoggerTag("ECConnection")
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class DefaultElementClassicConnection(
|
||||||
|
private val serviceBinder: ServiceBinder,
|
||||||
|
@AppCoroutineScope
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val matrixAuthenticationService: MatrixAuthenticationService,
|
||||||
|
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
|
||||||
|
) : 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
|
||||||
|
requestSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
if (serviceBinder.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
|
||||||
|
Timber.tag(loggerTag.value).d("Binding returned true")
|
||||||
|
} else {
|
||||||
|
// This happens when the app is not installed
|
||||||
|
Timber.tag(loggerTag.value).d("Binding returned false")
|
||||||
|
emitState(ElementClassicConnectionState.ElementClassicNotFound)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Timber.tag(loggerTag.value).e(e, "Can't bind to Service")
|
||||||
|
emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)")
|
||||||
|
if (bound) {
|
||||||
|
// Detach our existing connection.
|
||||||
|
serviceBinder.unbindService(serviceConnection)
|
||||||
|
bound = false
|
||||||
|
}
|
||||||
|
coroutineScope.launch {
|
||||||
|
emitState(ElementClassicConnectionState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestSession() {
|
||||||
|
Timber.tag(loggerTag.value).w("requestSession()")
|
||||||
|
coroutineScope.launch {
|
||||||
|
val finalMessenger = messenger
|
||||||
|
if (finalMessenger == null) {
|
||||||
|
Timber.tag(loggerTag.value).w("The messenger is null, can't request data")
|
||||||
|
// Do not emit error, else the regular on boarding flow will be displayed
|
||||||
|
// emitState(ElementClassicConnectionState.Error("The messenger is null, can't request data"))
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Get the data
|
||||||
|
val msg = Message.obtain(null, MSG_GET_SESSION)
|
||||||
|
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")
|
||||||
|
emitState(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestAvatar(userId: UserId) {
|
||||||
|
Timber.tag(loggerTag.value).w("requestAvatar()")
|
||||||
|
coroutineScope.launch {
|
||||||
|
val finalMessenger = messenger
|
||||||
|
if (finalMessenger == null) {
|
||||||
|
Timber.tag(loggerTag.value).w("The messenger is null, can't request extra data")
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Get the data
|
||||||
|
val msg = Message.obtain(null, MSG_GET_AVATAR)
|
||||||
|
msg.data = Bundle().apply {
|
||||||
|
putString(KEY_USER_ID_STR, userId.value)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_SESSION -> onSessionReceived(msg.data)
|
||||||
|
MSG_GET_AVATAR -> onAvatarReceived(msg.data)
|
||||||
|
else -> {
|
||||||
|
Timber.tag(loggerTag.value).w("Received unknown message ${msg.what}")
|
||||||
|
super.handleMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun onSessionReceived(data: Bundle) {
|
||||||
|
// The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied
|
||||||
|
val state = data.toElementClassicConnectionState()
|
||||||
|
coroutineScope.launch {
|
||||||
|
val updatedState = ensureHomeserverIsSupported(state)
|
||||||
|
emitState(updatedState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun onAvatarReceived(data: Bundle) {
|
||||||
|
val currentState = stateFlow.value
|
||||||
|
if (currentState is ElementClassicConnectionState.ElementClassicReady) {
|
||||||
|
// Check that the userId is still the same
|
||||||
|
val userId = data.getString(KEY_USER_ID_STR)
|
||||||
|
if (userId != currentState.elementClassicSession.userId.value) {
|
||||||
|
Timber.tag(loggerTag.value).w(
|
||||||
|
"Received profile data for userId $userId but current" +
|
||||||
|
" userId is ${currentState.elementClassicSession.userId}, ignoring"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java)
|
||||||
|
val updatedState = currentState.copy(
|
||||||
|
avatar = avatar,
|
||||||
|
)
|
||||||
|
coroutineScope.launch {
|
||||||
|
emitState(updatedState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.tag(loggerTag.value).w("Received profile data but current state is not ElementClassicReady: %s", currentState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureHomeserverIsSupported(state: ElementClassicConnectionState): ElementClassicConnectionState {
|
||||||
|
return if (state is ElementClassicConnectionState.ElementClassicReady) {
|
||||||
|
val elementXCanConnect = setOfNotNull(
|
||||||
|
// Try with the domain name first
|
||||||
|
state.elementClassicSession.userId.domainName?.ensureProtocol(),
|
||||||
|
// Then try with the resolved homeserver URL, if provided and distinct
|
||||||
|
state.elementClassicSession.homeserverUrl,
|
||||||
|
).any { url ->
|
||||||
|
val isCompatible = homeServerLoginCompatibilityChecker.check(url)
|
||||||
|
.onFailure {
|
||||||
|
Timber.tag(loggerTag.value).w(it, "Failed to check compatibility with homeserver: $url")
|
||||||
|
}
|
||||||
|
.getOrNull() == true
|
||||||
|
if (isCompatible) {
|
||||||
|
Timber.tag(loggerTag.value).d("Found compatible homeserver URL: %s", url)
|
||||||
|
} else {
|
||||||
|
Timber.tag(loggerTag.value).w("Homeserver URL is not compatible: %s", url)
|
||||||
|
}
|
||||||
|
isCompatible
|
||||||
|
}
|
||||||
|
if (elementXCanConnect) {
|
||||||
|
state
|
||||||
|
} else {
|
||||||
|
Timber.tag(loggerTag.value).w("Cannot import session because the homeserver is not compatible with Element X")
|
||||||
|
ElementClassicConnectionState.Error("The homeserver is not compatible with Element X")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun emitState(state: ElementClassicConnectionState) {
|
||||||
|
when (state) {
|
||||||
|
is ElementClassicConnectionState.Error -> {
|
||||||
|
Timber.tag(loggerTag.value).w("Error: %s", state.error)
|
||||||
|
}
|
||||||
|
is ElementClassicConnectionState.ElementClassicReady -> {
|
||||||
|
Timber.tag(loggerTag.value).d("Ready state for user: %s", state.elementClassicSession.userId)
|
||||||
|
}
|
||||||
|
ElementClassicConnectionState.ElementClassicReadyNoSession -> {
|
||||||
|
Timber.tag(loggerTag.value).d("No session from Element Classic")
|
||||||
|
}
|
||||||
|
ElementClassicConnectionState.ElementClassicNotFound -> {
|
||||||
|
Timber.tag(loggerTag.value).d("Element Classic not found")
|
||||||
|
}
|
||||||
|
ElementClassicConnectionState.Idle -> {
|
||||||
|
Timber.tag(loggerTag.value).d("Idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also give the Element Classic session info to the MatrixAuthenticationService
|
||||||
|
matrixAuthenticationService.setElementClassicSession(
|
||||||
|
session = (state as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession
|
||||||
|
)
|
||||||
|
mutableStateFlow.emit(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getElementClassicComponent() = ComponentName(
|
||||||
|
BuildConfig.elementClassicPackage,
|
||||||
|
ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Bundle.toElementClassicConnectionState(): ElementClassicConnectionState {
|
||||||
|
val error = getString(KEY_ERROR_STR)
|
||||||
|
return if (error != null) {
|
||||||
|
ElementClassicConnectionState.Error(error)
|
||||||
|
} else {
|
||||||
|
val userId = getString(KEY_USER_ID_STR)?.takeIf { it.isNotEmpty() }?.let(::UserId)
|
||||||
|
if (userId == null) {
|
||||||
|
ElementClassicConnectionState.ElementClassicReadyNoSession
|
||||||
|
} else {
|
||||||
|
val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() }
|
||||||
|
val roomKeysVersion = getString(KEY_ROOM_KEYS_VERSION_STR)?.takeIf { it.isNotEmpty() }
|
||||||
|
val homeserverUrl = getString(KEY_HOMESERVER_URL_STR)?.takeIf { it.isNotEmpty() }
|
||||||
|
val displayName = getString(KEY_USER_DISPLAY_NAME_STR)?.takeIf { it.isNotEmpty() }
|
||||||
|
val doesContainBackupKey = secrets != null &&
|
||||||
|
roomKeysVersion != null &&
|
||||||
|
matrixAuthenticationService.doSecretsContainBackupKey(userId, secrets, roomKeysVersion)
|
||||||
|
ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = ElementClassicSession(
|
||||||
|
userId = userId,
|
||||||
|
homeserverUrl = homeserverUrl,
|
||||||
|
secrets = secrets,
|
||||||
|
roomKeysVersion = roomKeysVersion,
|
||||||
|
doesContainBackupKey = doesContainBackupKey,
|
||||||
|
),
|
||||||
|
displayName = displayName,
|
||||||
|
avatar = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything in this companion object must match what is defined in Element Classic
|
||||||
|
companion object {
|
||||||
|
const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
|
||||||
|
|
||||||
|
// Command to the service to get the userId/displayName/secrets of a verified session.
|
||||||
|
const val MSG_GET_SESSION = 1
|
||||||
|
|
||||||
|
// Command to the service to get the avatar oor the session.
|
||||||
|
const val MSG_GET_AVATAR = 2
|
||||||
|
|
||||||
|
// Keys for the bundle returned from the service
|
||||||
|
const val KEY_ERROR_STR = "error"
|
||||||
|
const val KEY_USER_ID_STR = "userId"
|
||||||
|
const val KEY_HOMESERVER_URL_STR = "homeserverUrl"
|
||||||
|
const val KEY_USER_DISPLAY_NAME_STR = "displayName"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key to extract the secrets from the bundle, as a Json string.
|
||||||
|
* Json will have this format:
|
||||||
|
* {
|
||||||
|
* "cross_signing" : {
|
||||||
|
* "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o",
|
||||||
|
* "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms",
|
||||||
|
* "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM"
|
||||||
|
* },
|
||||||
|
* "backup" : {
|
||||||
|
* "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||||
|
* "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc",
|
||||||
|
* "backup_version" : "1"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const val KEY_SECRETS_STR = "secrets"
|
||||||
|
const val KEY_ROOM_KEYS_VERSION_STR = "roomKeysVersion"
|
||||||
|
|
||||||
|
// For the avatar
|
||||||
|
const val KEY_USER_AVATAR_PARCELABLE = "avatar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,6 @@ 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)
|
||||||
|
|
@ -23,7 +21,4 @@ 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>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,19 @@ class LoginHelper(
|
||||||
suspend fun submit(
|
suspend fun submit(
|
||||||
isAccountCreation: Boolean,
|
isAccountCreation: Boolean,
|
||||||
homeserverUrl: String,
|
homeserverUrl: String,
|
||||||
|
resolvedHomeserverUrl: String?,
|
||||||
loginHint: String?,
|
loginHint: String?,
|
||||||
) {
|
) {
|
||||||
suspend {
|
suspend {
|
||||||
authenticationService.setHomeserver(homeserverUrl).map { matrixHomeServerDetails ->
|
authenticationService.setHomeserver(homeserverUrl).recoverCatching {
|
||||||
|
// No .well-known file?
|
||||||
|
// If the homeserver is not reachable, try using resolvedHomeserverUrl.
|
||||||
|
if (resolvedHomeserverUrl != null && resolvedHomeserverUrl != homeserverUrl) {
|
||||||
|
authenticationService.setHomeserver(resolvedHomeserverUrl).getOrThrow()
|
||||||
|
} else {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
}.map { matrixHomeServerDetails ->
|
||||||
if (matrixHomeServerDetails.supportsOidcLogin) {
|
if (matrixHomeServerDetails.supportsOidcLogin) {
|
||||||
// Retrieve the details right now
|
// Retrieve the details right now
|
||||||
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
|
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter(
|
||||||
loginHelper.submit(
|
loginHelper.submit(
|
||||||
isAccountCreation = false,
|
isAccountCreation = false,
|
||||||
homeserverUrl = it.url,
|
homeserverUrl = it.url,
|
||||||
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.features.login.impl.screens.classic.loginwithclassic.LoginWithClassicNode
|
||||||
|
import io.element.android.features.login.impl.screens.classic.missingkeybackup.MissingKeyBackupNode
|
||||||
|
import io.element.android.features.login.impl.screens.classic.root.RootNode
|
||||||
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
|
import io.element.android.libraries.architecture.appyx.rememberFaderOrSliderTransitionHandler
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class ClassicFlowNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val classicFlowNodeHelper: ClassicFlowNodeHelper,
|
||||||
|
) : BaseFlowNode<ClassicFlowNode.NavTarget>(
|
||||||
|
backstack = BackStack(
|
||||||
|
initialElement = NavTarget.Root,
|
||||||
|
savedStateMap = buildContext.savedStateMap,
|
||||||
|
),
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = plugins,
|
||||||
|
) {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun navigateToOnBoarding(allowBackNavigation: Boolean)
|
||||||
|
fun navigateToLoginPassword()
|
||||||
|
fun navigateToOidc(oidcDetails: OidcDetails)
|
||||||
|
fun navigateToCreateAccount(url: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface NavTarget : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
data object Root : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class LoginWithClassic(
|
||||||
|
val userId: UserId,
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object MissingKeyBackup : NavTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback: Callback = callback()
|
||||||
|
|
||||||
|
override fun onBuilt() {
|
||||||
|
super.onBuilt()
|
||||||
|
observeElementClassicConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeElementClassicConnection() {
|
||||||
|
classicFlowNodeHelper.navigationEventFlow().onEach { navigationEvent ->
|
||||||
|
when (navigationEvent) {
|
||||||
|
is NavigationEvent.Idle -> Unit
|
||||||
|
is NavigationEvent.NavigateToOnBoarding -> callback.navigateToOnBoarding(allowBackNavigation = false)
|
||||||
|
is NavigationEvent.NavigateToLoginWithClassic -> backstack.newRoot(NavTarget.LoginWithClassic(navigationEvent.userId))
|
||||||
|
}
|
||||||
|
}.launchIn(lifecycleScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolve(
|
||||||
|
navTarget: NavTarget,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
): Node {
|
||||||
|
return when (navTarget) {
|
||||||
|
NavTarget.Root -> {
|
||||||
|
createNode<RootNode>(buildContext)
|
||||||
|
}
|
||||||
|
is NavTarget.LoginWithClassic -> {
|
||||||
|
val callback = object : LoginWithClassicNode.Callback {
|
||||||
|
override fun navigateToOtherOptions() {
|
||||||
|
callback.navigateToOnBoarding(allowBackNavigation = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToLoginPassword() {
|
||||||
|
callback.navigateToLoginPassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToOidc(oidcDetails: OidcDetails) {
|
||||||
|
callback.navigateToOidc(oidcDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToCreateAccount(url: String) {
|
||||||
|
callback.navigateToCreateAccount(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun navigateToMissingKeyBackup() {
|
||||||
|
backstack.push(NavTarget.MissingKeyBackup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val inputs = LoginWithClassicNode.Inputs(
|
||||||
|
userId = navTarget.userId,
|
||||||
|
)
|
||||||
|
createNode<LoginWithClassicNode>(buildContext, plugins = listOf(inputs, callback))
|
||||||
|
}
|
||||||
|
NavTarget.MissingKeyBackup -> {
|
||||||
|
val callback = object : MissingKeyBackupNode.Callback {
|
||||||
|
override fun navigateBack() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<MissingKeyBackupNode>(buildContext, listOf(callback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
BackstackView(
|
||||||
|
modifier = modifier,
|
||||||
|
transitionHandler = rememberFaderOrSliderTransitionHandler(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.api.toUserListFlow
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class ClassicFlowNodeHelper(
|
||||||
|
private val elementClassicConnection: ElementClassicConnection,
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
|
) {
|
||||||
|
// Ensure user is not stuck on the loading screen.
|
||||||
|
// If Element Classic is taking too long to communicate (or crashes), unblock the user after a few seconds.
|
||||||
|
private val timeoutFLow = flow {
|
||||||
|
emit(false)
|
||||||
|
delay(5_000)
|
||||||
|
emit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigationEventFlow(): Flow<NavigationEvent> {
|
||||||
|
return combine(
|
||||||
|
timeoutFLow,
|
||||||
|
elementClassicConnection.stateFlow
|
||||||
|
.distinctUntilChangedBy {
|
||||||
|
// Ignore change on ElementClassicConnectionState.ElementClassicReady.avatar
|
||||||
|
if (it is ElementClassicConnectionState.ElementClassicReady) {
|
||||||
|
it.copy(avatar = null)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sessionStore.sessionsFlow().toUserListFlow()
|
||||||
|
// Take only 1 emission of the sessions, else when the user actually logged in it will trigger a navigation to OnBoarding.
|
||||||
|
.take(1),
|
||||||
|
) { timeout, elementClassicConnectionState, existingSessions ->
|
||||||
|
when (elementClassicConnectionState) {
|
||||||
|
ElementClassicConnectionState.Idle -> {
|
||||||
|
if (timeout) {
|
||||||
|
NavigationEvent.NavigateToOnBoarding
|
||||||
|
} else {
|
||||||
|
NavigationEvent.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementClassicConnectionState.ElementClassicNotFound,
|
||||||
|
ElementClassicConnectionState.ElementClassicReadyNoSession,
|
||||||
|
is ElementClassicConnectionState.Error -> {
|
||||||
|
NavigationEvent.NavigateToOnBoarding
|
||||||
|
}
|
||||||
|
is ElementClassicConnectionState.ElementClassicReady -> {
|
||||||
|
if (elementClassicConnectionState.elementClassicSession.userId.value in existingSessions) {
|
||||||
|
NavigationEvent.NavigateToOnBoarding
|
||||||
|
} else {
|
||||||
|
// 2 cases when this can be run:
|
||||||
|
// First time this screen will be displayed
|
||||||
|
// Missing key backup screen was displayed, but the data has changed (user set up the key backup on Classic),
|
||||||
|
// and the app is resuming.
|
||||||
|
NavigationEvent.NavigateToLoginWithClassic(elementClassicConnectionState.elementClassicSession.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
sealed interface NavigationEvent {
|
||||||
|
data object Idle : NavigationEvent
|
||||||
|
data object NavigateToOnBoarding : NavigationEvent
|
||||||
|
data class NavigateToLoginWithClassic(
|
||||||
|
val userId: UserId,
|
||||||
|
) : NavigationEvent
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,10 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.onboarding.classic
|
package io.element.android.features.login.impl.screens.classic.loginwithclassic
|
||||||
|
|
||||||
sealed interface LoginWithClassicEvent {
|
sealed interface LoginWithClassicEvent {
|
||||||
data object RefreshData : LoginWithClassicEvent
|
data object RefreshData : LoginWithClassicEvent
|
||||||
data object StartLoginWithClassic : LoginWithClassicEvent
|
data object Submit : LoginWithClassicEvent
|
||||||
data object DoLoginWithClassic : LoginWithClassicEvent
|
data object ClearError : LoginWithClassicEvent
|
||||||
data object CloseDialog : LoginWithClassicEvent
|
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
interface LoginWithClassicNavigator {
|
||||||
|
fun navigateToMissingKeyBackup()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.features.login.impl.util.openLearnMorePage
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class LoginWithClassicNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: LoginWithClassicPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins),
|
||||||
|
LoginWithClassicNavigator {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun navigateToOtherOptions()
|
||||||
|
fun navigateToLoginPassword()
|
||||||
|
fun navigateToOidc(oidcDetails: OidcDetails)
|
||||||
|
fun navigateToCreateAccount(url: String)
|
||||||
|
fun navigateToMissingKeyBackup()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Inputs(
|
||||||
|
val userId: UserId,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
val presenter = presenterFactory.create(inputs.userId, this)
|
||||||
|
private val callback: Callback = callback()
|
||||||
|
|
||||||
|
override fun navigateToMissingKeyBackup() {
|
||||||
|
callback.navigateToMissingKeyBackup()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state = presenter.present()
|
||||||
|
LoginWithClassicView(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
onOtherOptionsClick = callback::navigateToOtherOptions,
|
||||||
|
onOidcDetails = callback::navigateToOidc,
|
||||||
|
onNeedLoginPassword = callback::navigateToLoginPassword,
|
||||||
|
onLearnMoreClick = { openLearnMorePage(context) },
|
||||||
|
onCreateAccountContinue = callback::navigateToCreateAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
|
||||||
|
import io.element.android.features.login.impl.login.LoginHelper
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@AssistedInject
|
||||||
|
class LoginWithClassicPresenter(
|
||||||
|
@Assisted private val userId: UserId,
|
||||||
|
@Assisted private val navigator: LoginWithClassicNavigator,
|
||||||
|
private val loginHelper: LoginHelper,
|
||||||
|
private val elementClassicConnection: ElementClassicConnection,
|
||||||
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Presenter<LoginWithClassicState> {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(
|
||||||
|
userId: UserId,
|
||||||
|
navigator: LoginWithClassicNavigator,
|
||||||
|
): LoginWithClassicPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): LoginWithClassicState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var loginWithClassicAction by remember {
|
||||||
|
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||||
|
}
|
||||||
|
val loginMode by loginHelper.collectLoginMode()
|
||||||
|
val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState()
|
||||||
|
|
||||||
|
fun handleEvent(event: LoginWithClassicEvent) {
|
||||||
|
when (event) {
|
||||||
|
LoginWithClassicEvent.RefreshData -> {
|
||||||
|
// Request the avatar if not known yet
|
||||||
|
val currentState = elementClassicConnection.stateFlow.value
|
||||||
|
if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) {
|
||||||
|
elementClassicConnection.requestAvatar(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoginWithClassicEvent.Submit -> {
|
||||||
|
val currentState = elementClassicConnection.stateFlow.value
|
||||||
|
if (currentState is ElementClassicConnectionState.ElementClassicReady) {
|
||||||
|
if (currentState.elementClassicSession.secrets != null &&
|
||||||
|
!currentState.elementClassicSession.doesContainBackupKey) {
|
||||||
|
navigator.navigateToMissingKeyBackup()
|
||||||
|
} else {
|
||||||
|
coroutineScope.launch {
|
||||||
|
loginWithClassicAction = AsyncAction.Loading
|
||||||
|
// Ensure that the current account provider is set
|
||||||
|
val elementClassicUserId = currentState.elementClassicSession.userId
|
||||||
|
val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol()
|
||||||
|
accountProviderDataSource.setUrl(accountProvider)
|
||||||
|
loginHelper.submit(
|
||||||
|
isAccountCreation = false,
|
||||||
|
homeserverUrl = accountProvider,
|
||||||
|
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
|
||||||
|
loginHint = "mxid:" + elementClassicUserId.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loginWithClassicAction = AsyncAction.Failure(IllegalStateException("Element Classic is not ready"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoginWithClassicEvent.ClearError -> {
|
||||||
|
loginWithClassicAction = AsyncAction.Uninitialized
|
||||||
|
loginHelper.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val elementClassicReady = elementClassicConnectionState as? ElementClassicConnectionState.ElementClassicReady
|
||||||
|
return LoginWithClassicState(
|
||||||
|
isElementPro = buildMeta.isEnterpriseBuild,
|
||||||
|
userId = userId,
|
||||||
|
displayName = elementClassicReady?.displayName,
|
||||||
|
avatar = elementClassicReady?.avatar,
|
||||||
|
loginMode = loginMode,
|
||||||
|
loginWithClassicAction = loginWithClassicAction,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import io.element.android.features.login.impl.login.LoginMode
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class LoginWithClassicState(
|
||||||
|
val isElementPro: Boolean,
|
||||||
|
val userId: UserId,
|
||||||
|
val displayName: String?,
|
||||||
|
val avatar: Bitmap?,
|
||||||
|
val loginWithClassicAction: AsyncAction<Unit>,
|
||||||
|
val loginMode: AsyncData<LoginMode>,
|
||||||
|
val eventSink: (LoginWithClassicEvent) -> Unit,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.login.impl.login.LoginMode
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
open class LoginWithClassicStateProvider : PreviewParameterProvider<LoginWithClassicState> {
|
||||||
|
override val values: Sequence<LoginWithClassicState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aLoginWithClassicState(),
|
||||||
|
aLoginWithClassicState(isElementPro = true, displayName = "Alice"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aLoginWithClassicState(
|
||||||
|
isElementPro: Boolean = false,
|
||||||
|
userId: UserId = UserId("@alice:matrix.org"),
|
||||||
|
displayName: String? = null,
|
||||||
|
avatar: Bitmap? = null,
|
||||||
|
loginWithClassicAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||||
|
eventSink: (LoginWithClassicEvent) -> Unit = {},
|
||||||
|
) = LoginWithClassicState(
|
||||||
|
isElementPro = isElementPro,
|
||||||
|
userId = userId,
|
||||||
|
displayName = displayName,
|
||||||
|
avatar = avatar,
|
||||||
|
loginWithClassicAction = loginWithClassicAction,
|
||||||
|
loginMode = loginMode,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.features.login.impl.login.LoginModeView
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||||
|
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.BitmapAvatar
|
||||||
|
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
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.testtags.testTag
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginWithClassicView(
|
||||||
|
state: LoginWithClassicState,
|
||||||
|
onOtherOptionsClick: () -> Unit,
|
||||||
|
onOidcDetails: (OidcDetails) -> Unit,
|
||||||
|
onNeedLoginPassword: () -> Unit,
|
||||||
|
onLearnMoreClick: () -> Unit,
|
||||||
|
onCreateAccountContinue: (url: String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
state.eventSink(LoginWithClassicEvent.RefreshData)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLoading by remember(state.loginMode) {
|
||||||
|
derivedStateOf {
|
||||||
|
state.loginMode is AsyncData.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderFooterPage(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.imePadding(),
|
||||||
|
background = { OnboardingBackground() },
|
||||||
|
isScrollable = true,
|
||||||
|
header = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(54.dp)
|
||||||
|
.shadow(elevation = 10.dp, shape = RoundedCornerShape(15.dp))
|
||||||
|
.background(ElementTheme.colors.bgCanvasDefault, shape = RoundedCornerShape(15.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val resId = if (state.isElementPro) {
|
||||||
|
R.drawable.element_pro_logo
|
||||||
|
} else {
|
||||||
|
R.drawable.element_foss_logo
|
||||||
|
}
|
||||||
|
Image(
|
||||||
|
modifier = Modifier.size(37.5.dp),
|
||||||
|
painter = painterResource(id = resId),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.screen_onboarding_welcome_title),
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
BitmapAvatar(
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = state.userId.value,
|
||||||
|
name = state.displayName,
|
||||||
|
// Not used here
|
||||||
|
url = null,
|
||||||
|
size = AvatarSize.UserHeader,
|
||||||
|
),
|
||||||
|
bitmap = state.avatar,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
text = stringResource(R.string.screen_onboarding_welcome_back),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
// User display name
|
||||||
|
if (state.displayName != null) {
|
||||||
|
Text(
|
||||||
|
text = state.displayName,
|
||||||
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
// UserId
|
||||||
|
Text(
|
||||||
|
text = state.userId.value,
|
||||||
|
style = if (state.displayName == null) ElementTheme.typography.fontHeadingLgBold else ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
// Min spacing
|
||||||
|
Spacer(Modifier.height(45.dp))
|
||||||
|
ButtonColumnMolecule {
|
||||||
|
Button(
|
||||||
|
text = stringResource(CommonStrings.action_continue),
|
||||||
|
showProgress = isLoading,
|
||||||
|
onClick = {
|
||||||
|
state.eventSink(LoginWithClassicEvent.Submit)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.loginContinue)
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
text = stringResource(CommonStrings.common_other_options),
|
||||||
|
onClick = onOtherOptionsClick,
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.loginContinue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncActionView(
|
||||||
|
async = state.loginWithClassicAction,
|
||||||
|
onErrorDismiss = {
|
||||||
|
state.eventSink(LoginWithClassicEvent.ClearError)
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
// noop, the view will be closed
|
||||||
|
},
|
||||||
|
progressDialog = {
|
||||||
|
// The button is showing the progress
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LoginModeView(
|
||||||
|
loginMode = state.loginMode,
|
||||||
|
onClearError = {
|
||||||
|
state.eventSink(LoginWithClassicEvent.ClearError)
|
||||||
|
},
|
||||||
|
onLearnMoreClick = onLearnMoreClick,
|
||||||
|
onOidcDetails = onOidcDetails,
|
||||||
|
onNeedLoginPassword = onNeedLoginPassword,
|
||||||
|
onCreateAccountContinue = onCreateAccountContinue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicStateProvider::class) state: LoginWithClassicState) = ElementPreview {
|
||||||
|
LoginWithClassicView(
|
||||||
|
state = state,
|
||||||
|
onOtherOptionsClick = {},
|
||||||
|
onOidcDetails = {},
|
||||||
|
onNeedLoginPassword = {},
|
||||||
|
onLearnMoreClick = {},
|
||||||
|
onCreateAccountContinue = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
sealed interface MissingKeyBackupEvent {
|
||||||
|
data object OnResume : MissingKeyBackupEvent
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.features.login.impl.BuildConfig
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class MissingKeyBackupNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: MissingKeyBackupPresenter,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback: Callback = callback()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Element Classic application.
|
||||||
|
*/
|
||||||
|
private fun openClassic(context: Context) {
|
||||||
|
context.packageManager.getLaunchIntentForPackage(
|
||||||
|
BuildConfig.elementClassicPackage,
|
||||||
|
)?.let { intent ->
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// Should not happen, Element Classic must be installed for this screen to be displayed.
|
||||||
|
Timber.e(e, "Element Classic app not found, cannot open it.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
val context = LocalContext.current
|
||||||
|
MissingKeyBackupView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = callback::navigateBack,
|
||||||
|
onOpenClassicClick = {
|
||||||
|
openClassic(context)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class MissingKeyBackupPresenter(
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
private val elementClassicConnection: ElementClassicConnection,
|
||||||
|
) : Presenter<MissingKeyBackupState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): MissingKeyBackupState {
|
||||||
|
var resumeCounter by remember { mutableIntStateOf(0) }
|
||||||
|
fun handleEvent(event: MissingKeyBackupEvent) {
|
||||||
|
when (event) {
|
||||||
|
MissingKeyBackupEvent.OnResume -> {
|
||||||
|
resumeCounter++
|
||||||
|
if (resumeCounter > 1) {
|
||||||
|
// The user has returned to this screen, we can assume they have gone to the backup flow and are now back here
|
||||||
|
elementClassicConnection.requestSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MissingKeyBackupState(
|
||||||
|
appName = buildMeta.applicationName,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
data class MissingKeyBackupState(
|
||||||
|
val appName: String,
|
||||||
|
val eventSink: (MissingKeyBackupEvent) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
open class MissingKeyBackupStateProvider : PreviewParameterProvider<MissingKeyBackupState> {
|
||||||
|
override val values: Sequence<MissingKeyBackupState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aMissingKeyBackupState(),
|
||||||
|
// Add other state here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aMissingKeyBackupState(
|
||||||
|
appName: String = "AppName",
|
||||||
|
eventSink: (MissingKeyBackupEvent) -> Unit = {},
|
||||||
|
) = MissingKeyBackupState(
|
||||||
|
appName = appName,
|
||||||
|
eventSink = eventSink
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
|
||||||
|
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
|
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
|
||||||
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MissingKeyBackupView(
|
||||||
|
state: MissingKeyBackupState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onOpenClassicClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
OnLifecycleEvent { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
state.eventSink.invoke(MissingKeyBackupEvent.OnResume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowStepPage(
|
||||||
|
modifier = modifier,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
|
||||||
|
title = stringResource(id = R.string.screen_missing_key_backup_title, state.appName),
|
||||||
|
content = { Content(state) },
|
||||||
|
buttons = {
|
||||||
|
Buttons(
|
||||||
|
onOpenClassicClick = onOpenClassicClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Content(
|
||||||
|
state: MissingKeyBackupState,
|
||||||
|
) {
|
||||||
|
NumberedListOrganism(
|
||||||
|
modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp),
|
||||||
|
items = persistentListOf(
|
||||||
|
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_1)),
|
||||||
|
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_2_android)),
|
||||||
|
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_3_android)),
|
||||||
|
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_4)),
|
||||||
|
AnnotatedString(stringResource(R.string.screen_missing_key_backup_step_5, state.appName)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.Buttons(
|
||||||
|
onOpenClassicClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
text = stringResource(id = R.string.screen_missing_key_backup_open_element_classic),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = onOpenClassicClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MissingKeyBackupViewPreview(@PreviewParameter(MissingKeyBackupStateProvider::class) state: MissingKeyBackupState) = ElementPreview {
|
||||||
|
MissingKeyBackupView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = {},
|
||||||
|
onOpenClassicClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.root
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class RootNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
RootView(modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.root
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RootView(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
DelayedVisibility(
|
||||||
|
duration = 100.milliseconds,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun RootViewPreview() = ElementPreview {
|
||||||
|
RootView()
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ class ConfirmAccountProviderPresenter(
|
||||||
loginHelper.submit(
|
loginHelper.submit(
|
||||||
isAccountCreation = params.isAccountCreation,
|
isAccountCreation = params.isAccountCreation,
|
||||||
homeserverUrl = accountProvider.url,
|
homeserverUrl = accountProvider.url,
|
||||||
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,23 @@ import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class LoginPasswordNode(
|
class LoginPasswordNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: LoginPasswordPresenter,
|
presenterFactory: LoginPasswordPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
data class Inputs(
|
||||||
|
val initialLogin: String,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
private val presenter = presenterFactory.create(inputs.initialLogin)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -25,11 +27,18 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Inject
|
@AssistedInject
|
||||||
class LoginPasswordPresenter(
|
class LoginPasswordPresenter(
|
||||||
|
@Assisted
|
||||||
|
private val initialLogin: String,
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
) : Presenter<LoginPasswordState> {
|
) : Presenter<LoginPasswordState> {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(initialLogin: String): LoginPasswordPresenter
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoginPasswordState {
|
override fun present(): LoginPasswordState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -38,7 +47,12 @@ class LoginPasswordPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
val formState = rememberSaveable {
|
val formState = rememberSaveable {
|
||||||
mutableStateOf(LoginFormState.Default)
|
mutableStateOf(
|
||||||
|
LoginFormState(
|
||||||
|
login = initialLogin,
|
||||||
|
password = "",
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class OnBoardingNode(
|
||||||
data class Params(
|
data class Params(
|
||||||
val accountProvider: String?,
|
val accountProvider: String?,
|
||||||
val loginHint: String?,
|
val loginHint: String?,
|
||||||
|
val showBackButton: Boolean,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
|
@ -61,6 +62,7 @@ class OnBoardingNode(
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
OnBoardingView(
|
OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ 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
|
||||||
|
|
@ -45,7 +44,6 @@ 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 {
|
||||||
|
|
@ -101,8 +99,6 @@ 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 {
|
||||||
|
|
@ -111,6 +107,7 @@ class OnBoardingPresenter(
|
||||||
loginHelper.submit(
|
loginHelper.submit(
|
||||||
isAccountCreation = false,
|
isAccountCreation = false,
|
||||||
homeserverUrl = event.defaultAccountProvider,
|
homeserverUrl = event.defaultAccountProvider,
|
||||||
|
resolvedHomeserverUrl = null,
|
||||||
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +124,7 @@ class OnBoardingPresenter(
|
||||||
|
|
||||||
return OnBoardingState(
|
return OnBoardingState(
|
||||||
isAddingAccount = isAddingAccount,
|
isAddingAccount = isAddingAccount,
|
||||||
|
showBackButton = params.showBackButton,
|
||||||
productionApplicationName = buildMeta.productionApplicationName,
|
productionApplicationName = buildMeta.productionApplicationName,
|
||||||
defaultAccountProvider = defaultAccountProvider,
|
defaultAccountProvider = defaultAccountProvider,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
|
@ -136,7 +134,6 @@ class OnBoardingPresenter(
|
||||||
loginMode = loginMode,
|
loginMode = loginMode,
|
||||||
version = buildMeta.versionName,
|
version = buildMeta.versionName,
|
||||||
onBoardingLogoResId = onBoardingLogoResId,
|
onBoardingLogoResId = onBoardingLogoResId,
|
||||||
loginWithClassicState = loginWithClassicState,
|
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ 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(
|
||||||
val isAddingAccount: Boolean,
|
val isAddingAccount: Boolean,
|
||||||
|
val showBackButton: Boolean,
|
||||||
val productionApplicationName: String,
|
val productionApplicationName: String,
|
||||||
val defaultAccountProvider: String?,
|
val defaultAccountProvider: String?,
|
||||||
val mustChooseAccountProvider: Boolean,
|
val mustChooseAccountProvider: Boolean,
|
||||||
|
|
@ -25,7 +25,6 @@ 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,8 +11,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -31,11 +29,15 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
||||||
canLoginWithQrCode = true,
|
canLoginWithQrCode = true,
|
||||||
canCreateAccount = true,
|
canCreateAccount = true,
|
||||||
),
|
),
|
||||||
|
anOnBoardingState(
|
||||||
|
showBackButton = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anOnBoardingState(
|
fun anOnBoardingState(
|
||||||
isAddingAccount: Boolean = false,
|
isAddingAccount: Boolean = false,
|
||||||
|
showBackButton: Boolean = false,
|
||||||
productionApplicationName: String = "Element",
|
productionApplicationName: String = "Element",
|
||||||
defaultAccountProvider: String? = null,
|
defaultAccountProvider: String? = null,
|
||||||
mustChooseAccountProvider: Boolean = false,
|
mustChooseAccountProvider: Boolean = false,
|
||||||
|
|
@ -46,10 +48,10 @@ 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,
|
||||||
|
showBackButton = showBackButton,
|
||||||
productionApplicationName = productionApplicationName,
|
productionApplicationName = productionApplicationName,
|
||||||
defaultAccountProvider = defaultAccountProvider,
|
defaultAccountProvider = defaultAccountProvider,
|
||||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||||
|
|
@ -59,6 +61,5 @@ fun anOnBoardingState(
|
||||||
version = version,
|
version = version,
|
||||||
loginMode = loginMode,
|
loginMode = loginMode,
|
||||||
onBoardingLogoResId = customLogoResId,
|
onBoardingLogoResId = customLogoResId,
|
||||||
loginWithClassicState = loginWithClassicState,
|
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,10 @@ 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
|
||||||
|
|
@ -47,11 +42,11 @@ 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
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
|
@ -114,45 +109,9 @@ fun OnBoardingView(
|
||||||
state = state,
|
state = state,
|
||||||
loginView = loginView,
|
loginView = loginView,
|
||||||
buttons = buttons,
|
buttons = buttons,
|
||||||
|
onBackClick = onBackClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -160,12 +119,16 @@ private fun AddFirstAccountScaffold(
|
||||||
state: OnBoardingState,
|
state: OnBoardingState,
|
||||||
loginView: @Composable () -> Unit,
|
loginView: @Composable () -> Unit,
|
||||||
buttons: @Composable () -> Unit,
|
buttons: @Composable () -> Unit,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnBoardingPage(
|
OnBoardingPage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
renderBackground = state.onBoardingLogoResId == null,
|
renderBackground = state.onBoardingLogoResId == null,
|
||||||
content = {
|
content = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
if (state.onBoardingLogoResId != null) {
|
if (state.onBoardingLogoResId != null) {
|
||||||
OnBoardingLogo(
|
OnBoardingLogo(
|
||||||
onBoardingLogoResId = state.onBoardingLogoResId,
|
onBoardingLogoResId = state.onBoardingLogoResId,
|
||||||
|
|
@ -173,6 +136,20 @@ private fun AddFirstAccountScaffold(
|
||||||
} else {
|
} else {
|
||||||
OnBoardingContent(state = state)
|
OnBoardingContent(state = state)
|
||||||
}
|
}
|
||||||
|
if (state.showBackButton) {
|
||||||
|
// Add icon button to "navigate back"
|
||||||
|
IconButton(
|
||||||
|
onClick = onBackClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.Close(),
|
||||||
|
contentDescription = stringResource(CommonStrings.action_cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
loginView()
|
loginView()
|
||||||
},
|
},
|
||||||
footer = {
|
footer = {
|
||||||
|
|
@ -283,18 +260,6 @@ 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),
|
||||||
|
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
/*
|
|
||||||
* 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.features.login.impl.BuildConfig
|
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
|
||||||
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,
|
|
||||||
val secrets: String,
|
|
||||||
) : 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,
|
|
||||||
) : 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())
|
|
||||||
if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
|
|
||||||
Timber.tag(loggerTag.value).d("Binding returned true")
|
|
||||||
} else {
|
|
||||||
// This happens 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() = ComponentName(
|
|
||||||
BuildConfig.elementClassicPackage,
|
|
||||||
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)?.takeIf { it.isNotEmpty() }?.let(::UserId)
|
|
||||||
if (userId != null) {
|
|
||||||
val secrets = getString(KEY_SECRETS_STR)?.takeIf { it.isNotEmpty() }
|
|
||||||
if (secrets == null) {
|
|
||||||
ElementClassicConnectionState.Error("No secrets received from Element Classic")
|
|
||||||
} else {
|
|
||||||
ElementClassicConnectionState.ElementClassicReady(userId, secrets)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ElementClassicConnectionState.ElementClassicReadyNoSession
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything in this companion object must match what is defined in Element Classic
|
|
||||||
private companion object {
|
|
||||||
const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
|
|
||||||
|
|
||||||
// Command to the service to get the data.
|
|
||||||
const val MSG_GET_DATA = 1
|
|
||||||
|
|
||||||
// Keys for the bundle returned from the service
|
|
||||||
const val KEY_ERROR_STR = "error"
|
|
||||||
const val KEY_USER_ID_STR = "userId"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Key to extract the secrets from the bundle, as a Json string.
|
|
||||||
* Json will have this format:
|
|
||||||
* {
|
|
||||||
* "cross_signing" : {
|
|
||||||
* "master_key" : "z8RUxnaAGu___REDACTED___k+BQL9o",
|
|
||||||
* "user_signing_key" : "baJHzA___REDACTED___xMLbSUAXw9QUzqms",
|
|
||||||
* "self_signing_key" : "DU0CvLtR2G/___REDACTED___dV/MONNq4nsQhM"
|
|
||||||
* },
|
|
||||||
* "backup" : {
|
|
||||||
* "algorithm" : "m.megolm_backup.v1.curve25519-aes-sha2",
|
|
||||||
* "key" : "VzncmQ+UOV___REDACTED___patxDz7m0Nc",
|
|
||||||
* "backup_version" : "1"
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
const val KEY_SECRETS_STR = "secrets"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -37,11 +37,19 @@
|
||||||
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
|
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
|
||||||
<string name="screen_login_title">"Welcome back!"</string>
|
<string name="screen_login_title">"Welcome back!"</string>
|
||||||
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
|
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
|
||||||
|
<string name="screen_missing_key_backup_open_element_classic">"Open Element Classic"</string>
|
||||||
|
<string name="screen_missing_key_backup_step_1">"Open Element Classic on your device"</string>
|
||||||
|
<string name="screen_missing_key_backup_step_2_android">"Go to Settings > Security & Privacy"</string>
|
||||||
|
<string name="screen_missing_key_backup_step_3_android">"In Cryptography keys management, select Encrypted message recovery"</string>
|
||||||
|
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
|
||||||
|
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
|
||||||
|
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
|
||||||
<string name="screen_onboarding_app_version">"Version %1$s"</string>
|
<string name="screen_onboarding_app_version">"Version %1$s"</string>
|
||||||
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
|
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
|
||||||
<string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string>
|
<string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string>
|
||||||
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
|
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
|
||||||
<string name="screen_onboarding_sign_up">"Create account"</string>
|
<string name="screen_onboarding_sign_up">"Create account"</string>
|
||||||
|
<string name="screen_onboarding_welcome_back">"Welcome back"</string>
|
||||||
<string name="screen_onboarding_welcome_message">"Welcome to the fastest %1$s ever. Supercharged for speed and simplicity."</string>
|
<string name="screen_onboarding_welcome_message">"Welcome to the fastest %1$s ever. Supercharged for speed and simplicity."</string>
|
||||||
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
|
<string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string>
|
||||||
<string name="screen_onboarding_welcome_title">"Be in your element"</string>
|
<string name="screen_onboarding_welcome_title">"Be in your element"</string>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
import io.element.android.features.login.api.LoginEntryPoint
|
import io.element.android.features.login.api.LoginEntryPoint
|
||||||
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.classic.FakeElementClassicConnection
|
||||||
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
|
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.node.TestParentNode
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
|
@ -39,6 +40,7 @@ class DefaultLoginEntryPointTest {
|
||||||
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
oidcActionFlow = FakeOidcActionFlow(),
|
oidcActionFlow = FakeOidcActionFlow(),
|
||||||
appCoroutineScope = backgroundScope,
|
appCoroutineScope = backgroundScope,
|
||||||
|
elementClassicConnection = FakeElementClassicConnection(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : LoginEntryPoint.Callback {
|
val callback = object : LoginEntryPoint.Callback {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,429 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.androidutils.service.ServiceBinder
|
||||||
|
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||||
|
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||||
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_SECRET
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DefaultElementClassicConnectionTest {
|
||||||
|
@Test
|
||||||
|
fun `connection can be started Element Classic service can be bound`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
serviceBinder = FakeServiceBinder(
|
||||||
|
bindServiceResult = {
|
||||||
|
// Element Classic is found
|
||||||
|
true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
connection.start()
|
||||||
|
runCurrent()
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `connection can be started Element Classic service cannot be bound`() = runTest {
|
||||||
|
val setElementClassicSessionResult = lambdaRecorder<ElementClassicSession?, Unit> { }
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
serviceBinder = FakeServiceBinder(
|
||||||
|
bindServiceResult = {
|
||||||
|
// Element Classic not found
|
||||||
|
false
|
||||||
|
},
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = setElementClassicSessionResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
connection.start()
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicNotFound)
|
||||||
|
setElementClassicSessionResult.assertions().isCalledOnce().with(value(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `connection cannot be started in case of security error`() = runTest {
|
||||||
|
val setElementClassicSessionResult = lambdaRecorder<ElementClassicSession?, Unit> { }
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
serviceBinder = FakeServiceBinder(
|
||||||
|
bindServiceResult = { throw SecurityException(A_FAILURE_REASON) },
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = setElementClassicSessionResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
connection.start()
|
||||||
|
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
|
||||||
|
setElementClassicSessionResult.assertions().isCalledOnce().with(value(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `requestSession when messenger is not ready has no effect`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection()
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
connection.requestSession()
|
||||||
|
runCurrent()
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when an error is received, an error is emitted`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_ERROR_STR, A_FAILURE_REASON)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when there is no Element Classic session, ElementClassicReadyNoSession is emitted`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving no session from Element Classic
|
||||||
|
connection.onSessionReceived(Bundle())
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicReadyNoSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when there is Element Classic session with empty userId, ElementClassicReadyNoSession is emitted`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving empty userId from Element Classic
|
||||||
|
connection.onSessionReceived(Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, "")
|
||||||
|
})
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.ElementClassicReadyNoSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when session is received, but homeserver is not supported, an error is emitted`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(false) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when session is received without secrets, and homeserver is supported, ElementClassicReady is emitted`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = ElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
homeserverUrl = null,
|
||||||
|
secrets = null,
|
||||||
|
roomKeysVersion = null,
|
||||||
|
doesContainBackupKey = false,
|
||||||
|
),
|
||||||
|
displayName = null,
|
||||||
|
avatar = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when session is received with all data including key backup, and homeserver is supported, ElementClassicReady is emitted`() {
|
||||||
|
`when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`(
|
||||||
|
withKeyBackup = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when session is received with all data without key backup, and homeserver is supported, ElementClassicReady is emitted - backup key is missing`() {
|
||||||
|
`when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`(
|
||||||
|
withKeyBackup = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun `when session is received with all data, and homeserver is supported, ElementClassicReady is emitted`(
|
||||||
|
withKeyBackup: Boolean,
|
||||||
|
) = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
doSecretsContainBackupKeyResult = { _, _, _ -> withKeyBackup },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, A_HOMESERVER_URL)
|
||||||
|
putString(DefaultElementClassicConnection.KEY_SECRETS_STR, A_SECRET)
|
||||||
|
putString(DefaultElementClassicConnection.KEY_ROOM_KEYS_VERSION_STR, ROOM_KEYS_VERSION)
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, A_USER_NAME)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = ElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
homeserverUrl = A_HOMESERVER_URL,
|
||||||
|
secrets = A_SECRET,
|
||||||
|
roomKeysVersion = ROOM_KEYS_VERSION,
|
||||||
|
doesContainBackupKey = withKeyBackup,
|
||||||
|
),
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
avatar = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when session is received with empty data, and homeserver is supported, ElementClassicReady is emitted`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
putString(DefaultElementClassicConnection.KEY_HOMESERVER_URL_STR, "")
|
||||||
|
putString(DefaultElementClassicConnection.KEY_SECRETS_STR, "")
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_DISPLAY_NAME_STR, "")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = ElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
homeserverUrl = null,
|
||||||
|
secrets = null,
|
||||||
|
roomKeysVersion = null,
|
||||||
|
doesContainBackupKey = false,
|
||||||
|
),
|
||||||
|
displayName = null,
|
||||||
|
avatar = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when avatar is received when the state is not ElementClassicReady, nothing happen`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving an avatar from Element Classic
|
||||||
|
connection.onAvatarReceived(Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888))
|
||||||
|
})
|
||||||
|
runCurrent()
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when avatar is received when the state is ElementClassicReady with a different user, nothing happen`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = ElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
homeserverUrl = null,
|
||||||
|
secrets = null,
|
||||||
|
roomKeysVersion = null,
|
||||||
|
doesContainBackupKey = false,
|
||||||
|
),
|
||||||
|
displayName = null,
|
||||||
|
avatar = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Simulate receiving an avatar for another user from Element Classic
|
||||||
|
connection.onAvatarReceived(Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID_2.value)
|
||||||
|
putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888))
|
||||||
|
})
|
||||||
|
runCurrent()
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when avatar is received, the state is updated`() = runTest {
|
||||||
|
val connection = createDefaultElementClassicConnection(
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
matrixAuthenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setElementClassicSessionResult = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.stateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
|
||||||
|
// Simulate receiving a session from Element Classic
|
||||||
|
connection.onSessionReceived(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assertThat(awaitItem()).isEqualTo(
|
||||||
|
ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = ElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
homeserverUrl = null,
|
||||||
|
secrets = null,
|
||||||
|
roomKeysVersion = null,
|
||||||
|
doesContainBackupKey = false,
|
||||||
|
),
|
||||||
|
displayName = null,
|
||||||
|
avatar = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Simulate receiving an avatar from Element Classic
|
||||||
|
connection.onAvatarReceived(Bundle().apply {
|
||||||
|
putString(DefaultElementClassicConnection.KEY_USER_ID_STR, A_USER_ID.value)
|
||||||
|
putParcelable(DefaultElementClassicConnection.KEY_USER_AVATAR_PARCELABLE, createBitmap(1, 1, Bitmap.Config.ARGB_8888))
|
||||||
|
})
|
||||||
|
assertThat((awaitItem() as? ElementClassicConnectionState.ElementClassicReady)?.avatar).isNotNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createDefaultElementClassicConnection(
|
||||||
|
serviceBinder: ServiceBinder = FakeServiceBinder(
|
||||||
|
bindServiceResult = { true },
|
||||||
|
unbindServiceResult = { },
|
||||||
|
),
|
||||||
|
coroutineScope: CoroutineScope = backgroundScope,
|
||||||
|
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
|
homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||||
|
checkResult = { Result.success(true) }
|
||||||
|
),
|
||||||
|
) = DefaultElementClassicConnection(
|
||||||
|
serviceBinder = serviceBinder,
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
matrixAuthenticationService = matrixAuthenticationService,
|
||||||
|
homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.onboarding.classic
|
package io.element.android.features.login.impl.classic
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -15,12 +16,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
class FakeElementClassicConnection(
|
class FakeElementClassicConnection(
|
||||||
private val startResult: () -> Unit = { lambdaError() },
|
private val startResult: () -> Unit = { lambdaError() },
|
||||||
private val stopResult: () -> Unit = { lambdaError() },
|
private val stopResult: () -> Unit = { lambdaError() },
|
||||||
private val requestDataResult: () -> Unit = { lambdaError() },
|
private val requestSessionResult: () -> Unit = { lambdaError() },
|
||||||
|
private val requestAvatarResult: (UserId) -> Unit = { lambdaError() },
|
||||||
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
|
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
|
||||||
) : ElementClassicConnection {
|
) : ElementClassicConnection {
|
||||||
override fun start() = startResult()
|
override fun start() = startResult()
|
||||||
override fun stop() = stopResult()
|
override fun stop() = stopResult()
|
||||||
override fun requestData() = requestDataResult()
|
override fun requestSession() = requestSessionResult()
|
||||||
|
override fun requestAvatar(userId: UserId) = requestAvatarResult(userId)
|
||||||
private val mutableStateFlow = MutableStateFlow(initialState)
|
private val mutableStateFlow = MutableStateFlow(initialState)
|
||||||
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
|
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
|
||||||
suspend fun emitState(state: ElementClassicConnectionState) {
|
suspend fun emitState(state: ElementClassicConnectionState) {
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import io.element.android.libraries.androidutils.service.ServiceBinder
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
class FakeServiceBinder(
|
||||||
|
private val bindServiceResult: () -> Boolean = { lambdaError() },
|
||||||
|
private val unbindServiceResult: () -> Unit = { lambdaError() },
|
||||||
|
) : ServiceBinder {
|
||||||
|
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
|
||||||
|
return bindServiceResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbindService(conn: ServiceConnection) {
|
||||||
|
unbindServiceResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
||||||
|
internal const val ROOM_KEYS_VERSION = "roomKeysVersion as Json data"
|
||||||
|
|
||||||
|
fun anElementClassicReady(
|
||||||
|
elementClassicSession: ElementClassicSession = anElementClassicSession(),
|
||||||
|
displayName: String? = null,
|
||||||
|
avatar: Bitmap? = null,
|
||||||
|
) = ElementClassicConnectionState.ElementClassicReady(
|
||||||
|
elementClassicSession = elementClassicSession,
|
||||||
|
displayName = displayName,
|
||||||
|
avatar = avatar,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun anElementClassicSession(
|
||||||
|
userId: UserId = A_USER_ID,
|
||||||
|
homeserverUrl: String? = null,
|
||||||
|
secrets: String? = null,
|
||||||
|
roomKeysVersion: String? = null,
|
||||||
|
doesContainBackupKey: Boolean = false,
|
||||||
|
) = ElementClassicSession(
|
||||||
|
userId = userId,
|
||||||
|
homeserverUrl = homeserverUrl,
|
||||||
|
secrets = secrets,
|
||||||
|
roomKeysVersion = roomKeysVersion,
|
||||||
|
doesContainBackupKey = doesContainBackupKey,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
/*
|
||||||
|
* 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.classic
|
||||||
|
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
|
||||||
|
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.anElementClassicReady
|
||||||
|
import io.element.android.features.login.impl.classic.anElementClassicSession
|
||||||
|
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||||
|
import io.element.android.libraries.matrix.test.A_SECRET
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
// Use AndroidJUnit4 for the test with the Bitmap.
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ClassicFlowNodeHelperTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state`() = runTest {
|
||||||
|
createHelper()
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `after a few seconds in Idle, NavigateToOnBoarding is emitted`() = runTest {
|
||||||
|
createHelper()
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to onboarding if a session with the same account already exists`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady()
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to onboarding if Element Classic is not found`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
ElementClassicConnectionState.ElementClassicNotFound
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to onboarding if Element Classic has no session`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
ElementClassicConnectionState.ElementClassicReadyNoSession
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to onboarding if there has been an error`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
ElementClassicConnectionState.Error(A_FAILURE_REASON)
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToOnBoarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to login with classic when the session can be retrieved`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady()
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to login with classic when the session can be retrieved - ignore avatar update`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady()
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
|
||||||
|
// When the avatar is retrieved, no new event is emitted
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
avatar = createBitmap(1, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to login with classic when the session can be retrieved and navigate again once the session is verified`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
secrets = A_SECRET,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val readyState = awaitItem()
|
||||||
|
assertThat(readyState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
|
||||||
|
// When the secret with the key backup is retrieved, NavigateToLoginWithClassic is emitted again
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
secrets = A_SECRET + A_SECRET,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to login with classic if a session with another account already exists`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID_2.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady()
|
||||||
|
)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `navigate to login with classic but do not navigate to OnBoarding once the user is logged in`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection()
|
||||||
|
val sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf()
|
||||||
|
)
|
||||||
|
createHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
)
|
||||||
|
.navigationEventFlow()
|
||||||
|
.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(NavigationEvent.Idle)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady()
|
||||||
|
)
|
||||||
|
val navigateToLoginWithClassicState = awaitItem()
|
||||||
|
assertThat(navigateToLoginWithClassicState).isEqualTo(NavigationEvent.NavigateToLoginWithClassic(A_USER_ID))
|
||||||
|
// User actually logs in
|
||||||
|
sessionStore.addSession(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_USER_ID.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expectNoEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHelper(
|
||||||
|
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
|
) = ClassicFlowNodeHelper(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
class FakeLoginWithClassicNavigator(
|
||||||
|
private val navigateToMissingKeyBackupResult: () -> Unit = { lambdaError() },
|
||||||
|
) : LoginWithClassicNavigator {
|
||||||
|
override fun navigateToMissingKeyBackup() {
|
||||||
|
navigateToMissingKeyBackupResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.loginwithclassic
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||||
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
|
||||||
|
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.ROOM_KEYS_VERSION
|
||||||
|
import io.element.android.features.login.impl.classic.anElementClassicReady
|
||||||
|
import io.element.android.features.login.impl.classic.anElementClassicSession
|
||||||
|
import io.element.android.features.login.impl.login.LoginHelper
|
||||||
|
import io.element.android.features.login.impl.screens.onboarding.createLoginHelper
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
|
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||||
|
import io.element.android.libraries.matrix.test.A_SECRET
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LoginWithClassicPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.isElementPro).isFalse()
|
||||||
|
assertThat(initialState.userId).isEqualTo(A_USER_ID)
|
||||||
|
assertThat(initialState.displayName).isNull()
|
||||||
|
assertThat(initialState.avatar).isNull()
|
||||||
|
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
|
||||||
|
assertThat(initialState.loginMode.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state - element Pro`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
isEnterpriseBuild = true,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.isElementPro).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - refresh data invokes the expected methods`() = runTest {
|
||||||
|
val requestAvatarResult = lambdaRecorder<UserId, Unit> { }
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
startResult = {},
|
||||||
|
requestAvatarResult = requestAvatarResult,
|
||||||
|
)
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
secrets = A_SECRET,
|
||||||
|
roomKeysVersion = ROOM_KEYS_VERSION,
|
||||||
|
),
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val readyState = awaitItem()
|
||||||
|
assertThat(readyState.userId).isEqualTo(A_USER_ID)
|
||||||
|
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
|
||||||
|
readyState.eventSink(LoginWithClassicEvent.RefreshData)
|
||||||
|
requestAvatarResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - start login with correct state - user can login`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setHomeserverResult = {
|
||||||
|
Result.failure(AN_EXCEPTION)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
startResult = {},
|
||||||
|
)
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
loginHelper = createLoginHelper(
|
||||||
|
authenticationService = authenticationService,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
secrets = A_SECRET,
|
||||||
|
roomKeysVersion = ROOM_KEYS_VERSION,
|
||||||
|
doesContainBackupKey = true,
|
||||||
|
),
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val readyState = awaitItem()
|
||||||
|
assertThat(readyState.userId).isEqualTo(A_USER_ID)
|
||||||
|
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
|
||||||
|
readyState.eventSink(LoginWithClassicEvent.Submit)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
|
||||||
|
skipItems(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - start login with no secrets - user can login and will have to verify manually`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setHomeserverResult = {
|
||||||
|
Result.failure(AN_EXCEPTION)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
startResult = {},
|
||||||
|
)
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
loginHelper = createLoginHelper(
|
||||||
|
authenticationService = authenticationService,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
secrets = null,
|
||||||
|
roomKeysVersion = null,
|
||||||
|
),
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val readyState = awaitItem()
|
||||||
|
assertThat(readyState.userId).isEqualTo(A_USER_ID)
|
||||||
|
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
|
||||||
|
readyState.eventSink(LoginWithClassicEvent.Submit)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
|
||||||
|
skipItems(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - start login with secrets and without key backup - user will see the screen to enable key backup`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setHomeserverResult = {
|
||||||
|
Result.failure(AN_EXCEPTION)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
startResult = {},
|
||||||
|
)
|
||||||
|
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
loginHelper = createLoginHelper(
|
||||||
|
authenticationService = authenticationService,
|
||||||
|
),
|
||||||
|
navigator = FakeLoginWithClassicNavigator(
|
||||||
|
navigateToMissingKeyBackupResult = navigateToMissingKeyBackupResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
secrets = A_SECRET,
|
||||||
|
roomKeysVersion = null,
|
||||||
|
doesContainBackupKey = false,
|
||||||
|
),
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val readyState = awaitItem()
|
||||||
|
assertThat(readyState.userId).isEqualTo(A_USER_ID)
|
||||||
|
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
|
||||||
|
readyState.eventSink(LoginWithClassicEvent.Submit)
|
||||||
|
navigateToMissingKeyBackupResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - start login with secrets and with invalid key backup - user will see the screen to enable key backup`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
setHomeserverResult = {
|
||||||
|
Result.failure(AN_EXCEPTION)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
startResult = {},
|
||||||
|
)
|
||||||
|
val navigateToMissingKeyBackupResult = lambdaRecorder<Unit> { }
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
loginHelper = createLoginHelper(
|
||||||
|
authenticationService = authenticationService,
|
||||||
|
),
|
||||||
|
navigator = FakeLoginWithClassicNavigator(
|
||||||
|
navigateToMissingKeyBackupResult = navigateToMissingKeyBackupResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
anElementClassicReady(
|
||||||
|
elementClassicSession = anElementClassicSession(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
secrets = A_SECRET,
|
||||||
|
roomKeysVersion = ROOM_KEYS_VERSION,
|
||||||
|
// false here
|
||||||
|
doesContainBackupKey = false,
|
||||||
|
),
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val readyState = awaitItem()
|
||||||
|
assertThat(readyState.userId).isEqualTo(A_USER_ID)
|
||||||
|
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
|
||||||
|
readyState.eventSink(LoginWithClassicEvent.Submit)
|
||||||
|
navigateToMissingKeyBackupResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - submit in wrong state and clear error`() = runTest {
|
||||||
|
val elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
startResult = {},
|
||||||
|
)
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
elementClassicConnection.emitState(
|
||||||
|
ElementClassicConnectionState.Error(
|
||||||
|
error = A_FAILURE_REASON,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
|
||||||
|
initialState.eventSink(LoginWithClassicEvent.Submit)
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.loginWithClassicAction.isFailure()).isTrue()
|
||||||
|
errorState.eventSink(LoginWithClassicEvent.ClearError)
|
||||||
|
val clearedState = awaitItem()
|
||||||
|
assertThat(clearedState.loginWithClassicAction.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
userId: UserId = A_USER_ID,
|
||||||
|
navigator: LoginWithClassicNavigator = FakeLoginWithClassicNavigator(),
|
||||||
|
loginHelper: LoginHelper = createLoginHelper(),
|
||||||
|
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
|
||||||
|
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
|
isEnterpriseBuild: Boolean = false,
|
||||||
|
) = LoginWithClassicPresenter(
|
||||||
|
userId = userId,
|
||||||
|
navigator = navigator,
|
||||||
|
loginHelper = loginHelper,
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
|
buildMeta = aBuildMeta(
|
||||||
|
isEnterpriseBuild = isEnterpriseBuild,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* 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.classic.missingkeybackup
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.classic.ElementClassicConnection
|
||||||
|
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MissingKeyBackupPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when the screen is resumed twice, the start over method is called`() = runTest {
|
||||||
|
val requestSessionResult = lambdaRecorder<Unit> { }
|
||||||
|
val presenter = createPresenter(
|
||||||
|
elementClassicConnection = FakeElementClassicConnection(
|
||||||
|
requestSessionResult = requestSessionResult,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MissingKeyBackupEvent.OnResume)
|
||||||
|
expectNoEvents()
|
||||||
|
initialState.eventSink(MissingKeyBackupEvent.OnResume)
|
||||||
|
requestSessionResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME),
|
||||||
|
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
|
||||||
|
) = MissingKeyBackupPresenter(
|
||||||
|
buildMeta = buildMeta,
|
||||||
|
elementClassicConnection = elementClassicConnection,
|
||||||
|
)
|
||||||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_PASSWORD
|
import io.element.android.libraries.matrix.test.A_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME_2
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
|
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -41,6 +42,20 @@ class LoginPasswordPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial login is in the first state and can be modified`() = runTest {
|
||||||
|
createLoginPasswordPresenter(
|
||||||
|
initialLogin = A_USER_NAME,
|
||||||
|
).test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.formState.login).isEqualTo(A_USER_NAME)
|
||||||
|
// Login can be changed
|
||||||
|
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME_2))
|
||||||
|
val loginChangedState = awaitItem()
|
||||||
|
assertThat(loginChangedState.formState.login).isEqualTo(A_USER_NAME_2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - enter login and password`() = runTest {
|
fun `present - enter login and password`() = runTest {
|
||||||
val authenticationService = FakeMatrixAuthenticationService(
|
val authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
|
@ -140,9 +155,11 @@ class LoginPasswordPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createLoginPasswordPresenter(
|
private fun createLoginPasswordPresenter(
|
||||||
|
initialLogin: String = "",
|
||||||
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||||
): LoginPasswordPresenter = LoginPasswordPresenter(
|
): LoginPasswordPresenter = LoginPasswordPresenter(
|
||||||
|
initialLogin = initialLogin,
|
||||||
authenticationService = authenticationService,
|
authenticationService = authenticationService,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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
|
||||||
|
|
@ -83,16 +82,31 @@ class OnBoardingPresenterTest {
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showBackButton).isFalse()
|
||||||
assertThat(initialState.defaultAccountProvider).isNull()
|
assertThat(initialState.defaultAccountProvider).isNull()
|
||||||
assertThat(initialState.canLoginWithQrCode).isFalse()
|
assertThat(initialState.canLoginWithQrCode).isFalse()
|
||||||
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
||||||
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(initialState.loginWithClassicState.canLoginWithClassic).isFalse()
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.canLoginWithQrCode).isTrue()
|
assertThat(finalState.canLoginWithQrCode).isTrue()
|
||||||
assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state with back button`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
params = OnBoardingNode.Params(
|
||||||
|
accountProvider = null,
|
||||||
|
loginHint = null,
|
||||||
|
showBackButton = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showBackButton).isTrue()
|
||||||
|
skipItems(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +176,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
|
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
|
||||||
|
|
@ -184,6 +199,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
||||||
|
|
@ -206,6 +222,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||||
loginHint = null,
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
|
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
|
||||||
|
|
@ -233,6 +250,7 @@ class OnBoardingPresenterTest {
|
||||||
params = OnBoardingNode.Params(
|
params = OnBoardingNode.Params(
|
||||||
accountProvider = A_HOMESERVER_URL,
|
accountProvider = A_HOMESERVER_URL,
|
||||||
loginHint = A_LOGIN_HINT,
|
loginHint = A_LOGIN_HINT,
|
||||||
|
showBackButton = false,
|
||||||
),
|
),
|
||||||
enterpriseService = FakeEnterpriseService(
|
enterpriseService = FakeEnterpriseService(
|
||||||
isAllowedToConnectToHomeserverResult = { true },
|
isAllowedToConnectToHomeserverResult = { true },
|
||||||
|
|
@ -265,7 +283,11 @@ class OnBoardingPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
|
params: OnBoardingNode.Params = OnBoardingNode.Params(
|
||||||
|
accountProvider = null,
|
||||||
|
loginHint = null,
|
||||||
|
showBackButton = false,
|
||||||
|
),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||||
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
||||||
|
|
@ -287,7 +309,6 @@ private fun createPresenter(
|
||||||
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
loginWithClassicPresenter = { aLoginWithClassicState() },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createLoginHelper(
|
fun createLoginHelper(
|
||||||
|
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
/*
|
|
||||||
* 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_SECRET
|
|
||||||
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, secrets = A_SECRET)
|
|
||||||
)
|
|
||||||
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, secrets = A_SECRET)
|
|
||||||
)
|
|
||||||
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, secrets = A_SECRET)
|
|
||||||
)
|
|
||||||
// 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, secrets = A_SECRET)
|
|
||||||
)
|
|
||||||
// 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,
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.androidutils.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
|
||||||
|
interface ServiceBinder {
|
||||||
|
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
|
||||||
|
fun unbindService(conn: ServiceConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultServiceBinder(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ServiceBinder {
|
||||||
|
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
|
||||||
|
return context.bindService(service, conn, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbindService(conn: ServiceConnection) {
|
||||||
|
context.unbindService(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.architecture.appyx
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.Transition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
|
||||||
|
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.Replace
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
|
||||||
|
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
|
||||||
|
* and slide transition for all other cases.
|
||||||
|
*/
|
||||||
|
private class FaderOrSliderTransitionHandler<NavTarget>(
|
||||||
|
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
|
||||||
|
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
|
||||||
|
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
|
||||||
|
override fun createModifier(
|
||||||
|
modifier: Modifier,
|
||||||
|
transition: Transition<BackStack.State>,
|
||||||
|
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
|
||||||
|
): Modifier {
|
||||||
|
val operation = descriptor.operation
|
||||||
|
val useFader = operation is Replace || operation is NewRoot
|
||||||
|
val handler = if (useFader) fader else slider
|
||||||
|
return handler.createModifier(modifier, transition, descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
|
||||||
|
val slider = rememberBackstackSlider<NavTarget>(
|
||||||
|
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||||
|
)
|
||||||
|
val fader = rememberBackstackFader<NavTarget>(
|
||||||
|
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||||
|
)
|
||||||
|
return rememberDelegateTransitionHandler {
|
||||||
|
FaderOrSliderTransitionHandler(slider, fader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.designsystem.components.avatar
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
|
import coil3.compose.SubcomposeAsyncImageContent
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
// For user avatar only.
|
||||||
|
@Composable
|
||||||
|
fun BitmapAvatar(
|
||||||
|
avatarData: AvatarData,
|
||||||
|
bitmap: Bitmap?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
|
) {
|
||||||
|
val avatarShape = AvatarType.User.avatarShape()
|
||||||
|
when {
|
||||||
|
bitmap == null -> InitialLetterAvatar(
|
||||||
|
avatarData = avatarData,
|
||||||
|
avatarShape = avatarShape,
|
||||||
|
forcedAvatarSize = null,
|
||||||
|
modifier = modifier,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
val size = avatarData.size.dp
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = bitmap,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(avatarShape)
|
||||||
|
) {
|
||||||
|
val collectedState by painter.state.collectAsState()
|
||||||
|
when (val state = collectedState) {
|
||||||
|
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||||
|
is AsyncImagePainter.State.Error -> {
|
||||||
|
SideEffect {
|
||||||
|
Timber.e(
|
||||||
|
state.result.throwable,
|
||||||
|
"Error loading avatar $state\n${state.result}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InitialLetterAvatar(
|
||||||
|
avatarData = avatarData,
|
||||||
|
avatarShape = avatarShape,
|
||||||
|
forcedAvatarSize = null,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> InitialLetterAvatar(
|
||||||
|
avatarData = avatarData,
|
||||||
|
avatarShape = avatarShape,
|
||||||
|
forcedAvatarSize = null,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,14 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.onboarding.classic
|
package io.element.android.libraries.matrix.api.auth
|
||||||
|
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
class ConfirmingLoginWithElementClassic(
|
data class ElementClassicSession(
|
||||||
val userId: UserId,
|
val userId: UserId,
|
||||||
) : AsyncAction.Confirming
|
val homeserverUrl: String?,
|
||||||
|
val secrets: String?,
|
||||||
|
val roomKeysVersion: String?,
|
||||||
|
val doesContainBackupKey: Boolean,
|
||||||
|
)
|
||||||
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
interface MatrixAuthenticationService {
|
interface MatrixAuthenticationService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
|
||||||
*/
|
*/
|
||||||
suspend fun cancelOidcLogin(): Result<Unit>
|
suspend fun cancelOidcLogin(): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the existing data about Element Classic session, if any.
|
||||||
|
*/
|
||||||
|
fun setElementClassicSession(session: ElementClassicSession?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provided secrets from Element Classic session contain a key backup.
|
||||||
|
*/
|
||||||
|
fun doSecretsContainBackupKey(
|
||||||
|
userId: UserId,
|
||||||
|
secrets: String,
|
||||||
|
backupInfo: String,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to login using the [callbackUrl] provided by the Oidc page.
|
* Attempt to login using the [callbackUrl] provided by the Oidc page.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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.libraries.matrix.api.core
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class UserIdTest {
|
||||||
|
@Test
|
||||||
|
fun `valid user id`() {
|
||||||
|
val userId = UserId("@alice:example.org")
|
||||||
|
assertThat(userId.extractedDisplayName).isEqualTo("alice")
|
||||||
|
assertThat(userId.domainName).isEqualTo("example.org")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||||
|
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
|
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
|
||||||
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
|
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
|
||||||
|
|
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
|
||||||
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
||||||
import org.matrix.rustcomponents.sdk.QrLoginProgress
|
import org.matrix.rustcomponents.sdk.QrLoginProgress
|
||||||
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
||||||
|
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uniffi.matrix_sdk.OAuthAuthorizationData
|
import uniffi.matrix_sdk.OAuthAuthorizationData
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
|
||||||
private val passphraseGenerator: PassphraseGenerator,
|
private val passphraseGenerator: PassphraseGenerator,
|
||||||
private val oidcConfigurationProvider: OidcConfigurationProvider,
|
private val oidcConfigurationProvider: OidcConfigurationProvider,
|
||||||
) : MatrixAuthenticationService {
|
) : MatrixAuthenticationService {
|
||||||
|
// Any existing Element Classic session that we want to try to import secrets from during login.
|
||||||
|
private var elementClassicSession: ElementClassicSession? = null
|
||||||
|
|
||||||
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
|
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
|
||||||
// stored in the SessionData.
|
// stored in the SessionData.
|
||||||
private val pendingPassphrase = getDatabasePassphrase()
|
private val pendingPassphrase = getDatabasePassphrase()
|
||||||
|
|
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||||
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||||
client.login(username, password, "Element X Android", null)
|
client.login(
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
initialDeviceName = "Element X Android",
|
||||||
|
deviceId = null,
|
||||||
|
)
|
||||||
// Ensure that the user is not already logged in with the same account
|
// Ensure that the user is not already logged in with the same account
|
||||||
ensureNotAlreadyLoggedIn(client)
|
ensureNotAlreadyLoggedIn(client)
|
||||||
|
tryToImportSecretForElementClassicSession(client)
|
||||||
val sessionData = client.session()
|
val sessionData = client.session()
|
||||||
.toSessionData(
|
.toSessionData(
|
||||||
isTokenValid = true,
|
isTokenValid = true,
|
||||||
|
|
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
|
||||||
|
elementClassicSession
|
||||||
|
?.takeIf {
|
||||||
|
// Note: the SDK will also do this check
|
||||||
|
it.userId.value == client.userId()
|
||||||
|
}
|
||||||
|
?.let {
|
||||||
|
val secrets = it.secrets
|
||||||
|
val roomKeysVersion = it.roomKeysVersion
|
||||||
|
if (secrets == null || roomKeysVersion == null) {
|
||||||
|
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
|
||||||
|
} else {
|
||||||
|
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
|
||||||
|
runCatchingExceptions {
|
||||||
|
SecretsBundleWithUserId.fromStr(
|
||||||
|
userId = it.userId.value,
|
||||||
|
bundle = secrets,
|
||||||
|
backupInfo = roomKeysVersion,
|
||||||
|
).use { secretsBundle ->
|
||||||
|
client.encryption().importSecretsBundle(secretsBundle)
|
||||||
|
}
|
||||||
|
}.onFailure { failure ->
|
||||||
|
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doSecretsContainBackupKey(
|
||||||
|
userId: UserId,
|
||||||
|
secrets: String,
|
||||||
|
backupInfo: String,
|
||||||
|
): Boolean {
|
||||||
|
return try {
|
||||||
|
SecretsBundleWithUserId.fromStr(
|
||||||
|
userId = userId.value,
|
||||||
|
bundle = secrets,
|
||||||
|
backupInfo = backupInfo,
|
||||||
|
).use { secretsBundle ->
|
||||||
|
secretsBundle.containsBackupKey()
|
||||||
|
}
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
|
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
|
|
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setElementClassicSession(session: ElementClassicSession?) {
|
||||||
|
elementClassicSession = session
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
|
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
|
||||||
*/
|
*/
|
||||||
|
|
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||||
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||||
client.loginWithOidcCallback(callbackUrl)
|
client.loginWithOidcCallback(
|
||||||
|
callbackUrl = callbackUrl,
|
||||||
|
)
|
||||||
// Free the pending data since we won't use it to abort the flow anymore
|
// Free the pending data since we won't use it to abort the flow anymore
|
||||||
pendingOAuthAuthorizationData?.close()
|
pendingOAuthAuthorizationData?.close()
|
||||||
pendingOAuthAuthorizationData = null
|
pendingOAuthAuthorizationData = null
|
||||||
|
|
||||||
// Ensure that the user is not already logged in with the same account
|
// Ensure that the user is not already logged in with the same account
|
||||||
ensureNotAlreadyLoggedIn(client)
|
ensureNotAlreadyLoggedIn(client)
|
||||||
|
tryToImportSecretForElementClassicSession(client)
|
||||||
val sessionData = client.session().toSessionData(
|
val sessionData = client.session().toSessionData(
|
||||||
isTokenValid = true,
|
isTokenValid = true,
|
||||||
loginType = LoginType.OIDC,
|
loginType = LoginType.OIDC,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
package io.element.android.libraries.matrix.test.auth
|
package io.element.android.libraries.matrix.test.auth
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
|
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
|
||||||
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
|
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
|
||||||
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
|
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
|
||||||
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
|
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
|
||||||
|
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
|
||||||
|
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
|
||||||
) : MatrixAuthenticationService {
|
) : MatrixAuthenticationService {
|
||||||
private var oidcError: Throwable? = null
|
private var oidcError: Throwable? = null
|
||||||
private var oidcCancelError: Throwable? = null
|
private var oidcCancelError: Throwable? = null
|
||||||
|
|
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
|
||||||
fun givenMatrixClient(matrixClient: MatrixClient) {
|
fun givenMatrixClient(matrixClient: MatrixClient) {
|
||||||
this.matrixClient = matrixClient
|
this.matrixClient = matrixClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setElementClassicSession(session: ElementClassicSession?) {
|
||||||
|
setElementClassicSessionResult(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
|
||||||
|
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ forbiddenTerms = {
|
||||||
# We explicitly want to mention Element Pro in these 2:
|
# We explicitly want to mention Element Pro in these 2:
|
||||||
"screen_change_server_error_element_pro_required_title",
|
"screen_change_server_error_element_pro_required_title",
|
||||||
"screen_change_server_error_element_pro_required_message",
|
"screen_change_server_error_element_pro_required_message",
|
||||||
|
# Contains "Element Classic"
|
||||||
|
"screen_missing_key_backup_open_element_classic",
|
||||||
|
"screen_missing_key_backup_step_1",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@
|
||||||
"name" : ":features:login:impl",
|
"name" : ":features:login:impl",
|
||||||
"includeRegex" : [
|
"includeRegex" : [
|
||||||
"screen_onboarding_.*",
|
"screen_onboarding_.*",
|
||||||
|
"screen\\.onboarding\\..*",
|
||||||
|
"screen\\.missing_key_backup\\..*",
|
||||||
"screen_login_.*",
|
"screen_login_.*",
|
||||||
"screen_server_confirmation_.*",
|
"screen_server_confirmation_.*",
|
||||||
"screen_change_server_.*",
|
"screen_change_server_.*",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue