305 lines
11 KiB
Kotlin
305 lines
11 KiB
Kotlin
@file:Suppress("ktlint:standard:function-naming")
|
|
|
|
package cash.z.ecc.android.sdk.demoapp.ui.screen.server.view
|
|
|
|
import androidx.compose.animation.animateContentSize
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
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.layout.wrapContentSize
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.RadioButton
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextField
|
|
import androidx.compose.material3.TopAppBar
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.tooling.preview.Preview
|
|
import androidx.compose.ui.unit.dp
|
|
import cash.z.ecc.android.sdk.demoapp.R
|
|
import cash.z.ecc.android.sdk.demoapp.ext.Mainnet
|
|
import cash.z.ecc.android.sdk.demoapp.ext.Testnet
|
|
import cash.z.ecc.android.sdk.demoapp.ext.isValid
|
|
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 co.electriccoin.lightwallet.client.model.LightWalletEndpoint
|
|
|
|
@Preview(name = "Server")
|
|
@Composable
|
|
private fun ComposablePreview() {
|
|
MaterialTheme {
|
|
// TODO [#1090]: Demo: Add Compose Previews
|
|
// TODO [#1090]: https://github.com/zcash/zcash-android-wallet-sdk/issues/1090
|
|
// Server()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
const val HOST_NA = "na.lightwalletd.com" // NON-NLS
|
|
const val HOST_SA = "sa.lightwalletd.com" // NON-NLS
|
|
const val HOST_EU = "eu.lightwalletd.com" // NON-NLS
|
|
const val HOST_AI = "ai.lightwalletd.com" // NON-NLS
|
|
const val PORT = 443
|
|
|
|
@Composable
|
|
fun Server(
|
|
buildInNetwork: ZcashNetwork,
|
|
onBack: () -> Unit,
|
|
onServerChange: (LightWalletEndpoint) -> Unit,
|
|
wallet: PersistableWallet,
|
|
validationResult: ServerValidation,
|
|
) {
|
|
Scaffold(
|
|
topBar = { ServerTopAppBar(onBack) },
|
|
) { paddingValues ->
|
|
ServerSwitch(
|
|
buildInNetwork = buildInNetwork,
|
|
wallet = wallet,
|
|
onServerChange = onServerChange,
|
|
validationResult = validationResult,
|
|
modifier =
|
|
Modifier.padding(
|
|
top = paddingValues.calculateTopPadding() + 16.dp,
|
|
bottom = paddingValues.calculateBottomPadding() + 16.dp,
|
|
start = 16.dp,
|
|
end = 16.dp
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
private fun ServerTopAppBar(onBack: () -> Unit) {
|
|
TopAppBar(
|
|
title = { Text(text = stringResource(id = R.string.menu_server)) },
|
|
navigationIcon = {
|
|
IconButton(
|
|
onClick = onBack
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
contentDescription = null
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
@Suppress("LongMethod")
|
|
fun ServerSwitch(
|
|
buildInNetwork: ZcashNetwork,
|
|
onServerChange: (LightWalletEndpoint) -> Unit,
|
|
wallet: PersistableWallet,
|
|
validationResult: ServerValidation,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
Twig.info { "Currently selected wallet: ${wallet.toSafeString()}" }
|
|
|
|
val context = LocalContext.current
|
|
|
|
val options =
|
|
buildList {
|
|
if (buildInNetwork == 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)
|
|
}
|
|
|
|
if (contains(wallet.endpoint)) {
|
|
// The custom server is defined as secured by default
|
|
add(LightWalletEndpoint("", -1, true))
|
|
} else {
|
|
add(wallet.endpoint)
|
|
}
|
|
}.toMutableList()
|
|
|
|
var selectedOptionIndex: Int by rememberSaveable { mutableIntStateOf(options.indexOf(wallet.endpoint)) }
|
|
|
|
val initialCustomServerValue =
|
|
options.last().run {
|
|
if (options.last().isValid()) {
|
|
stringResource(R.string.server_textfield_value, options.last().host, options.last().port)
|
|
} else {
|
|
""
|
|
}
|
|
}
|
|
var customServerTextFieldValue: String by rememberSaveable { mutableStateOf(initialCustomServerValue) }
|
|
|
|
var customServerError: String? by rememberSaveable { mutableStateOf(null) }
|
|
|
|
LaunchedEffect(key1 = validationResult) {
|
|
when (validationResult) {
|
|
is ServerValidation.InValid -> {
|
|
customServerError = context.getString(R.string.server_textfield_error)
|
|
}
|
|
else -> {}
|
|
}
|
|
}
|
|
|
|
Column(
|
|
modifier =
|
|
modifier
|
|
.verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
|
) {
|
|
options.forEachIndexed { index, endpoint ->
|
|
val isSelected = index == selectedOptionIndex
|
|
val isLast = index == options.lastIndex
|
|
|
|
if (!isLast) {
|
|
LabeledRadioButton(
|
|
endpoint = endpoint,
|
|
changeClick = { selectedOptionIndex = index },
|
|
name = stringResource(id = R.string.server_textfield_value, endpoint.host, endpoint.port),
|
|
selected = isSelected,
|
|
)
|
|
} else {
|
|
Column(
|
|
modifier = Modifier.animateContentSize()
|
|
) {
|
|
LabeledRadioButton(
|
|
endpoint = endpoint,
|
|
changeClick = { selectedOptionIndex = index },
|
|
name = stringResource(id = R.string.server_custom),
|
|
selected = isSelected,
|
|
)
|
|
|
|
if (isSelected) {
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
|
|
TextField(
|
|
value = customServerTextFieldValue,
|
|
onValueChange = {
|
|
customServerError = null
|
|
customServerTextFieldValue = it
|
|
},
|
|
placeholder = {
|
|
Text(text = stringResource(R.string.server_textfield_hint))
|
|
}
|
|
)
|
|
|
|
if (!customServerError.isNullOrEmpty()) {
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
|
|
Text(
|
|
text = customServerError!!,
|
|
color = Color.Red,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
Button(
|
|
enabled = validationResult != ServerValidation.Running,
|
|
onClick = {
|
|
val selectedServer =
|
|
if (selectedOptionIndex == options.lastIndex) {
|
|
Twig.info { "Built custom server from: $customServerTextFieldValue" }
|
|
|
|
if (!validateCustomServerValue(customServerTextFieldValue)) {
|
|
customServerError = context.getString(R.string.server_textfield_error)
|
|
return@Button
|
|
}
|
|
|
|
customServerTextFieldValue.toEndpoint()
|
|
} else {
|
|
options[selectedOptionIndex]
|
|
}
|
|
|
|
Twig.info { "Selected server: $selectedServer" }
|
|
onServerChange(selectedServer)
|
|
},
|
|
modifier = Modifier.fillMaxWidth(fraction = 0.75f)
|
|
) {
|
|
Text(stringResource(id = R.string.server_save))
|
|
}
|
|
}
|
|
}
|
|
|
|
fun String.toEndpoint(): LightWalletEndpoint {
|
|
val parts = split(":")
|
|
return LightWalletEndpoint(parts[0], parts[1].toInt(), true)
|
|
}
|
|
|
|
// 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 it doesn't 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
|
|
fun LabeledRadioButton(
|
|
name: String,
|
|
endpoint: LightWalletEndpoint,
|
|
selected: Boolean,
|
|
changeClick: (LightWalletEndpoint) -> Unit
|
|
) {
|
|
Row(
|
|
modifier =
|
|
Modifier
|
|
.wrapContentSize()
|
|
.clickable { changeClick(endpoint) },
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
RadioButton(
|
|
selected = selected,
|
|
onClick = { changeClick(endpoint) }
|
|
)
|
|
Text(
|
|
text = name,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier =
|
|
Modifier
|
|
.padding(all = 8.dp)
|
|
)
|
|
}
|
|
}
|