Refine sign in flow to match designs and iOS flow (#100)

* Fix dark theme

* First attempt at replicating iOS' UI & flows.

* Try to fix Maestro tests

* Add error dialogs and tests

* Remove unused modifiers

* Try to fix detekt issues

* Tidy up maestro login flow a bit

* Add `CompoundColorPalette` with some needed colors

* Fixes to designs

* Fix detekt issues

* More design fixes

* Some other minor design fixes

* Add changelog

* Minor tweaks.

* Remove legacy dark material theme as it's no longer needed.

* Move sliding sync 'learn more' url to constants object

* Remove unused focusManager

* Change how the displayed homeserver works

* Keep user input as homeserver if it's valid

* Remove `CompoundColorPalette`, try to fix issue when toggling dark mode.

* Add `@Stable` to the theme, adjust how it toggles in dark mode

* Remove unused strings

* Update screenshots

* Re-organize components in LoginRootScreen

* Bump min coverage to 55, max to 60

* Always replace the snapshots contents when running `recordPaparazzi`

* Fix dark theme preview of components using content colors

* Add `BackButton` component

* Handle errors with dialogs in a generic way

* Align our Dialog components with the designs, use them were needed

* Use a `MatrixHomeserverDetails` data class instead of just an URL.

* `AuthenticationService.getHomeserverDetails()` now returns a `StateFlow`.

Also, try to fix coverage issues in tests.
This commit is contained in:
Jorge Martin Espinosa 2023-03-06 09:30:16 +01:00 committed by GitHub
parent 7997a65182
commit c87c0ea28c
271 changed files with 1431 additions and 871 deletions

View file

@ -1,5 +1,6 @@
appId: ${APP_ID}
---
- tapOn: "Change"
- tapOn:
id: "login-change_server"
- takeScreenshot: build/maestro/200-ChangeServer
- tapOn: "Continue"

View file

@ -5,17 +5,14 @@ appId: ${APP_ID}
- takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml
- runFlow: ../assertions/assertLoginDisplayed.yaml
- tapOn: "Username or email"
# ios
# - tapOn:
# id: "usernameTextField"
# index: 0
- tapOn:
id: "login-email_username"
- inputText: ${USERNAME}
- pressKey: Enter
- tapOn: "Password"
# iOS
#- tapOn:
# id: "passwordTextField"
# index: 0
- tapOn:
id: "login-password"
- inputText: ${PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View file

@ -18,8 +18,10 @@ package io.element.android.x
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
@ -42,7 +44,7 @@ class MainActivity : NodeComponentActivity() {
setContent {
ElementTheme {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
RootFlowNode(

View file

@ -208,11 +208,11 @@ koverMerged {
name = "Global minimum code coverage."
target = kotlinx.kover.api.VerificationTarget.ALL
bound {
minValue = 50
minValue = 55
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
// For instance if we have minValue = 25 and maxValue = 30, and current code coverage is now 37.32%, update
// minValue to 35 and maxValue to 40.
maxValue = 55
maxValue = 60
counter = kotlinx.kover.api.CounterType.INSTRUCTION
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
}
@ -297,3 +297,15 @@ tasks.register("runQualityChecks") {
}
dependsOn(":app:knitCheck")
}
// Make sure to delete old screenshots before recording new ones
subprojects {
val snapshotsDir = File("${project.path}/src/test/snapshots")
val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") {
onlyIf { snapshotsDir.exists() }
doFirst {
snapshotsDir.deleteRecursively()
}
}
tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask)
}

1
changelog.d/88.bugfix Normal file
View file

@ -0,0 +1 @@
Fix designs in sign in and change server flows

View file

@ -19,4 +19,5 @@ package io.element.android.features.login.changeserver
sealed interface ChangeServerEvents {
data class SetServer(val server: String) : ChangeServerEvents
object Submit : ChangeServerEvents
object ClearError : ChangeServerEvents
}

View file

@ -16,14 +16,20 @@
package io.element.android.features.login.changeserver
import android.content.Context
import android.content.Intent
import android.net.Uri
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.features.login.util.LoginConstants
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
@ -32,18 +38,25 @@ class ChangeServerNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val presenter: ChangeServerPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onSuccess() {
navigateUp()
}
private fun openLearnMorePage(context: Context) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
tryOrNull { context.startActivity(intent) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ChangeServerView(
state = state,
modifier = modifier,
onChangeServerSuccess = this::onSuccess,
onBackPressed = { navigateUp() },
onLearnMoreClicked = { openLearnMorePage(context) },
)
}
}

View file

@ -22,12 +22,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.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.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URL
import javax.inject.Inject
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> {
@ -35,8 +38,9 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
@Composable
override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverOrDefault())
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
}
val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
@ -45,7 +49,10 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
fun handleEvents(event: ChangeServerEvents) {
when (event) {
is ChangeServerEvents.SetServer -> homeserver.value = event.server
ChangeServerEvents.Submit -> localCoroutineScope.submit(homeserver.value, changeServerAction)
ChangeServerEvents.Submit -> {
localCoroutineScope.submit(homeserver, changeServerAction)
}
ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized
}
}
@ -56,9 +63,11 @@ class ChangeServerPresenter @Inject constructor(private val authenticationServic
)
}
private fun CoroutineScope.submit(homeserver: String, changeServerAction: MutableState<Async<Unit>>) = launch {
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch {
suspend {
authenticationService.setHomeserver(homeserver)
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
authenticationService.setHomeserver(domain)
homeserverUrl.value = domain
}.execute(changeServerAction)
}
}

View file

@ -19,164 +19,240 @@ package io.element.android.features.login.changeserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.login.R
import io.element.android.features.login.error.changeServerError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.BackButton
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.IconButton
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.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import org.matrix.rustcomponents.sdk.AuthenticationException
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeServerView(
state: ChangeServerState,
onLearnMoreClicked: () -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
onChangeServerSuccess: () -> Unit = {},
) {
val eventSink = state.eventSink
val scrollState = rememberScrollState()
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp)
) {
val isError = state.changeServerAction is Async.Failure
Box(
modifier = Modifier
.padding(top = 99.dp)
.size(width = 81.dp, height = 73.dp)
.align(Alignment.CenterHorizontally)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(32.dp)
)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
// TODO Update with design input
resourceId = R.drawable.ic_baseline_dataset_24,
contentDescription = "",
)
}
Text(
text = "Your server",
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.align(Alignment.CenterHorizontally)
.padding(top = 38.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "A server is a home for all your data.\n" +
"You choose your server and its easy to make one.", // TODO "Learn more.",
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField(
value = homeserverFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerServer)
.padding(top = 200.dp),
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
label = {
Text(text = "Server")
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(ChangeServerEvents.Submit) }
)
)
if (state.changeServerAction is Async.Failure) {
Text(
text = changeServerError(
state.homeserver,
state.changeServerAction.error
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Button(
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerContinue)
.padding(top = 44.dp)
) {
Text(text = "Continue")
}
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
val interactionEnabled by remember(state.changeServerAction) {
derivedStateOf {
state.changeServerAction !is Async.Loading
}
}
val focusManager = LocalFocusManager.current
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(action = onBackPressed, enabled = interactionEnabled) }
)
}
) { padding ->
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(padding)
) {
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp)
) {
Spacer(Modifier.height(42.dp))
Box(
modifier = Modifier
.size(width = 70.dp, height = 70.dp)
.align(Alignment.CenterHorizontally)
.background(
color = LocalColors.current.quinary,
shape = RoundedCornerShape(14.dp)
)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 32.dp, height = 32.dp),
tint = MaterialTheme.colorScheme.secondary,
resourceId = R.drawable.ic_homeserver,
contentDescription = "",
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = StringR.string.ftue_auth_choose_server_title),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
style = ElementTextStyles.Bold.title2,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = StringR.string.ex_choose_server_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
textAlign = TextAlign.Center,
style = ElementTextStyles.Regular.subheadline,
color = MaterialTheme.colorScheme.secondary,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(StringR.string.hs_url),
style = ElementTextStyles.Regular.formHeader,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
TextField(
value = homeserverFieldState,
readOnly = !interactionEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerServer)
.onTabOrEnterKeyFocusNext(focusManager),
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(ChangeServerEvents.Submit) }
),
singleLine = true,
maxLines = 1,
trailingIcon = if (homeserverFieldState.isNotEmpty()) {
{
IconButton(onClick = {
homeserverFieldState = ""
}, enabled = interactionEnabled) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
}
}
} else null,
)
if (state.changeServerAction is Async.Failure) {
if (state.changeServerAction.error is AuthenticationException.SlidingSyncNotAvailable) {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
onLearnMoreClicked()
eventSink(ChangeServerEvents.ClearError)
}, onDismiss = {
eventSink(ChangeServerEvents.ClearError)
})
} else {
ChangeServerErrorDialog(
error = state.changeServerAction.error,
onDismiss = {
eventSink(ChangeServerEvents.ClearError)
}
)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(StringR.string.server_selection_server_footer),
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTextStyles.Regular.caption1,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.tertiary,
)
Spacer(Modifier.height(32.dp))
Button(
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = interactionEnabled && state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerContinue)
) {
Text(text = stringResource(id = StringR.string.login_continue), style = ElementTextStyles.Button)
}
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
@Composable
internal fun ChangeServerErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
content = error.localizedMessage ?: stringResource(id = StringR.string.unknown_error),
onDismiss = onDismiss
)
}
@Composable
internal fun SlidingSyncNotSupportedDialog(onLearnMoreClicked: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
onDismiss = onDismiss,
submitText = stringResource(StringR.string.action_learn_more),
onSubmitClicked = onLearnMoreClicked,
title = stringResource(StringR.string.server_selection_sliding_sync_alert_title),
content = stringResource(StringR.string.server_selection_sliding_sync_alert_message),
)
}
@Preview
@ -191,5 +267,5 @@ internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProv
@Composable
private fun ContentToPreview(state: ChangeServerState) {
ChangeServerView(state = state)
ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {})
}

