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 ->
|
||||
when (navTarget) {
|
||||
is NavTarget.SplashScreen,
|
||||
is NavTarget.LoggedInFlow -> backstackFader
|
||||
is NavTarget.LoggedInFlow,
|
||||
is NavTarget.NotLoggedInFlow -> backstackFader
|
||||
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.operation.pop
|
||||
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 dev.zacsweers.metro.AppScope
|
||||
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.features.login.api.LoginEntryPoint
|
||||
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.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||
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.createaccount.CreateAccountNode
|
||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||
|
|
@ -63,9 +66,10 @@ class LoginFlowNode(
|
|||
private val oidcActionFlow: OidcActionFlow,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val elementClassicConnection: ElementClassicConnection,
|
||||
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.OnBoarding,
|
||||
initialElement = NavTarget.CheckClassicFlow,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
|
|
@ -103,7 +107,12 @@ class LoginFlowNode(
|
|||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object OnBoarding : NavTarget
|
||||
data object CheckClassicFlow : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class OnBoarding(
|
||||
val showBackButton: Boolean,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object QrCode : NavTarget
|
||||
|
|
@ -123,7 +132,9 @@ class LoginFlowNode(
|
|||
data object SearchAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LoginPassword : NavTarget
|
||||
data class LoginPassword(
|
||||
val initialLogin: String = "",
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class CreateAccount(val url: String) : NavTarget
|
||||
|
|
@ -131,7 +142,31 @@ class LoginFlowNode(
|
|||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
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 {
|
||||
override fun navigateToSignUpFlow() {
|
||||
backstack.push(
|
||||
|
|
@ -166,17 +201,22 @@ class LoginFlowNode(
|
|||
}
|
||||
|
||||
override fun navigateToLoginPassword() {
|
||||
backstack.push(NavTarget.LoginPassword)
|
||||
backstack.push(NavTarget.LoginPassword())
|
||||
}
|
||||
|
||||
override fun onDone() {
|
||||
if (navTarget.showBackButton) {
|
||||
backstack.pop()
|
||||
} else {
|
||||
callback.onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
val params = inputs<Params>()
|
||||
val inputs = OnBoardingNode.Params(
|
||||
accountProvider = params.accountProvider,
|
||||
loginHint = params.loginHint,
|
||||
showBackButton = navTarget.showBackButton,
|
||||
)
|
||||
createNode<OnBoardingNode>(buildContext, listOf(callback, inputs))
|
||||
}
|
||||
|
|
@ -191,7 +231,7 @@ class LoginFlowNode(
|
|||
}
|
||||
|
||||
override fun navigateToLoginPassword() {
|
||||
backstack.push(NavTarget.LoginPassword)
|
||||
backstack.push(NavTarget.LoginPassword())
|
||||
}
|
||||
}
|
||||
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
|
||||
|
|
@ -218,7 +258,7 @@ class LoginFlowNode(
|
|||
}
|
||||
|
||||
override fun navigateToLoginPassword() {
|
||||
backstack.push(NavTarget.LoginPassword)
|
||||
backstack.push(NavTarget.LoginPassword())
|
||||
}
|
||||
|
||||
override fun navigateToChangeAccountProvider() {
|
||||
|
|
@ -257,8 +297,11 @@ class LoginFlowNode(
|
|||
|
||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.LoginPassword -> {
|
||||
createNode<LoginPasswordNode>(buildContext)
|
||||
is NavTarget.LoginPassword -> {
|
||||
val inputs = LoginPasswordNode.Inputs(
|
||||
initialLogin = navTarget.initialLogin,
|
||||
)
|
||||
createNode<LoginPasswordNode>(buildContext, plugins = listOf(inputs))
|
||||
}
|
||||
is NavTarget.CreateAccount -> {
|
||||
val inputs = CreateAccountNode.Inputs(
|
||||
|
|
@ -280,6 +323,14 @@ class LoginFlowNode(
|
|||
override fun View(modifier: Modifier) {
|
||||
activity = requireNotNull(LocalActivity.current)
|
||||
darkTheme = !ElementTheme.isLightTheme
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
elementClassicConnection.start()
|
||||
onDispose {
|
||||
elementClassicConnection.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
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 io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerState
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
|
|
@ -23,7 +21,4 @@ import io.element.android.libraries.architecture.Presenter
|
|||
interface LoginModule {
|
||||
@Binds
|
||||
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
|
||||
|
||||
@Binds
|
||||
fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter<LoginWithClassicState>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,19 @@ class LoginHelper(
|
|||
suspend fun submit(
|
||||
isAccountCreation: Boolean,
|
||||
homeserverUrl: String,
|
||||
resolvedHomeserverUrl: String?,
|
||||
loginHint: String?,
|
||||
) {
|
||||
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) {
|
||||
// Retrieve the details right now
|
||||
val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class ChooseAccountProviderPresenter(
|
|||
loginHelper.submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = it.url,
|
||||
resolvedHomeserverUrl = 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.screens.onboarding.classic
|
||||
package io.element.android.features.login.impl.screens.classic.loginwithclassic
|
||||
|
||||
sealed interface LoginWithClassicEvent {
|
||||
data object RefreshData : LoginWithClassicEvent
|
||||
data object StartLoginWithClassic : LoginWithClassicEvent
|
||||
data object DoLoginWithClassic : LoginWithClassicEvent
|
||||
data object CloseDialog : LoginWithClassicEvent
|
||||
data object Submit : LoginWithClassicEvent
|
||||
data object ClearError : 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(
|
||||
isAccountCreation = params.isAccountCreation,
|
||||
homeserverUrl = accountProvider.url,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,23 @@ 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.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
@AssistedInject
|
||||
class LoginPasswordNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LoginPasswordPresenter,
|
||||
presenterFactory: LoginPasswordPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(
|
||||
val initialLogin: String,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
private val presenter = presenterFactory.create(inputs.initialLogin)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.libraries.architecture.AsyncData
|
||||
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.launch
|
||||
|
||||
@Inject
|
||||
@AssistedInject
|
||||
class LoginPasswordPresenter(
|
||||
@Assisted
|
||||
private val initialLogin: String,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
) : Presenter<LoginPasswordState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(initialLogin: String): LoginPasswordPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): LoginPasswordState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -38,7 +47,12 @@ class LoginPasswordPresenter(
|
|||
}
|
||||
|
||||
val formState = rememberSaveable {
|
||||
mutableStateOf(LoginFormState.Default)
|
||||
mutableStateOf(
|
||||
LoginFormState(
|
||||
login = initialLogin,
|
||||
password = "",
|
||||
)
|
||||
)
|
||||
}
|
||||
val accountProvider by accountProviderDataSource.flow.collectAsState()
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class OnBoardingNode(
|
|||
data class Params(
|
||||
val accountProvider: String?,
|
||||
val loginHint: String?,
|
||||
val showBackButton: Boolean,
|
||||
) : NodeInputs
|
||||
|
||||
private val callback: Callback = callback()
|
||||
|
|
@ -61,6 +62,7 @@ class OnBoardingNode(
|
|||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val context = LocalContext.current
|
||||
|
||||
OnBoardingView(
|
||||
state = state,
|
||||
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.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
|
||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
|
|
@ -45,7 +44,6 @@ class OnBoardingPresenter(
|
|||
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
|
||||
private val sessionStore: SessionStore,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val loginWithClassicPresenter: Presenter<LoginWithClassicState>,
|
||||
) : Presenter<OnBoardingState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -101,8 +99,6 @@ class OnBoardingPresenter(
|
|||
|
||||
val loginMode by loginHelper.collectLoginMode()
|
||||
|
||||
val loginWithClassicState = loginWithClassicPresenter.present()
|
||||
|
||||
fun handleEvent(event: OnBoardingEvents) {
|
||||
when (event) {
|
||||
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
|
||||
|
|
@ -111,6 +107,7 @@ class OnBoardingPresenter(
|
|||
loginHelper.submit(
|
||||
isAccountCreation = false,
|
||||
homeserverUrl = event.defaultAccountProvider,
|
||||
resolvedHomeserverUrl = null,
|
||||
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
|
||||
)
|
||||
}
|
||||
|
|
@ -127,6 +124,7 @@ class OnBoardingPresenter(
|
|||
|
||||
return OnBoardingState(
|
||||
isAddingAccount = isAddingAccount,
|
||||
showBackButton = params.showBackButton,
|
||||
productionApplicationName = buildMeta.productionApplicationName,
|
||||
defaultAccountProvider = defaultAccountProvider,
|
||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||
|
|
@ -136,7 +134,6 @@ class OnBoardingPresenter(
|
|||
loginMode = loginMode,
|
||||
version = buildMeta.versionName,
|
||||
onBoardingLogoResId = onBoardingLogoResId,
|
||||
loginWithClassicState = loginWithClassicState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ package io.element.android.features.login.impl.screens.onboarding
|
|||
|
||||
import androidx.annotation.DrawableRes
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
data class OnBoardingState(
|
||||
val isAddingAccount: Boolean,
|
||||
val showBackButton: Boolean,
|
||||
val productionApplicationName: String,
|
||||
val defaultAccountProvider: String?,
|
||||
val mustChooseAccountProvider: Boolean,
|
||||
|
|
@ -25,7 +25,6 @@ data class OnBoardingState(
|
|||
@DrawableRes
|
||||
val onBoardingLogoResId: Int?,
|
||||
val loginMode: AsyncData<LoginMode>,
|
||||
val loginWithClassicState: LoginWithClassicState,
|
||||
val eventSink: (OnBoardingEvents) -> Unit,
|
||||
) {
|
||||
val submitEnabled: Boolean
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ package io.element.android.features.login.impl.screens.onboarding
|
|||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.login.LoginMode
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.R
|
||||
|
||||
|
|
@ -31,11 +29,15 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
|
|||
canLoginWithQrCode = true,
|
||||
canCreateAccount = true,
|
||||
),
|
||||
anOnBoardingState(
|
||||
showBackButton = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun anOnBoardingState(
|
||||
isAddingAccount: Boolean = false,
|
||||
showBackButton: Boolean = false,
|
||||
productionApplicationName: String = "Element",
|
||||
defaultAccountProvider: String? = null,
|
||||
mustChooseAccountProvider: Boolean = false,
|
||||
|
|
@ -46,10 +48,10 @@ fun anOnBoardingState(
|
|||
@DrawableRes
|
||||
customLogoResId: Int? = null,
|
||||
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
|
||||
loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
|
||||
eventSink: (OnBoardingEvents) -> Unit = {},
|
||||
) = OnBoardingState(
|
||||
isAddingAccount = isAddingAccount,
|
||||
showBackButton = showBackButton,
|
||||
productionApplicationName = productionApplicationName,
|
||||
defaultAccountProvider = defaultAccountProvider,
|
||||
mustChooseAccountProvider = mustChooseAccountProvider,
|
||||
|
|
@ -59,6 +61,5 @@ fun anOnBoardingState(
|
|||
version = version,
|
||||
loginMode = loginMode,
|
||||
onBoardingLogoResId = customLogoResId,
|
||||
loginWithClassicState = loginWithClassicState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,15 +31,10 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.login.LoginModeView
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
|
||||
|
|
@ -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.OnBoardingPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
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.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
|
|
@ -114,45 +109,9 @@ fun OnBoardingView(
|
|||
state = state,
|
||||
loginView = loginView,
|
||||
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
|
||||
|
|
@ -160,12 +119,16 @@ private fun AddFirstAccountScaffold(
|
|||
state: OnBoardingState,
|
||||
loginView: @Composable () -> Unit,
|
||||
buttons: @Composable () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnBoardingPage(
|
||||
modifier = modifier,
|
||||
renderBackground = state.onBoardingLogoResId == null,
|
||||
content = {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (state.onBoardingLogoResId != null) {
|
||||
OnBoardingLogo(
|
||||
onBoardingLogoResId = state.onBoardingLogoResId,
|
||||
|
|
@ -173,6 +136,20 @@ private fun AddFirstAccountScaffold(
|
|||
} else {
|
||||
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()
|
||||
},
|
||||
footer = {
|
||||
|
|
@ -283,18 +260,6 @@ private fun OnBoardingButtons(
|
|||
} else {
|
||||
CommonStrings.action_continue
|
||||
}
|
||||
if (state.loginWithClassicState.canLoginWithClassic) {
|
||||
Button(
|
||||
text = "Sign in with Element Classic",
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
||||
onClick = {
|
||||
state.loginWithClassicState.eventSink(
|
||||
LoginWithClassicEvent.StartLoginWithClassic
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (state.canLoginWithQrCode) {
|
||||
Button(
|
||||
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
|
||||
|
|
|
|||
|
|
@ -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_title">"Welcome back!"</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_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_with_qr_code">"Sign in with QR code"</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_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</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.login.api.LoginEntryPoint
|
||||
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.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
|
|
@ -39,6 +40,7 @@ class DefaultLoginEntryPointTest {
|
|||
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||
oidcActionFlow = FakeOidcActionFlow(),
|
||||
appCoroutineScope = backgroundScope,
|
||||
elementClassicConnection = FakeElementClassicConnection(),
|
||||
)
|
||||
}
|
||||
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.
|
||||
*/
|
||||
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -15,12 +16,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
class FakeElementClassicConnection(
|
||||
private val startResult: () -> 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
|
||||
) : ElementClassicConnection {
|
||||
override fun start() = startResult()
|
||||
override fun stop() = stopResult()
|
||||
override fun requestData() = requestDataResult()
|
||||
override fun requestSession() = requestSessionResult()
|
||||
override fun requestAvatar(userId: UserId) = requestAvatarResult(userId)
|
||||
private val mutableStateFlow = MutableStateFlow(initialState)
|
||||
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
|
||||
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_SESSION_ID
|
||||
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.aMatrixHomeServerDetails
|
||||
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
|
||||
fun `present - enter login and password`() = runTest {
|
||||
val authenticationService = FakeMatrixAuthenticationService(
|
||||
|
|
@ -140,9 +155,11 @@ class LoginPasswordPresenterTest {
|
|||
}
|
||||
|
||||
private fun createLoginPasswordPresenter(
|
||||
initialLogin: String = "",
|
||||
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
|
||||
): LoginPasswordPresenter = LoginPasswordPresenter(
|
||||
initialLogin = initialLogin,
|
||||
authenticationService = authenticationService,
|
||||
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.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.login.LoginHelper
|
||||
import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
|
||||
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||
import io.element.android.features.wellknown.test.FakeWellknownRetriever
|
||||
|
|
@ -83,16 +82,31 @@ class OnBoardingPresenterTest {
|
|||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showBackButton).isFalse()
|
||||
assertThat(initialState.defaultAccountProvider).isNull()
|
||||
assertThat(initialState.canLoginWithQrCode).isFalse()
|
||||
assertThat(initialState.productionApplicationName).isEqualTo("B")
|
||||
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
|
||||
assertThat(initialState.canReportBug).isFalse()
|
||||
assertThat(initialState.isAddingAccount).isFalse()
|
||||
assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse()
|
||||
val finalState = awaitItem()
|
||||
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(
|
||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||
loginHint = null,
|
||||
showBackButton = false,
|
||||
),
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
|
||||
|
|
@ -184,6 +199,7 @@ class OnBoardingPresenterTest {
|
|||
params = OnBoardingNode.Params(
|
||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||
loginHint = null,
|
||||
showBackButton = false,
|
||||
),
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) },
|
||||
|
|
@ -206,6 +222,7 @@ class OnBoardingPresenterTest {
|
|||
params = OnBoardingNode.Params(
|
||||
accountProvider = ACCOUNT_PROVIDER_FROM_LINK,
|
||||
loginHint = null,
|
||||
showBackButton = false,
|
||||
),
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) },
|
||||
|
|
@ -233,6 +250,7 @@ class OnBoardingPresenterTest {
|
|||
params = OnBoardingNode.Params(
|
||||
accountProvider = A_HOMESERVER_URL,
|
||||
loginHint = A_LOGIN_HINT,
|
||||
showBackButton = false,
|
||||
),
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
isAllowedToConnectToHomeserverResult = { true },
|
||||
|
|
@ -265,7 +283,11 @@ class OnBoardingPresenterTest {
|
|||
}
|
||||
|
||||
private fun createPresenter(
|
||||
params: OnBoardingNode.Params = OnBoardingNode.Params(null, null),
|
||||
params: OnBoardingNode.Params = OnBoardingNode.Params(
|
||||
accountProvider = null,
|
||||
loginHint = null,
|
||||
showBackButton = false,
|
||||
),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
enterpriseService: EnterpriseService = FakeEnterpriseService(),
|
||||
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
|
||||
|
|
@ -287,7 +309,6 @@ private fun createPresenter(
|
|||
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
|
||||
sessionStore = sessionStore,
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
loginWithClassicPresenter = { aLoginWithClassicState() },
|
||||
)
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
class ConfirmingLoginWithElementClassic(
|
||||
data class ElementClassicSession(
|
||||
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.QrCodeLoginStep
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
interface MatrixAuthenticationService {
|
||||
/**
|
||||
|
|
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.matrix.api.MatrixClient
|
||||
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.MatrixHomeServerDetails
|
||||
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.QrCodeLoginStep
|
||||
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.impl.ClientBuilderSlidingSync
|
||||
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.QrLoginProgress
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
||||
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.OAuthAuthorizationData
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
|
|||
private val passphraseGenerator: PassphraseGenerator,
|
||||
private val oidcConfigurationProvider: OidcConfigurationProvider,
|
||||
) : 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
|
||||
// stored in the SessionData.
|
||||
private val pendingPassphrase = getDatabasePassphrase()
|
||||
|
|
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
|
|||
runCatchingExceptions {
|
||||
val client = currentClient ?: 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
|
||||
ensureNotAlreadyLoggedIn(client)
|
||||
tryToImportSecretForElementClassicSession(client)
|
||||
val sessionData = client.session()
|
||||
.toSessionData(
|
||||
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> =
|
||||
withContext(coroutineDispatchers.io) {
|
||||
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).
|
||||
*/
|
||||
|
|
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
|
|||
runCatchingExceptions {
|
||||
val client = currentClient ?: 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
|
||||
pendingOAuthAuthorizationData?.close()
|
||||
pendingOAuthAuthorizationData = null
|
||||
|
||||
// Ensure that the user is not already logged in with the same account
|
||||
ensureNotAlreadyLoggedIn(client)
|
||||
tryToImportSecretForElementClassicSession(client)
|
||||
val sessionData = client.session().toSessionData(
|
||||
isTokenValid = true,
|
||||
loginType = LoginType.OIDC,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.libraries.matrix.test.auth
|
||||
|
||||
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.MatrixHomeServerDetails
|
||||
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.QrCodeLoginStep
|
||||
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_USER_ID
|
||||
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) },
|
||||
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
|
||||
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
|
||||
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
|
||||
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
|
||||
) : MatrixAuthenticationService {
|
||||
private var oidcError: Throwable? = null
|
||||
private var oidcCancelError: Throwable? = null
|
||||
|
|
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
|
|||
fun givenMatrixClient(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:
|
||||
"screen_change_server_error_element_pro_required_title",
|
||||
"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",
|
||||
"includeRegex" : [
|
||||
"screen_onboarding_.*",
|
||||
"screen\\.onboarding\\..*",
|
||||
"screen\\.missing_key_backup\\..*",
|
||||
"screen_login_.*",
|
||||
"screen_server_confirmation_.*",
|
||||
"screen_change_server_.*",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue