[#1235] Server switching

- Closes #1235
- Note that failures and server saving success are reported to the UI with the Android system Snackbar or via the existing Textfield’s error bottom text until we define popup design as filed in #1242
- Changelog update
This commit is contained in:
Honza Rychnovský 2024-03-04 16:53:30 +01:00 committed by GitHub
parent 8f44794f9e
commit cc5f3504fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 805 additions and 62 deletions

View File

@ -12,6 +12,8 @@ directly impact users rather than highlighting other key architectural updates.*
### Added
- Advanced Settings screen that provides more technical options like Export private data, Recovery phrase, or
Choose server
- A new Server switching screen was added. Its purpose is to enable switching between predefined and custom
lightwalletd servers in runtime.
## [0.2.0 (560)] - 2024-02-27

View File

@ -34,3 +34,8 @@ val LightWalletEndpoint.Companion.Testnet
DEFAULT_PORT,
isSecure = true
)
const val MIN_PORT_NUMBER = 1
const val MAX_PORT_NUMBER = 65535
fun LightWalletEndpoint.isValid() = host.isNotBlank() && port in MIN_PORT_NUMBER..MAX_PORT_NUMBER

View File

@ -7,12 +7,16 @@ import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonDefaults.buttonColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -36,6 +40,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview
@ -45,6 +50,7 @@ private fun ButtonComposablePreview() {
GradientSurface {
Column(Modifier.padding(ZcashTheme.dimens.spacingDefault)) {
PrimaryButton(onClick = { }, text = "Primary")
PrimaryButton(onClick = { }, text = "Primary", showProgressBar = true)
SecondaryButton(onClick = { }, text = "Secondary")
TertiaryButton(onClick = { }, text = "Tertiary")
TertiaryButton(onClick = { }, text = "Tertiary", enabled = false)
@ -56,13 +62,14 @@ private fun ButtonComposablePreview() {
}
@Composable
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
fun PrimaryButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier,
buttonColor: Color = MaterialTheme.colorScheme.primary,
enabled: Boolean = true,
showProgressBar: Boolean = false,
buttonColor: Color = MaterialTheme.colorScheme.primary,
textColor: Color = MaterialTheme.colorScheme.onPrimary,
textStyle: TextStyle = ZcashTheme.extendedTypography.buttonText,
outerPaddingValues: PaddingValues =
@ -100,12 +107,52 @@ fun PrimaryButton(
),
onClick = onClick,
) {
Text(
style = textStyle,
textAlign = TextAlign.Center,
text = text.uppercase(),
color = textColor
)
ConstraintLayout(
modifier = Modifier.fillMaxWidth(),
) {
val (title, spacer, progress) = createRefs()
Text(
style = textStyle,
textAlign = TextAlign.Center,
text = text.uppercase(),
color = textColor,
modifier =
Modifier.constrainAs(title) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
if (showProgressBar) {
Spacer(
modifier =
Modifier
.width(12.dp)
.constrainAs(spacer) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(title.end)
end.linkTo(progress.start)
}
)
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier =
Modifier
.size(18.dp)
.constrainAs(progress) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(spacer.end)
}
)
}
}
}
}

View File

@ -0,0 +1,79 @@
package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
@Preview
@Composable
private fun ComposablePreview() {
ZcashTheme(forceDarkMode = false) {
RadioButton(
text = "test",
selected = true,
onClick = {},
modifier = Modifier
)
}
}
@Composable
fun RadioButton(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
testTag: String? = null,
) {
Row(
modifier =
Modifier
.wrapContentSize()
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onClick() }
.then(
if (testTag != null) {
Modifier.testTag(testTag)
} else {
Modifier
}
)
.then(modifier),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = onClick,
colors =
RadioButtonDefaults.colors(
selectedColor = ZcashTheme.colors.radioButtonColor,
unselectedColor = ZcashTheme.colors.radioButtonColor,
)
)
Text(
text = text,
style = ZcashTheme.extendedTypography.radioButton,
color = ZcashTheme.colors.radioButtonTextColor,
modifier =
Modifier.padding(
top = 16.dp,
bottom = 16.dp,
start = 0.dp,
end = ZcashTheme.dimens.spacingDefault
)
)
}
}

View File

@ -2,8 +2,10 @@ package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.text.KeyboardActions
@ -17,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
@ -51,7 +54,7 @@ fun FormTextField(
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = ZcashTheme.colors.textDisabled,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Green,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
@ -61,57 +64,66 @@ fun FormTextField(
withBorder: Boolean = true,
bringIntoViewRequester: BringIntoViewRequester? = null,
minHeight: Dp = ZcashTheme.dimens.textFieldDefaultHeight,
testTag: String? = ""
) {
val coroutineScope = rememberCoroutineScope()
val composedTextFieldModifier =
modifier
.defaultMinSize(minHeight = minHeight)
.onFocusEvent { focusState ->
bringIntoViewRequester?.run {
if (focusState.isFocused) {
coroutineScope.launch {
bringIntoView()
Column(modifier = Modifier.then(modifier)) {
TextField(
value = value,
onValueChange = onValueChange,
placeholder =
if (enabled) {
placeholder
} else {
null
},
textStyle = textStyle,
keyboardOptions = keyboardOptions,
colors = colors,
modifier =
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = minHeight)
.onFocusEvent { focusState ->
bringIntoViewRequester?.run {
if (focusState.isFocused) {
coroutineScope.launch {
bringIntoView()
}
}
}
}
}
}
.then(
if (withBorder) {
modifier.border(width = 1.dp, color = ZcashTheme.colors.textFieldFrame)
} else {
Modifier
}
)
TextField(
value = value,
onValueChange = onValueChange,
placeholder =
if (enabled) {
placeholder
} else {
null
},
textStyle = textStyle,
keyboardOptions = keyboardOptions,
colors = colors,
modifier = composedTextFieldModifier,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
keyboardActions = keyboardActions,
shape = shape,
enabled = enabled
)
if (!error.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BodySmall(
text = error,
color = ZcashTheme.colors.textFieldError,
maxLines = 1,
overflow = TextOverflow.Ellipsis
.then(
if (withBorder) {
Modifier.border(width = 1.dp, color = ZcashTheme.colors.textFieldFrame)
} else {
Modifier
}
)
.then(
if (testTag.isNullOrEmpty()) {
Modifier
} else {
Modifier.testTag(testTag)
}
),
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
keyboardActions = keyboardActions,
shape = shape,
enabled = enabled
)
if (!error.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
BodySmall(
text = error,
color = ZcashTheme.colors.textFieldError,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@ -43,6 +43,8 @@ data class ExtendedColors(
val darkDividerColor: Color,
val tabTextColor: Color,
val panelBackgroundColor: Color,
val radioButtonColor: Color,
val radioButtonTextColor: Color,
) {
@Composable
fun surfaceGradient() =

View File

@ -56,6 +56,9 @@ internal object Dark {
val navigationButton = Color(0xFFFFFFFF)
val navigationButtonPressed = Color(0xFFFFFFFF)
val radioButtonColor = Color(0xFF070707)
val radioButtonTextColor = Color(0xFF4E4E4E)
val circularProgressBarSmall = Color(0xFF8B8A8A)
val circularProgressBarScreen = Color(0xFFFFFFFF)
val linearProgressBarTrack = Color(0xFFD9D9D9)
@ -120,6 +123,9 @@ internal object Light {
val navigationButton = Color(0xFFFFFFFF)
val navigationButtonPressed = Color(0xFFFFFFFF)
val radioButtonColor = Color(0xFF070707)
val radioButtonTextColor = Color(0xFF4E4E4E)
val circularProgressBarSmall = Color(0xFF8B8A8A)
val circularProgressBarScreen = Color(0xFF000000)
val linearProgressBarTrack = Color(0xFFD9D9D9)
@ -202,6 +208,8 @@ internal val DarkExtendedColorPalette =
darkDividerColor = Dark.darkDividerColor,
tabTextColor = Dark.tabTextColor,
panelBackgroundColor = Dark.panelBackgroundColor,
radioButtonColor = Dark.radioButtonColor,
radioButtonTextColor = Dark.radioButtonTextColor,
)
internal val LightExtendedColorPalette =
@ -241,6 +249,8 @@ internal val LightExtendedColorPalette =
darkDividerColor = Light.darkDividerColor,
tabTextColor = Light.tabTextColor,
panelBackgroundColor = Light.panelBackgroundColor,
radioButtonColor = Dark.radioButtonColor,
radioButtonTextColor = Dark.radioButtonTextColor,
)
@Suppress("CompositionLocalAllowlist")
@ -282,5 +292,7 @@ internal val LocalExtendedColors =
darkDividerColor = Color.Unspecified,
tabTextColor = Color.Unspecified,
panelBackgroundColor = Color.Unspecified,
radioButtonColor = Color.Unspecified,
radioButtonTextColor = Color.Unspecified,
)
}

View File

@ -170,6 +170,7 @@ data class ExtendedTypography(
val textFieldBirthday: TextStyle,
val textNavTab: TextStyle,
val referenceSmall: TextStyle,
val radioButton: TextStyle,
)
@Suppress("CompositionLocalAllowlist")
@ -269,6 +270,11 @@ val LocalExtendedTypography =
referenceSmall =
PrimaryTypography.bodySmall.copy(
fontSize = 13.sp
),
radioButton =
PrimaryTypography.bodySmall.copy(
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
)
}

View File

@ -38,6 +38,7 @@ android {
"src/main/res/ui/export_data",
"src/main/res/ui/history",
"src/main/res/ui/home",
"src/main/res/ui/choose_server",
"src/main/res/ui/new_wallet_recovery",
"src/main/res/ui/onboarding",
"src/main/res/ui/receive",

View File

@ -15,6 +15,8 @@ import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.type.AddressType
import cash.z.ecc.android.sdk.type.ConsensusMatchType
import cash.z.ecc.android.sdk.type.ServerValidation
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -155,6 +157,13 @@ internal class MockSynchronizer : CloseableSynchronizer {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
override suspend fun validateServerEndpoint(
context: Context,
endpoint: LightWalletEndpoint
): ServerValidation {
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
}
override suspend fun getExistingDataDbFilePath(
context: Context,
network: ZcashNetwork,

View File

@ -11,6 +11,7 @@ import co.electriccoin.zcash.ui.NavigationArguments.SEND_MEMO
import co.electriccoin.zcash.ui.NavigationArguments.SEND_RECIPIENT_ADDRESS
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HISTORY
import co.electriccoin.zcash.ui.NavigationTargets.HOME
@ -23,6 +24,7 @@ import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
import co.electriccoin.zcash.ui.configuration.RemoteConfig
import co.electriccoin.zcash.ui.screen.about.WrapAbout
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.history.WrapHistory
import co.electriccoin.zcash.ui.screen.home.WrapHome
@ -102,8 +104,14 @@ internal fun MainActivity.Navigation() {
navController.navigateJustOnce(SEED_RECOVERY)
},
goChooseServer = {
// TODO [#1235]: Create screen for selecting the lightwalletd server
// TODO [#1235]: https://github.com/Electric-Coin-Company/zashi-android/issues/1235
navController.navigateJustOnce(CHOOSE_SERVER)
}
)
}
composable(CHOOSE_SERVER) {
WrapChooseServer(
goBack = {
navController.popBackStackJustOnce(CHOOSE_SERVER)
}
)
}
@ -194,6 +202,7 @@ object NavigationTargets {
const val EXPORT_PRIVATE_DATA = "export_private_data"
const val HISTORY = "history"
const val HOME = "home"
const val CHOOSE_SERVER = "choose_server"
const val RECEIVE = "receive"
const val REQUEST = "request"
const val SCAN = "scan"

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.SdkSynchronizer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.WalletCoordinator
import cash.z.ecc.android.sdk.WalletInitMode
@ -283,6 +284,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
fun resetSdk() {
walletCoordinator.resetSdk()
}
/**
* This safely and asynchronously stops [Synchronizer].
*/
fun closeSynchronizer() {
val synchronizer = synchronizer.value
if (null != synchronizer) {
viewModelScope.launch {
(synchronizer as SdkSynchronizer).close()
}
}
}
}
/**

View File

@ -0,0 +1,123 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.chooseserver
import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.android.sdk.type.ServerValidation
import cash.z.ecc.sdk.type.fromResources
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.chooseserver.view.ChooseServer
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapChooseServer(goBack: () -> Unit) {
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
WrapChooseServer(
activity = this,
goBack = goBack,
secretState = secretState,
synchronizer = synchronizer,
onSynchronizerClose = {
walletViewModel.closeSynchronizer()
},
onWalletPersist = {
walletViewModel.persistExistingWallet(it)
}
)
}
@Composable
@Suppress("LongParameterList")
private fun WrapChooseServer(
activity: MainActivity,
goBack: () -> Unit,
onSynchronizerClose: () -> Unit,
onWalletPersist: (PersistableWallet) -> Unit,
secretState: SecretState,
synchronizer: Synchronizer?,
) {
if (synchronizer == null || secretState !is SecretState.Ready) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
// TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
CircularScreenProgressIndicator()
} else {
val wallet = secretState.persistableWallet
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var validationResult: ServerValidation by remember { mutableStateOf(ServerValidation.Valid) }
val onCheckedBack = {
if (validationResult !is ServerValidation.Running) {
goBack()
}
}
BackHandler { onCheckedBack() }
ChooseServer(
availableServers = AvailableServerProvider.toList(ZcashNetwork.fromResources(activity)),
onBack = onCheckedBack,
onServerChange = { newEndpoint ->
scope.launch {
validationResult = ServerValidation.Running
validationResult = synchronizer.validateServerEndpoint(activity, newEndpoint)
Twig.debug { "Choose Server: Validation result: $validationResult" }
when (validationResult) {
ServerValidation.Valid -> {
onSynchronizerClose()
val newWallet =
wallet.copy(
endpoint = newEndpoint
)
Twig.debug { "Choose Server: New wallet: ${newWallet.toSafeString()}" }
onWalletPersist(newWallet)
snackbarHostState.showSnackbar(
message = activity.getString(R.string.choose_server_saved),
duration = SnackbarDuration.Short
)
}
is ServerValidation.InValid -> {
Twig.error { "Choose Server: Failed to validate the new endpoint: $newEndpoint" }
}
else -> {
// Should not happen
Twig.warn { "Choose Server: Server validation state: $validationResult" }
}
}
}
},
snackbarHostState = snackbarHostState,
validationResult = validationResult,
wallet = wallet,
)
}
}

View File

@ -0,0 +1,39 @@
package co.electriccoin.zcash.ui.screen.chooseserver
import cash.z.ecc.android.sdk.model.ZcashNetwork
import cash.z.ecc.sdk.extension.Mainnet
import cash.z.ecc.sdk.extension.Testnet
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import kotlinx.collections.immutable.toImmutableList
// TODO [#1273]: Add ChooseServer Tests #1273
// TODO [#1273]: https://github.com/Electric-Coin-Company/zashi-android/issues/1273
object AvailableServerProvider {
// North America | [na.lightwalletd.com](http://na.lightwalletd.com/) | 443
// South America | [sa.lightwalletd.com](http://sa.lightwalletd.com/) | 443
// Europe & Africa | [eu.lightwalletd.com](http://eu.lightwalletd.com/) | 443
// Asia & Oceania | [ai.lightwalletd.com](http://ai.lightwalletd.com/) | 443
// Plus current network defaults:
// Mainnet: mainnet.lightwalletd.com | 9067
// Testnet: lightwalletd.testnet.electriccoin.co | 9067
private const val HOST_NA = "na.lightwalletd.com" // NON-NLS
private const val HOST_SA = "sa.lightwalletd.com" // NON-NLS
private const val HOST_EU = "eu.lightwalletd.com" // NON-NLS
private const val HOST_AI = "ai.lightwalletd.com" // NON-NLS
private const val PORT = 443
fun toList(network: ZcashNetwork) =
buildList {
if (network == ZcashNetwork.Mainnet) {
add(LightWalletEndpoint.Mainnet)
add(LightWalletEndpoint(HOST_NA, PORT, true))
add(LightWalletEndpoint(HOST_SA, PORT, true))
add(LightWalletEndpoint(HOST_EU, PORT, true))
add(LightWalletEndpoint(HOST_AI, PORT, true))
} else {
add(LightWalletEndpoint.Testnet)
}
}.toImmutableList()
}

View File

@ -0,0 +1,8 @@
package co.electriccoin.zcash.ui.screen.chooseserver
/**
* These are only used for automated testing.
*/
object ChooseServerTag {
const val CHOOSE_SERVER_TOP_APP_BAR = "choose_server_top_app_bar"
}

View File

@ -0,0 +1,354 @@
package co.electriccoin.zcash.ui.screen.chooseserver.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
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 cash.z.ecc.android.sdk.internal.Twig
import cash.z.ecc.android.sdk.model.PersistableWallet
import cash.z.ecc.android.sdk.type.ServerValidation
import cash.z.ecc.sdk.extension.isValid
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.FormTextField
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.RadioButton
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.component.SubHeader
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Preview("Choose Server")
@Composable
private fun PreviewChooseServer() {
ZcashTheme(forceDarkMode = false) {
GradientSurface {
ChooseServer(
availableServers = emptyList<LightWalletEndpoint>().toImmutableList(),
onBack = {},
onServerChange = {},
snackbarHostState = SnackbarHostState(),
validationResult = ServerValidation.Valid,
wallet = PersistableWalletFixture.new(),
)
}
}
}
@Composable
@Suppress("LongParameterList")
fun ChooseServer(
availableServers: ImmutableList<LightWalletEndpoint>,
onBack: () -> Unit,
onServerChange: (LightWalletEndpoint) -> Unit,
snackbarHostState: SnackbarHostState,
validationResult: ServerValidation,
wallet: PersistableWallet,
) {
Scaffold(
topBar = {
ChooseServerTopAppBar(onBack = onBack)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
ChooseServerMainContent(
modifier =
Modifier
.verticalScroll(
rememberScrollState()
)
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
end = ZcashTheme.dimens.screenHorizontalSpacingRegular
)
.fillMaxWidth(),
availableServers = availableServers,
onServerChange = onServerChange,
validationResult = validationResult,
wallet = wallet,
)
}
}
@Composable
private fun ChooseServerTopAppBar(onBack: () -> Unit) {
SmallTopAppBar(
backText = stringResource(id = R.string.choose_server_back).uppercase(),
backContentDescriptionText = stringResource(R.string.choose_server_back_content_description),
onBack = onBack,
showTitleLogo = true,
modifier = Modifier.testTag(ChooseServerTag.CHOOSE_SERVER_TOP_APP_BAR)
)
}
@Composable
@Suppress("LongMethod")
private fun ChooseServerMainContent(
availableServers: ImmutableList<LightWalletEndpoint>,
onServerChange: (LightWalletEndpoint) -> Unit,
validationResult: ServerValidation,
wallet: PersistableWallet,
modifier: Modifier = Modifier,
) {
val options =
availableServers.toMutableList().apply {
if (contains(wallet.endpoint)) {
// We define the custom server as secured by default
add(LightWalletEndpoint("", -1, true))
} else {
// Adding previously chosen custom endpoint
add(wallet.endpoint)
}
}.toImmutableList()
val (selectedOption, setSelectedOption) =
rememberSaveable {
mutableIntStateOf(options.indexOf(wallet.endpoint))
}
val initialCustomServerValue =
options.last().run {
if (options.last().isValid()) {
stringResource(R.string.choose_server_textfield_value, options.last().host, options.last().port)
} else {
""
}
}
val (customServerValue, setCustomServerValue) =
rememberSaveable {
mutableStateOf(initialCustomServerValue)
}
val (customServerError, setCustomServerError) =
rememberSaveable {
mutableStateOf<String?>(null)
}
val context = LocalContext.current
LaunchedEffect(key1 = validationResult) {
when (validationResult) {
is ServerValidation.InValid -> {
setCustomServerError(context.getString(R.string.choose_server_textfield_error))
}
else -> {
// Expected state: do nothing
}
}
}
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
SubHeader(
text = stringResource(id = R.string.choose_server_title),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
ServerList(
options = options,
selectedOption = selectedOption,
setSelectedOption = setSelectedOption,
customServerError = customServerError,
setCustomServerError = setCustomServerError,
customServerValue = customServerValue,
setCustomServerValue = setCustomServerValue,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
SaveButton(
enabled = validationResult != ServerValidation.Running,
options = options,
customServerValue = customServerValue,
onServerChange = {
// Check if the selected is different from the current
if (it != wallet.endpoint) {
onServerChange(it)
}
},
selectedOption = selectedOption,
setCustomServerError = setCustomServerError,
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingXlarge)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
@Suppress("LongParameterList")
fun ServerList(
options: ImmutableList<LightWalletEndpoint>,
customServerError: String?,
setCustomServerError: (String?) -> Unit,
customServerValue: String,
setCustomServerValue: (String) -> Unit,
selectedOption: Int,
setSelectedOption: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
options.forEachIndexed { index, endpoint ->
val isSelected = index == selectedOption
if (index == options.lastIndex) {
Column(
modifier = Modifier.animateContentSize()
) {
LabeledRadioButton(
endpoint = endpoint,
changeClick = { setSelectedOption(index) },
name = stringResource(id = R.string.choose_server_custom),
selected = isSelected,
modifier = Modifier.fillMaxWidth()
)
if (isSelected) {
val focusManager = LocalFocusManager.current
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
FormTextField(
value = customServerValue,
onValueChange = {
setCustomServerError(null)
setCustomServerValue(it)
},
placeholder = {
Text(text = stringResource(R.string.choose_server_textfield_hint))
},
error = customServerError,
keyboardActions =
KeyboardActions(
onDone = {
focusManager.clearFocus(true)
}
),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done
),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = ZcashTheme.dimens.spacingSmall)
)
}
}
} else {
LabeledRadioButton(
endpoint = endpoint,
changeClick = { setSelectedOption(index) },
name = stringResource(id = R.string.choose_server_textfield_value, endpoint.host, endpoint.port),
selected = isSelected,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
fun String.toEndpoint(delimiter: String): LightWalletEndpoint {
val parts = split(delimiter)
return LightWalletEndpoint(parts[0], parts[1].toInt(), true)
}
@Composable
fun LabeledRadioButton(
name: String,
endpoint: LightWalletEndpoint,
selected: Boolean,
changeClick: (LightWalletEndpoint) -> Unit,
modifier: Modifier = Modifier
) {
RadioButton(
text = name,
selected = selected,
onClick = { changeClick(endpoint) },
modifier = modifier
)
}
// This regex validates server URLs with ports while ensuring:
// - Valid hostname format (excluding spaces and special characters)
// - Port numbers within the valid range (1-65535) and without leading zeros
// - Note that this does not cover other URL components like paths or query strings
val regex = "^(([^:/?#\\s]+)://)?([^/?#\\s]+):([1-9][0-9]{3}|[1-5][0-9]{2}|[0-9]{1,2})$".toRegex()
fun validateCustomServerValue(customServer: String): Boolean = regex.matches(customServer)
@Composable
@Suppress("LongParameterList")
fun SaveButton(
enabled: Boolean,
customServerValue: String,
onServerChange: (LightWalletEndpoint) -> Unit,
options: ImmutableList<LightWalletEndpoint>,
selectedOption: Int,
setCustomServerError: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
PrimaryButton(
enabled = enabled,
showProgressBar = !enabled,
text = stringResource(id = R.string.choose_server_save),
onClick = {
val selectedServer =
if (selectedOption == options.lastIndex) {
if (!validateCustomServerValue(customServerValue)) {
setCustomServerError(context.getString(R.string.choose_server_textfield_error))
return@PrimaryButton
}
customServerValue.toEndpoint(context.getString(R.string.choose_server_custom_delimiter))
} else {
options[selectedOption]
}
Twig.info { "Choose Server: Selected server: $selectedServer" }
onServerChange(selectedServer)
},
modifier = modifier
)
}

View File

@ -715,12 +715,20 @@ private fun RestoreBirthdayMainContent(
val filteredHeightString = heightString.filter { it.isDigit() }
setHeight(filteredHeightString)
},
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = ZcashTheme.colors.textDisabled,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = ZcashTheme.colors.darkDividerColor,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier =
Modifier
.fillMaxWidth()
.padding(ZcashTheme.dimens.spacingTiny)
.focusRequester(focusRequester)
.testTag(RestoreTag.BIRTHDAY_TEXT_FIELD),
.focusRequester(focusRequester),
textStyle = ZcashTheme.extendedTypography.textFieldBirthday,
keyboardOptions =
KeyboardOptions(
@ -731,6 +739,7 @@ private fun RestoreBirthdayMainContent(
),
keyboardActions = KeyboardActions(onAny = {}),
withBorder = false,
testTag = RestoreTag.BIRTHDAY_TEXT_FIELD
)
Spacer(

View File

@ -1,9 +1,8 @@
<resources>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="advanced_settings_back">Back</string>
<string name="advanced_settings_back_content_description">Back</string>
<string name="advanced_settings_backup_wallet">Recovery phrase</string>
<string name="advanced_settings_export_private_data">Export private data</string>
<string name="advanced_settings_choose_server">Choose a server</string>
</resources>

View File

@ -0,0 +1,14 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="choose_server_back">Back</string>
<string name="choose_server_back_content_description">Back</string>
<string name="choose_server_title">Server</string>
<string name="choose_server_custom">custom</string>
<string name="choose_server_custom_delimiter">:</string>
<string name="choose_server_textfield_value"><xliff:g id="host" example="example.com">%1$s</xliff:g>:<xliff:g
id="port" example="508">%2$d</xliff:g></string>
<string name="choose_server_textfield_hint">&lt;host&gt;:&lt;port&gt;</string>
<string name="choose_server_textfield_error">Invalid server</string>
<string name="choose_server_save">Save</string>
<string name="choose_server_saved">Server successfully set</string>
</resources>