View file

@ -17,8 +17,8 @@
package io.element.android.features.login.root
sealed interface LoginRootEvents {
object RefreshHomeServer : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents
object ClearError : LoginRootEvents
}

View file

@ -47,16 +47,11 @@ class LoginRootNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer)
else -> Unit
}
}
LoginRootScreen(
state = state,
modifier = modifier,
onChangeServer = this::onChangeHomeServer,
onBackPressed = this::navigateUp
)
}
}

View file

@ -18,25 +18,30 @@ package io.element.android.features.login.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.util.LoginConstants
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.tryOrNull
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.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
@Composable
override fun present(): LoginRootState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverOrDefault())
}
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn)
}
@ -46,19 +51,19 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
fun handleEvents(event: LoginRootEvents) {
when (event) {
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.value, formState.value, loggedInState)
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
}
}
return LoginRootState(
homeserver = homeserver.value,
homeserverDetails = homeserver,
loggedInState = loggedInState.value,
formState = formState.value,
eventSink = ::handleEvents
@ -83,7 +88,4 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
formState.value = updateLambda(formState.value)
}
private fun refreshHomeServer(homeserver: MutableState<String>) {
homeserver.value = authenticationService.getHomeserverOrDefault()
}
}

View file

@ -16,222 +16,332 @@
package io.element.android.features.login.root
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.login.error.loginError
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.BackButton
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginRootScreen(
state: LoginRootState,
modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onLoginWithSuccess: (UserId) -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
onBackPressed: () -> Unit,
) {
val eventSink = state.eventSink
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title
Text(
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 48.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
val interactionEnabled by remember(state.loggedInState) {
derivedStateOf {
state.loggedInState != LoggedInState.LoggingIn
}
}
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(action = onBackPressed, enabled = interactionEnabled) },
)
// Form
Column(
// modifier = Modifier.weight(1f),
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = state.homeserver,
modifier = Modifier.fillMaxWidth(),
onValueChange = { /* no op */ },
enabled = false,
label = {
Text(text = "Server")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
),
)
Button(
onClick = onChangeServer,
modifier = Modifier
.align(Alignment.CenterEnd)
.testTag(TestTags.loginChangeServer)
.padding(top = 8.dp, end = 8.dp),
content = {
Text(text = "Change")
}
)
}
OutlinedTextField(
value = loginFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginEmailUsername)
.padding(top = 60.dp),
label = {
Text(text = stringResource(id = StringR.string.login_signin_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
OutlinedTextField(
value = passwordFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginPassword)
.padding(top = 24.dp),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = "Password")
},
isError = isError,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description =
if (passwordVisible) "Hide password" else "Show password"
}
) { padding ->
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(padding)
) {
val scrollState = rememberScrollState()
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(LoginRootEvents.Submit) }
),
)
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text(
text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
}
// Submit
Button(
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = state.submitEnabled,
Column(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
.padding(vertical = 32.dp)
.verticalScroll(state = scrollState)
.padding(horizontal = 16.dp),
) {
Text(text = "Continue")
Spacer(Modifier.height(16.dp))
// Title
Text(
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
modifier = Modifier
.fillMaxWidth(),
style = ElementTextStyles.Bold.title1,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(32.dp))
ChangeServerSection(
interactionEnabled = interactionEnabled,
homeserver = state.homeserverDetails.url,
onChangeServer = onChangeServer
)
Spacer(Modifier.height(32.dp))
LoginForm(state = state, interactionEnabled = interactionEnabled)
Spacer(modifier = Modifier.height(32.dp))
}
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
else -> Unit
}
}
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
@Preview
@Composable
internal fun LoginRootScreenLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun LoginRootScreenDarkPreview() = ElementPreviewDark { ContentToPreview() }
internal fun ChangeServerSection(
interactionEnabled: Boolean,
homeserver: String,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
Text(
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
text = stringResource(id = StringR.string.ftue_auth_sign_in_choose_server_header),
style = ElementTextStyles.Regular.formHeader,
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(TestTags.loginChangeServer)
.clickable {
if (interactionEnabled) {
onChangeServer()
}
},
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = homeserver,
style = ElementTextStyles.Bold.body,
textAlign = TextAlign.Start,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 16.dp)
)
IconButton(
modifier = Modifier.size(24.dp),
onClick = {
if (interactionEnabled) {
onChangeServer()
}
}
) {
Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary)
}
Spacer(Modifier.width(8.dp))
}
}
}
@Composable
private fun ContentToPreview() {
LoginRootScreen(
state = aLoginRootState().copy(
homeserver = "matrix.org",
),
internal fun LoginForm(
state: LoginRootState,
interactionEnabled: Boolean,
modifier: Modifier = Modifier
) {
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
val focusManager = LocalFocusManager.current
val eventSink = state.eventSink
Column(modifier) {
Text(
text = stringResource(StringR.string.login_form_title),
modifier = Modifier.padding(start = 16.dp),
style = ElementTextStyles.Regular.formHeader
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = loginFieldState,
readOnly = !interactionEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginEmailUsername)
.onTabOrEnterKeyFocusNext(focusManager),
label = {
Text(text = stringResource(StringR.string.ex_login_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = {
focusManager.moveFocus(FocusDirection.Down)
}),
singleLine = true,
maxLines = 1,
trailingIcon = if (loginFieldState.isNotEmpty()) {
{
IconButton(onClick = {
loginFieldState = ""
}) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
}
}
} else null,
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
Spacer(Modifier.height(20.dp))
TextField(
value = passwordFieldState,
readOnly = !interactionEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginPassword)
.onTabOrEnterKeyFocusNext(focusManager),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = stringResource(StringR.string.login_signup_password_hint))
},
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description =
if (passwordVisible) stringResource(StringR.string.login_hide_password) else stringResource(StringR.string.login_show_password)
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(LoginRootEvents.Submit) }
),
singleLine = true,
maxLines = 1,
)
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
LoginErrorDialog(error = state.loggedInState.failure, onDismiss = {
eventSink(LoginRootEvents.ClearError)
})
}
Spacer(Modifier.height(28.dp))
// Submit
Button(
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = interactionEnabled && state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
) {
Text(text = stringResource(StringR.string.login_continue), style = ElementTextStyles.Button)
}
}
}
@Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
content = error.localizedMessage ?: stringResource(id = StringR.string.unknown_error),
onDismiss = onDismiss
)
}
@Preview
@Composable
internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: LoginRootState) {
LoginRootScreen(
state = state,
onBackPressed = {}
)
}

