Account Creation using a WebView
This commit is contained in:
parent
6efa31257d
commit
bed7776674
35 changed files with 1106 additions and 10 deletions
|
|
@ -44,6 +44,7 @@ dependencies {
|
||||||
implementation(projects.libraries.oidc.api)
|
implementation(projects.libraries.oidc.api)
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
implementation(platform(libs.network.retrofit.bom))
|
implementation(platform(libs.network.retrofit.bom))
|
||||||
|
implementation(libs.androidx.webkit)
|
||||||
implementation(libs.network.retrofit)
|
implementation(libs.network.retrofit)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
api(projects.features.login.api)
|
api(projects.features.login.api)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
|
||||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
||||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
||||||
|
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||||
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
|
|
@ -109,6 +110,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object LoginPassword : NavTarget
|
data object LoginPassword : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class CreateAccount(val url: String) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +144,10 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateAccountContinue(url: String) {
|
||||||
|
backstack.push(NavTarget.CreateAccount(url))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onLoginPasswordNeeded() {
|
override fun onLoginPasswordNeeded() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword)
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +188,12 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
is NavTarget.OidcView -> {
|
is NavTarget.OidcView -> {
|
||||||
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
||||||
}
|
}
|
||||||
|
is NavTarget.CreateAccount -> {
|
||||||
|
val inputs = CreateAccountNode.Inputs(
|
||||||
|
url = navTarget.url,
|
||||||
|
)
|
||||||
|
createNode<CreateAccountNode>(buildContext, listOf(inputs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.resolver.network
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example:
|
||||||
|
* <pre>
|
||||||
|
* {
|
||||||
|
* "registration_helper_url": "https://element.io"
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
* .
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ElementWellKnown(
|
||||||
|
@SerialName("registration_helper_url")
|
||||||
|
val registrationHelperUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -12,4 +12,7 @@ import retrofit2.http.GET
|
||||||
internal interface WellknownAPI {
|
internal interface WellknownAPI {
|
||||||
@GET(".well-known/matrix/client")
|
@GET(".well-known/matrix/client")
|
||||||
suspend fun getWellKnown(): WellKnown
|
suspend fun getWellKnown(): WellKnown
|
||||||
|
|
||||||
|
@GET(".well-known/element/element.json")
|
||||||
|
suspend fun getElementWellKnown(): ElementWellKnown
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onLoginPasswordNeeded()
|
fun onLoginPasswordNeeded()
|
||||||
fun onOidcDetails(oidcDetails: OidcDetails)
|
fun onOidcDetails(oidcDetails: OidcDetails)
|
||||||
|
fun onCreateAccountContinue(url: String)
|
||||||
fun onChangeAccountProvider()
|
fun onChangeAccountProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +55,10 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
|
||||||
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
|
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onCreateAccountContinue(url: String) {
|
||||||
|
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun onChangeAccountProvider() {
|
private fun onChangeAccountProvider() {
|
||||||
plugins<Callback>().forEach { it.onChangeAccountProvider() }
|
plugins<Callback>().forEach { it.onChangeAccountProvider() }
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +72,7 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onOidcDetails = ::onOidcDetails,
|
onOidcDetails = ::onOidcDetails,
|
||||||
onNeedLoginPassword = ::onLoginPasswordNeeded,
|
onNeedLoginPassword = ::onLoginPasswordNeeded,
|
||||||
|
onCreateAccountContinue = ::onCreateAccountContinue,
|
||||||
onChange = ::onChangeAccountProvider,
|
onChange = ::onChangeAccountProvider,
|
||||||
onLearnMoreClick = { openLearnMorePage(context) },
|
onLearnMoreClick = { openLearnMorePage(context) },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
|
@ -36,6 +38,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||||
|
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
|
||||||
) : Presenter<ConfirmAccountProviderState> {
|
) : Presenter<ConfirmAccountProviderState> {
|
||||||
data class Params(
|
data class Params(
|
||||||
val isAccountCreation: Boolean,
|
val isAccountCreation: Boolean,
|
||||||
|
|
@ -90,13 +93,24 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
||||||
if (matrixHomeServerDetails.supportsOidcLogin) {
|
if (matrixHomeServerDetails.supportsOidcLogin) {
|
||||||
// Retrieve the details right now
|
// Retrieve the details right now
|
||||||
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
|
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
|
||||||
|
} else if (params.isAccountCreation) {
|
||||||
|
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
|
||||||
|
LoginFlow.AccountCreationFlow(url)
|
||||||
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
|
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
|
||||||
LoginFlow.PasswordLogin
|
LoginFlow.PasswordLogin
|
||||||
} else {
|
} else {
|
||||||
error("Unsupported login flow")
|
error("Unsupported login flow")
|
||||||
}
|
}
|
||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
}.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from)
|
}.runCatchingUpdatingState(
|
||||||
|
state = loginFlowAction,
|
||||||
|
errorTransform = {
|
||||||
|
when (it) {
|
||||||
|
is AccountCreationNotSupported -> it
|
||||||
|
else -> ChangeServerError.from(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onOidcAction(
|
private suspend fun onOidcAction(
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ data class ConfirmAccountProviderState(
|
||||||
sealed interface LoginFlow {
|
sealed interface LoginFlow {
|
||||||
data object PasswordLogin : LoginFlow
|
data object PasswordLogin : LoginFlow
|
||||||
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
|
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
|
||||||
|
data class AccountCreationFlow(val url: String) : LoginFlow
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,33 @@
|
||||||
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||||
import io.element.android.features.login.impl.accountprovider.anAccountProvider
|
import io.element.android.features.login.impl.accountprovider.anAccountProvider
|
||||||
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<ConfirmAccountProviderState> {
|
open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<ConfirmAccountProviderState> {
|
||||||
override val values: Sequence<ConfirmAccountProviderState>
|
override val values: Sequence<ConfirmAccountProviderState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aConfirmAccountProviderState(),
|
aConfirmAccountProviderState(),
|
||||||
// Add other state here
|
aConfirmAccountProviderState(
|
||||||
|
isAccountCreation = true,
|
||||||
|
),
|
||||||
|
aConfirmAccountProviderState(
|
||||||
|
isAccountCreation = true,
|
||||||
|
loginFlow = AsyncData.Failure(AccountCreationNotSupported())
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aConfirmAccountProviderState() = ConfirmAccountProviderState(
|
private fun aConfirmAccountProviderState(
|
||||||
accountProvider = anAccountProvider(),
|
accountProvider: AccountProvider = anAccountProvider(),
|
||||||
isAccountCreation = false,
|
isAccountCreation: Boolean = false,
|
||||||
loginFlow = AsyncData.Uninitialized,
|
loginFlow: AsyncData<LoginFlow> = AsyncData.Uninitialized,
|
||||||
eventSink = {}
|
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
|
||||||
|
) = ConfirmAccountProviderState(
|
||||||
|
accountProvider = accountProvider,
|
||||||
|
isAccountCreation = isAccountCreation,
|
||||||
|
loginFlow = loginFlow,
|
||||||
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
|
||||||
import io.element.android.features.login.impl.error.ChangeServerError
|
import io.element.android.features.login.impl.error.ChangeServerError
|
||||||
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
|
|
@ -42,6 +43,7 @@ fun ConfirmAccountProviderView(
|
||||||
onOidcDetails: (OidcDetails) -> Unit,
|
onOidcDetails: (OidcDetails) -> Unit,
|
||||||
onNeedLoginPassword: () -> Unit,
|
onNeedLoginPassword: () -> Unit,
|
||||||
onLearnMoreClick: () -> Unit,
|
onLearnMoreClick: () -> Unit,
|
||||||
|
onCreateAccountContinue: (url: String) -> Unit,
|
||||||
onChange: () -> Unit,
|
onChange: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -116,6 +118,14 @@ fun ConfirmAccountProviderView(
|
||||||
eventSink(ConfirmAccountProviderEvents.ClearError)
|
eventSink(ConfirmAccountProviderEvents.ClearError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
is AccountCreationNotSupported -> {
|
||||||
|
ErrorDialog(
|
||||||
|
content = stringResource(CommonStrings.error_account_creation_not_possible),
|
||||||
|
onDismiss = {
|
||||||
|
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is AsyncData.Loading -> Unit // The Continue button shows the loading state
|
is AsyncData.Loading -> Unit // The Continue button shows the loading state
|
||||||
|
|
@ -123,6 +133,7 @@ fun ConfirmAccountProviderView(
|
||||||
when (val loginFlowState = state.loginFlow.data) {
|
when (val loginFlowState = state.loginFlow.data) {
|
||||||
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
|
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
|
||||||
LoginFlow.PasswordLogin -> onNeedLoginPassword()
|
LoginFlow.PasswordLogin -> onNeedLoginPassword()
|
||||||
|
is LoginFlow.AccountCreationFlow -> onCreateAccountContinue(loginFlowState.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AsyncData.Uninitialized -> Unit
|
AsyncData.Uninitialized -> Unit
|
||||||
|
|
@ -139,6 +150,7 @@ internal fun ConfirmAccountProviderViewPreview(
|
||||||
state = state,
|
state = state,
|
||||||
onOidcDetails = {},
|
onOidcDetails = {},
|
||||||
onNeedLoginPassword = {},
|
onNeedLoginPassword = {},
|
||||||
|
onCreateAccountContinue = {},
|
||||||
onLearnMoreClick = {},
|
onLearnMoreClick = {},
|
||||||
onChange = {},
|
onChange = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
class AccountCreationNotSupported : Exception()
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
sealed interface CreateAccountEvents {
|
||||||
|
data class SetPageProgress(val progress: Int) : CreateAccountEvents
|
||||||
|
data class OnMessageReceived(val message: String) : CreateAccountEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
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 dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
class CreateAccountNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: CreateAccountPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
data class Inputs(
|
||||||
|
val url: String,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(inputs<Inputs>().url)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
CreateAccountView(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
onBackClick = ::navigateUp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.extensions.flatMap
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CreateAccountPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val url: String,
|
||||||
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
|
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||||
|
private val messageParser: MessageParser,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Presenter<CreateAccountState> {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(url: String): CreateAccountPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): CreateAccountState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val pageProgress: MutableState<Int> = remember { mutableIntStateOf(0) }
|
||||||
|
val createAction: MutableState<AsyncAction<SessionId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
|
fun handleEvents(event: CreateAccountEvents) {
|
||||||
|
when (event) {
|
||||||
|
is CreateAccountEvents.SetPageProgress -> {
|
||||||
|
pageProgress.value = event.progress
|
||||||
|
}
|
||||||
|
is CreateAccountEvents.OnMessageReceived -> {
|
||||||
|
// Ignore unexpected message
|
||||||
|
if (event.message.contains("isTrusted")) return
|
||||||
|
coroutineScope.importSession(event.message, createAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateAccountState(
|
||||||
|
url = url,
|
||||||
|
pageProgress = pageProgress.value,
|
||||||
|
isDebugBuild = buildMeta.isDebuggable,
|
||||||
|
createAction = createAction.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch {
|
||||||
|
loggedInState.value = AsyncAction.Loading
|
||||||
|
runCatching {
|
||||||
|
messageParser.parse(message)
|
||||||
|
}.flatMap { externalSession ->
|
||||||
|
authenticationService.importCreatedSession(externalSession)
|
||||||
|
}.onSuccess { sessionId ->
|
||||||
|
// We will not navigate to the WaitList screen, so the login user story is done
|
||||||
|
defaultLoginUserStory.setLoginFlowIsDone(true)
|
||||||
|
loggedInState.value = AsyncAction.Success(sessionId)
|
||||||
|
}.onFailure { failure ->
|
||||||
|
loggedInState.value = AsyncAction.Failure(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
data class CreateAccountState(
|
||||||
|
val url: String,
|
||||||
|
val pageProgress: Int,
|
||||||
|
val createAction: AsyncAction<SessionId>,
|
||||||
|
val isDebugBuild: Boolean,
|
||||||
|
val eventSink: (CreateAccountEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
open class CreateAccountStateProvider : PreviewParameterProvider<CreateAccountState> {
|
||||||
|
override val values: Sequence<CreateAccountState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aCreateAccountState(),
|
||||||
|
aCreateAccountState(pageProgress = 33),
|
||||||
|
aCreateAccountState(createAction = AsyncAction.Loading),
|
||||||
|
aCreateAccountState(createAction = AsyncAction.Failure(Throwable("Failed to create account"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aCreateAccountState(
|
||||||
|
pageProgress: Int = 100,
|
||||||
|
createAction: AsyncAction<SessionId> = AsyncAction.Uninitialized,
|
||||||
|
) = CreateAccountState(
|
||||||
|
url = "https://example.com",
|
||||||
|
isDebugBuild = true,
|
||||||
|
pageProgress = pageProgress,
|
||||||
|
createAction = createAction,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CreateAccountView(
|
||||||
|
state: CreateAccountState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.screen_create_account_title),
|
||||||
|
style = ElementTheme.typography.aliasScreenTitle,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = onBackClick)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { contentPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(contentPadding)
|
||||||
|
.consumeWindowInsets(contentPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
CreateAccountWebView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
state = state,
|
||||||
|
onWebViewCreate = { webView ->
|
||||||
|
WebViewMessageInterceptor(webView, state.isDebugBuild) {
|
||||||
|
state.eventSink(CreateAccountEvents.OnMessageReceived(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.pageProgress != 100,
|
||||||
|
// Disable enter animation
|
||||||
|
enter = fadeIn(initialAlpha = 1f),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp),
|
||||||
|
progress = { state.pageProgress / 100f },
|
||||||
|
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncActionView(
|
||||||
|
async = state.createAction,
|
||||||
|
onSuccess = {},
|
||||||
|
onErrorDismiss = onBackClick,
|
||||||
|
onRetry = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateAccountWebView(
|
||||||
|
state: CreateAccountState,
|
||||||
|
onWebViewCreate: (WebView) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
|
Text("WebView - can't be previewed")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
onWebViewCreate(this)
|
||||||
|
setup(state)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
if (webView.url != state.url) {
|
||||||
|
webView.loadUrl(state.url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRelease = { webView ->
|
||||||
|
webView.destroy()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun WebView.setup(state: CreateAccountState) {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
with(settings) {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
super.onProgressChanged(view, newProgress)
|
||||||
|
state.eventSink(CreateAccountEvents.SetPageProgress(newProgress))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun CreateAccountViewPreview(@PreviewParameter(CreateAccountStateProvider::class) state: CreateAccountState) = ElementPreview {
|
||||||
|
CreateAccountView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface MessageParser {
|
||||||
|
/**
|
||||||
|
* Parse the message and return the ExternalSession object, or
|
||||||
|
* throw an exception if the message is invalid.
|
||||||
|
*/
|
||||||
|
fun parse(message: String): ExternalSession
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultMessageParser @Inject constructor(
|
||||||
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
|
) : MessageParser {
|
||||||
|
override fun parse(message: String): ExternalSession {
|
||||||
|
val parser = Json { ignoreUnknownKeys = true }
|
||||||
|
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
|
||||||
|
val userId = response.userId ?: error("No user ID in response")
|
||||||
|
val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url
|
||||||
|
val accessToken = response.accessToken ?: error("No access token in response")
|
||||||
|
val deviceId = response.deviceId ?: error("No device ID in response")
|
||||||
|
return ExternalSession(
|
||||||
|
userId = userId,
|
||||||
|
homeserverUrl = homeServer,
|
||||||
|
accessToken = accessToken,
|
||||||
|
deviceId = deviceId,
|
||||||
|
refreshToken = null,
|
||||||
|
slidingSyncProxy = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For ref:
|
||||||
|
* https://github.com/element-hq/matrix-react-sdk/pull/42/files#diff-2bbba5a742004fd4e924a639ded444279f66f7ad890cb669fbc91ac6b8638c64R56
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MobileRegistrationResponse(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: String? = null,
|
||||||
|
@SerialName("home_server")
|
||||||
|
val homeServer: String? = null,
|
||||||
|
@SerialName("access_token")
|
||||||
|
val accessToken: String? = null,
|
||||||
|
@SerialName("device_id")
|
||||||
|
val deviceId: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
|
||||||
|
class WebViewMessageInterceptor(
|
||||||
|
webView: WebView,
|
||||||
|
private val debugLog: Boolean,
|
||||||
|
private val onMessage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this
|
||||||
|
// 'listenerName' so they can both receive the data from the WebView when
|
||||||
|
// `${LISTENER_NAME}.postMessage(...)` is called
|
||||||
|
const val LISTENER_NAME = "elementX"
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
webView.webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
super.onPageStarted(view, url, favicon)
|
||||||
|
|
||||||
|
// We inject this JS code when the page starts loading to attach a message listener to the window.
|
||||||
|
// This listener will receive both messages:
|
||||||
|
// - EC widget API -> Element X (message.data.api == "fromWidget")
|
||||||
|
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
|
||||||
|
view?.evaluateJavascript(
|
||||||
|
"""
|
||||||
|
window.addEventListener(
|
||||||
|
"mobileregistrationresponse",
|
||||||
|
(event) => {
|
||||||
|
let json = JSON.stringify(event.detail)
|
||||||
|
${"console.log('message sent: ' + json);".takeIf { debugLog }}
|
||||||
|
$LISTENER_NAME.postMessage(json);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
""".trimIndent(),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use WebMessageListener if supported, otherwise use JavascriptInterface
|
||||||
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||||
|
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
|
||||||
|
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
|
||||||
|
onMessageReceived(message.data)
|
||||||
|
}
|
||||||
|
WebViewCompat.addWebMessageListener(
|
||||||
|
webView,
|
||||||
|
LISTENER_NAME,
|
||||||
|
setOf("*"),
|
||||||
|
webMessageListener
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
webView.addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun postMessage(json: String?) {
|
||||||
|
onMessageReceived(json)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
LISTENER_NAME,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMessageReceived(json: String?) {
|
||||||
|
// Here is where we would handle the messages from the WebView, passing them to the listener
|
||||||
|
json?.let { onMessage(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.web
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.login.impl.resolver.network.WellknownAPI
|
||||||
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.network.RetrofitFactory
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface WebClientUrlForAuthenticationRetriever {
|
||||||
|
suspend fun retrieve(homeServerUrl: String): String
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultWebClientUrlForAuthenticationRetriever @Inject constructor(
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
) : WebClientUrlForAuthenticationRetriever {
|
||||||
|
override suspend fun retrieve(homeServerUrl: String): String {
|
||||||
|
val wellknownApi = retrofitFactory.create(homeServerUrl)
|
||||||
|
.create(WellknownAPI::class.java)
|
||||||
|
val result = try {
|
||||||
|
wellknownApi.getElementWellKnown()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw when {
|
||||||
|
e is retrofit2.HttpException &&
|
||||||
|
e.code() == HttpURLConnection.HTTP_NOT_FOUND -> AccountCreationNotSupported()
|
||||||
|
else -> e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val registrationHelperUrl = result.registrationHelperUrl
|
||||||
|
return if (registrationHelperUrl != null) {
|
||||||
|
Uri.parse(registrationHelperUrl)
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter("hs_url", homeServerUrl)
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
} else {
|
||||||
|
throw AccountCreationNotSupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<string name="screen_change_server_form_notice">"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"</string>
|
<string name="screen_change_server_form_notice">"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"</string>
|
||||||
<string name="screen_change_server_subtitle">"What is the address of your server?"</string>
|
<string name="screen_change_server_subtitle">"What is the address of your server?"</string>
|
||||||
<string name="screen_change_server_title">"Select your server"</string>
|
<string name="screen_change_server_title">"Select your server"</string>
|
||||||
|
<string name="screen_create_account_title">"Create account"</string>
|
||||||
<string name="screen_login_error_deactivated_account">"This account has been deactivated."</string>
|
<string name="screen_login_error_deactivated_account">"This account has been deactivated."</string>
|
||||||
<string name="screen_login_error_invalid_credentials">"Incorrect username and/or password"</string>
|
<string name="screen_login_error_invalid_credentials">"Incorrect username and/or password"</string>
|
||||||
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: ‘@user:homeserver.org’"</string>
|
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: ‘@user:homeserver.org’"</string>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
|
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
|
||||||
import io.element.android.features.login.impl.util.defaultAccountProvider
|
import io.element.android.features.login.impl.util.defaultAccountProvider
|
||||||
|
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
|
||||||
|
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
|
|
@ -255,17 +258,109 @@ class ConfirmAccountProviderPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - confirm account creation without oidc and without url generates an error`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService()
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
|
||||||
|
matrixAuthenticationService = authenticationService,
|
||||||
|
webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever {
|
||||||
|
throw AccountCreationNotSupported()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||||
|
skipItems(1) // Loading
|
||||||
|
// Check an error was returned
|
||||||
|
val submittedState = awaitItem()
|
||||||
|
assertThat(submittedState.loginFlow.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java)
|
||||||
|
// Assert the error is then cleared
|
||||||
|
submittedState.eventSink(ConfirmAccountProviderEvents.ClearError)
|
||||||
|
val clearedState = awaitItem()
|
||||||
|
assertThat(clearedState.loginFlow).isEqualTo(AsyncData.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - confirm account creation with oidc is successful`() = runTest {
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService()
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
|
||||||
|
matrixAuthenticationService = authenticationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||||
|
skipItems(1) // Loading
|
||||||
|
val submittedState = awaitItem()
|
||||||
|
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
|
||||||
|
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - confirm account creation with oidc and url continues with oidc`() = runTest {
|
||||||
|
val aUrl = "aUrl"
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService()
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
|
||||||
|
matrixAuthenticationService = authenticationService,
|
||||||
|
webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl },
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||||
|
skipItems(1) // Loading
|
||||||
|
val submittedState = awaitItem()
|
||||||
|
assertThat(submittedState.loginFlow).isInstanceOf(AsyncData.Success::class.java)
|
||||||
|
assertThat(submittedState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - confirm account creation without oidc and with url continuing with url`() = runTest {
|
||||||
|
val aUrl = "aUrl"
|
||||||
|
val authenticationService = FakeMatrixAuthenticationService()
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
|
val presenter = createConfirmAccountProviderPresenter(
|
||||||
|
params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true),
|
||||||
|
matrixAuthenticationService = authenticationService,
|
||||||
|
webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl },
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ConfirmAccountProviderEvents.Continue)
|
||||||
|
skipItems(1) // Loading
|
||||||
|
val submittedState = awaitItem()
|
||||||
|
assertThat(submittedState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.AccountCreationFlow(aUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createConfirmAccountProviderPresenter(
|
private fun createConfirmAccountProviderPresenter(
|
||||||
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
|
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
|
||||||
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(),
|
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(),
|
||||||
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(),
|
defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(),
|
||||||
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
|
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
|
||||||
|
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
|
||||||
) = ConfirmAccountProviderPresenter(
|
) = ConfirmAccountProviderPresenter(
|
||||||
params = params,
|
params = params,
|
||||||
accountProviderDataSource = accountProviderDataSource,
|
accountProviderDataSource = accountProviderDataSource,
|
||||||
authenticationService = matrixAuthenticationService,
|
authenticationService = matrixAuthenticationService,
|
||||||
oidcActionFlow = defaultOidcActionFlow,
|
oidcActionFlow = defaultOidcActionFlow,
|
||||||
defaultLoginUserStory = defaultLoginUserStory,
|
defaultLoginUserStory = defaultLoginUserStory,
|
||||||
|
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionMode
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreateAccountPresenterTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.url).isEqualTo("aUrl")
|
||||||
|
assertThat(initialState.pageProgress).isEqualTo(0)
|
||||||
|
assertThat(initialState.createAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
assertThat(initialState.isDebugBuild).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - set up progress update the state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(CreateAccountEvents.SetPageProgress(33))
|
||||||
|
assertThat(awaitItem().pageProgress).isEqualTo(33)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - receiving a message not able to be parsed change the state to error`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
messageParser = FakeMessageParser { error("An error") }
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(CreateAccountEvents.OnMessageReceived(""))
|
||||||
|
assertThat(awaitItem().createAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - receiving a message containing isTrusted is ignored`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(CreateAccountEvents.OnMessageReceived("isTrusted"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - receiving a message able to be parsed change the state to success`() = runTest {
|
||||||
|
val defaultLoginUserStory = DefaultLoginUserStory()
|
||||||
|
defaultLoginUserStory.setLoginFlowIsDone(false)
|
||||||
|
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
|
val lambda = lambdaRecorder<String, ExternalSession> { _ -> anExternalSession() }
|
||||||
|
val presenter = createPresenter(
|
||||||
|
authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
|
||||||
|
),
|
||||||
|
messageParser = FakeMessageParser(lambda),
|
||||||
|
defaultLoginUserStory = defaultLoginUserStory,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage"))
|
||||||
|
assertThat(awaitItem().createAction.isLoading()).isTrue()
|
||||||
|
assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID)
|
||||||
|
}
|
||||||
|
lambda.assertions().isCalledOnce().with(value("aMessage"))
|
||||||
|
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - receiving a message able to be parsed but error in importing change the state to error`() = runTest {
|
||||||
|
val defaultLoginUserStory = DefaultLoginUserStory()
|
||||||
|
defaultLoginUserStory.setLoginFlowIsDone(false)
|
||||||
|
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
|
val presenter = createPresenter(
|
||||||
|
authenticationService = FakeMatrixAuthenticationService(
|
||||||
|
importCreatedSessionLambda = { Result.failure(AN_EXCEPTION) }
|
||||||
|
),
|
||||||
|
messageParser = FakeMessageParser { anExternalSession() }
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(CreateAccountEvents.OnMessageReceived(""))
|
||||||
|
assertThat(awaitItem().createAction.isLoading()).isTrue()
|
||||||
|
assertThat(awaitItem().createAction.errorOrNull()).isNotNull()
|
||||||
|
}
|
||||||
|
assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPresenter(
|
||||||
|
url: String = "aUrl",
|
||||||
|
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||||
|
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
|
||||||
|
messageParser: MessageParser = FakeMessageParser(),
|
||||||
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
|
) = CreateAccountPresenter(
|
||||||
|
url = url,
|
||||||
|
authenticationService = authenticationService,
|
||||||
|
defaultLoginUserStory = defaultLoginUserStory,
|
||||||
|
messageParser = messageParser,
|
||||||
|
buildMeta = buildMeta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
|
import io.element.android.features.login.impl.util.defaultAccountProvider
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultMessageParserTest {
|
||||||
|
private val validMessage = """
|
||||||
|
{
|
||||||
|
"user_id": "user_id",
|
||||||
|
"home_server": "home_server",
|
||||||
|
"access_token": "access_token",
|
||||||
|
"device_id": "device_id"
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DefaultMessageParser is able to parse correct message`() {
|
||||||
|
val sut = DefaultMessageParser(
|
||||||
|
AccountProviderDataSource()
|
||||||
|
)
|
||||||
|
assertThat(sut.parse(validMessage)).isEqualTo(
|
||||||
|
anExternalSession(
|
||||||
|
homeserverUrl = "home_server",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DefaultMessageParser should throw Exception in case of error`() {
|
||||||
|
val sut = DefaultMessageParser(
|
||||||
|
AccountProviderDataSource()
|
||||||
|
)
|
||||||
|
// kotlinx.serialization.json.internal.JsonDecodingException
|
||||||
|
assertThrows(SerializationException::class.java) { sut.parse("invalid json") }
|
||||||
|
// missing userId
|
||||||
|
assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""user_id": "user_id",""", "")) }
|
||||||
|
// missing accessToken
|
||||||
|
assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""access_token": "access_token",""", "")) }
|
||||||
|
// missing deviceId
|
||||||
|
assertThrows(IllegalStateException::class.java) {
|
||||||
|
sut.parse(
|
||||||
|
validMessage
|
||||||
|
.replace(""""access_token": "access_token",""", """"access_token": "access_token"""")
|
||||||
|
.replace(""""device_id": "device_id"""", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DefaultMessageParser should be successful even is homeserver url is missing`() {
|
||||||
|
val sut = DefaultMessageParser(
|
||||||
|
AccountProviderDataSource()
|
||||||
|
)
|
||||||
|
// missing homeServer
|
||||||
|
assertThat(sut.parse(validMessage.replace(""""home_server": "home_server",""", ""))).isEqualTo(
|
||||||
|
anExternalSession(
|
||||||
|
homeserverUrl = defaultAccountProvider.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun anExternalSession(
|
||||||
|
homeserverUrl: String = "home_server",
|
||||||
|
) = ExternalSession(
|
||||||
|
userId = "user_id",
|
||||||
|
homeserverUrl = homeserverUrl,
|
||||||
|
accessToken = "access_token",
|
||||||
|
deviceId = "device_id",
|
||||||
|
refreshToken = null,
|
||||||
|
slidingSyncProxy = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.createaccount
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
class FakeMessageParser(
|
||||||
|
private val parseResult: (String) -> ExternalSession = { lambdaError() }
|
||||||
|
) : MessageParser {
|
||||||
|
override fun parse(message: String): ExternalSession {
|
||||||
|
return parseResult(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.web
|
||||||
|
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
class FakeWebClientUrlForAuthenticationRetriever(
|
||||||
|
private val retrieveLambda: suspend (homeServerUrl: String) -> String = { lambdaError() }
|
||||||
|
) : WebClientUrlForAuthenticationRetriever {
|
||||||
|
override suspend fun retrieve(homeServerUrl: String): String {
|
||||||
|
return retrieveLambda(homeServerUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.api.auth
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -30,6 +31,11 @@ interface MatrixAuthenticationService {
|
||||||
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
||||||
suspend fun login(username: String, password: String): Result<SessionId>
|
suspend fun login(username: String, password: String): Result<SessionId>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a session that was created using another client, for instance Element Web.
|
||||||
|
*/
|
||||||
|
suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId>
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* OIDC part.
|
* OIDC part.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.auth.external
|
||||||
|
|
||||||
|
/***
|
||||||
|
* Represents a session data of a session created by another client.
|
||||||
|
*/
|
||||||
|
data class ExternalSession(
|
||||||
|
val userId: String,
|
||||||
|
val deviceId: String,
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val homeserverUrl: String,
|
||||||
|
val slidingSyncProxy: String?
|
||||||
|
)
|
||||||
|
|
@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -160,6 +161,23 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
|
||||||
|
withContext(coroutineDispatchers.io) {
|
||||||
|
runCatching {
|
||||||
|
currentClient ?: error("You need to call `setHomeserver()` first")
|
||||||
|
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||||
|
val sessionData = externalSession.toSessionData(
|
||||||
|
isTokenValid = true,
|
||||||
|
loginType = LoginType.PASSWORD,
|
||||||
|
passphrase = pendingPassphrase,
|
||||||
|
sessionPaths = currentSessionPaths,
|
||||||
|
)
|
||||||
|
clear()
|
||||||
|
sessionStore.storeData(sessionData)
|
||||||
|
SessionId(sessionData.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var pendingOidcAuthorizationData: OidcAuthorizationData? = null
|
private var pendingOidcAuthorizationData: OidcAuthorizationData? = null
|
||||||
|
|
||||||
override suspend fun getOidcUrl(): Result<OidcDetails> {
|
override suspend fun getOidcUrl(): Result<OidcDetails> {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.impl.mapper
|
package io.element.android.libraries.matrix.impl.mapper
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.impl.paths.SessionPaths
|
import io.element.android.libraries.matrix.impl.paths.SessionPaths
|
||||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
|
|
@ -35,3 +36,24 @@ internal fun Session.toSessionData(
|
||||||
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
||||||
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun ExternalSession.toSessionData(
|
||||||
|
isTokenValid: Boolean,
|
||||||
|
loginType: LoginType,
|
||||||
|
passphrase: String?,
|
||||||
|
sessionPaths: SessionPaths,
|
||||||
|
) = SessionData(
|
||||||
|
userId = userId,
|
||||||
|
deviceId = deviceId,
|
||||||
|
accessToken = accessToken,
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
homeserverUrl = homeserverUrl,
|
||||||
|
oidcData = null,
|
||||||
|
slidingSyncProxy = slidingSyncProxy,
|
||||||
|
loginTimestamp = Date(),
|
||||||
|
isTokenValid = isTokenValid,
|
||||||
|
loginType = loginType,
|
||||||
|
passphrase = passphrase,
|
||||||
|
sessionPath = sessionPaths.fileDirectory.absolutePath,
|
||||||
|
cachePath = sessionPaths.cacheDirectory.absolutePath,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -30,6 +32,7 @@ class FakeMatrixAuthenticationService(
|
||||||
var matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null,
|
var matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null,
|
||||||
var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result<SessionId> =
|
var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result<SessionId> =
|
||||||
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
|
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
|
||||||
|
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() }
|
||||||
) : MatrixAuthenticationService {
|
) : MatrixAuthenticationService {
|
||||||
private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
private var oidcError: Throwable? = null
|
private var oidcError: Throwable? = null
|
||||||
|
|
@ -73,6 +76,10 @@ class FakeMatrixAuthenticationService(
|
||||||
loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> = simulateLongTask {
|
||||||
|
return importCreatedSessionLambda(externalSession)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getOidcUrl(): Result<OidcDetails> = simulateLongTask {
|
override suspend fun getOidcUrl(): Result<OidcDetails> = simulateLongTask {
|
||||||
oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
|
oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,6 @@
|
||||||
<string name="invite_friends_text">"Γεια, μίλα μου στην εφαρμογή %1$s :%2$s"</string>
|
<string name="invite_friends_text">"Γεια, μίλα μου στην εφαρμογή %1$s :%2$s"</string>
|
||||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||||
<string name="preference_rageshake">"Κούνησε δυνατά τη συσκευή σου για να αναφέρεις κάποιο σφάλμα"</string>
|
<string name="preference_rageshake">"Κούνησε δυνατά τη συσκευή σου για να αναφέρεις κάποιο σφάλμα"</string>
|
||||||
<string name="screen_create_account_title">"Δημιουργία λογαριασμού"</string>
|
|
||||||
<string name="screen_media_picker_error_failed_selection">"Αποτυχία επιλογής πολυμέσου, δοκίμασε ξανά."</string>
|
<string name="screen_media_picker_error_failed_selection">"Αποτυχία επιλογής πολυμέσου, δοκίμασε ξανά."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_processing">"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."</string>
|
<string name="screen_media_upload_preview_error_failed_processing">"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_sending">"Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά."</string>
|
<string name="screen_media_upload_preview_error_failed_sending">"Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά."</string>
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,6 @@ Põhjus: %1$s."</string>
|
||||||
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
|
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
|
||||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||||
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
|
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
|
||||||
<string name="screen_create_account_title">"Loo kasutajakonto"</string>
|
|
||||||
<string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string>
|
<string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_processing">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
|
<string name="screen_media_upload_preview_error_failed_processing">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_sending">"Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti."</string>
|
<string name="screen_media_upload_preview_error_failed_sending">"Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti."</string>
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,6 @@ Reason: %1$s."</string>
|
||||||
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
|
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
|
||||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||||
<string name="preference_rageshake">"Rageshake to report bug"</string>
|
<string name="preference_rageshake">"Rageshake to report bug"</string>
|
||||||
<string name="screen_create_account_title">"Create account"</string>
|
|
||||||
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
||||||
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@
|
||||||
"screen_server_confirmation_.*",
|
"screen_server_confirmation_.*",
|
||||||
"screen_change_server_.*",
|
"screen_change_server_.*",
|
||||||
"screen_change_account_provider_.*",
|
"screen_change_account_provider_.*",
|
||||||
|
"screen_create_account_.*",
|
||||||
"screen_account_provider_.*",
|
"screen_account_provider_.*",
|
||||||
"screen_waitlist_.*",
|
"screen_waitlist_.*",
|
||||||
"screen_qr_code_login_.*"
|
"screen_qr_code_login_.*"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue