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
sealed interface LoginRootEvents {
object RetryFetchServerInfo : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
package io.element.android.features.login.impl.root
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.core.SessionId
@@ -24,20 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
override val values: Sequence<LoginRootState>
get() = sequenceOf(
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"), loggedInState = LoggedInState.LoggingIn),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
// 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
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(
homeserverDetails = MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false),
homeserverUrl = "matrix.org",
homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidc = false)),
loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default,
eventSink = {}

View File

@@ -68,7 +68,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
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.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.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@@ -146,39 +149,22 @@ fun LoginRootView(
ChangeServerSection(
interactionEnabled = !isLoading,
homeserver = state.homeserverDetails.url,
homeserver = state.homeserverUrl,
onChangeServer = onChangeServer
)
Spacer(Modifier.height(32.dp))
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)
}
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)
when (state.homeserverDetails) {
Async.Uninitialized,
is Async.Loading -> AsyncLoading()
is Async.Failure -> AsyncFailure(
throwable = state.homeserverDetails.error,
onRetry = {
state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
}
)
Spacer(modifier = Modifier.height(32.dp))
is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
}
}
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
internal fun ChangeServerSection(
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()
}