View file

@ -17,11 +17,12 @@
package io.element.android.features.login.root
import android.os.Parcelable
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
data class LoginRootState(
val homeserver: String,
val homeserverDetails: MatrixHomeServerDetails,
val loggedInState: LoggedInState,
val formState: LoginFormState,
val eventSink: (LoginRootEvents) -> Unit

View file

@ -16,8 +16,24 @@
package io.element.android.features.login.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId
open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
override val values: Sequence<LoginRootState>
get() = sequenceOf(
aLoginRootState(),
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", true, null)),
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("1234"))),
)
}
fun aLoginRootState() = LoginRootState(
homeserver = "",
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default,
eventSink = {}

View file

@ -0,0 +1,24 @@
/*
* 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.features.login.util
object LoginConstants {
const val DEFAULT_HOMESERVER_URL = "matrix.org"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
}

View file

@ -1,26 +0,0 @@
<!--
~ Copyright (c) 2022 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.
-->
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM11,17H7v-4h4V17zM11,11H7V7h4V11zM17,17h-4v-4h4V17zM17,11h-4V7h4V11z" />
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="42dp"
android:height="42dp"
android:viewportWidth="42"
android:viewportHeight="42">
<group>
<clip-path
android:pathData="M0,0h42v42h-42z"/>
<path
android:pathData="M33.25,22.75H8.75C6.825,22.75 5.25,24.325 5.25,26.25V33.25C5.25,35.175 6.825,36.75 8.75,36.75H33.25C35.175,36.75 36.75,35.175 36.75,33.25V26.25C36.75,24.325 35.175,22.75 33.25,22.75ZM12.25,33.25C10.325,33.25 8.75,31.675 8.75,29.75C8.75,27.825 10.325,26.25 12.25,26.25C14.175,26.25 15.75,27.825 15.75,29.75C15.75,31.675 14.175,33.25 12.25,33.25ZM33.25,5.25H8.75C6.825,5.25 5.25,6.825 5.25,8.75V15.75C5.25,17.675 6.825,19.25 8.75,19.25H33.25C35.175,19.25 36.75,17.675 36.75,15.75V8.75C36.75,6.825 35.175,5.25 33.25,5.25ZM12.25,15.75C10.325,15.75 8.75,14.175 8.75,12.25C8.75,10.325 10.325,8.75 12.25,8.75C14.175,8.75 15.75,10.325 15.75,12.25C15.75,14.175 14.175,15.75 12.25,15.75Z"
android:fillColor="#737D8C"/>
</group>
</vector>

View file

@ -24,6 +24,9 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@ -39,7 +42,23 @@ class ChangeServerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
assertThat(initialState.submitEnabled).isTrue()
}
}
@Test
fun `present - authentication service can provide a homeserver`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService().apply {
givenHomeserver(A_HOMESERVER.copy(url = A_HOMESERVER_URL_2))
},
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL_2)
assertThat(initialState.submitEnabled).isTrue()
}
}
@ -78,4 +97,70 @@ class ChangeServerPresenterTest {
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
}
}
@Test
fun `present - submit parses URL`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val longUrl = "https://matrix.org/.well-known/"
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.SetServer(longUrl))
awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.Submit)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isFalse()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
awaitItem() // Skip changing the url to the parsed domain
val successState = awaitItem()
assertThat(successState.submitEnabled).isTrue()
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
assertThat(successState.homeserver).isEqualTo("matrix.org")
}
}
@Test
fun `present - submit fails`() = runTest {
val authServer = FakeAuthenticationService()
val presenter = ChangeServerPresenter(authServer)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
authServer.givenChangeServerError(Throwable())
initialState.eventSink.invoke(ChangeServerEvents.Submit)
val failureState = awaitItem()
assertThat(failureState.submitEnabled).isTrue()
assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
}
}
@Test
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = ChangeServerPresenter(
authenticationService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Submit will return an error
authenticationService.givenChangeServerError(A_THROWABLE)
initialState.eventSink(ChangeServerEvents.Submit)
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.changeServerAction).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
// Assert the error is then cleared
submittedState.eventSink(ChangeServerEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.changeServerAction).isEqualTo(Async.Uninitialized)
}
}
}

View file

@ -23,7 +23,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_2
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
@ -43,7 +42,7 @@ class LoginRootPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
@ -115,7 +114,7 @@ class LoginRootPresenterTest {
}
@Test
fun `present - refresh server`() = runTest {
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
@ -124,11 +123,20 @@ class LoginRootPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
authenticationService.givenHomeserver(A_HOMESERVER_2)
initialState.eventSink.invoke(LoginRootEvents.RefreshHomeServer)
val refreshedState = awaitItem()
assertThat(refreshedState.homeserver).isEqualTo(A_HOMESERVER_2)
// Submit will return an error
authenticationService.givenLoginError(A_THROWABLE)
initialState.eventSink(LoginRootEvents.Submit)
awaitItem() // Skip LoggingIn state
// Check an error was returned
val submittedState = awaitItem()
assertThat(submittedState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
// Assert the error is then cleared
submittedState.eventSink(LoginRootEvents.ClearError)
val clearedState = awaitItem()
assertThat(clearedState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
}
}
}

View file

@ -41,6 +41,22 @@ val SystemGrey5Dark = Color(0xFF2C2C2E)
val SystemGrey6Light = Color(0xFFF2F2F7)
val SystemGrey6Dark = Color(0xFF1C1C1E)
// For light themes
val Gray_25 = Color(0xFFF4F6FA)
val Gray_50 = Color(0xFFE3E8F0)
val Gray_100 = Color(0xFFC1C6CD)
val Gray_150 = Color(0xFF8D97A5)
val Gray_200 = Color(0xFF737D8C)
val Black_900 = Color(0xFF17191C)
// For dark themes
val Gray_250 = Color(0xFFA9B2BC)
val Gray_300 = Color(0xFF8E99A4)
val Gray_400 = Color(0xFF6F7882)
val Gray_450 = Color(0xFF394049)
val Black_800 = Color(0xFF15191E)
val Black_950 = Color(0xFF21262C)
val Azure = Color(0xFF368BD6)
val Kiwi = Color(0xFF74D12C)
val Grape = Color(0xFFAC3BA8)

View file

@ -25,6 +25,14 @@ import androidx.compose.ui.unit.sp
// TODO Remove
object ElementTextStyles {
val Button = TextStyle(
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
lineHeight = 22.sp,
fontStyle = FontStyle.Normal,
textAlign = TextAlign.Center,
)
object Bold {
val largeTitle = TextStyle(
fontSize = 34.sp,
@ -180,6 +188,14 @@ object ElementTextStyles {
textAlign = TextAlign.Center
)
val formHeader = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal,
lineHeight = 20.sp,
textAlign = TextAlign.Start
)
val footnote = TextStyle(
fontSize = 13.sp,
fontWeight = FontWeight.Normal,

View file

@ -17,13 +17,12 @@
package io.element.android.libraries.designsystem.components.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -31,10 +30,8 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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
import io.element.android.libraries.ui.strings.R as StringR
@ -67,43 +64,24 @@ fun ConfirmationDialog(
Text(content)
},
dismissButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Column {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onCancelClicked()
}) {
Text(cancelText)
}
if (thirdButtonText != null) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onThirdButtonClicked()
}) {
Text(thirdButtonText)
}
if (thirdButtonText != null) {
// If there is a 3rd item it should be at the end of the dialog
// Having this 3rd action is discouraged, see https://m3.material.io/components/dialogs/guidelines#e13b68f5-e367-4275-ad6f-c552ee8e358f
TextButton(onClick = onThirdButtonClicked) {
Text(thirdButtonText)
}
}
TextButton(onClick = onCancelClicked) {
Text(cancelText)
}
},
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onSubmitClicked()
}
) {
Text(submitText)
TextButton(
onClick = {
onSubmitClicked()
}
) {
Text(submitText)
}
},
shape = shape,

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -55,24 +56,14 @@ fun ErrorDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = {
Text(text = title)
Text(title)
},
text = {
Text(content)
},
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDismiss()
}
) {
Text(submitText)
}
TextButton(onClick = onDismiss) {
Text(submitText)
}
},
shape = shape,

View file

@ -16,12 +16,9 @@
package io.element.android.libraries.designsystem.preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
@Composable
fun ElementPreviewLight(
@ -55,9 +52,8 @@ private fun ElementPreview(
) {
ElementTheme(darkTheme = darkTheme) {
if (showBackground) {
Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
content()
}
// If we have a proper contentColor applied we need a Surface instead of a Box
Surface { content() }
} else {
content()
}

View file

@ -21,7 +21,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.Black_800
import io.element.android.libraries.designsystem.Black_950
import io.element.android.libraries.designsystem.DarkGrey
import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Gray_450
import io.element.android.libraries.designsystem.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey6Dark
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
@ -30,6 +34,8 @@ fun elementColorsDark() = ElementColors(
messageFromMeBackground = SystemGrey5Dark,
messageFromOtherBackground = SystemGrey6Dark,
messageHighlightedBackground = Azure,
quaternary = Gray_400,
quinary = Gray_450,
isLight = false,
)
@ -48,12 +54,12 @@ val materialColorSchemeDark = darkColorScheme(
// TODO onTertiary = ColorDarkTokens.OnTertiary,
// TODO tertiaryContainer = ColorDarkTokens.TertiaryContainer,
// TODO onTertiaryContainer = ColorDarkTokens.OnTertiaryContainer,
background = Color.Black,
background = Black_800,
onBackground = Color.White,
surface = Color.Black,
surface = Black_800,
onSurface = Color.White,
surfaceVariant = SystemGrey5Dark,
// TODO onSurfaceVariant = ColorDarkTokens.OnSurfaceVariant,
surfaceVariant = Black_950,
onSurfaceVariant = Color.White,
// TODO surfaceTint = primary,
// TODO inverseSurface = ColorDarkTokens.InverseSurface,
// TODO inverseOnSurface = ColorDarkTokens.InverseOnSurface,

View file

@ -21,7 +21,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.LightGrey
import io.element.android.libraries.designsystem.Black_900
import io.element.android.libraries.designsystem.Gray_100
import io.element.android.libraries.designsystem.Gray_150
import io.element.android.libraries.designsystem.Gray_200
import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light
import io.element.android.libraries.designsystem.theme.previews.ColorsSchemePreview
@ -30,21 +35,23 @@ fun elementColorsLight() = ElementColors(
messageFromMeBackground = SystemGrey5Light,
messageFromOtherBackground = SystemGrey6Light,
messageHighlightedBackground = Azure,
quaternary = Gray_100,
quinary = Gray_50,
isLight = true,
)
// TODO Lots of colors are missing
val materialColorSchemeLight = lightColorScheme(
primary = Color.Black,
primary = Black_900,
onPrimary = Color.White,
// TODO primaryContainer = ColorLightTokens.PrimaryContainer,
// TODO onPrimaryContainer = ColorLightTokens.OnPrimaryContainer,
// TODO inversePrimary = ColorLightTokens.InversePrimary,
secondary = LightGrey,
secondary = Gray_200,
// TODO onSecondary = ColorLightTokens.OnSecondary,
// TODO secondaryContainer = ColorLightTokens.SecondaryContainer,
// TODO onSecondaryContainer = ColorLightTokens.OnSecondaryContainer,
tertiary = Color.Black,
tertiary = Gray_150,
// TODO onTertiary = ColorLightTokens.OnTertiary,
// TODO tertiaryContainer = ColorLightTokens.TertiaryContainer,
// TODO onTertiaryContainer = ColorLightTokens.OnTertiaryContainer,
@ -52,8 +59,8 @@ val materialColorSchemeLight = lightColorScheme(
onBackground = Color.Black,
surface = Color.White,
onSurface = Color.Black,
surfaceVariant = SystemGrey5Light,
onSurfaceVariant = Color.Black,
surfaceVariant = Gray_25,
onSurfaceVariant = Gray_150,
// TODO surfaceTint = primary,
// TODO inverseSurface = ColorLightTokens.InverseSurface,
// TODO inverseOnSurface = ColorLightTokens.InverseOnSurface,

View file

@ -16,15 +16,19 @@
package io.element.android.libraries.designsystem.theme
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
@Stable
class ElementColors(
messageFromMeBackground: Color,
messageFromOtherBackground: Color,
messageHighlightedBackground: Color,
quaternary: Color,
quinary: Color,
isLight: Boolean,
) {
var messageFromMeBackground by mutableStateOf(messageFromMeBackground)
@ -34,6 +38,12 @@ class ElementColors(
var messageHighlightedBackground by mutableStateOf(messageHighlightedBackground)
private set
var quaternary by mutableStateOf(quaternary)
private set
var quinary by mutableStateOf(quinary)
private set
var isLight by mutableStateOf(isLight)
private set
@ -41,11 +51,15 @@ class ElementColors(
messageFromMeBackground: Color = this.messageFromMeBackground,
messageFromOtherBackground: Color = this.messageFromOtherBackground,
messageHighlightedBackground: Color = this.messageHighlightedBackground,
quaternary: Color = this.quaternary,
quinary: Color = this.quinary,
isLight: Boolean = this.isLight,
) = ElementColors(
messageFromMeBackground = messageFromMeBackground,
messageFromOtherBackground = messageFromOtherBackground,
messageHighlightedBackground = messageHighlightedBackground,
quaternary = quaternary,
quinary = quinary,
isLight = isLight,
)
@ -53,6 +67,8 @@ class ElementColors(
messageFromMeBackground = other.messageFromMeBackground
messageFromOtherBackground = other.messageFromOtherBackground
messageHighlightedBackground = other.messageHighlightedBackground
quaternary = other.quaternary
quinary = other.quinary
isLight = other.isLight
}
}

View file

@ -49,15 +49,16 @@ val LocalColors = staticCompositionLocalOf { elementColorsLight() }
fun ElementTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, /* true to enable MaterialYou */
lightColors: ElementColors = elementColorsLight(),
darkColors: ElementColors = elementColorsDark(),
colors: ElementColors = if (darkTheme) elementColorsDark() else elementColorsLight(),
materialLightColors: ColorScheme = materialColorSchemeLight,
materialDarkColors: ColorScheme = materialColorSchemeDark,
content: @Composable () -> Unit,
) {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !darkTheme
val currentColor = remember { if (darkTheme) darkColors else lightColors }
val currentColor = remember(darkTheme) {
colors.copy()
}.apply { updateColorsFrom(colors) }
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
@ -75,9 +76,8 @@ fun ElementTheme(
darkIcons = useDarkIcons
)
}
val rememberedColors = remember { currentColor.copy() }.apply { updateColorsFrom(currentColor) }
CompositionLocalProvider(
LocalColors provides rememberedColors,
LocalColors provides currentColor,
) {
MaterialTheme(
colorScheme = colorScheme,

View file

@ -0,0 +1,62 @@
/*
* 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.theme.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R.string as StringR
@Composable
fun BackButton(
action: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector = Icons.Default.ArrowBack,
contentDescription: String = stringResource(StringR.action_back),
enabled: Boolean = true
) {
IconButton(
modifier = modifier,
onClick = action,
enabled = enabled,
) {
Icon(imageVector = icon, contentDescription = contentDescription)
}
}
@Preview
@Composable
internal fun BackButtonPreviewLight() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun BackButtonPreviewDark() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
BackButton(action = { }, enabled = true, contentDescription = "Back")
BackButton(action = { }, enabled = false, contentDescription = "Back")
}
}

View file

@ -29,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
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
@ -37,11 +38,11 @@ fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = ButtonDefaults.shape,
colors: ButtonColors = ButtonDefaults.buttonColors(),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
shape: Shape = ElementButtonDefaults.shape,
colors: ButtonColors = ElementButtonDefaults.buttonColors(),
elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(),
border: BorderStroke? = null,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable RowScope.() -> Unit
) {
@ -59,6 +60,17 @@ fun Button(
)
}
object ElementButtonDefaults {
val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
val shape: Shape @Composable get() = ButtonDefaults.shape
@Composable
fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation()
@Composable
fun buttonColors(): ButtonColors = ButtonDefaults.buttonColors()
}
@Preview
@Composable
internal fun ButtonsLightPreview() = ElementPreviewLight { ContentToPreview() }

View file

@ -29,8 +29,17 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
@ -40,7 +49,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.utils.allBooleans
import io.element.android.libraries.designsystem.utils.asInt
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun OutlinedTextField(
value: String,
@ -88,6 +97,16 @@ fun OutlinedTextField(
)
}
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && (event.key == Key.Tab || event.key == Key.Enter)) {
focusManager.moveFocus(FocusDirection.Down)
true
} else {
false
}
}
@Preview
@Composable
internal fun OutlinedTextFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }

View file

@ -18,6 +18,7 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
alias(libs.plugins.anvil)
kotlin("plugin.serialization") version "1.8.10"
}

View file

@ -19,13 +19,13 @@ package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface MatrixAuthenticationService {
fun isLoggedIn(): Flow<Boolean>
suspend fun getLatestSessionId(): SessionId?
suspend fun restoreSession(sessionId: SessionId): MatrixClient?
fun getHomeserver(): String?
fun getHomeserverOrDefault(): String
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
suspend fun setHomeserver(homeserver: String)
suspend fun login(username: String, password: String): SessionId
}

View file

@ -0,0 +1,34 @@
/*
* 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.matrix.api.auth
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
@Parcelize
data class MatrixHomeServerDetails(
val url: String,
val supportsPasswordLogin: Boolean,
val authenticationIssuer: String?
): Parcelable {
constructor(homeserverLoginDetails: HomeserverLoginDetails) : this(
homeserverLoginDetails.url(),
homeserverLoginDetails.supportsPasswordLogin(),
homeserverLoginDetails.authenticationIssuer()
)
}

View file

@ -19,8 +19,10 @@ package io.element.android.libraries.matrix.impl.auth
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
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.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.impl.RustMatrixClient
@ -29,6 +31,8 @@ import io.element.android.libraries.matrix.session.SessionData
import io.element.android.libraries.sessionstorage.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.AuthenticationService
import org.matrix.rustcomponents.sdk.Client
@ -39,6 +43,7 @@ import java.io.File
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class RustMatrixAuthenticationService @Inject constructor(
private val baseDirectory: File,
private val coroutineScope: CoroutineScope,
@ -47,6 +52,8 @@ class RustMatrixAuthenticationService @Inject constructor(
private val authService: AuthenticationService,
) : MatrixAuthenticationService {
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
override fun isLoggedIn(): Flow<Boolean> {
return sessionStore.isLoggedIn()
}
@ -74,13 +81,15 @@ class RustMatrixAuthenticationService @Inject constructor(
}
}
override fun getHomeserver(): String? = authService.homeserverDetails()?.url()
override fun getHomeserverOrDefault(): String = getHomeserver() ?: "matrix.org"
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> = currentHomeserver
override suspend fun setHomeserver(homeserver: String) {
withContext(coroutineDispatchers.io) {
authService.configureHomeserver(homeserver)
val homeServerDetails = authService.homeserverDetails()?.use { MatrixHomeServerDetails(it) }
if (homeServerDetails != null) {
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
}
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@ -34,8 +35,10 @@ const val A_MESSAGE = "Hello world!"
const val A_REPLY = "OK, I'll be there!"
const val ANOTHER_MESSAGE = "Hello universe!"
const val A_HOMESERVER = "matrix.org"
const val A_HOMESERVER_2 = "matrix-client.org"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
const val AN_AVATAR_URL = "mxc://data"

View file

@ -18,46 +18,47 @@ package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
class FakeAuthenticationService : MatrixAuthenticationService {
private var homeserver: String = A_HOMESERVER
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var loginError: Throwable? = null
private var changeServerError: Throwable? = null
override fun isLoggedIn(): Flow<Boolean> {
return flowOf(false)
}
override suspend fun getLatestSessionId(): UserId? {
override suspend fun getLatestSessionId(): SessionId? {
return null
}
override suspend fun restoreSession(userId: UserId): MatrixClient? {
override suspend fun restoreSession(sessionId: SessionId): MatrixClient? {
return null
}
override fun getHomeserver(): String? {
return null
}
fun givenHomeserver(homeserver: String) {
this.homeserver = homeserver
}
override fun getHomeserverOrDefault(): String {
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> {
return homeserver
}
fun givenHomeserver(homeserver: MatrixHomeServerDetails) {
this.homeserver.value = homeserver
}
override suspend fun setHomeserver(homeserver: String) {
changeServerError?.let { throw it }
delay(100)
}
override suspend fun login(username: String, password: String): UserId {
override suspend fun login(username: String, password: String): SessionId {
delay(100)
loginError?.let { throw it }
return A_USER_ID
@ -66,4 +67,8 @@ class FakeAuthenticationService : MatrixAuthenticationService {
fun givenLoginError(throwable: Throwable?) {
loginError = throwable
}
fun givenChangeServerError(throwable: Throwable?) {
changeServerError = throwable
}
}

View file

@ -2,5 +2,17 @@
<resources>
<!-- Add new strings for Element X Android here -->
<string name="action_back">Back</string>
<string name="action_clear">Clear</string>
<string name="login_form_title">Enter your details</string>
<string name="ex_login_username_hint">Email or username</string>
<string name="login_show_password">Show password</string>
<string name="login_hide_password">Hide password</string>
<string name="ex_choose_server_subtitle">What is the address of your server?</string>
<string name="server_selection_server_footer">You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it.</string>
<string name="server_selection_sliding_sync_alert_title">Server not supported</string>
<string name="server_selection_sliding_sync_alert_message">This server currently doesn\'t support sliding sync.</string>
</resources>

View file

@ -33,7 +33,8 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
val state = presenter.present()
LoginRootScreen(
state = state,
modifier = modifier
modifier = modifier,
onBackPressed = {},
)
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4842e6ffb2cca53e163a1a91f7d536ff65b832a2cadd8a76dff73aab69d3b695
size 26047
oid sha256:0c48397dc1a7ec94678c9423d3f4734422390679882d838c391913fe544042bc
size 39873

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c434e7bb1316fdd1668c9d41675ff7fe1acc62dfdc6c64e96e485155a51befc4
size 27698
oid sha256:be550cedf5e99a3c80c50d201e8cf6faaed9161a1e5007eeff11fd9b46d844d8
size 43345

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d90fc0189e3acdcadbf8bf5f346dfa72d7023888a37f1cd4dd2a11edfed20ca
size 27833
oid sha256:fde980094076a921eee29d84663b6b5f09cf363c51681eb7702d15e8922d2950
size 42663

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11b2bffc585116f73f393908635c086eabf5a7bf3197f18c9110128e79ede540
size 30989
oid sha256:625bcb0c020582e470474463458c4a8335c84e6048bb12c4f57dbed92f257f57
size 43384

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c434e7bb1316fdd1668c9d41675ff7fe1acc62dfdc6c64e96e485155a51befc4
size 27698
oid sha256:be550cedf5e99a3c80c50d201e8cf6faaed9161a1e5007eeff11fd9b46d844d8
size 43345

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7dbf6d3203fb9ee8c9d4d16cd98fea617ebf3ee1adb746e868da9cf589ed902
size 25342
oid sha256:fc9385ba5a8f78b91146404e40328b378038962171da2c00483b91cffbaea74c
size 36587

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:555b8b55dc2873ab2963ae65ac13e11913794d2e344ec01d53eebe48cbfbcb39
size 27048
oid sha256:e6ecfecebd483538023eca4e3ccaae2c43758c68e93dc75db861eec480386ab1
size 40491

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18cde5fc76af83edc321223a51385629d845899efcb645c378c512b70a9976eb
size 27193
oid sha256:8355435407ed01e3807c6ac397891262f6d3286956f8d8c181dc329ca899674e
size 39292

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18a7585bcfd2bb6424b88e295ec2a3f6f84c205fa1631f13e01eecb91be4b60a
size 30085
oid sha256:1f4005736ea7e7b5986e7dbbecb6c221b9a518ad45ed9d11abc6b0d5fd22721f
size 40529

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:555b8b55dc2873ab2963ae65ac13e11913794d2e344ec01d53eebe48cbfbcb39
size 27048
oid sha256:e6ecfecebd483538023eca4e3ccaae2c43758c68e93dc75db861eec480386ab1
size 40491

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:155e39fcadcc8bc039267a8b0dca338a92bcb3b4b23ab55a0cd9f3ef91c729fc
size 8888
oid sha256:ef0c92421e87d1d100a1ad72f7643c99d42167414c5d21ac4a11391078733644
size 9016

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48045852c9c677d54db29baa1484eb281aa9678518c4a47ae05e43b4604a77c5
size 8369
oid sha256:3c0411a2f6cdbad8d2115c734d54ad13c1f0c6b24dfc90d33a7781d751a60d54
size 8622

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a904cb92b66b66730f5ac1a81b46710668d440e702f5b51320ecf5a29823e47
size 4471
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9
size 4478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a904cb92b66b66730f5ac1a81b46710668d440e702f5b51320ecf5a29823e47
size 4471
oid sha256:a5948ff8260dc73ff19232679312141dd4021a00cce2871f69870b291a237aa9
size 4478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce15df8f50bf352fa93f5682a23b255ba143c0665d29bc8b7d7ecd0f588011c9
size 14736
oid sha256:49aafbdb693cccd3ab7ca94e61f7a1de437766a29967dd89c4aaf5134b55c004
size 14860

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37edc587d96587aab57013d09def27c807d247d78fa90a69cdcb8d7d9a0344ec
size 13676
oid sha256:857fedd347931600aa5613565887384579b565efb86d5eeca6e93c13f5ff442c
size 13994

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:834da45ed9a3107006daaef96a0cf95c814c845fd4651bd6c18b6f88638d579d
oid sha256:a48ccf1ff3570915b0673de7c0367d951b795c833d7896645b37000b8dc09c15
size 9649

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb147a7f07bc20bf7ba9052d39121afdd6cd6b0326b893941b8df75c76754206
size 8869
oid sha256:945b360a62877d16ec7c25942158ae47e244df85118ca861482ff3bbf0aef55f
size 9268

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80a83189267c837b6c178ff3086d4bdcee809d3a88b6eb08e99c7a8b0261b822
size 8326
oid sha256:5a1c7d44e491508deb7f85bf84afb5fd82dbfe30779a231957fb1117c4a9a555
size 8374

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a56b35783886433d788f2ce0c8c39e507c77272ecaeb3648e15f739942ed229
size 7848
oid sha256:dd5d243ba8f4fc26b79f506fb0c1c36ebfccd07d8a49c6f2eac67ab23d3c1053
size 8157

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7546994a2e6c1b79cf58e71c061805c0385904eb244f39f6dd7d38bb23237380
size 264001
oid sha256:2e7b963daafe38fbf25c66d887c617820f2937e0f5467c04abee1458db722e29
size 263224

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d82e862a35c6b7ab602cc0d42e658b30915d391d15e8d6b17c7233e161c87fc7
size 262144
oid sha256:713bc925e6075a16b7a95d1083ead3b0230628f75c80cc911e3498544916edfd
size 262092

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9cbabaea8df5961f1a59ee0517da0133d85a71cc1303d83b243166918c36890f
size 335842
oid sha256:eb7b807522be4a1d634935e9d3ffe2aa75fdcd143ff2c43df0c129133cab21d9
size 335599

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ef86e92ea3caad3687d66458e3b0832361808692783671db54dd321d61564792
size 5836
oid sha256:1e90887d2e7d3ac6deb63e6877b9d7d173d094d005aa56850f15b0d0f7d8bef1
size 5846

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:674265aa2728d2f1724abcf600a573307d69d8dd77212eaf269176e56dc82fd5
size 5610
oid sha256:0cabb7173940f98c41aae4788ccbd810811bdcdb98e9f4f3ba9351ea54389580
size 5682

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76dc061b4702c0699a66f21283bc888b7d2d78d0afd7c2783898b83ed1efc28d
size 10610
oid sha256:a9842fc45e320050bf95bdc292f8769c25a12f988f4100510b1596603e3b578d
size 10730

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cd0a90cecd6cb93873c3bc670affef6b076209b684275bf1c1e6ab7191671279
size 9870
oid sha256:84be926e67f25f098b25bc52a9eab1734e66c9e92b4bcddc750f337c9f79cca0
size 10334

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e71fcdba69104b2d6fd38f3fac57be9ee814e89d5eefea970121195e0100ac07
size 6038
oid sha256:06d2fc04aaf51576601032f41e5d8ff92b604cd03e15045bd42332fcc8db198f
size 6054

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e71fcdba69104b2d6fd38f3fac57be9ee814e89d5eefea970121195e0100ac07
size 6038
oid sha256:06d2fc04aaf51576601032f41e5d8ff92b604cd03e15045bd42332fcc8db198f
size 6054

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23b3887a959548539a24af273c5b5d0adc5f8a462d968c1c42735d29186c9144
size 6354
oid sha256:61e4c27beae19cc5ce1c12f8321eaa4de3b78e3faf0e4adf0700366e49fe1652
size 6427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23b3887a959548539a24af273c5b5d0adc5f8a462d968c1c42735d29186c9144
size 6354
oid sha256:61e4c27beae19cc5ce1c12f8321eaa4de3b78e3faf0e4adf0700366e49fe1652
size 6427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fd317f2b833d0a6a30c2439df978e9f5dac47489544e3c450949c6f277b998c3
size 5636
oid sha256:d43a623e695f97507c1bfce7e2034b407afbc790e0ba64b8ef5b016dedf2d21c
size 5594

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fd317f2b833d0a6a30c2439df978e9f5dac47489544e3c450949c6f277b998c3
size 5636
oid sha256:d43a623e695f97507c1bfce7e2034b407afbc790e0ba64b8ef5b016dedf2d21c
size 5594

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbc833eaedfe356238c96ef9b64b5eb4cafc742a7bbc61fdf5669a338343eeae
size 5810
oid sha256:d106fa79f7bd95a730b7f8c8cdd16cb876728bc0cd290a2d7adbf122a2777e20
size 5936

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbc833eaedfe356238c96ef9b64b5eb4cafc742a7bbc61fdf5669a338343eeae
size 5810
oid sha256:d106fa79f7bd95a730b7f8c8cdd16cb876728bc0cd290a2d7adbf122a2777e20
size 5936

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22619ddf9fe74cbe2dc8ef35c3d836976ce9de0d381f1b417c04c5daa8794a98
size 6119
oid sha256:34e59f52df4d9cc21073fe5e130691754b01080e96b049f8e3d27352d5e67db0
size 6236

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22619ddf9fe74cbe2dc8ef35c3d836976ce9de0d381f1b417c04c5daa8794a98
size 6119
oid sha256:34e59f52df4d9cc21073fe5e130691754b01080e96b049f8e3d27352d5e67db0
size 6236

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6e9edc116b41f826850f80b4c317b6f66dc83891af29a1fe5b4753eea5147f2
size 5462
oid sha256:870982b7b06cba6197a387f5f46e6b8707d52430bc88bc8ece14d90ba62c3a8b
size 5539

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6e9edc116b41f826850f80b4c317b6f66dc83891af29a1fe5b4753eea5147f2
size 5462
oid sha256:870982b7b06cba6197a387f5f46e6b8707d52430bc88bc8ece14d90ba62c3a8b
size 5539

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2a06418de0da9b4f0853cf746d0c1fe168d9a2fc0d639af166f4eeda3dfb1b4
size 9661
oid sha256:e40769a91da1c77db701684425ee74e1b41daa6bbff978d7c46131433a6c9cd9
size 9811

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f5184619b88e0f8d631265a800db0265af9be2913d974c65f68ebd415ab9b4f
size 9122
oid sha256:370f14cbc1a98766901a9ebd2731ade03dbdc4d52d7452b6fd4f67973eb542b1
size 9519

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51223efd8b6dafa3eec8f8c59cdef68d9cc40998acaa1299622768a4102280c5
size 5552
oid sha256:3fdb4d4ff9808604226ed8d61c4b92baa996b891c1b2b7ded8d170a14da53d07
size 5572

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:40535a40237d85a5ce26c07f55369651fb255fa354ef29f0b2a0d35d62febdc0
size 6388
oid sha256:df2d45add15b1c8bd2b4dd57c7b2d445ccdef8d374591c6154ce2c32cc85ff3b
size 6464

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:413dc7a95d136d7c4870f861083490fe374a584d5bb748fbffb52b8885ffd12d
size 5427
oid sha256:5e2b529fe83808704bd2d054bc5b8695ef29534e80724aa9490f77a476746bb2
size 5378

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a66e9b552fea34a9a70d23b6227ef37ec0659d8e356f2d45b117d3984e3aa72
size 23185
oid sha256:468e18a6db15ab0ba082629fcfa80b4e975c3cd284acdc789bdfa551b7f61065
size 23434

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b55a38258ef2d7101b07479d5858a175df368df874b67953ade1b2c4f332ec07
size 5189
oid sha256:76623bcd15bba7ba56a377396ae5909fb11be2b823ad9acec3460bf303744b76
size 5087

Some files were not shown because too many files have changed in this diff Show more