Account provider form screen.

This commit is contained in:
Benoit Marty 2023-06-06 14:40:16 +02:00 committed by Benoit Marty
parent 7ac8843bf8
commit d8db9edafc
25 changed files with 810 additions and 128 deletions

View file

@ -19,7 +19,7 @@ plugins {
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize") id("kotlin-parcelize")
} kotlin("plugin.serialization") version "1.8.21"}
android { android {
namespace = "io.element.android.features.login.impl" namespace = "io.element.android.features.login.impl"
@ -41,11 +41,15 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.login.api) api(projects.features.login.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -32,6 +32,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.accountprovider.AccountProviderNode import io.element.android.features.login.impl.accountprovider.AccountProviderNode
import io.element.android.features.login.impl.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.changeaccountprovider.form.ChangeAccountProviderFormNode
import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
import io.element.android.features.login.impl.changeserver.ChangeServerNode import io.element.android.features.login.impl.changeserver.ChangeServerNode
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
@ -82,6 +85,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
object ChangeAccountProvider : NavTarget object ChangeAccountProvider : NavTarget
@Parcelize
object ChangeAccountProviderForm : NavTarget
// Not used anymore // Not used anymore
@Parcelize @Parcelize
object ChangeServer : NavTarget object ChangeServer : NavTarget
@ -134,7 +140,26 @@ class LoginFlowNode @AssistedInject constructor(
createNode<AccountProviderNode>(buildContext, plugins = listOf(inputs, callback)) createNode<AccountProviderNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.ChangeAccountProvider -> { NavTarget.ChangeAccountProvider -> {
TODO() val callback = object : ChangeAccountProviderNode.Callback {
override fun onAccountProviderItemClicked(data: AccountProviderItem) {
TODO("Not yet implemented")
}
override fun onOtherClicked() {
backstack.push(NavTarget.ChangeAccountProviderForm)
}
}
createNode<ChangeAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.ChangeAccountProviderForm -> {
val callback = object : ChangeAccountProviderFormNode.Callback {
override fun onAccountProviderItemClicked(data: AccountProviderItem) {
TODO("Not yet implemented")
}
}
createNode<ChangeAccountProviderFormNode>(buildContext, plugins = listOf(callback))
} }
} }
} }

View file

@ -54,7 +54,8 @@ fun AccountProviderView(
R.string.screen_account_provider_signup_title R.string.screen_account_provider_signup_title
} else { } else {
R.string.screen_account_provider_signin_title R.string.screen_account_provider_signin_title
} },
state.homeserver
), ),
subTitle = stringResource( subTitle = stringResource(
id = if (state.isAccountCreation) { id = if (state.isAccountCreation) {

View file

@ -17,7 +17,4 @@
package io.element.android.features.login.impl.changeaccountprovider package io.element.android.features.login.impl.changeaccountprovider
sealed interface ChangeAccountProviderEvents { sealed interface ChangeAccountProviderEvents {
data class SetServer(val server: String) : ChangeAccountProviderEvents
object Submit : ChangeAccountProviderEvents
object ClearError : ChangeAccountProviderEvents
} }

View file

@ -21,9 +21,11 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@ -33,14 +35,28 @@ class ChangeAccountProviderNode @AssistedInject constructor(
private val presenter: ChangeAccountProviderPresenter, private val presenter: ChangeAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onAccountProviderItemClicked(data: AccountProviderItem)
fun onOtherClicked()
}
private fun onAccountProviderItemClicked(data: AccountProviderItem) {
plugins<Callback>().forEach { it.onAccountProviderItemClicked(data) }
}
private fun onOtherClicked() {
plugins<Callback>().forEach { it.onOtherClicked() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
ChangeAccountProviderView( ChangeAccountProviderView(
state = state, state = state,
modifier = modifier, modifier = modifier,
// TODO onBackPressed = ::navigateUp,
onBackPressed = {} onAccountProviderItemClicked = ::onAccountProviderItemClicked,
onOtherProviderClicked = ::onOtherClicked,
) )
} }
} }

View file

@ -17,63 +17,25 @@
package io.element.android.features.login.impl.changeaccountprovider package io.element.android.features.login.impl.changeaccountprovider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
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.impl.changeserver.ChangeServerError
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.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 import javax.inject.Inject
class ChangeAccountProviderPresenter @Inject constructor( class ChangeAccountProviderPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService
) : Presenter<ChangeAccountProviderState> { ) : Presenter<ChangeAccountProviderState> {
@Composable @Composable
override fun present(): ChangeAccountProviderState { override fun present(): ChangeAccountProviderState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
}
val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: ChangeAccountProviderEvents) {
when (event) {
is ChangeAccountProviderEvents.SetServer -> {
homeserver.value = event.server
handleEvents(ChangeAccountProviderEvents.ClearError)
}
ChangeAccountProviderEvents.Submit -> {
localCoroutineScope.submit(homeserver, changeServerAction)
}
ChangeAccountProviderEvents.ClearError -> changeServerAction.value = Async.Uninitialized
}
}
return ChangeAccountProviderState( return ChangeAccountProviderState(
homeserver = homeserver.value, // Just matrix.org by default for now
changeServerAction = changeServerAction.value, accountProviderItems = listOf(
eventSink = ::handleEvents AccountProviderItem(
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
),
) )
} }
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch {
suspend {
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
authenticationService.setHomeserver(domain).getOrThrow()
homeserverUrl.value = domain
}.execute(changeServerAction, errorMapping = ChangeServerError::from)
}
} }

