Add reorg detection, server failover, and live server switching

- Add reorg detection with user-facing repair dialog (clear app data)
- Add server picker dialog with connectivity indicators for all 6 lite servers
- Probe server reachability via TCP before connecting; auto-skip dead servers
- Support live server switching without app restart (resetSynchronizer)
- Update server list and default server URL to dragonx.is endpoints
- Update build config for DragonX mainnet
This commit is contained in:
2026-03-21 03:50:04 -05:00
parent 2d676f27ee
commit d501255c6b
7 changed files with 197 additions and 18 deletions

1
.gitignore vendored
View File

@@ -71,3 +71,4 @@ fastlane/readme.md
# misc.
backup/
.editorconfig
external/

View File

@@ -47,13 +47,13 @@ android {
zcashtestnet {
dimension 'network'
applicationId 'cash.z.ecc.android.testnet'
buildConfigField "String", "DEFAULT_SERVER_URL", '"dragonlite.printogre.com"'
buildConfigField "String", "DEFAULT_SERVER_URL", '"lite.dragonx.is"'
matchingFallbacks = ['zcashtestnet', 'debug']
}
zcashmainnet {
dimension 'network'
buildConfigField "String", "DEFAULT_SERVER_URL", '"dragonlite.printogre.com"'
buildConfigField "String", "DEFAULT_SERVER_URL", '"lite.dragonx.is"'
matchingFallbacks = ['zcashmainnet', 'release']
}
}

View File

