Merge pull request #3467 from element-hq/feature/bma/accountCreation
Temporary account creation using Element Web.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@GET(".well-known/matrix/client")
|
||||
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 {
|
||||
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) },
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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,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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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,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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.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> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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_.*"
|
||||
|
||||
Reference in New Issue
Block a user