Merge pull request #3467 from element-hq/feature/bma/accountCreation

Temporary account creation using Element Web.
This commit is contained in:
Benoit Marty
2024-09-16 16:52:26 +02:00
committed by GitHub
54 changed files with 1203 additions and 29 deletions

View File

@@ -12,5 +12,5 @@ object OnBoardingConfig {
const val CAN_LOGIN_WITH_QR_CODE = true
/** Whether the user can create an account using the app. */
const val CAN_CREATE_ACCOUNT = false
const val CAN_CREATE_ACCOUNT = true
}

View File

@@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.oidc.api)
implementation(libs.androidx.browser)
implementation(platform(libs.network.retrofit.bom))
implementation(libs.androidx.webkit)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.login.api)

View File

@@ -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.screens.changeaccountprovider.ChangeAccountProviderNode
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.searchaccountprovider.SearchAccountProviderNode
import io.element.android.libraries.architecture.BackstackView
@@ -109,6 +110,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
data object LoginPassword : NavTarget
@Parcelize
data class CreateAccount(val url: String) : NavTarget
@Parcelize
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() {
backstack.push(NavTarget.LoginPassword)
}
@@ -180,6 +188,12 @@ class LoginFlowNode @AssistedInject constructor(
is NavTarget.OidcView -> {
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
url = navTarget.url,
)
createNode<CreateAccountNode>(buildContext, listOf(inputs))
}
}
}

View File

@@ -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,
)

View File

@@ -12,4 +12,7 @@ import retrofit2.http.GET
internal interface WellknownAPI {
@GET(".well-known/matrix/client")
suspend fun getWellKnown(): WellKnown
@GET(".well-known/element/element.json")
suspend fun getElementWellKnown(): ElementWellKnown
}

View File

@@ -43,6 +43,7 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
interface Callback : Plugin {
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onCreateAccountContinue(url: String)
fun onChangeAccountProvider()
}
@@ -54,6 +55,10 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onCreateAccountContinue(url: String) {
plugins<Callback>().forEach { it.onCreateAccountContinue(url) }
}
private fun onChangeAccountProvider() {
plugins<Callback>().forEach { it.onChangeAccountProvider() }
}
@@ -67,6 +72,7 @@ class ConfirmAccountProviderNode @AssistedInject constructor(
modifier = modifier,
onOidcDetails = ::onOidcDetails,
onNeedLoginPassword = ::onLoginPasswordNeeded,
onCreateAccountContinue = ::onCreateAccountContinue,
onChange = ::onChangeAccountProvider,
onLearnMoreClick = { openLearnMorePage(context) },
)

View File

