diff --git a/.gitignore b/.gitignore index 37033b6..50f64d4 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ fastlane/readme.md # misc. backup/ .editorconfig +external/ \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index e2df26b..0235acb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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'] } } diff --git a/app/src/main/java/cash/z/ecc/android/di/DependenciesHolder.kt b/app/src/main/java/cash/z/ecc/android/di/DependenciesHolder.kt index da1f057..2956464 100644 --- a/app/src/main/java/cash/z/ecc/android/di/DependenciesHolder.kt +++ b/app/src/main/java/cash/z/ecc/android/di/DependenciesHolder.kt @@ -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 } diff --git a/app/src/main/java/cash/z/ecc/android/ext/Const.kt b/app/src/main/java/cash/z/ecc/android/ext/Const.kt index 420650e..7cec4c1 100644 --- a/app/src/main/java/cash/z/ecc/android/ext/Const.kt +++ b/app/src/main/java/cash/z/ecc/android/ext/Const.kt @@ -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] diff --git a/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt b/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt index 3a49b93..a319b24 100644 --- a/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt +++ b/app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt @@ -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()?.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 +} diff --git a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt index cd65c76..ac87c2b 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt @@ -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)) } diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/WalletSetupViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/WalletSetupViewModel.kt index 4351529..a608fc9 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/setup/WalletSetupViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/setup/WalletSetupViewModel.kt @@ -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)