View file

@ -16,14 +16,9 @@
package io.element.android.features.login.impl.changeaccountprovider package io.element.android.features.login.impl.changeaccountprovider
import io.element.android.libraries.architecture.Async import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
// Do not use default value, so no member get forgotten in the presenters. // Do not use default value, so no member get forgotten in the presenters.
data class ChangeAccountProviderState( data class ChangeAccountProviderState constructor(
val homeserver: String, val accountProviderItems: List<AccountProviderItem>,
val changeServerAction: Async<Unit>, )
val eventSink: (ChangeAccountProviderEvents) -> Unit
) {
// TODO Remove
val submitEnabled: Boolean get() = changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading
}

View file

@ -17,7 +17,7 @@
package io.element.android.features.login.impl.changeaccountprovider package io.element.android.features.login.impl.changeaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> { open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState> override val values: Sequence<ChangeAccountProviderState>
@ -28,7 +28,12 @@ open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeA
} }
fun aChangeAccountProviderState() = ChangeAccountProviderState( fun aChangeAccountProviderState() = ChangeAccountProviderState(
homeserver = "", accountProviderItems = listOf(
changeServerAction = Async.Uninitialized, AccountProviderItem(
eventSink = {} title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
)
),
) )

View file

@ -34,21 +34,14 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.changeaccountprovider.item.ChangeAccountProviderItem import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
import io.element.android.features.login.impl.changeaccountprovider.item.ChangeAccountProviderItemView import io.element.android.features.login.impl.changeaccountprovider.item.ChangeAccountProviderItemView
import io.element.android.features.login.impl.changeserver.ChangeServerError
import io.element.android.features.login.impl.changeserver.SlidingSyncNotSupportedDialog
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -64,26 +57,10 @@ fun ChangeAccountProviderView(
state: ChangeAccountProviderState, state: ChangeAccountProviderState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onAccountProviderItemClicked: (AccountProviderItem) -> Unit = {},
onOtherProviderClicked: () -> Unit = {}, onOtherProviderClicked: () -> Unit = {},
onChangeServerSuccess: () -> Unit = {},
) { ) {
val eventSink = state.eventSink
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val isLoading by remember(state.changeServerAction) {
derivedStateOf {
state.changeServerAction is Async.Loading
}
}
val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage
val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert
val focusManager = LocalFocusManager.current
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
eventSink(ChangeAccountProviderEvents.Submit)
}
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
@ -115,34 +92,30 @@ fun ChangeAccountProviderView(
subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle), subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
) )
if (slidingSyncNotSupportedError != null) { state.accountProviderItems.forEach { item ->
SlidingSyncNotSupportedDialog(onLearnMoreClicked = { val alteredItem = if (item.isMatrixOrg) {
eventSink(ChangeAccountProviderEvents.ClearError) // Set the subtitle from the resource
}, onDismiss = { item.copy(
eventSink(ChangeAccountProviderEvents.ClearError) subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
}) )
} } else {
ChangeAccountProviderItemView( item
item = ChangeAccountProviderItem(
title = "matrix.org",
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
isPublic = true,
isMatrix = true,
),
onClick = {
TODO()
} }
) ChangeAccountProviderItemView(
item = alteredItem,
onClick = {
onAccountProviderItemClicked(alteredItem)
}
)
}
// Other
ChangeAccountProviderItemView( ChangeAccountProviderItemView(
item = ChangeAccountProviderItem( item = AccountProviderItem(
title = stringResource(id = R.string.screen_change_account_provider_other), title = stringResource(id = R.string.screen_change_account_provider_other),
), ),
onClick = onOtherProviderClicked onClick = onOtherProviderClicked
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
} }
} }
} }

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.impl.changeaccountprovider.form
sealed interface ChangeAccountProviderFormEvents {
/**
* The user has typed something, expect to get a list of result in the state
*/
data class UserInput(val input: String) : ChangeAccountProviderFormEvents
}

