v1.1.2: Fix server reconnection, sync progress, and seed phrase handling
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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, null, network, LightWalletEndpoint(host, port, true))
|
||||
it.importWallet(vk, birthdayHeight, network, LightWalletEndpoint(host, port, true))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _loadingMessage = MutableStateFlow<String?>("\u23F3 Loading...")
|
||||
private val _syncReady = MutableStateFlow(false)
|
||||
val syncRestarted = MutableStateFlow(false)
|
||||
val loadingMessage: StateFlow<String?> get() = _loadingMessage
|
||||
val isLoading get() = loadingMessage.value != null
|
||||
|
||||
|
||||
@@ -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<FragmentHomeBinding>() {
|
||||
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<FragmentHomeBinding>() {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -127,7 +127,7 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), 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<TextView>(R.id.input_birthdate).text.toString()
|
||||
.let { birthdateString ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
27
build.sh
Executable file
27
build.sh
Executable 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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user