@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) ) } }