3 Commits

Author SHA1 Message Date
857e016678 v1.1.2: Fix server reconnection, sync progress, and seed phrase handling 2026-03-24 16:42:54 -05:00
cced1a34ce Bump version to 1.1.0 and unify signing config
- Bump versionName to 1.1.0 and versionCode to 1_01_00_800
- Use placeholder keystore for debug builds to match release signing
2026-03-21 04:20:46 -05:00
d501255c6b 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
2026-03-21 03:50:04 -05:00
15 changed files with 343 additions and 40 deletions

1
.gitignore vendored
View File

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

View File

@@ -47,13 +47,13 @@ android {
zcashtestnet { zcashtestnet {
dimension 'network' dimension 'network'
applicationId 'cash.z.ecc.android.testnet' 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'] matchingFallbacks = ['zcashtestnet', 'debug']
} }
zcashmainnet { zcashmainnet {
dimension 'network' dimension 'network'
buildConfigField "String", "DEFAULT_SERVER_URL", '"dragonlite.printogre.com"' buildConfigField "String", "DEFAULT_SERVER_URL", '"lite.dragonx.is"'
matchingFallbacks = ['zcashmainnet', 'release'] matchingFallbacks = ['zcashmainnet', 'release']
} }
} }
@@ -76,6 +76,7 @@ android {
minifyEnabled false minifyEnabled false
shrinkResources false shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.placeholder
} }
// builds for testing only in the wallet team, typically unfinished features // builds for testing only in the wallet team, typically unfinished features
// this flavor can be installed alongside the others // this flavor can be installed alongside the others

View File

@@ -55,7 +55,13 @@ class ZcashWalletApp : Application(), CameraXConfig.Provider {
// reported by the crash reporting. // reported by the crash reporting.
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
StrictModeHelper.enableStrictMode() StrictModeHelper.enableStrictMode()
cash.z.ecc.android.sdk.internal.Twig.enabled(true) }
if (BuildConfig.DEBUG) {
cash.z.ecc.android.sdk.internal.Twig.plant(
cash.z.ecc.android.sdk.internal.TroubleshootingTwig(
printer = { android.util.Log.d("@TWIG", it); Unit }
)
)
cash.z.ecc.android.util.Twig.enabled(true) cash.z.ecc.android.util.Twig.enabled(true)
} }

View File

@@ -6,7 +6,11 @@ import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.ext.Const import cash.z.ecc.android.ext.Const
import cash.z.ecc.android.feedback.* import cash.z.ecc.android.feedback.*
import cash.z.ecc.android.lockbox.LockBox 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.Synchronizer
import cash.z.ecc.android.sdk.model.BlockHeight
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.ui.util.DebugFileTwig
import cash.z.ecc.android.util.SilentTwig import cash.z.ecc.android.util.SilentTwig
import cash.z.ecc.android.util.Twig import cash.z.ecc.android.util.Twig
@@ -18,7 +22,46 @@ object DependenciesHolder {
val initializerComponent by lazy { InitializerComponent() } 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 {
try {
it.stop()
} catch (e: Exception) {
// Ignore errors during teardown
}
_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)
// Load the stored birthday so the processor uses the correct scan start height
val birthdayHeight: BlockHeight? = lockBox.get<Int>(Const.Backup.BIRTHDAY_HEIGHT)?.let {
BlockHeight.new(network, it.toLong())
}
if (extfvk != null && extpub != null) {
val vk = UnifiedViewingKey(extfvk = String(extfvk), extpub = String(extpub))
initializerComponent.createInitializer(Initializer.Config {
it.importWallet(vk, birthdayHeight, network, LightWalletEndpoint(host, port, true))
})
}
}
val clipboardManager by lazy { provideAppContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } val clipboardManager by lazy { provideAppContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }

View File

