Rework the set homeserver part: get the info, instead of hard-coded value, and implement retry in case of error.

This commit is contained in:
Benoit Marty 2023-04-20 16:13:02 +02:00 committed by Benoit Marty
parent a7eae1cda5
commit b08021f1d9
11 changed files with 270 additions and 51 deletions

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.root
sealed interface LoginRootEvents { sealed interface LoginRootEvents {
object RetryFetchServerInfo : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents object Submit : LoginRootEvents

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -24,25 +25,38 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.impl.util.LoginConstants import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> { class LoginRootPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val defaultHomeserver = MatrixHomeServerDetails( ) : Presenter<LoginRootState> {
url = LoginConstants.DEFAULT_HOMESERVER_URL,
supportsPasswordLogin = true,
supportsOidc = false,
)
@Composable @Composable
override fun present(): LoginRootState { override fun present(): LoginRootState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL
val getHomeServerDetailsAction: MutableState<Async<MatrixHomeServerDetails>> = remember {
if (currentHomeServerDetails != null) {
mutableStateOf(Async.Success(currentHomeServerDetails))
} else {
mutableStateOf(Async.Uninitialized)
}
}
LaunchedEffect(Unit) {
if (currentHomeServerDetails == null) {
getHomeServerDetails(homeserver, getHomeServerDetailsAction)
}
}
val loggedInState: MutableState<LoggedInState> = remember { val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn) mutableStateOf(LoggedInState.NotLoggedIn)
} }
@ -52,6 +66,7 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
fun handleEvents(event: LoginRootEvents) { fun handleEvents(event: LoginRootEvents) {
when (event) { when (event) {
LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
is LoginRootEvents.SetLogin -> updateFormState(formState) { is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login) copy(login = event.login)
} }
@ -59,9 +74,10 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
copy(password = event.password) copy(password = event.password)
} }
LoginRootEvents.Submit -> { LoginRootEvents.Submit -> {
val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return
when { when {
homeserver.supportsOidc -> localCoroutineScope.submitOidc(homeserver.url, loggedInState) homeServerDetails.supportsOidc -> localCoroutineScope.submitOidc(loggedInState)
homeserver.supportsPasswordLogin -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState) homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState)
} }
} }
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
@ -69,17 +85,27 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
} }
return LoginRootState( return LoginRootState(
homeserverDetails = homeserver, homeserverUrl = homeserver,
homeserverDetails = getHomeServerDetailsAction.value,
loggedInState = loggedInState.value, loggedInState = loggedInState.value,
formState = formState.value, formState = formState.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.submitOidc(homeserver: String, loggedInState: MutableState<LoggedInState>) = launch { private fun CoroutineScope.getHomeServerDetails(
homeserver: String,
state: MutableState<Async<MatrixHomeServerDetails>>,
) = launch {
state.value = Async.Loading()
suspend {
authenticationService.setHomeserver(homeserver)
authenticationService.getHomeserverDetails().value!!
}.execute(state)
}
private fun CoroutineScope.submitOidc(loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn loggedInState.value = LoggedInState.LoggingIn
// TODO rework the setHomeserver flow
authenticationService.setHomeserver(homeserver)
authenticationService.getOidcUrl() authenticationService.getOidcUrl()
.onSuccess { .onSuccess {
loggedInState.value = LoggedInState.OidcStarted(it) loggedInState.value = LoggedInState.OidcStarted(it)
@ -89,10 +115,8 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
} }
} }
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch { private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn loggedInState.value = LoggedInState.LoggingIn
// TODO rework the setHomeserver flow
authenticationService.setHomeserver(homeserver)
authenticationService.login(formState.login.trim(), formState.password) authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId -> .onSuccess { sessionId ->
loggedInState.value = LoggedInState.LoggedIn(sessionId) loggedInState.value = LoggedInState.LoggedIn(sessionId)

View file

@ -17,19 +17,22 @@
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.root
import android.os.Parcelable import android.os.Parcelable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
data class LoginRootState( data class LoginRootState(
val homeserverDetails: MatrixHomeServerDetails, val homeserverUrl: String,
val homeserverDetails: Async<MatrixHomeServerDetails>,
val loggedInState: LoggedInState, val loggedInState: LoggedInState,
val formState: LoginFormState, val formState: LoginFormState,
val eventSink: (LoginRootEvents) -> Unit val eventSink: (LoginRootEvents) -> Unit
) { ) {
val supportPasswordLogin = homeserverDetails.supportsPasswordLogin val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
val supportOidcLogin = homeserverDetails.supportsOidc val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidc.orFalse()
val submitEnabled: Boolean val submitEnabled: Boolean
get() = loggedInState !is LoggedInState.ErrorLoggingIn && get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin) ((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -24,20 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
override val values: Sequence<LoginRootState> override val values: Sequence<LoginRootState>
get() = sequenceOf( get() = sequenceOf(
aLoginRootState(), aLoginRootState(),
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", supportsPasswordLogin = true, supportsOidc = false)), aLoginRootState().copy(
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"some-custom-server.com",
supportsPasswordLogin = true,
supportsOidc = false
)
)
),
aLoginRootState().copy(formState = LoginFormState("user", "pass")), aLoginRootState().copy(formState = LoginFormState("user", "pass")),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn), aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())), aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))), aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
// Oidc // Oidc
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("server-with-oidc.org", supportsPasswordLogin = false, supportsOidc = true)), aLoginRootState().copy(
homeserverUrl = "server-with-oidc.org",
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"server-with-oidc.org",
supportsPasswordLogin = false,
supportsOidc = true
)
)
),
// No password, no oidc support // No password, no oidc support
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("wrong.org", supportsPasswordLogin = false, supportsOidc = false)), aLoginRootState().copy(
homeserverUrl = "wrong.org",
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"wrong.org",
supportsPasswordLogin = false,
supportsOidc = false
)
)
),
// Loading
aLoginRootState().copy(homeserverDetails = Async.Loading()),
//Error
aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))),
) )
} }
fun aLoginRootState() = LoginRootState( fun aLoginRootState() = LoginRootState(
homeserverDetails = MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false), homeserverUrl = "matrix.org",
homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false)),
loggedInState = LoggedInState.NotLoggedIn, loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default, formState = LoginFormState.Default,
eventSink = {} eventSink = {}