View file

@ -0,0 +1,56 @@
/*
* 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.impl.changeaccountprovider.form
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.changeaccountprovider.item.AccountProviderItem
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class ChangeAccountProviderFormNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ChangeAccountProviderFormPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onAccountProviderItemClicked(data: AccountProviderItem)
}
private fun onAccountProviderItemClicked(data: AccountProviderItem) {
plugins<Callback>().forEach { it.onAccountProviderItemClicked(data) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ChangeAccountProviderFormView(
state = state,
modifier = modifier,
onBackPressed = ::navigateUp,
onProviderClicked = ::onAccountProviderItemClicked
)
}
}

View file

@ -0,0 +1,71 @@
/*
* 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.impl.changeaccountprovider.form
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import javax.inject.Inject
class ChangeAccountProviderFormPresenter @Inject constructor(
private val homeserverResolver: HomeserverResolver,
) : Presenter<ChangeAccountProviderFormState> {
@Composable
override fun present(): ChangeAccountProviderFormState {
val localCoroutineScope = rememberCoroutineScope()
var currentJob: Job? = remember { null }
val userInput = rememberSaveable {
mutableStateOf("")
}
val userInputResult: MutableState<Async<List<HomeserverData>>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: ChangeAccountProviderFormEvents) {
when (event) {
is ChangeAccountProviderFormEvents.UserInput -> {
currentJob?.cancel()
currentJob = localCoroutineScope.userInput(event.input, userInputResult)
}
}
}
return ChangeAccountProviderFormState(
userInput = userInput.value,
userInputResult = userInputResult.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.userInput(userInput: String, userInputResult: MutableState<Async<List<HomeserverData>>>) = launch {
suspend {
homeserverResolver.resolve(userInput)
}.execute(userInputResult)
}
}

View file

@ -0,0 +1,202 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
package io.element.android.features.login.impl.changeaccountprovider.form
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
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.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
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.changeaccountprovider.item.AccountProviderItem
import io.element.android.features.login.impl.changeaccountprovider.item.ChangeAccountProviderItemView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
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.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.Text
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
*/
@Composable
fun ChangeAccountProviderFormView(
state: ChangeAccountProviderFormState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit,
onProviderClicked: (AccountProviderItem) -> Unit = {},
) {
val eventSink = state.eventSink
val scrollState = rememberScrollState()
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Home,
iconTint = MaterialTheme.colorScheme.primary,
title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
)
// TextInput
var userInputState by textFieldState(stateValue = state.userInput)
OutlinedTextField(
value = userInputState,
// readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 30.dp),
onValueChange = {
userInputState = it
eventSink(ChangeAccountProviderFormEvents.UserInput(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
singleLine = true,
maxLines = 1,
trailingIcon = if (userInputState.isNotEmpty()) {
{
IconButton(onClick = {
userInputState = ""
eventSink(ChangeAccountProviderFormEvents.UserInput(""))
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(io.element.android.libraries.ui.strings.R.string.action_clear)
)
}
}
} else null,
supportingText = {
Text(text = stringResource(id = R.string.screen_account_provider_form_notice), color = MaterialTheme.colorScheme.secondary)
}
)
when (state.userInputResult) {
is Async.Failure -> {
// Ignore errors (let the user type more chars)
}
is Async.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
is Async.Success -> {
state.userInputResult.state.forEach { homeserverData ->
val isMatrixOrg = homeserverData.homeserverUrl == "https://matrix.org"
val item = AccountProviderItem(
title = homeserverData.homeserverUrl.removePrefix("http://").removePrefix("https://"),
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
isPublic = isMatrixOrg, // There is no need to know for other servers right now
isMatrixOrg = isMatrixOrg,
)
ChangeAccountProviderItemView(
item = item,
onClick = {
onProviderClicked(item)
}
)
}
}
Async.Uninitialized -> Unit
}
Spacer(Modifier.height(32.dp))
}
}
}
}
@Preview
@Composable
fun ChangeAccountProviderFormViewLightPreview(@PreviewParameter(ChangeAccountProviderStateFormProvider::class) state: ChangeAccountProviderFormState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ChangeAccountProviderFormViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateFormProvider::class) state: ChangeAccountProviderFormState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ChangeAccountProviderFormState) {
ChangeAccountProviderFormView(
state = state,
onBackPressed = { }
)
}

View file

