Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 857e016678 | |||
| cced1a34ce | |||
| d501255c6b | |||
|
|
2d676f27ee | ||
|
|
b913dacebf | ||
|
|
fb0d13dffb | ||
|
|
ec853d8bdc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -71,3 +71,4 @@ fastlane/readme.md
|
|||||||
# misc.
|
# misc.
|
||||||
backup/
|
backup/
|
||||||
.editorconfig
|
.editorconfig
|
||||||
|
external/
|
||||||
@@ -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
|
||||||
@@ -180,7 +181,10 @@ dependencies {
|
|||||||
|
|
||||||
// Misc.
|
// Misc.
|
||||||
implementation Deps.Misc.LOTTIE
|
implementation Deps.Misc.LOTTIE
|
||||||
implementation Deps.Misc.CHIPS
|
implementation Deps.Misc.CHIPS, {
|
||||||
|
exclude module: 'ChipsLayoutManager'
|
||||||
|
}
|
||||||
|
implementation 'com.github.BelooS:ChipsLayoutManager:v0.3.7'
|
||||||
implementation Deps.Misc.Plugins.QR_SCANNER
|
implementation Deps.Misc.Plugins.QR_SCANNER
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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 firstMatchingWord = (mDataSource.filteredChips.firstOrNull() as? SeedWordChip)?.word?.takeUnless {
|
val normalizedText = text.trim().lowercase()
|
||||||
!it.startsWith(text)
|
if (normalizedText.isEmpty()) return
|
||||||
}
|
// Prefer an exact match from the word list over autocomplete
|
||||||
if (firstMatchingWord != null) {
|
val exactMatch = mDataSource.originalChips.firstOrNull {
|
||||||
onKeyboardActionDone(firstMatchingWord)
|
it.title.equals(normalizedText, ignoreCase = true)
|
||||||
|
}?.title
|
||||||
|
if (exactMatch != null) {
|
||||||
|
onKeyboardActionDone(exactMatch)
|
||||||
} else {
|
} else {
|
||||||
onKeyboardActionDone(text)
|
val firstMatchingWord = (mDataSource.filteredChips.firstOrNull() as? SeedWordChip)?.word?.takeUnless {
|
||||||
|
!it.startsWith(normalizedText)
|
||||||
|
}
|
||||||
|
if (firstMatchingWord != null) {
|
||||||
|
onKeyboardActionDone(firstMatchingWord)
|
||||||
|
} else {
|
||||||
|
onKeyboardActionDone(normalizedText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 compileSdkVersion = 31
|
||||||
const val minSdkVersion = 21
|
const val minSdkVersion = 21
|
||||||
const val targetSdkVersion = 30
|
const val targetSdkVersion = 30
|
||||||
const val versionName = "1.0.3"
|
const val versionName = "1.1.2"
|
||||||
const val versionCode = 1_03_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"
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ object Deps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
object Zcash {
|
object Zcash {
|
||||||
const val ANDROID_WALLET_PLUGINS = "cash.z.ecc.android:zcash-android-wallet-plugins:1.0.0"
|
const val ANDROID_WALLET_PLUGINS = "com.github.zcash:zcash-android-wallet-plugins:1.0.0"
|
||||||
const val KOTLIN_BIP39 = "cash.z.ecc.android:kotlin-bip39:1.0.4"
|
const val KOTLIN_BIP39 = "cash.z.ecc.android:kotlin-bip39:1.0.4"
|
||||||
|
|
||||||
/* SDK uses mavenLocal build with DRGX customizations for now
|
/* SDK uses mavenLocal build with DRGX customizations for now
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import java.util.*
|
|||||||
import java.util.Locale.ENGLISH
|
import java.util.Locale.ENGLISH
|
||||||
|
|
||||||
class Mnemonics : MnemonicPlugin {
|
class Mnemonics : MnemonicPlugin {
|
||||||
|
/* Build fails for overrides nothing
|
||||||
override fun fullWordList(languageCode: String) = Mnemonics.getCachedWords(Locale.ENGLISH.language)
|
override fun fullWordList(languageCode: String) = Mnemonics.getCachedWords(Locale.ENGLISH.language)
|
||||||
|
*/
|
||||||
override fun nextEntropy(): ByteArray = WordCount.COUNT_24.toEntropy()
|
override fun nextEntropy(): ByteArray = WordCount.COUNT_24.toEntropy()
|
||||||
override fun nextMnemonic(): CharArray = MnemonicCode(WordCount.COUNT_24).chars
|
override fun nextMnemonic(): CharArray = MnemonicCode(WordCount.COUNT_24).chars
|
||||||
override fun nextMnemonic(entropy: ByteArray): CharArray = MnemonicCode(entropy).chars
|
override fun nextMnemonic(entropy: ByteArray): CharArray = MnemonicCode(entropy).chars
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ dependencyResolutionManagement {
|
|||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://jitpack.io")
|
maven("https://jitpack.io")
|
||||||
jcenter()
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
// Uncomment to use a snapshot version of the SDK, e.g. when the SDK version ends in -SNAPSHOT
|
// Uncomment to use a snapshot version of the SDK, e.g. when the SDK version ends in -SNAPSHOT
|
||||||
|
|||||||
Reference in New Issue
Block a user