@@ -6,7 +6,10 @@ import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.ext.Const
import cash.z.ecc.android.feedback.*
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.android.ui.util.DebugFileTwig
import cash.z.ecc.android.util.SilentTwig
import cash.z.ecc.android.util.Twig
@@ -18,7 +21,38 @@ object DependenciesHolder {
val initializerComponent by lazy { InitializerComponent() }
val synchronizer by lazy { Synchronizer.newBlocking(initializerComponent.initializer) }
private var _synchronizer: Synchronizer? = null
val synchronizer: Synchronizer
get() {
if (_synchronizer == null) {
_synchronizer = Synchronizer.newBlocking(initializerComponent.initializer)
}
return _synchronizer!!
}
/**
* Stop the current synchronizer and reinitialize with the server
* currently saved in preferences, so a new synchronizer will be
* created on next access pointing to the new server.
*/
fun resetSynchronizer() {
_synchronizer?.let {
it.stop()
_synchronizer = null
}
// Rebuild the initializer with the new server from prefs
val host = prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST
val port = prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT
val network = ZcashWalletApp.instance.defaultNetwork
val extfvk = lockBox.getCharsUtf8(Const.Backup.VIEWING_KEY)
val extpub = lockBox.getCharsUtf8(Const.Backup.PUBLIC_KEY)
if (extfvk != null && extpub != null) {
val vk = UnifiedViewingKey(extfvk = String(extfvk), extpub = String(extpub))
initializerComponent.createInitializer(Initializer.Config {
it.importWallet(vk, null, network, LightWalletEndpoint(host, port, true))
})
}
}
val clipboardManager by lazy { provideAppContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }

View File

@@ -48,9 +48,13 @@ object Const {
object Default {
object Server {
// Select a random server from list
private val serverList = listOf(
"dragonlite.printogre.com",
"lite.dragonx.is"
val serverList = listOf(
"lite.dragonx.is",
"lite1.dragonx.is",
"lite2.dragonx.is",
"lite3.dragonx.is",
"lite4.dragonx.is",
"lite5.dragonx.is"
)
private val randomIndex = Random.nextInt(serverList.size);
private val randomServer = serverList[randomIndex]

View File

@@ -5,12 +5,20 @@ import android.app.Dialog
import android.content.Context
import android.text.Html
import android.util.Log
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService
import cash.z.ecc.android.R
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.ui.MainActivity
import cash.z.ecc.android.ui.scan.ScanFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.*
import java.net.InetSocketAddress
import java.net.Socket
import kotlin.system.exitProcess
fun Context.showClearDataConfirmation(onDismiss: () -> Unit = {}, onCancel: () -> Unit = {}): Dialog {
@@ -99,15 +107,9 @@ fun Context.showCriticalMessage(title: String, message: String, onDismiss: () ->
var pluckedError = splitError[0]
if(pluckedError == "UNAVAILABLE"){
return MaterialAlertDialogBuilder(this)
.setTitle("Server Unavailable")
.setMessage("Please close and restart the app to try another random server.")
.setCancelable(false)
.setNegativeButton("Exit") { dialog, _ ->
dialog.dismiss()
exitProcess(0)
}
.show()
return showServerPickerDialog(onServerSelected = { host ->
onDismiss()
})
}
return MaterialAlertDialogBuilder(this)
@@ -212,3 +214,87 @@ fun Context.showSharedLibraryCriticalError(e: Throwable): Dialog = showCriticalM
messageResId = R.string.dialog_error_critical_link_message,
onDismiss = { throw e }
)
fun Context.showReorgRepairDialog(onDismiss: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle("Incompatible Block Data")
.setMessage(
"The wallet contains block data from an old or incompatible chain. " +
"This prevents syncing.\n\n" +
"Would you like to clear the old data and sync fresh from the network?\n" +
"(Your wallet keys and addresses will be preserved)"
)
.setCancelable(false)
.setPositiveButton("Clear & Resync") { dialog, _ ->
dialog.dismiss()
onDismiss()
getSystemService<ActivityManager>()?.clearApplicationUserData()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
onDismiss()
}
.show()
}
fun Context.showServerPickerDialog(onServerSelected: (String) -> Unit = {}): Dialog {
val servers = Const.Default.Server.serverList
val port = Const.Default.Server.PORT
// Display names start as "Checking..." and get updated
val displayItems = servers.map { "$it — checking..." }.toMutableList()
val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, displayItems)
val listView = ListView(this).apply {
this.adapter = adapter
setPadding(32, 16, 32, 16)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle("Select a Server")
.setMessage("The current server is unavailable. Choose a server to connect to:")
.setView(listView)
.setCancelable(false)
.setNegativeButton("Exit") { d, _ ->
d.dismiss()
exitProcess(0)
}
.show()
// Check each server's connectivity in the background
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
servers.forEachIndexed { index, host ->
scope.launch {
val online = try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 5000)
true
}
} catch (e: Exception) {
false
}
withContext(Dispatchers.Main) {
displayItems[index] = if (online) "$host" else "$host (offline)"
adapter.notifyDataSetChanged()
}
}
}
listView.setOnItemClickListener { _, _, position, _ ->
val host = servers[position]
// Save selected server to preferences
val prefs = cash.z.ecc.android.di.DependenciesHolder.prefs
prefs[Const.Pref.SERVER_HOST] = host
prefs[Const.Pref.SERVER_PORT] = port
dialog.dismiss()
scope.cancel()
onServerSelected(host)
// Reconnect with the new server without restarting
(this as? MainActivity)?.let { activity ->
cash.z.ecc.android.di.DependenciesHolder.resetSynchronizer()
activity.startSync(isRestart = true)
}
}
return dialog
}

View File

@@ -528,6 +528,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
// TODO: clean up this error handling
private var ignoredErrors = 0
private var reorgCount = 0
private fun onProcessorError(error: Throwable?): Boolean {
var notified = false
when (error) {
@@ -553,6 +554,17 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
}
}
}
is CompactBlockProcessorException.FailedReorgRepair -> {
if (dialog == null) {
notified = true
runOnUiThread {
dialog = showReorgRepairDialog {
dialog = null
}
}
}
return false // stop retrying
}
}
if (!notified) {
ignoredErrors++
@@ -573,6 +585,8 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
}
private fun onChainError(errorHeight: BlockHeight, rewindHeight: BlockHeight) {
reorgCount++
twig("Chain reorg detected (#$reorgCount): error at $errorHeight, rewinding to $rewindHeight")
feedback.report(Reorg(errorHeight, rewindHeight))
}

View File

@@ -20,6 +20,8 @@ import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
import cash.z.ecc.android.util.twig
import cash.z.ecc.kotlin.mnemonic.Mnemonics
import java.net.InetSocketAddress
import java.net.Socket
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -99,12 +101,13 @@ class WalletSetupViewModel : ViewModel() {
val vk =
loadUnifiedViewingKey() ?: onMissingViewingKey(network).also { overwriteVks = true }
val birthdayHeight = loadBirthdayHeight() ?: onMissingBirthday(network)
val host = prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST
val savedHost = prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST
val port = prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT
Log.d("SilentDragon", "host: $host")
// Check if the preferred server is reachable; if not, try others
val host = findReachableServer(savedHost, port)
// TODO: Maybe check server availability here
Log.d("SilentDragon", "host: $host")
twig("Done loading config variables")
return Initializer.Config {
@@ -113,6 +116,43 @@ class WalletSetupViewModel : ViewModel() {
}
}
/**
* Check if the preferred server is reachable via TCP. If not, try each server
* in the list until one responds. Returns the first reachable server, or falls
* back to the preferred server if none respond.
*/
private fun findReachableServer(preferredHost: String, port: Int): String {
// Try preferred server first
if (isServerReachable(preferredHost, port)) {
return preferredHost
}
twig("Preferred server $preferredHost is unreachable, trying alternatives...")
// Try each server in the list
for (server in Const.Default.Server.serverList) {
if (server != preferredHost && isServerReachable(server, port)) {
twig("Found reachable server: $server")
// Save working server to preferences for next time
prefs[Const.Pref.SERVER_HOST] = server
return server
}
}
twig("WARNING: No servers responded, using preferred: $preferredHost")
return preferredHost
}
private fun isServerReachable(host: String, port: Int): Boolean {
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 5000)
true
}
} catch (e: Exception) {
false
}
}
private fun loadUnifiedViewingKey(): UnifiedViewingKey? {
val extfvk = lockBox.getCharsUtf8(Const.Backup.VIEWING_KEY)
val extpub = lockBox.getCharsUtf8(Const.Backup.PUBLIC_KEY)