@ -0,0 +1,26 @@
/*
* 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.impl.changeaccountprovider.form
import io.element.android.libraries.architecture.Async
// Do not use default value, so no member get forgotten in the presenters.
data class ChangeAccountProviderFormState(
val userInput: String,
val userInputResult: Async<List<HomeserverData>>,
val eventSink: (ChangeAccountProviderFormEvents) -> Unit
)

View file

@ -0,0 +1,53 @@
/*
* 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.impl.changeaccountprovider.form
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class ChangeAccountProviderStateFormProvider : PreviewParameterProvider<ChangeAccountProviderFormState> {
override val values: Sequence<ChangeAccountProviderFormState>
get() = sequenceOf(
aChangeAccountProviderFormState(),
aChangeAccountProviderFormState(userInputResult = Async.Success(aHomeserverDataList())),
// Add other state here
)
}
fun aChangeAccountProviderFormState(
userInput: String = "",
userInputResult: Async<List<HomeserverData>> = Async.Uninitialized,
) = ChangeAccountProviderFormState(
userInput = userInput,
userInputResult = userInputResult,
eventSink = {}
)
fun aHomeserverDataList(): List<HomeserverData> {
return listOf(
HomeserverData(
userInput = "matrix",
homeserverUrl = "https://matrix.org",
isWellknownValid = true,
),
HomeserverData(
userInput = "matrix",
homeserverUrl = "https://matrix.io",
isWellknownValid = false,
)
)
}

View file

@ -0,0 +1,26 @@
/*
* 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.impl.changeaccountprovider.form
data class HomeserverData(
// What the user has entered
val userInput: String,
// The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
val homeserverUrl: String,
// True if a wellknown file has been found and is valid. If false, it means that the [homeserverUrl] is valid
val isWellknownValid: Boolean,
)

View file

@ -0,0 +1,95 @@
/*
* 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.impl.changeaccountprovider.form
import io.element.android.features.login.impl.changeaccountprovider.form.network.WellknownRequest
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.withContext
import javax.inject.Inject
/**
* Resolve homeserver base on search terms
*/
class HomeserverResolver @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val wellknownRequest: WellknownRequest,
) {
suspend fun resolve(userInput: String): List<HomeserverData> {
return withContext(dispatchers.io) {
val cleanedUpUserInput = userInput.trim()
if (cleanedUpUserInput.length < 4) {
// Wait for more chars
emptyList()
} else {
val list = getUrlCandidate(cleanedUpUserInput)
val resolvedList = resolveList(userInput, list)
// If list is empty, and the user as entered an URL, do not block the user.
if (resolvedList.isEmpty() && userInput.isValidUrl()) {
listOf(
HomeserverData(
userInput = userInput,
homeserverUrl = userInput,
isWellknownValid = false
)
)
} else {
resolvedList
}
}
}
}
private suspend fun resolveList(userInput: String, list: List<String>): List<HomeserverData> {
return coroutineScope {
buildList {
list.map {
async {
val isValid = wellknownRequest.execute(it)
if (isValid) {
add(HomeserverData(userInput, it, true))
}
}
}.joinAll()
}
}
}
private fun getUrlCandidate(data: String): List<String> {
return buildList {
val s = data.ensureProtocol()
.removeSuffix("/")
// Always try what the user has entered
add(s)
if (s.contains(".")) {
// TLD detected?
} else {
add("$s.org")
add("$s.com")
add("$s.io")
}
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.impl.changeaccountprovider.form.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "m.homeserver": {
* "base_url": "https://matrix.org"
* },
* "m.identity_server": {
* "base_url": "https://vector.im"
* }
* }
* </pre>
* .
*/
@Serializable
data class WellKnown(
@SerialName("m.homeserver")
val homeServer: WellKnownBaseConfig? = null,
@SerialName("m.identity_server")
val identityServer: WellKnownBaseConfig? = null,
)

View file

@ -0,0 +1,35 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.impl.changeaccountprovider.form.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "base_url": "https://element.io"
* }
* </pre>
* .
*/
@Serializable
data class WellKnownBaseConfig(
@SerialName("base_url")
val baseURL: String? = null
)

View file

@ -0,0 +1,23 @@
/*
* 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.impl.changeaccountprovider.form.network
import retrofit2.http.GET
internal interface WellknownAPI {
@GET(".well-known/matrix/client")
suspend fun getWellKnown(): WellKnown
}

View file

@ -0,0 +1,46 @@
/*
* 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.impl.changeaccountprovider.form.network
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.network.RetrofitFactory
import timber.log.Timber
import javax.inject.Inject
class WellknownRequest @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) {
/**
* Return true if the wellknown can be retrieved and is valid
* @param baseUrl for instance https://matrix.org
*/
suspend fun execute(baseUrl: String): Boolean {
val wellknownApi = retrofitFactory.create(baseUrl)
.create(WellknownAPI::class.java)
return try {
val response = wellknownApi.getWellKnown()
response.isValid()
} catch (throwable: Throwable) {
Timber.e(throwable)
false
}
}
}
private fun WellKnown.isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}

