1 Commits

12 changed files with 151 additions and 28 deletions

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

@@ -8,6 +8,7 @@ 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.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.model.LightWalletEndpoint
import cash.z.ecc.android.sdk.type.UnifiedViewingKey import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.android.ui.util.DebugFileTwig import cash.z.ecc.android.ui.util.DebugFileTwig
@@ -37,7 +38,11 @@ object DependenciesHolder {
*/ */
fun resetSynchronizer() { fun resetSynchronizer() {
_synchronizer?.let { _synchronizer?.let {
try {
it.stop() it.stop()
} catch (e: Exception) {
// Ignore errors during teardown
}
_synchronizer = null _synchronizer = null
} }
// Rebuild the initializer with the new server from prefs // Rebuild the initializer with the new server from prefs
@@ -46,10 +51,14 @@ object DependenciesHolder {
val network = ZcashWalletApp.instance.defaultNetwork val network = ZcashWalletApp.instance.defaultNetwork
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)
// 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) { if (extfvk != null && extpub != null) {
val vk = UnifiedViewingKey(extfvk = String(extfvk), extpub = String(extpub)) val vk = UnifiedViewingKey(extfvk = String(extfvk), extpub = String(extpub))
initializerComponent.createInitializer(Initializer.Config { initializerComponent.createInitializer(Initializer.Config {
it.importWallet(vk, null, network, LightWalletEndpoint(host, port, true)) it.importWallet(vk, birthdayHeight, network, LightWalletEndpoint(host, port, true))
}) })
} }
} }

View File

@@ -106,9 +106,9 @@ 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 showServerPickerDialog(onServerSelected = { host -> 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) onServerSelected(host)
// Reconnect with the new server without restarting // Reconnect with the new server without restarting
(this as? MainActivity)?.let { activity -> (this as? MainActivity)?.let { activity ->
try {
cash.z.ecc.android.di.DependenciesHolder.resetSynchronizer() cash.z.ecc.android.di.DependenciesHolder.resetSynchronizer()
activity.startSync(isRestart = true) activity.startSync(isRestart = true)
} catch (e: Exception) {
android.util.Log.e("SilentDragon", "Error reconnecting after server change", e)
}
} }
} }

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!")
@@ -566,6 +572,22 @@ class MainActivity : AppCompatActivity(R.layout.main_activity) {
return false // stop retrying 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++
if (ignoredErrors >= ZcashSdk.RETRIES) { if (ignoredErrors >= ZcashSdk.RETRIES) {

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

@@ -105,7 +105,8 @@ class WalletSetupViewModel : ViewModel() {
val port = prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT val port = prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT
// Check if the preferred server is reachable; if not, try others // 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") Log.d("SilentDragon", "host: $host")

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.1.0" const val versionName = "1.1.2"
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 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"