[#1393] Support server switching
- This ensures that the SDK supports apps switching between different lightwalletd servers - Synchronizer.validateServerEndpoint API added - Demo app updated to leverage this new feature - Changelog update - Closes #1393
This commit is contained in:
parent
36c803ab2e
commit
381bd42b89
|
@ -17,6 +17,13 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
- `WalletBalanceFixture` class with mock values that are supposed to be used only for testing purposes
|
||||
- `Memo.countLength(memoString: String)` to count memo length in bytes
|
||||
- `PersistableWallet.toSafeString` is a safe alternative for the regular [toString] function that prints only
|
||||
non-sensitive parts
|
||||
- `Synchronizer.validateServerEndpoint` this function checks whether the provided server endpoint is valid.
|
||||
The validation is based on comparing:
|
||||
* network type
|
||||
* sapling activation height
|
||||
* consensus branch id
|
||||
|
||||
## [2.0.6] - 2024-01-30
|
||||
|
||||
|
|
|
@ -5,12 +5,17 @@ import android.content.ClipboardManager
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.viewModels
|
||||
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 androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
|
@ -21,22 +26,27 @@ import cash.z.ecc.android.sdk.SdkSynchronizer
|
|||
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.BALANCE
|
||||
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.HOME
|
||||
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.SEND
|
||||
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.SERVER
|
||||
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.TRANSACTIONS
|
||||
import cash.z.ecc.android.sdk.demoapp.NavigationTargets.WALLET_ADDRESS_DETAILS
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.addresses.view.Addresses
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.balance.view.Balance
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.view.Home
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.SecretState
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.home.viewmodel.WalletViewModel
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.send.view.Send
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.server.view.Server
|
||||
import cash.z.ecc.android.sdk.demoapp.ui.screen.transactions.view.Transactions
|
||||
import cash.z.ecc.android.sdk.demoapp.util.AndroidApiVersion
|
||||
import cash.z.ecc.android.sdk.demoapp.util.fromResources
|
||||
import cash.z.ecc.android.sdk.internal.Twig
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.sdk.type.ServerValidation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod", "ktlint:standard:function-naming")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "ktlint:standard:function-naming")
|
||||
internal fun ComposeActivity.Navigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
|
@ -63,6 +73,7 @@ internal fun ComposeActivity.Navigation() {
|
|||
}
|
||||
},
|
||||
goTransactions = { navController.navigateJustOnce(TRANSACTIONS) },
|
||||
goServer = { navController.navigateJustOnce(SERVER) },
|
||||
resetSdk = { walletViewModel.resetSdk() },
|
||||
rewind = { walletViewModel.rewind() }
|
||||
)
|
||||
|
@ -136,6 +147,76 @@ internal fun ComposeActivity.Navigation() {
|
|||
)
|
||||
}
|
||||
}
|
||||
composable(SERVER) {
|
||||
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
|
||||
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
|
||||
Twig.info { "Current secrets state: $secretState" }
|
||||
|
||||
if (synchronizer == null || secretState !is SecretState.Ready) {
|
||||
// Display loading indicator
|
||||
} else {
|
||||
val wallet = secretState.persistableWallet
|
||||
|
||||
Twig.info { "Current persisted wallet: ${wallet.toSafeString()}" }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var validationResult: ServerValidation by remember { mutableStateOf(ServerValidation.Valid) }
|
||||
|
||||
val onBack = {
|
||||
if (validationResult !is ServerValidation.Running) {
|
||||
navController.popBackStackJustOnce(SERVER)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Server(
|
||||
buildInNetwork = ZcashNetwork.fromResources(application),
|
||||
onBack = onBack,
|
||||
onServerChange = { newEndpoint ->
|
||||
scope.launch {
|
||||
validationResult = ServerValidation.Running
|
||||
validationResult = synchronizer.validateServerEndpoint(application, newEndpoint)
|
||||
|
||||
Twig.info { "Validation result: $validationResult" }
|
||||
|
||||
when (validationResult) {
|
||||
ServerValidation.Valid -> {
|
||||
walletViewModel.closeSynchronizer()
|
||||
|
||||
val newWallet =
|
||||
wallet.copy(
|
||||
endpoint = newEndpoint
|
||||
)
|
||||
|
||||
Twig.info { "New wallet: ${newWallet.toSafeString()}" }
|
||||
|
||||
walletViewModel.persistExistingWallet(newWallet)
|
||||
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
"Server saved",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
is ServerValidation.InValid -> {
|
||||
Twig.error { "Failed to validate the new endpoint: $newEndpoint" }
|
||||
}
|
||||
else -> {
|
||||
// Should not happen
|
||||
Twig.info { "Server validation state: $validationResult" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
validationResult = validationResult,
|
||||
wallet = wallet,
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(TRANSACTIONS) {
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
if (null == synchronizer) {
|
||||
|
@ -225,5 +306,7 @@ object NavigationTargets {
|
|||
|
||||
const val SEND = "send" // NON-NLS
|
||||
|
||||
const val SERVER = "server" // NON-NLS
|
||||
|
||||
const val TRANSACTIONS = "transactions" // NON-NLS
|
||||
}
|
||||
|
|
|
@ -34,3 +34,8 @@ internal val LightWalletEndpoint.Companion.Testnet
|
|||
DEFAULT_PORT,
|
||||
isSecure = true
|
||||
)
|
||||
|
||||
const val MIN_PORT_NUMBER = 1
|
||||
const val MAX_PORT_NUMBER = 65535
|
||||
|
||||
internal fun LightWalletEndpoint.isValid() = host.isNotBlank() && port in MIN_PORT_NUMBER..MAX_PORT_NUMBER
|
|
@ -44,6 +44,7 @@ private fun ComposablePreviewHome() {
|
|||
goAddressDetails = {},
|
||||
goTransactions = {},
|
||||
goTestnetFaucet = {},
|
||||
goServer = {},
|
||||
resetSdk = {},
|
||||
rewind = {},
|
||||
)
|
||||
|
@ -59,6 +60,7 @@ fun Home(
|
|||
goSend: () -> Unit,
|
||||
goAddressDetails: () -> Unit,
|
||||
goTransactions: () -> Unit,
|
||||
goServer: () -> Unit,
|
||||
goTestnetFaucet: () -> Unit,
|
||||
resetSdk: () -> Unit,
|
||||
rewind: () -> Unit,
|
||||
|
@ -76,6 +78,7 @@ fun Home(
|
|||
walletSnapshot,
|
||||
goBalance = goBalance,
|
||||
goSend = goSend,
|
||||
goServer = goServer,
|
||||
goAddressDetails = goAddressDetails,
|
||||
goTransactions = goTransactions
|
||||
)
|
||||
|
@ -155,8 +158,9 @@ private fun HomeMainContent(
|
|||
walletSnapshot: WalletSnapshot,
|
||||
goBalance: () -> Unit,
|
||||
goSend: () -> Unit,
|
||||
goServer: () -> Unit,
|
||||
goAddressDetails: () -> Unit,
|
||||
goTransactions: () -> Unit
|
||||
goTransactions: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
@ -179,6 +183,10 @@ private fun HomeMainContent(
|
|||
Text(text = stringResource(id = R.string.menu_transactions))
|
||||
}
|
||||
|
||||
Button(goServer) {
|
||||
Text(text = stringResource(id = R.string.menu_server))
|
||||
}
|
||||
|
||||
Text(text = stringResource(id = R.string.home_status, walletSnapshot.status.toString()))
|
||||
if (walletSnapshot.status != Synchronizer.Status.SYNCED) {
|
||||
@Suppress("MagicNumber")
|
||||
|
|
|
@ -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
|
||||
|
@ -267,6 +268,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This safely and asynchronously stops the [Synchronizer].
|
||||
*/
|
||||
fun closeSynchronizer() {
|
||||
val synchronizer = synchronizer.value
|
||||
if (null != synchronizer) {
|
||||
viewModelScope.launch {
|
||||
(synchronizer as SdkSynchronizer).close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,304 @@
|
|||
@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.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.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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@
|
|||
<string name="menu_home">Home</string>
|
||||
<string name="menu_private_key">Get Private Key</string>
|
||||
<string name="menu_address">Get Address</string>
|
||||
<string name="menu_server">Server</string>
|
||||
<string name="menu_balance">Get Balance</string>
|
||||
<string name="menu_latest_height">Get Latest Height</string>
|
||||
<string name="menu_block">Get Block</string>
|
||||
|
@ -72,5 +73,11 @@
|
|||
<string name="send_memo">Memo</string>
|
||||
<string name="send_button">Send</string>
|
||||
|
||||
<string name="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="server_textfield_hint"><host>:<port></string>
|
||||
<string name="server_textfield_error">Invalid server</string>
|
||||
<string name="server_custom">custom</string>
|
||||
<string name="server_save">Save</string>
|
||||
|
||||
<string name="transactions_refresh">Refresh transactions</string>
|
||||
</resources>
|
||||
|
|
|
@ -14,6 +14,8 @@ import co.electriccoin.lightwallet.client.model.SendResponseUnsafe
|
|||
import co.electriccoin.lightwallet.client.model.ShieldedProtocolEnum
|
||||
import co.electriccoin.lightwallet.client.model.SubtreeRootUnsafe
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Client for interacting with lightwalletd.
|
||||
|
@ -114,5 +116,13 @@ interface LightWalletClient {
|
|||
*/
|
||||
fun LightWalletClient.Companion.new(
|
||||
context: Context,
|
||||
lightWalletEndpoint: LightWalletEndpoint
|
||||
): LightWalletClient = LightWalletClientImpl.new(AndroidChannelFactory(context), lightWalletEndpoint)
|
||||
lightWalletEndpoint: LightWalletEndpoint,
|
||||
singleRequestTimeout: Duration = 10.seconds,
|
||||
streamingRequestTimeout: Duration = 90.seconds
|
||||
): LightWalletClient =
|
||||
LightWalletClientImpl.new(
|
||||
channelFactory = AndroidChannelFactory(context),
|
||||
lightWalletEndpoint = lightWalletEndpoint,
|
||||
singleRequestTimeout = singleRequestTimeout,
|
||||
streamingRequestTimeout = streamingRequestTimeout
|
||||
)
|
||||
|
|
|
@ -42,8 +42,8 @@ import kotlin.time.Duration.Companion.seconds
|
|||
internal class LightWalletClientImpl private constructor(
|
||||
private val channelFactory: ChannelFactory,
|
||||
private val lightWalletEndpoint: LightWalletEndpoint,
|
||||
private val singleRequestTimeout: Duration = 10.seconds,
|
||||
private val streamingRequestTimeout: Duration = 90.seconds
|
||||
private val singleRequestTimeout: Duration,
|
||||
private val streamingRequestTimeout: Duration
|
||||
) : LightWalletClient {
|
||||
private var channel = channelFactory.newChannel(lightWalletEndpoint)
|
||||
|
||||
|
@ -260,9 +260,16 @@ internal class LightWalletClientImpl private constructor(
|
|||
companion object {
|
||||
fun new(
|
||||
channelFactory: ChannelFactory,
|
||||
lightWalletEndpoint: LightWalletEndpoint
|
||||
lightWalletEndpoint: LightWalletEndpoint,
|
||||
singleRequestTimeout: Duration = 10.seconds,
|
||||
streamingRequestTimeout: Duration = 90.seconds
|
||||
): LightWalletClientImpl {
|
||||
return LightWalletClientImpl(channelFactory, lightWalletEndpoint)
|
||||
return LightWalletClientImpl(
|
||||
channelFactory = channelFactory,
|
||||
lightWalletEndpoint = lightWalletEndpoint,
|
||||
singleRequestTimeout = singleRequestTimeout,
|
||||
streamingRequestTimeout = streamingRequestTimeout
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package co.electriccoin.lightwallet.client.model
|
||||
|
||||
import cash.z.wallet.sdk.internal.rpc.Service
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A lightwalletd endpoint information, which has come from the Light Wallet server.
|
||||
|
@ -10,14 +11,29 @@ import cash.z.wallet.sdk.internal.rpc.Service
|
|||
data class LightWalletEndpointInfoUnsafe(
|
||||
val chainName: String,
|
||||
val consensusBranchId: String,
|
||||
val blockHeightUnsafe: BlockHeightUnsafe
|
||||
val blockHeightUnsafe: BlockHeightUnsafe,
|
||||
val saplingActivationHeightUnsafe: BlockHeightUnsafe
|
||||
) {
|
||||
companion object {
|
||||
internal fun new(lightdInfo: Service.LightdInfo) =
|
||||
LightWalletEndpointInfoUnsafe(
|
||||
lightdInfo.chainName,
|
||||
lightdInfo.consensusBranchId,
|
||||
BlockHeightUnsafe(lightdInfo.blockHeight)
|
||||
BlockHeightUnsafe(lightdInfo.blockHeight),
|
||||
BlockHeightUnsafe(lightdInfo.saplingActivationHeight),
|
||||
)
|
||||
}
|
||||
|
||||
// [chainName] is either "main" or "test"
|
||||
fun matchingNetwork(network: String): Boolean {
|
||||
fun String.toId() =
|
||||
lowercase(Locale.ROOT).run {
|
||||
when {
|
||||
contains("main") -> "mainnet"
|
||||
contains("test") -> "testnet"
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
return chainName.toId() == network.toId()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,16 @@ data class PersistableWallet(
|
|||
// For security, intentionally override the toString method to reduce risk of accidentally logging secrets
|
||||
override fun toString() = "PersistableWallet"
|
||||
|
||||
/**
|
||||
* This is a safe alternative for the regular [toString] function that prints only non-sensitive parts
|
||||
*/
|
||||
fun toSafeString() =
|
||||
"PersistableWallet:" +
|
||||
" network: $network," +
|
||||
" endpoint: $endpoint," +
|
||||
" birthday: $birthday," +
|
||||
" wallet mode: $walletInitMode"
|
||||
|
||||
companion object {
|
||||
internal const val VERSION_1 = 1
|
||||
internal const val VERSION_2 = 2
|
||||
|
|
|
@ -11,6 +11,7 @@ import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Initia
|
|||
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Stopped
|
||||
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Synced
|
||||
import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Syncing
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
|
||||
import cash.z.ecc.android.sdk.exception.InitializeException
|
||||
import cash.z.ecc.android.sdk.exception.TransactionEncoderException
|
||||
import cash.z.ecc.android.sdk.exception.TransactionSubmitException
|
||||
|
@ -51,6 +52,7 @@ import cash.z.ecc.android.sdk.type.AddressType.Shielded
|
|||
import cash.z.ecc.android.sdk.type.AddressType.Transparent
|
||||
import cash.z.ecc.android.sdk.type.AddressType.Unified
|
||||
import cash.z.ecc.android.sdk.type.ConsensusMatchType
|
||||
import cash.z.ecc.android.sdk.type.ServerValidation
|
||||
import co.electriccoin.lightwallet.client.LightWalletClient
|
||||
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
|
||||
import co.electriccoin.lightwallet.client.model.Response
|
||||
|
@ -73,8 +75,10 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* A Synchronizer that attempts to remain operational, despite any number of errors that can occur.
|
||||
|
@ -631,6 +635,8 @@ class SdkSynchronizer private constructor(
|
|||
AddressType.Invalid(error.message ?: "Invalid")
|
||||
}
|
||||
|
||||
// TODO [#1405]: Fix/Remove broken SdkSynchronizer.validateConsensusBranch function
|
||||
// TODO [#1405]: https://github.com/Electric-Coin-Company/zcash-android-wallet-sdk/issues/1405
|
||||
override suspend fun validateConsensusBranch(): ConsensusMatchType {
|
||||
val serverBranchId = tryNull { processor.downloader.getServerInfo()?.consensusBranchId }
|
||||
|
||||
|
@ -665,6 +671,89 @@ class SdkSynchronizer private constructor(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "ReturnCount")
|
||||
override suspend fun validateServerEndpoint(
|
||||
context: Context,
|
||||
endpoint: LightWalletEndpoint
|
||||
): ServerValidation {
|
||||
// Create a dedicated light wallet client for the validation
|
||||
// The single request timeout is changed from default to 5 seconds to speed up a possible custom server
|
||||
// endpoint validation
|
||||
val lightWalletClient =
|
||||
LightWalletClient.new(
|
||||
context = context,
|
||||
lightWalletEndpoint = endpoint,
|
||||
singleRequestTimeout = 5.seconds
|
||||
)
|
||||
|
||||
val remoteInfo =
|
||||
when (val response = lightWalletClient.getServerInfo()) {
|
||||
is Response.Success -> response.result
|
||||
is Response.Failure -> {
|
||||
return ServerValidation.InValid(response.toThrowable())
|
||||
}
|
||||
}
|
||||
|
||||
// Check network type
|
||||
if (!remoteInfo.matchingNetwork(network.networkName)) {
|
||||
return ServerValidation.InValid(
|
||||
CompactBlockProcessorException.MismatchedNetwork(
|
||||
clientNetwork = network.networkName,
|
||||
serverNetwork = remoteInfo.chainName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check sapling activation height
|
||||
runCatching {
|
||||
val remoteSaplingActivationHeight = remoteInfo.saplingActivationHeightUnsafe.toBlockHeight(network)
|
||||
if (network.saplingActivationHeight != remoteSaplingActivationHeight) {
|
||||
return ServerValidation.InValid(
|
||||
CompactBlockProcessorException.MismatchedSaplingActivationHeight(
|
||||
clientHeight = network.saplingActivationHeight.value,
|
||||
serverHeight = remoteSaplingActivationHeight.value
|
||||
)
|
||||
)
|
||||
}
|
||||
}.getOrElse {
|
||||
return ServerValidation.InValid(it)
|
||||
}
|
||||
|
||||
val currentChainTip =
|
||||
when (val response = lightWalletClient.getLatestBlockHeight()) {
|
||||
is Response.Success -> {
|
||||
runCatching { response.result.toBlockHeight(network) }.getOrElse {
|
||||
return ServerValidation.InValid(it)
|
||||
}
|
||||
}
|
||||
is Response.Failure -> {
|
||||
return ServerValidation.InValid(response.toThrowable())
|
||||
}
|
||||
}
|
||||
|
||||
val sdkBranchId =
|
||||
runCatching {
|
||||
"%x".format(
|
||||
Locale.ROOT,
|
||||
backend.getBranchIdForHeight(currentChainTip)
|
||||
)
|
||||
}.getOrElse {
|
||||
return ServerValidation.InValid(it)
|
||||
}
|
||||
|
||||
// Check branch id
|
||||
return if (remoteInfo.consensusBranchId.equals(sdkBranchId, true)) {
|
||||
ServerValidation.Valid
|
||||
} else {
|
||||
ServerValidation.InValid(
|
||||
CompactBlockProcessorException.MismatchedConsensusBranch(
|
||||
sdkBranchId,
|
||||
remoteInfo.consensusBranchId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(InitializeException.MissingDatabaseException::class)
|
||||
override suspend fun getExistingDataDbFilePath(
|
||||
context: Context,
|
||||
|
|
|
@ -24,6 +24,7 @@ import cash.z.ecc.android.sdk.model.ZcashNetwork
|
|||
import cash.z.ecc.android.sdk.tool.CheckpointTool
|
||||
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 co.electriccoin.lightwallet.client.model.Response
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -242,6 +243,22 @@ interface Synchronizer {
|
|||
*/
|
||||
suspend fun validateConsensusBranch(): ConsensusMatchType
|
||||
|
||||
/**
|
||||
* This function checks whether the provided server endpoint is valid. The validation is based on comparing:
|
||||
* - network types,
|
||||
* - sapling activation heights
|
||||
* - consensus branches
|
||||
*
|
||||
* @param endpoint LightWalletEndpoint data to be validated
|
||||
* @param context Context
|
||||
*
|
||||
* @return an instance of [ServerValidation] that provides details about the validation result
|
||||
*/
|
||||
suspend fun validateServerEndpoint(
|
||||
context: Context,
|
||||
endpoint: LightWalletEndpoint
|
||||
): ServerValidation
|
||||
|
||||
/**
|
||||
* Validates the given address, returning information about why it is invalid. This is a
|
||||
* convenience method that combines the behavior of [isValidShieldedAddr],
|
||||
|
|
|
@ -17,6 +17,7 @@ import cash.z.ecc.android.sdk.block.processor.model.VerifySuggestedScanRange
|
|||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDecryptError
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.EnhanceTransactionError.EnhanceTxDownloadError
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedConsensusBranch
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedNetwork
|
||||
import cash.z.ecc.android.sdk.exception.InitializeException
|
||||
import cash.z.ecc.android.sdk.exception.LightWalletException
|
||||
|
@ -53,7 +54,6 @@ import cash.z.ecc.android.sdk.model.Zatoshi
|
|||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import co.electriccoin.lightwallet.client.model.BlockHeightUnsafe
|
||||
import co.electriccoin.lightwallet.client.model.GetAddressUtxosReplyUnsafe
|
||||
import co.electriccoin.lightwallet.client.model.LightWalletEndpointInfoUnsafe
|
||||
import co.electriccoin.lightwallet.client.model.Response
|
||||
import co.electriccoin.lightwallet.client.model.ShieldedProtocolEnum
|
||||
import co.electriccoin.lightwallet.client.model.SubtreeRootUnsafe
|
||||
|
@ -849,17 +849,17 @@ class CompactBlockProcessor internal constructor(
|
|||
// Note: we could better signal network connection issue
|
||||
CompactBlockProcessorException.BadBlockHeight(info.blockHeightUnsafe)
|
||||
} else {
|
||||
val clientBranch =
|
||||
val clientBranchId =
|
||||
"%x".format(
|
||||
Locale.ROOT,
|
||||
backend.getBranchIdForHeight(serverBlockHeight)
|
||||
)
|
||||
val network = backend.network.networkName
|
||||
|
||||
if (!clientBranch.equals(info.consensusBranchId, true)) {
|
||||
MismatchedNetwork(
|
||||
clientNetwork = network,
|
||||
serverNetwork = info.chainName
|
||||
if (!clientBranchId.equals(info.consensusBranchId, true)) {
|
||||
MismatchedConsensusBranch(
|
||||
clientBranchId = clientBranchId,
|
||||
serverBranchId = info.consensusBranchId
|
||||
)
|
||||
} else if (!info.matchingNetwork(network)) {
|
||||
MismatchedNetwork(
|
||||
|
@ -2247,15 +2247,3 @@ class CompactBlockProcessor internal constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LightWalletEndpointInfoUnsafe.matchingNetwork(network: String): Boolean {
|
||||
fun String.toId() =
|
||||
lowercase(Locale.ROOT).run {
|
||||
when {
|
||||
contains("main") -> "mainnet"
|
||||
contains("test") -> "testnet"
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
return chainName.toId() == network.toId()
|
||||
}
|
||||
|
|
|
@ -118,6 +118,21 @@ sealed class CompactBlockProcessorException(message: String, cause: Throwable? =
|
|||
"updating the client or switching servers."
|
||||
)
|
||||
|
||||
class MismatchedConsensusBranch(clientBranchId: String, serverBranchId: String) : CompactBlockProcessorException(
|
||||
message =
|
||||
"Incompatible server: this client expects a consensus branch $clientBranchId but it " +
|
||||
"was $serverBranchId! Try updating the client or switching servers."
|
||||
)
|
||||
|
||||
class MismatchedSaplingActivationHeight(
|
||||
clientHeight: Long,
|
||||
serverHeight: Long
|
||||
) : CompactBlockProcessorException(
|
||||
message =
|
||||
"Incompatible server: this client expects a sapling activation height $clientHeight but it " +
|
||||
"was $serverHeight! Try updating the client or switching servers."
|
||||
)
|
||||
|
||||
class BadBlockHeight(serverBlockHeight: BlockHeightUnsafe) : CompactBlockProcessorException(
|
||||
"The server returned a block height of $serverBlockHeight which is not valid."
|
||||
)
|
||||
|
|
|
@ -22,12 +22,12 @@ enum class ConsensusBranchId(val displayName: String, val id: Long, val hexId: S
|
|||
override fun toString(): String = displayName
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String): ConsensusBranchId? = values().firstOrNull { it.displayName.equals(name, true) }
|
||||
fun fromName(name: String): ConsensusBranchId? = entries.firstOrNull { it.displayName.equals(name, true) }
|
||||
|
||||
fun fromId(id: Long): ConsensusBranchId? = values().firstOrNull { it.id == id }
|
||||
fun fromId(id: Long): ConsensusBranchId? = entries.firstOrNull { it.id == id }
|
||||
|
||||
fun fromHex(hex: String): ConsensusBranchId? =
|
||||
values().firstOrNull { branch ->
|
||||
entries.firstOrNull { branch ->
|
||||
hex.lowercase(Locale.US).replace("_", "").replaceFirst("0x", "").let { sanitized ->
|
||||
branch.hexId.equals(sanitized, true)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package cash.z.ecc.android.sdk.type
|
||||
|
||||
/**
|
||||
* Validation helper class, representing result an server endpoint validation
|
||||
*/
|
||||
sealed class ServerValidation {
|
||||
data object Valid : ServerValidation()
|
||||
|
||||
data object Running : ServerValidation()
|
||||
|
||||
data class InValid(val reason: Throwable) : ServerValidation()
|
||||
}
|
Loading…
Reference in New Issue