View file

@ -68,7 +68,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.loginError import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@ -146,39 +149,22 @@ fun LoginRootView(
ChangeServerSection( ChangeServerSection(
interactionEnabled = !isLoading, interactionEnabled = !isLoading,
homeserver = state.homeserverDetails.url, homeserver = state.homeserverUrl,
onChangeServer = onChangeServer onChangeServer = onChangeServer
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
when { when (state.homeserverDetails) {
state.supportOidcLogin -> { Async.Uninitialized,
// Oidc, in this case, just display a Spacer and the submit button is Async.Loading -> AsyncLoading()
Spacer(Modifier.height(28.dp)) is Async.Failure -> AsyncFailure(
} throwable = state.homeserverDetails.error,
state.supportPasswordLogin -> { onRetry = {
LoginForm(state = state, isLoading = isLoading, onSubmit = ::submit) state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
} }
else -> {
Text(text = "No supported login flow")
}
}
Spacer(Modifier.height(28.dp))
if (state.supportOidcLogin || state.supportPasswordLogin) {
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
) )
Spacer(modifier = Modifier.height(32.dp)) is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
} }
} }
when (val loggedInState = state.loggedInState) { when (val loggedInState = state.loggedInState) {
@ -195,6 +181,43 @@ fun LoginRootView(
} }
} }
@Composable
fun ServerDetailForm(
state: LoginRootState,
isLoading: Boolean,
submit: () -> Unit,
modifier: Modifier = Modifier,
) {
when {
state.supportOidcLogin -> {
// Oidc, in this case, just display a Spacer and the submit button
Spacer(modifier.height(28.dp))
}
state.supportPasswordLogin -> {
LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
}
else -> {
Text(modifier = modifier, text = "No supported login flow")
}
}
Spacer(Modifier.height(28.dp))
if (state.supportOidcLogin || state.supportPasswordLogin) {
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable @Composable
internal fun ChangeServerSection( internal fun ChangeServerSection(
interactionEnabled: Boolean, interactionEnabled: Boolean,

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun AsyncFailure(
throwable: Throwable,
onRetry: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = throwable.message ?: "An error occurred")
if (onRetry != null) {
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Text(text = "Retry")
}
}
}
}
@Preview
@Composable
internal fun AsyncFailurePreviewLight() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun AsyncFailurePreviewDark() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AsyncFailure(
throwable = IllegalStateException("An error occurred"),
onRetry = {}
)
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@Composable
fun AsyncLoading(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
internal fun AsyncLoadingPreviewLight() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun AsyncLoadingPreviewDark() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AsyncLoading()
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee096c4234d5f983e84113058518af756ff03696ed5a733ec7540a842ff59ba4
size 11435

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2e2f7614737130cb1a23105faab7e46b39580eadcd05b6d7f811ddef525bef11
size 10640

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:226b5056efcf7371c76729caf4105d94f1d945f40f1d6715d538ed19154ea0f0
size 4971

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:59580eab9625ce67761ab8674f2adb0d9e4f0d2b27bbc132f60ae021828fd558
size 4644