@@ -21,6 +21,8 @@ import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.DefaultLoginUserStory
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.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.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -36,6 +38,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
private val authenticationService: MatrixAuthenticationService,
private val oidcActionFlow: OidcActionFlow,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@@ -90,13 +93,24 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
} else if (params.isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginFlow.AccountCreationFlow(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from)
}.runCatchingUpdatingState(
state = loginFlowAction,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOidcAction(

View File

@@ -24,4 +24,5 @@ data class ConfirmAccountProviderState(
sealed interface LoginFlow {
data object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
data class AccountCreationFlow(val url: String) : LoginFlow
}

View File

@@ -8,20 +8,33 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
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.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<ConfirmAccountProviderState> {
override val values: Sequence<ConfirmAccountProviderState>
get() = sequenceOf(
aConfirmAccountProviderState(),
// Add other state here
aConfirmAccountProviderState(
isAccountCreation = true,
),
aConfirmAccountProviderState(
isAccountCreation = true,
loginFlow = AsyncData.Failure(AccountCreationNotSupported())
),
)
}
fun aConfirmAccountProviderState() = ConfirmAccountProviderState(
accountProvider = anAccountProvider(),
isAccountCreation = false,
loginFlow = AsyncData.Uninitialized,
eventSink = {}
private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(),
isAccountCreation: Boolean = false,
loginFlow: AsyncData<LoginFlow> = AsyncData.Uninitialized,
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = isAccountCreation,
loginFlow = loginFlow,
eventSink = eventSink
)

View File

@@ -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.dialogs.SlidingSyncNotSupportedDialog
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.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -42,6 +43,7 @@ fun ConfirmAccountProviderView(
onOidcDetails: (OidcDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -109,12 +111,23 @@ fun ConfirmAccountProviderView(
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(onLearnMoreClick = {
onLearnMoreClick()
eventSink(ConfirmAccountProviderEvents.ClearError)
}, onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
})
SlidingSyncNotSupportedDialog(
onLearnMoreClick = {
onLearnMoreClick()
eventSink(ConfirmAccountProviderEvents.ClearError)
},
onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
}
)
}
is AccountCreationNotSupported -> {
ErrorDialog(
content = stringResource(CommonStrings.error_account_creation_not_possible),
onSubmit = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
}
}
@@ -123,6 +136,7 @@ fun ConfirmAccountProviderView(
when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onNeedLoginPassword()
is LoginFlow.AccountCreationFlow -> onCreateAccountContinue(loginFlowState.url)
}
}
AsyncData.Uninitialized -> Unit
@@ -139,6 +153,7 @@ internal fun ConfirmAccountProviderViewPreview(
state = state,
onOidcDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {},
onLearnMoreClick = {},
onChange = {},
)

View File

@@ -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()

View File

@@ -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
}

View File

@@ -0,0 +1,56 @@
/*
* 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.app.Activity
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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
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)
private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
CreateAccountView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOpenExternalUrl = {
onOpenExternalUrl(activity, isDark, it)
},
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
)

View File

@@ -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 = {}
)

View File

@@ -0,0 +1,181 @@
/*
* 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.JsResult
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
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateAccountView(
state: CreateAccountState,
onBackClick: () -> Unit,
onOpenExternalUrl: (String) -> 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,
onOpenExternalUrl = onOpenExternalUrl,
onMessage = {
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))
}
override fun onJsBeforeUnload(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
Timber.w("onJsBeforeUnload, cancelling the dialog, we will open external links in a Custom Chrome Tab")
result?.confirm()
return true
}
}
}
@PreviewsDayNight
@Composable
internal fun CreateAccountViewPreview(@PreviewParameter(CreateAccountStateProvider::class) state: CreateAccountState) = ElementPreview {
CreateAccountView(
state = state,
onBackClick = {},
onOpenExternalUrl = {},
)
}

View File

@@ -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
)
}
}

View File

@@ -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,
)

View File

@@ -0,0 +1,90 @@
/*
* 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.WebResourceRequest
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 onOpenExternalUrl: (String) -> Unit,
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.
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
)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
request ?: return super.shouldOverrideUrlLoading(view, request)
// Load the URL in a Chrome Custom Tab, and return true to cancel the load
onOpenExternalUrl(request.url.toString())
return true
}
}
// 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) }
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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.appconfig.AuthenticationConfig
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 timber.log.Timber
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 {
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
Timber.w("Temporary account creation flow is only supported on matrix.org")
throw AccountCreationNotSupported()
}
val wellknownApi = retrofitFactory.create(homeServerUrl)
.create(WellknownAPI::class.java)
val result = try {
wellknownApi.getElementWellKnown()
} catch (e: retrofit2.HttpException) {
throw when {
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()
}
}
}

View File

@@ -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_subtitle">"What is the address of 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_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>

View File

@@ -13,7 +13,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.DefaultLoginUserStory
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.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
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(
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(),
defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
authenticationService = matrixAuthenticationService,
oidcActionFlow = defaultOidcActionFlow,
defaultLoginUserStory = defaultLoginUserStory,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever
)
}

View File

@@ -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,
)
}

View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -39,8 +39,8 @@ 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.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -171,10 +171,9 @@ private fun OnBoardingButtons(
.testTag(TestTags.onBoardingSignIn)
)
if (state.canCreateAccount) {
OutlinedButton(
TextButton(
text = stringResource(id = R.string.screen_onboarding_sign_up),
onClick = onCreateAccount,
enabled = true,
modifier = Modifier
.fillMaxWidth()
)

View File

@@ -11,6 +11,7 @@ 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.appconfig.OnBoardingConfig
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -46,7 +47,7 @@ class OnBoardingPresenterTest {
assertThat(initialState.isDebugBuild).isTrue()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isFalse()
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(awaitItem().canLoginWithQrCode).isTrue()
}

View File

@@ -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.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.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
@@ -30,6 +31,11 @@ interface MatrixAuthenticationService {
suspend fun setHomeserver(homeserver: String): Result<Unit>
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.
*/

View File

@@ -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?
)

View File

@@ -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.MatrixHomeServerDetails
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.QrCodeLoginStep
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
override suspend fun getOidcUrl(): Result<OidcDetails> {

View File

@@ -7,6 +7,7 @@
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.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
@@ -35,3 +36,24 @@ internal fun Session.toSessionData(
sessionPath = sessionPaths.fileDirectory.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,
)

View File

@@ -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.MatrixHomeServerDetails
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.QrCodeLoginStep
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_USER_ID
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.simulateLongTask
import kotlinx.coroutines.flow.Flow
@@ -30,6 +32,7 @@ class FakeMatrixAuthenticationService(
var matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null,
var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result<SessionId> =
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() }
) : MatrixAuthenticationService {
private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var oidcError: Throwable? = null
@@ -73,6 +76,10 @@ class FakeMatrixAuthenticationService(
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 {
oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
}

View File

@@ -273,7 +273,6 @@
<string name="invite_friends_text">"Γεια, μίλα μου στην εφαρμογή %1$s :%2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</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_upload_preview_error_failed_processing">"Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά."</string>

View File

@@ -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="login_initial_device_name_android">"%1$s Android"</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_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>

View File

@@ -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="login_initial_device_name_android">"%1$s Android"</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_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>

View File

@@ -127,6 +127,7 @@
"screen_server_confirmation_.*",
"screen_change_server_.*",
"screen_change_account_provider_.*",
"screen_create_account_.*",
"screen_account_provider_.*",
"screen_waitlist_.*",
"screen_qr_code_login_.*"