From 857e01667852f80644a167ff2c69e5a8370832b8 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 24 Mar 2026 16:33:41 -0500 Subject: [PATCH] v1.1.2: Fix server reconnection, sync progress, and seed phrase handling --- .../java/cash/z/ecc/android/ZcashWalletApp.kt | 8 ++++- .../z/ecc/android/di/DependenciesHolder.kt | 13 ++++++-- .../java/cash/z/ecc/android/ext/Dialogs.kt | 12 ++++--- .../cash/z/ecc/android/ui/MainActivity.kt | 22 +++++++++++++ .../cash/z/ecc/android/ui/MainViewModel.kt | 1 + .../z/ecc/android/ui/home/HomeFragment.kt | 32 ++++++++++++++----- .../z/ecc/android/ui/home/HomeViewModel.kt | 25 +++++++++++++++ .../z/ecc/android/ui/setup/RestoreFragment.kt | 2 +- .../z/ecc/android/ui/setup/SeedWordAdapter.kt | 28 +++++++++++----- .../android/ui/setup/WalletSetupViewModel.kt | 3 +- build.sh | 27 ++++++++++++++++ .../java/cash/z/ecc/android/Dependencies.kt | 6 ++-- 12 files changed, 151 insertions(+), 28 deletions(-) create mode 100755 build.sh diff --git a/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt b/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt index a0a5fd1..f626f2e 100644 --- a/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt +++ b/app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt @@ -55,7 +55,13 @@ class ZcashWalletApp : Application(), CameraXConfig.Provider { // reported by the crash reporting. if (BuildConfig.DEBUG) { 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) } 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 2956464..1c6049a 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 @@ -8,6 +8,7 @@ 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.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 @@ -37,7 +38,11 @@ object DependenciesHolder { */ fun resetSynchronizer() { _synchronizer?.let { - it.stop() + try { + it.stop() + } catch (e: Exception) { + // Ignore errors during teardown + } _synchronizer = null } // Rebuild the initializer with the new server from prefs @@ -46,10 +51,14 @@ object DependenciesHolder { 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(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, null, network, LightWalletEndpoint(host, port, true)) + it.importWallet(vk, birthdayHeight, network, LightWalletEndpoint(host, port, true)) }) } } 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 a319b24..655570e 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 @@ -106,9 +106,9 @@ fun Context.showCriticalMessage(title: String, message: String, onDismiss: () -> val splitError = message.split(delimiter) var pluckedError = splitError[0] - if(pluckedError == "UNAVAILABLE"){ + if(pluckedError == "UNAVAILABLE" || pluckedError == "DEADLINE_EXCEEDED"){ return showServerPickerDialog(onServerSelected = { host -> - onDismiss() + // Do NOT call onDismiss() here — it throws the original error }) } @@ -291,8 +291,12 @@ fun Context.showServerPickerDialog(onServerSelected: (String) -> Unit = {}): Dia 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) + 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) + } } } 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 ac87c2b..e8847f9 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 @@ -232,6 +232,12 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) { synchronizer.start(lifecycleScope) 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 { twig("Ignoring request to start sync because sync has already been started!") @@ -566,6 +572,22 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) { 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) { ignoredErrors++ if (ignoredErrors >= ZcashSdk.RETRIES) { diff --git a/app/src/main/java/cash/z/ecc/android/ui/MainViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/MainViewModel.kt index a8a9198..b317cd6 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/MainViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/MainViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow class MainViewModel : ViewModel() { private val _loadingMessage = MutableStateFlow("\u23F3 Loading...") private val _syncReady = MutableStateFlow(false) + val syncRestarted = MutableStateFlow(false) val loadingMessage: StateFlow get() = _loadingMessage val isLoading get() = loadingMessage.value != null diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt index 61dd024..16c9896 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt @@ -38,11 +38,14 @@ import cash.z.ecc.android.util.twig import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.runningReduce import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlin.math.roundToInt // There are deprecations with the use of BroadcastChannel @kotlinx.coroutines.ObsoleteCoroutinesApi @@ -189,6 +192,18 @@ class HomeFragment : BaseFragment() { twig("Sync ready! Monitoring synchronizer state...") 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") } @@ -240,23 +255,24 @@ class HomeFragment : BaseFragment() { val sendText = when { 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 ) uiModel.status == STOPPED -> getString(R.string.home_button_send_idle) uiModel.isDownloading -> { + val pct = (uiModel.totalProgress * 100).roundToInt() when (snake.downloadProgress) { 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 -> { - when (snake.scanProgress) { - 0 -> "Preparing to scan..." - 100 -> "Finalizing..." - else -> getString(R.string.home_button_send_scanning, snake.scanProgress) - } + val pct = (uiModel.totalProgress * 100).roundToInt() + "Scanning... $pct% (${uiModel.lastScannedBlockHeight}/${uiModel.networkHeight})" } else -> getString(R.string.home_button_send_updating) } diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt index a5a238e..c017101 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt @@ -29,6 +29,12 @@ class HomeViewModel : ViewModel() { var initialized = false + fun reinitialize() { + twig("HomeViewModel.reinitialize: rebinding to new synchronizer") + initialized = false + initializeMaybe() + } + fun initializeMaybe(preTypedChars: String = "0") { twig("init called") if (initialized) { @@ -94,6 +100,7 @@ class HomeViewModel : ViewModel() { ) }.onStart { emit(UiModel(orchardBalance = null, saplingBalance = null, transparentBalance = null)) } }.conflate() + initialized = true } override fun onCleared() { @@ -120,6 +127,13 @@ class HomeViewModel : ViewModel() { val hasAutoshieldFunds: Boolean get() = (transparentBalance?.available?.value ?: 0) >= ZcashWalletApp.instance.autoshieldThreshold val isSynced: Boolean get() = status == SYNCED 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 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 downloadWeighted = 0.40f * (downloadProgress.toFloat() / 100.0f).coerceAtMost(1.0f) val scanWeighted = 0.60f * (scanProgress.toFloat() / 100.0f).coerceAtMost(1.0f) diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt index a718cd4..056675e 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt @@ -127,7 +127,7 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen mainActivity?.hideKeyboard() val activation = ZcashWalletApp.instance.defaultNetwork.saplingActivationHeight val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") { - it.title + it.title.trim().lowercase() } var birthday = binding.root.findViewById(R.id.input_birthdate).text.toString() .let { birthdateString -> diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt index 139333c..b9986d2 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt @@ -61,9 +61,11 @@ class SeedWordAdapter : ChipsAdapter { override fun onKeyboardActionDone(text: String?) { if (TextUtils.isEmpty(text)) return + val normalizedText = text!!.trim().lowercase() + if (normalizedText.isEmpty()) return - if (mDataSource.originalChips.firstOrNull { it.title == text } != null) { - mDataSource.addSelectedChip(DefaultCustomChip(text)) + if (mDataSource.originalChips.firstOrNull { it.title.equals(normalizedText, ignoreCase = true) } != null) { + mDataSource.addSelectedChip(DefaultCustomChip(normalizedText)) mEditText.apply { postDelayed( { @@ -78,13 +80,23 @@ class SeedWordAdapter : ChipsAdapter { // this function is called with the contents of the field, split by the delimiter override fun onKeyboardDelimiter(text: String) { - val firstMatchingWord = (mDataSource.filteredChips.firstOrNull() as? SeedWordChip)?.word?.takeUnless { - !it.startsWith(text) - } - if (firstMatchingWord != null) { - onKeyboardActionDone(firstMatchingWord) + 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 { - onKeyboardActionDone(text) + val firstMatchingWord = (mDataSource.filteredChips.firstOrNull() as? SeedWordChip)?.word?.takeUnless { + !it.startsWith(normalizedText) + } + if (firstMatchingWord != null) { + onKeyboardActionDone(firstMatchingWord) + } else { + onKeyboardActionDone(normalizedText) + } } } 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 a608fc9..14c0f3e 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 @@ -105,7 +105,8 @@ class WalletSetupViewModel : ViewModel() { val port = prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT // Check if the preferred server is reachable; if not, try others - val host = findReachableServer(savedHost, port) + // Run on IO dispatcher since findReachableServer does blocking Socket I/O + val host = withContext(IO) { findReachableServer(savedHost, port) } Log.d("SilentDragon", "host: $host") diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..414f841 --- /dev/null +++ b/build.sh @@ -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 diff --git a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt index 4b3b988..6b41c04 100644 --- a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt +++ b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt @@ -9,8 +9,8 @@ object Deps { const val compileSdkVersion = 31 const val minSdkVersion = 21 const val targetSdkVersion = 30 - const val versionName = "1.1.0" - const val versionCode = 1_01_00_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 versionName = "1.1.2" + 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" @@ -78,7 +78,7 @@ object Deps { ./gradlew build ./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 { const val LOTTIE = "com.airbnb.android:lottie:3.7.0"