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

View File

@@ -32,6 +32,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
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.oidc.CustomTabAvailabilityChecker
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
@@ -82,6 +85,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
object ChangeAccountProvider : NavTarget
@Parcelize
object ChangeAccountProviderForm : NavTarget
// Not used anymore
@Parcelize
object ChangeServer : NavTarget
@@ -134,7 +140,26 @@ class LoginFlowNode @AssistedInject constructor(
createNode<AccountProviderNode>(buildContext, plugins = listOf(inputs, callback))
}
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
} else {
R.string.screen_account_provider_signin_title
}
},
state.homeserver
),
subTitle = stringResource(
id = if (state.isAccountCreation) {

View File

@@ -17,7 +17,4 @@
package io.element.android.features.login.impl.changeaccountprovider
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.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)
@@ -33,14 +35,28 @@ class ChangeAccountProviderNode @AssistedInject constructor(
private val presenter: ChangeAccountProviderPresenter,
) : 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
override fun View(modifier: Modifier) {
val state = presenter.present()
ChangeAccountProviderView(
state = state,
modifier = modifier,
// TODO
onBackPressed = {}
onBackPressed = ::navigateUp,
onAccountProviderItemClicked = ::onAccountProviderItemClicked,
onOtherProviderClicked = ::onOtherClicked,
)
}
}

View File

@@ -17,63 +17,25 @@
package io.element.android.features.login.impl.changeaccountprovider
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.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.features.login.impl.changeaccountprovider.item.AccountProviderItem
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 ChangeAccountProviderPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService
) : Presenter<ChangeAccountProviderState> {
@Composable
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(
homeserver = homeserver.value,
changeServerAction = changeServerAction.value,
eventSink = ::handleEvents
// Just matrix.org by default for now
accountProviderItems = listOf(
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
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.
data class ChangeAccountProviderState(
val homeserver: String,
val changeServerAction: Async<Unit>,
val eventSink: (ChangeAccountProviderEvents) -> Unit
) {
// TODO Remove
val submitEnabled: Boolean get() = changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading
}
data class ChangeAccountProviderState constructor(
val accountProviderItems: List<AccountProviderItem>,
)

View File

@@ -17,7 +17,7 @@
package io.element.android.features.login.impl.changeaccountprovider
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> {
override val values: Sequence<ChangeAccountProviderState>
@@ -28,7 +28,12 @@ open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeA
}
fun aChangeAccountProviderState() = ChangeAccountProviderState(
homeserver = "",
changeServerAction = Async.Uninitialized,
eventSink = {}
accountProviderItems = listOf(
AccountProviderItem(
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.MaterialTheme
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.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
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.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.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.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -64,26 +57,10 @@ fun ChangeAccountProviderView(
state: ChangeAccountProviderState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit,
onAccountProviderItemClicked: (AccountProviderItem) -> Unit = {},
onOtherProviderClicked: () -> Unit = {},
onChangeServerSuccess: () -> Unit = {},
) {
val eventSink = state.eventSink
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(
modifier = modifier,
@@ -115,34 +92,30 @@ fun ChangeAccountProviderView(
subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
)
if (slidingSyncNotSupportedError != null) {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
eventSink(ChangeAccountProviderEvents.ClearError)
}, onDismiss = {
eventSink(ChangeAccountProviderEvents.ClearError)
})
}
ChangeAccountProviderItemView(
item = ChangeAccountProviderItem(
title = "matrix.org",
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
isPublic = true,
isMatrix = true,
),
onClick = {
TODO()
state.accountProviderItems.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
)
ChangeAccountProviderItemView(
item = alteredItem,
onClick = {
onAccountProviderItemClicked(alteredItem)
}
)
}
// Other
ChangeAccountProviderItemView(
item = ChangeAccountProviderItem(
item = AccountProviderItem(
title = stringResource(id = R.string.screen_change_account_provider_other),
),
onClick = onOtherProviderClicked
)
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
data class ChangeAccountProviderItem(
data class AccountProviderItem constructor(
val title: String,
val subtitle: String? = null,
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
open class ChangeAccountProviderItemProvider : PreviewParameterProvider<ChangeAccountProviderItem> {
override val values: Sequence<ChangeAccountProviderItem>
open class ChangeAccountProviderItemProvider : PreviewParameterProvider<AccountProviderItem> {
override val values: Sequence<AccountProviderItem>
get() = sequenceOf(
aChangeAccountProviderItem(),
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
)
}
fun aChangeAccountProviderItem() = ChangeAccountProviderItem(
fun aChangeAccountProviderItem() = AccountProviderItem(
title = "matrix.org",
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrix = true,
isMatrixOrg = true,
)

View File

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

View File

@@ -2,6 +2,10 @@
<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_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_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>
@@ -27,4 +31,4 @@
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_submit">"Continue"</string>
<string name="screen_login_username_hint">"Username"</string>
</resources>
</resources>