View file

@ -16,9 +16,9 @@
package io.element.android.features.login.impl.changeaccountprovider.item package io.element.android.features.login.impl.changeaccountprovider.item
data class ChangeAccountProviderItem( data class AccountProviderItem constructor(
val title: String, val title: String,
val subtitle: String? = null, val subtitle: String? = null,
val isPublic: Boolean = false, val isPublic: Boolean = false,
val isMatrix: Boolean = false, val isMatrixOrg: Boolean = false,
) )

View file

@ -18,19 +18,19 @@ package io.element.android.features.login.impl.changeaccountprovider.item
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class ChangeAccountProviderItemProvider : PreviewParameterProvider<ChangeAccountProviderItem> { open class ChangeAccountProviderItemProvider : PreviewParameterProvider<AccountProviderItem> {
override val values: Sequence<ChangeAccountProviderItem> override val values: Sequence<AccountProviderItem>
get() = sequenceOf( get() = sequenceOf(
aChangeAccountProviderItem(), aChangeAccountProviderItem(),
aChangeAccountProviderItem().copy(subtitle = null), aChangeAccountProviderItem().copy(subtitle = null),
aChangeAccountProviderItem().copy(title = "Other", subtitle = null, isPublic = false, isMatrix = false), aChangeAccountProviderItem().copy(title = "Other", subtitle = null, isPublic = false, isMatrixOrg = false),
// Add other state here // Add other state here
) )
} }
fun aChangeAccountProviderItem() = ChangeAccountProviderItem( fun aChangeAccountProviderItem() = AccountProviderItem(
title = "matrix.org", title = "matrix.org",
subtitle = "Matrix.org is an open network for secure, decentralized communication.", subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true, isPublic = true,
isMatrix = true, isMatrixOrg = true,
) )

View file

@ -49,7 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
*/ */
@Composable @Composable
fun ChangeAccountProviderItemView( fun ChangeAccountProviderItemView(
item: ChangeAccountProviderItem, item: AccountProviderItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@ -68,7 +68,7 @@ fun ChangeAccountProviderItemView(
.heightIn(min = 44.dp), .heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.isMatrix) { if (item.isMatrixOrg) {
RoundedIconAtom( RoundedIconAtom(
size = RoundedIconAtomSize.Medium, size = RoundedIconAtomSize.Medium,
resourceId = R.drawable.ic_matrix, resourceId = R.drawable.ic_matrix,
@ -115,16 +115,16 @@ fun ChangeAccountProviderItemView(
@Preview @Preview
@Composable @Composable
fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderItemProvider::class) item: ChangeAccountProviderItem) = fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderItemProvider::class) item: AccountProviderItem) =
ElementPreviewLight { ContentToPreview(item) } ElementPreviewLight { ContentToPreview(item) }
@Preview @Preview
@Composable @Composable
fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderItemProvider::class) item: ChangeAccountProviderItem) = fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderItemProvider::class) item: AccountProviderItem) =
ElementPreviewDark { ContentToPreview(item) } ElementPreviewDark { ContentToPreview(item) }
@Composable @Composable
private fun ContentToPreview(item: ChangeAccountProviderItem) { private fun ContentToPreview(item: AccountProviderItem) {
ChangeAccountProviderItemView( ChangeAccountProviderItemView(
item = item, item = item,
onClick = { } onClick = { }

View file

@ -2,6 +2,10 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Change account provider"</string> <string name="screen_account_provider_change">"Change account provider"</string>
<string name="screen_account_provider_continue">"Continue"</string> <string name="screen_account_provider_continue">"Continue"</string>
<string name="screen_account_provider_form_hint">"Homeserver address"</string>
<string name="screen_account_provider_form_notice">"Enter a search term or a domain address."</string>
<string name="screen_account_provider_form_subtitle">"Search for a company, community, or private server."</string>
<string name="screen_account_provider_form_title">"Find an account provider"</string>
<string name="screen_account_provider_signin_title">"Youre about to sign in to %s"</string> <string name="screen_account_provider_signin_title">"Youre about to sign in to %s"</string>
<string name="screen_account_provider_signup_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string> <string name="screen_account_provider_signup_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signup_title">"Youre about to create an account on %s"</string> <string name="screen_account_provider_signup_title">"Youre about to create an account on %s"</string>