@@ -48,9 +48,13 @@ object Const {
object Default { object Default {
object Server { object Server {
// Select a random server from list // Select a random server from list
private val serverList = listOf( val serverList = listOf(
"dragonlite.printogre.com", "lite.dragonx.is",
"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 randomIndex = Random.nextInt(serverList.size);
private val randomServer = serverList[randomIndex] private val randomServer = serverList[randomIndex]

View File

@@ -5,12 +5,20 @@ import android.app.Dialog
import android.content.Context import android.content.Context
import android.text.Html import android.text.Html
import android.util.Log import android.util.Log
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import cash.z.ecc.android.R import cash.z.ecc.android.R
import cash.z.ecc.android.feedback.Report import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.ui.MainActivity
import cash.z.ecc.android.ui.scan.ScanFragment import cash.z.ecc.android.ui.scan.ScanFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.*
import java.net.InetSocketAddress
import java.net.Socket
import kotlin.system.exitProcess import kotlin.system.exitProcess
fun Context.showClearDataConfirmation(onDismiss: () -> Unit = {}, onCancel: () -> Unit = {}): Dialog { fun Context.showClearDataConfirmation(onDismiss: () -> Unit = {}, onCancel: () -> Unit = {}): Dialog {
@@ -98,16 +106,10 @@ fun Context.showCriticalMessage(title: String, message: String, onDismiss: () ->
val splitError = message.split(delimiter) val splitError = message.split(delimiter)
var pluckedError = splitError[0] var pluckedError = splitError[0]
if(pluckedError == "UNAVAILABLE"){ if(pluckedError == "UNAVAILABLE" || pluckedError == "DEADLINE_EXCEEDED"){
return MaterialAlertDialogBuilder(this) return showServerPickerDialog(onServerSelected = { host ->
.setTitle("Server Unavailable") // Do NOT call onDismiss() here — it throws the original error
.setMessage("Please close and restart the app to try another random server.") })
.setCancelable(false)
.setNegativeButton("Exit") { dialog, _ ->
dialog.dismiss()
exitProcess(0)
}
.show()
} }
return MaterialAlertDialogBuilder(this) return MaterialAlertDialogBuilder(this)
@@ -212,3 +214,91 @@ fun Context.showSharedLibraryCriticalError(e: Throwable): Dialog = showCriticalM
messageResId = R.string.dialog_error_critical_link_message, messageResId = R.string.dialog_error_critical_link_message,
onDismiss = { throw e } 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 ->
try {
cash.z.ecc.android.di.DependenciesHolder.resetSynchronizer()
activity.startSync(isRestart = true)
} catch (e: Exception) {
android.util.Log.e("SilentDragon", "Error reconnecting after server change", e)
}
}
}
return dialog
}

View File

@@ -232,6 +232,12 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
synchronizer.start(lifecycleScope) synchronizer.start(lifecycleScope)
mainViewModel.setSyncReady(true) mainViewModel.setSyncReady(true)
// When restarting (e.g. after server switch), the HomeFragment's
// flow is still bound to the old synchronizer. Signal it to rebind.
if (isRestart) {
mainViewModel.syncRestarted.value = true
}
} }
} else { } else {
twig("Ignoring request to start sync because sync has already been started!") twig("Ignoring request to start sync because sync has already been started!")
@@ -528,6 +534,7 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
// TODO: clean up this error handling // TODO: clean up this error handling
private var ignoredErrors = 0 private var ignoredErrors = 0
private var reorgCount = 0
private fun onProcessorError(error: Throwable?): Boolean { private fun onProcessorError(error: Throwable?): Boolean {
var notified = false var notified = false
when (error) { when (error) {
@@ -553,6 +560,33 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
} }
} }
} }
is CompactBlockProcessorException.FailedReorgRepair -> {
if (dialog == null) {
notified = true
runOnUiThread {
dialog = showReorgRepairDialog {
dialog = null
}
}
}
return false // stop retrying
}
}
// Handle gRPC connectivity errors (DEADLINE_EXCEEDED, UNAVAILABLE) by showing server picker
if (!notified && error is io.grpc.StatusRuntimeException) {
val code = (error as io.grpc.StatusRuntimeException).status.code
if (code == io.grpc.Status.Code.DEADLINE_EXCEEDED || code == io.grpc.Status.Code.UNAVAILABLE) {
if (dialog == null) {
notified = true
runOnUiThread {
dialog = showServerPickerDialog { host ->
dialog = null
ignoredErrors = 0
}
}
}
return true
}
} }
if (!notified) { if (!notified) {
ignoredErrors++ ignoredErrors++
@@ -573,6 +607,8 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
} }
private fun onChainError(errorHeight: BlockHeight, rewindHeight: BlockHeight) { private fun onChainError(errorHeight: BlockHeight, rewindHeight: BlockHeight) {
reorgCount++
twig("Chain reorg detected (#$reorgCount): error at $errorHeight, rewinding to $rewindHeight")
feedback.report(Reorg(errorHeight, rewindHeight)) feedback.report(Reorg(errorHeight, rewindHeight))
} }

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
class MainViewModel : ViewModel() { class MainViewModel : ViewModel() {
private val _loadingMessage = MutableStateFlow<String?>("\u23F3 Loading...") private val _loadingMessage = MutableStateFlow<String?>("\u23F3 Loading...")
private val _syncReady = MutableStateFlow(false) private val _syncReady = MutableStateFlow(false)
val syncRestarted = MutableStateFlow(false)
val loadingMessage: StateFlow<String?> get() = _loadingMessage val loadingMessage: StateFlow<String?> get() = _loadingMessage
val isLoading get() = loadingMessage.value != null val isLoading get() = loadingMessage.value != null

View File

@@ -38,11 +38,14 @@ import cash.z.ecc.android.util.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningReduce import kotlinx.coroutines.flow.runningReduce
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt
// There are deprecations with the use of BroadcastChannel // There are deprecations with the use of BroadcastChannel
@kotlinx.coroutines.ObsoleteCoroutinesApi @kotlinx.coroutines.ObsoleteCoroutinesApi
@@ -189,6 +192,18 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
twig("Sync ready! Monitoring synchronizer state...") twig("Sync ready! Monitoring synchronizer state...")
monitorUiModelChanges() monitorUiModelChanges()
// When the synchronizer is restarted (e.g. after a server switch),
// rebind the UI flows to the new synchronizer instance.
mainActivity?.mainViewModel?.syncRestarted
?.filter { it }
?.onEach {
twig("Sync restarted detected — reinitializing HomeViewModel flows")
mainActivity?.mainViewModel?.syncRestarted?.value = false
viewModel.reinitialize()
monitorUiModelChanges()
}
?.launchIn(resumedScope)
twig("HomeFragment.onSyncReady COMPLETE") twig("HomeFragment.onSyncReady COMPLETE")
} }
@@ -240,23 +255,24 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
val sendText = when { val sendText = when {
uiModel.status == DISCONNECTED -> getString(R.string.home_button_send_disconnected) uiModel.status == DISCONNECTED -> getString(R.string.home_button_send_disconnected)
uiModel.isSynced -> if (uiModel.hasFunds) getString(R.string.home_button_send_has_funds) else getString( uiModel.isSynced || uiModel.isEffectivelySynced -> if (uiModel.hasFunds) getString(R.string.home_button_send_has_funds) else getString(
R.string.home_button_send_no_funds R.string.home_button_send_no_funds
) )
uiModel.status == STOPPED -> getString(R.string.home_button_send_idle) uiModel.status == STOPPED -> getString(R.string.home_button_send_idle)
uiModel.isDownloading -> { uiModel.isDownloading -> {
val pct = (uiModel.totalProgress * 100).roundToInt()
when (snake.downloadProgress) { when (snake.downloadProgress) {
0 -> "Preparing to download..." 0 -> "Preparing to download..."
else -> getString(R.string.home_button_send_downloading, snake.downloadProgress) else -> "Downloading... $pct% (${uiModel.lastDownloadedBlockHeight}/${uiModel.networkHeight})"
} }
} }
uiModel.isValidating -> getString(R.string.home_button_send_validating) uiModel.isValidating -> {
val pct = (uiModel.totalProgress * 100).roundToInt()
"Validating... $pct% (${uiModel.lastScannedBlockHeight}/${uiModel.networkHeight})"
}
uiModel.isScanning -> { uiModel.isScanning -> {
when (snake.scanProgress) { val pct = (uiModel.totalProgress * 100).roundToInt()
0 -> "Preparing to scan..." "Scanning... $pct% (${uiModel.lastScannedBlockHeight}/${uiModel.networkHeight})"
100 -> "Finalizing..."
else -> getString(R.string.home_button_send_scanning, snake.scanProgress)
}
} }
else -> getString(R.string.home_button_send_updating) else -> getString(R.string.home_button_send_updating)
} }

View File

@@ -29,6 +29,12 @@ class HomeViewModel : ViewModel() {
var initialized = false var initialized = false
fun reinitialize() {
twig("HomeViewModel.reinitialize: rebinding to new synchronizer")
initialized = false
initializeMaybe()
}
fun initializeMaybe(preTypedChars: String = "0") { fun initializeMaybe(preTypedChars: String = "0") {
twig("init called") twig("init called")
if (initialized) { if (initialized) {
@@ -94,6 +100,7 @@ class HomeViewModel : ViewModel() {
) )
}.onStart { emit(UiModel(orchardBalance = null, saplingBalance = null, transparentBalance = null)) } }.onStart { emit(UiModel(orchardBalance = null, saplingBalance = null, transparentBalance = null)) }
}.conflate() }.conflate()
initialized = true
} }
override fun onCleared() { override fun onCleared() {
@@ -120,6 +127,13 @@ class HomeViewModel : ViewModel() {
val hasAutoshieldFunds: Boolean get() = (transparentBalance?.available?.value ?: 0) >= ZcashWalletApp.instance.autoshieldThreshold val hasAutoshieldFunds: Boolean get() = (transparentBalance?.available?.value ?: 0) >= ZcashWalletApp.instance.autoshieldThreshold
val isSynced: Boolean get() = status == SYNCED val isSynced: Boolean get() = status == SYNCED
val isSendEnabled: Boolean get() = isSynced && hasFunds val isSendEnabled: Boolean get() = isSynced && hasFunds
// Consider the wallet synced when within 100 blocks of the tip
// so routine polling of 1-2 new blocks doesn't flash "Scanning..." text
val isEffectivelySynced: Boolean get() {
val scanned = processorInfo.lastScannedHeight?.value ?: return false
val tip = processorInfo.networkBlockHeight?.value ?: return false
return tip > 0 && (tip - scanned) < 100
}
// Processor Info // Processor Info
val isDownloading = status == DOWNLOADING val isDownloading = status == DOWNLOADING
@@ -149,6 +163,17 @@ class HomeViewModel : ViewModel() {
} }
} }
} }
val overallProgress: Int get() {
return processorInfo.run {
val scanned = lastScannedHeight?.value ?: return@run 0
val tip = networkBlockHeight?.value ?: return@run 0
if (tip <= 0) 0
else ((scanned.toFloat() / tip.toFloat()) * 100.0f).coerceIn(0f, 100f).roundToInt()
}
}
val lastScannedBlockHeight: Long get() = processorInfo.lastScannedHeight?.value ?: 0
val lastDownloadedBlockHeight: Long get() = processorInfo.lastDownloadedHeight?.value ?: 0
val networkHeight: Long get() = processorInfo.networkBlockHeight?.value ?: 0
val totalProgress: Float get() { val totalProgress: Float get() {
val downloadWeighted = 0.40f * (downloadProgress.toFloat() / 100.0f).coerceAtMost(1.0f) val downloadWeighted = 0.40f * (downloadProgress.toFloat() / 100.0f).coerceAtMost(1.0f)
val scanWeighted = 0.60f * (scanProgress.toFloat() / 100.0f).coerceAtMost(1.0f) val scanWeighted = 0.60f * (scanProgress.toFloat() / 100.0f).coerceAtMost(1.0f)

View File

@@ -127,7 +127,7 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
mainActivity?.hideKeyboard() mainActivity?.hideKeyboard()
val activation = ZcashWalletApp.instance.defaultNetwork.saplingActivationHeight val activation = ZcashWalletApp.instance.defaultNetwork.saplingActivationHeight
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") { val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
it.title it.title.trim().lowercase()
} }
var birthday = binding.root.findViewById<TextView>(R.id.input_birthdate).text.toString() var birthday = binding.root.findViewById<TextView>(R.id.input_birthdate).text.toString()
.let { birthdateString -> .let { birthdateString ->

View File

@@ -61,9 +61,11 @@ class SeedWordAdapter : ChipsAdapter {
override fun onKeyboardActionDone(text: String?) { override fun onKeyboardActionDone(text: String?) {
if (TextUtils.isEmpty(text)) return if (TextUtils.isEmpty(text)) return
val normalizedText = text!!.trim().lowercase()
if (normalizedText.isEmpty()) return
if (mDataSource.originalChips.firstOrNull { it.title == text } != null) { if (mDataSource.originalChips.firstOrNull { it.title.equals(normalizedText, ignoreCase = true) } != null) {
mDataSource.addSelectedChip(DefaultCustomChip(text)) mDataSource.addSelectedChip(DefaultCustomChip(normalizedText))
mEditText.apply { mEditText.apply {
postDelayed( postDelayed(
{ {
@@ -78,13 +80,23 @@ class SeedWordAdapter : ChipsAdapter {
// this function is called with the contents of the field, split by the delimiter // this function is called with the contents of the field, split by the delimiter
override fun onKeyboardDelimiter(text: String) { override fun onKeyboardDelimiter(text: String) {
val normalizedText = text.trim().lowercase()
if (normalizedText.isEmpty()) return
// Prefer an exact match from the word list over autocomplete
val exactMatch = mDataSource.originalChips.firstOrNull {
it.title.equals(normalizedText, ignoreCase = true)
}?.title
if (exactMatch != null) {
onKeyboardActionDone(exactMatch)
} else {
val firstMatchingWord = (mDataSource.filteredChips.firstOrNull() as? SeedWordChip)?.word?.takeUnless { val firstMatchingWord = (mDataSource.filteredChips.firstOrNull() as? SeedWordChip)?.word?.takeUnless {
!it.startsWith(text) !it.startsWith(normalizedText)
} }
if (firstMatchingWord != null) { if (firstMatchingWord != null) {
onKeyboardActionDone(firstMatchingWord) onKeyboardActionDone(firstMatchingWord)
} else { } else {
onKeyboardActionDone(text) onKeyboardActionDone(normalizedText)
}
} }
} }

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.ui.setup.WalletSetupViewModel.WalletSetupState.*
import cash.z.ecc.android.util.twig import cash.z.ecc.android.util.twig
import cash.z.ecc.kotlin.mnemonic.Mnemonics import cash.z.ecc.kotlin.mnemonic.Mnemonics
import java.net.InetSocketAddress
import java.net.Socket
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -99,12 +101,14 @@ class WalletSetupViewModel : ViewModel() {
val vk = val vk =
loadUnifiedViewingKey() ?: onMissingViewingKey(network).also { overwriteVks = true } loadUnifiedViewingKey() ?: onMissingViewingKey(network).also { overwriteVks = true }
val birthdayHeight = loadBirthdayHeight() ?: onMissingBirthday(network) 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 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
// Run on IO dispatcher since findReachableServer does blocking Socket I/O
val host = withContext(IO) { findReachableServer(savedHost, port) }
// TODO: Maybe check server availability here Log.d("SilentDragon", "host: $host")
twig("Done loading config variables") twig("Done loading config variables")
return Initializer.Config { return Initializer.Config {
@@ -113,6 +117,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? { private fun loadUnifiedViewingKey(): UnifiedViewingKey? {
val extfvk = lockBox.getCharsUtf8(Const.Backup.VIEWING_KEY) val extfvk = lockBox.getCharsUtf8(Const.Backup.VIEWING_KEY)
val extpub = lockBox.getCharsUtf8(Const.Backup.PUBLIC_KEY) val extpub = lockBox.getCharsUtf8(Const.Backup.PUBLIC_KEY)

27
build.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$REPO_ROOT"
if [ "$1" = "--release" ] || [ "$1" = "-r" ]; then
echo "Building SilentDragonXAndroid release APK..."
bash gradlew assembleZcashmainnetRelease
APK_DIR="app/build/outputs/apk/zcashmainnet/release"
UNIVERSAL_APK=$(find "$APK_DIR" -name "*-null.apk" -type f | head -1)
if [ -z "$UNIVERSAL_APK" ]; then
echo "ERROR: Universal APK not found in $APK_DIR"
exit 1
fi
mkdir -p "$REPO_ROOT/release"
cp "$UNIVERSAL_APK" "$REPO_ROOT/release/SilentDragonXAndroid - v1.1.2.apk"
echo "Release APK: release/SilentDragonXAndroid - v1.1.2.apk"
else
echo "Building SilentDragonXAndroid debug APK..."
bash gradlew assembleZcashmainnetDebug
echo "Debug build complete."
fi

View File

@@ -9,8 +9,8 @@ object Deps {
const val compileSdkVersion = 31 const val compileSdkVersion = 31
const val minSdkVersion = 21 const val minSdkVersion = 21
const val targetSdkVersion = 30 const val targetSdkVersion = 30
const val versionName = "1.0.4" const val versionName = "1.1.2"
const val versionCode = 1_04_00 // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release. const val versionCode = 1_01_02_800 // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
const val packageName = "dragonx.android" const val packageName = "dragonx.android"
@@ -78,7 +78,7 @@ object Deps {
./gradlew build ./gradlew build
./gradlew build publishToMavenLocal ./gradlew build publishToMavenLocal
*/ */
const val SDK = "hush.android:hush-android-sdk:1.9.0-beta01-SNAPSHOT" const val SDK = "hush.android:hush-android-sdk:1.9.1-beta01-SNAPSHOT"
} }
object Misc { object Misc {
const val LOTTIE = "com.airbnb.android:lottie:3.7.0" const val LOTTIE = "com.airbnb.android:lottie:3.7.0"