Initial commit

This initial commit includes HUSH specific changes starting at this commit:
d14637012c
This commit is contained in:
fekt
2022-11-29 20:49:44 -05:00
commit 4adbc901a0
355 changed files with 31799 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
package cash.z.ecc.android.ext
import cash.z.ecc.android.BuildConfig
object Const {
/**
* Named objects for Dependency Injection.
*/
object Name {
/** application data other than cryptographic keys */
const val APP_PREFS = "const.name.app_prefs"
const val BEFORE_SYNCHRONIZER = "const.name.before_synchronizer"
const val SYNCHRONIZER = "const.name.synchronizer"
}
/**
* App preference key names.
*/
object Pref {
const val FIRST_USE_VIEW_TX = "const.pref.first_use_view_tx"
const val EASTER_EGG_TRIGGERED_SHIELDING = "const.pref.easter_egg_shielding"
const val FEEDBACK_ENABLED = "const.pref.feedback_enabled"
const val SERVER_HOST = "const.pref.server_host"
const val SERVER_PORT = "const.pref.server_port"
}
/**
* Constants used for wallet backup.
*/
object Backup {
const val SEED = "cash.z.ecc.android.SEED"
const val SEED_PHRASE = "cash.z.ecc.android.SEED_PHRASE"
const val HAS_SEED = "cash.z.ecc.android.HAS_SEED"
const val HAS_SEED_PHRASE = "cash.z.ecc.android.HAS_SEED_PHRASE"
const val HAS_BACKUP = "cash.z.ecc.android.HAS_BACKUP"
// Config
const val VIEWING_KEY = "cash.z.ecc.android.VIEWING_KEY"
const val PUBLIC_KEY = "cash.z.ecc.android.PUBLIC_KEY"
const val BIRTHDAY_HEIGHT = "cash.z.ecc.android.BIRTHDAY_HEIGHT"
}
/**
* Default values to use application-wide. Ideally, this set of values should remain very short.
*/
object Default {
object Server {
// If you've forked the ECC repo, change this to your hosted lightwalletd instance
const val HOST = BuildConfig.DEFAULT_SERVER_URL
const val PORT = 9067
}
}
}

View File

@@ -0,0 +1,76 @@
package cash.z.ecc.android.ext
import cash.z.ecc.android.ext.ConversionsUniform.FULL_FORMATTER
import cash.z.ecc.android.ext.ConversionsUniform.LONG_SCALE
import cash.z.ecc.android.ext.ConversionsUniform.SHORT_FORMATTER
import cash.z.ecc.android.sdk.ext.Conversions
import cash.z.ecc.android.sdk.ext.ZcashSdk
import cash.z.ecc.android.sdk.model.Zatoshi
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
/**
* Do the necessary conversions in one place
*
* "1.234" -> to zatoshi
* (zecStringToZatoshi)
* String.toZatoshi()
*
* 123123 -> to "1.2132"
* (zatoshiToZecString)
* Long.toZecString()
*
*/
object ConversionsUniform {
val ONE_ZEC_IN_ZATOSHI = BigDecimal(Zatoshi.ZATOSHI_PER_ZEC, MathContext.DECIMAL128)
val LONG_SCALE = 8
val SHORT_SCALE = 4
val SHORT_FORMATTER = from(SHORT_SCALE, SHORT_SCALE)
val FULL_FORMATTER = from(LONG_SCALE)
val roundingMode = RoundingMode.HALF_EVEN
private fun from(maxDecimals: Int = 8, minDecimals: Int = 0) = (NumberFormat.getNumberInstance(Locale("en", "USA")) as DecimalFormat).apply {
// applyPattern("###.##")
isParseBigDecimal = true
roundingMode = roundingMode
maximumFractionDigits = maxDecimals
minimumFractionDigits = minDecimals
minimumIntegerDigits = 1
}
}
object WalletZecFormmatter {
fun toZatoshi(zecString: String): Long? {
return toBigDecimal(zecString)?.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128)?.toLong()
}
fun toZecStringShort(amount: Zatoshi?): String {
return SHORT_FORMATTER.format((amount ?: Zatoshi(0)).toZec())
}
fun toZecStringFull(amount: Zatoshi?): String {
return formatFull((amount ?: Zatoshi(0)).toZec())
}
fun formatFull(zec: BigDecimal): String {
return FULL_FORMATTER.format(zec)
}
fun toBigDecimal(zecString: String?): BigDecimal? {
if (zecString.isNullOrEmpty()) return BigDecimal.ZERO
return try {
// ignore commas and whitespace
var sanitizedInput = zecString.filter { it.isDigit() or (it == '.') }
BigDecimal.ZERO.max(FULL_FORMATTER.parse(sanitizedInput) as BigDecimal)
} catch (t: Throwable) {
return null
}
}
// convert a zatoshi value to ZEC as a BigDecimal
private fun Zatoshi?.toZec(): BigDecimal =
BigDecimal(this?.value ?: 0L, MathContext.DECIMAL128)
.divide(ConversionsUniform.ONE_ZEC_IN_ZATOSHI)
.setScale(LONG_SCALE, ConversionsUniform.roundingMode)
}

View File

@@ -0,0 +1,192 @@
package cash.z.ecc.android.ext
import android.app.ActivityManager
import android.app.Dialog
import android.content.Context
import android.text.Html
import androidx.annotation.StringRes
import androidx.core.content.getSystemService
import cash.z.ecc.android.R
import com.google.android.material.dialog.MaterialAlertDialogBuilder
fun Context.showClearDataConfirmation(onDismiss: () -> Unit = {}, onCancel: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_nuke_wallet_title)
.setMessage(R.string.dialog_nuke_wallet_message)
.setCancelable(false)
.setPositiveButton(R.string.dialog_nuke_wallet_button_positive) { dialog, _ ->
dialog.dismiss()
onDismiss()
onCancel()
}
.setNegativeButton(R.string.dialog_nuke_wallet_button_negative) { dialog, _ ->
dialog.dismiss()
onDismiss()
getSystemService<ActivityManager>()?.clearApplicationUserData()
}
.show()
}
fun Context.showUninitializedError(error: Throwable? = null, onDismiss: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_error_uninitialized_title)
.setMessage(R.string.dialog_error_uninitialized_message)
.setCancelable(false)
.setPositiveButton(getString(R.string.dialog_error_uninitialized_button_positive)) { dialog, _ ->
dialog.dismiss()
onDismiss()
if (error != null) throw error
}
.setNegativeButton(getString(R.string.dialog_error_uninitialized_button_negative)) { dialog, _ ->
showClearDataConfirmation(
onDismiss,
onCancel = {
// do not let the user back into the app because we cannot recover from this case
showUninitializedError(error, onDismiss)
}
)
}
.show()
}
fun Context.showInvalidSeedPhraseError(error: Throwable? = null, onDismiss: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_error_invalid_seed_phrase_title)
.setMessage(getString(R.string.dialog_error_invalid_seed_phrase_message, error?.message ?: ""))
.setCancelable(false)
.setPositiveButton(getString(R.string.dialog_error_invalid_seed_phrase_button_positive)) { dialog, _ ->
dialog.dismiss()
onDismiss()
}
.show()
}
fun Context.showScanFailure(error: Throwable?, onCancel: () -> Unit = {}, onDismiss: () -> Unit = {}): Dialog {
val message = if (error == null) {
"Unknown error"
} else {
"${error.message}${if (error.cause != null) "\n\nCaused by: ${error.cause}" else ""}"
}
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_error_scan_failure_title)
.setMessage(message)
.setCancelable(true)
.setPositiveButton(R.string.dialog_error_scan_failure_button_positive) { d, _ ->
d.dismiss()
onDismiss()
}
.setNegativeButton(R.string.dialog_error_scan_failure_button_negative) { d, _ ->
d.dismiss()
onCancel()
onDismiss()
}
.show()
}
fun Context.showCriticalMessage(@StringRes titleResId: Int, @StringRes messageResId: Int, onDismiss: () -> Unit = {}): Dialog {
return showCriticalMessage(titleResId.toAppString(), messageResId.toAppString(), onDismiss)
}
fun Context.showCriticalMessage(title: String, message: String, onDismiss: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { d, _ ->
d.dismiss()
onDismiss()
}
.show()
}
fun Context.showCriticalProcessorError(error: Throwable?, onRetry: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_error_processor_critical_title)
.setMessage(error?.message ?: getString(R.string.dialog_error_processor_critical_message))
.setCancelable(false)
.setPositiveButton(R.string.dialog_error_processor_critical_button_positive) { d, _ ->
d.dismiss()
onRetry()
}
.setNegativeButton(R.string.dialog_error_processor_critical_button_negative) { dialog, _ ->
dialog.dismiss()
throw error ?: RuntimeException("Critical error while processing blocks and the user chose to exit.")
}
.show()
}
fun Context.showUpdateServerCriticalError(userFacingMessage: String, onConfirm: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_error_change_server_title)
.setMessage(userFacingMessage)
.setCancelable(false)
.setPositiveButton(R.string.dialog_error_change_server_button_positive) { d, _ ->
d.dismiss()
onConfirm()
}
.show()
}
fun Context.showUpdateServerDialog(positiveResId: Int = R.string.dialog_modify_server_button_positive, onCancel: () -> Unit = {}, onUpdate: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_modify_server_title)
.setMessage(R.string.dialog_modify_server_message)
.setCancelable(false)
.setPositiveButton(positiveResId) { dialog, _ ->
dialog.dismiss()
onUpdate()
}
.setNegativeButton(R.string.dialog_modify_server_button_negative) { dialog, _ ->
dialog.dismiss()
onCancel()
}
.show()
}
fun Context.showRescanWalletDialog(quickDistance: String, quickEstimate: String, fullDistance: String, fullEstimate: String, onWipe: () -> Unit = {}, onFullRescan: () -> Unit = {}, onQuickRescan: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_rescan_wallet_title)
.setMessage(Html.fromHtml(getString(R.string.dialog_rescan_wallet_message, quickDistance, quickEstimate, fullDistance, fullEstimate)))
.setCancelable(true)
.setPositiveButton(R.string.dialog_rescan_wallet_button_positive) { dialog, _ ->
dialog.dismiss()
onQuickRescan()
}
.setNeutralButton(R.string.dialog_rescan_wallet_button_neutral) { dialog, _ ->
dialog.dismiss()
onWipe()
}
.setNegativeButton(R.string.dialog_rescan_wallet_button_negative) { dialog, _ ->
dialog.dismiss()
onFullRescan()
}
.show()
}
fun Context.showConfirmation(title: String, message: String, positiveButton: String, negativeButton: String = "Cancel", onPositive: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton(positiveButton) { dialog, _ ->
dialog.dismiss()
onPositive()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
.show()
}
/**
* Error to show when the Rust libraries did not properly link. This problem can happen pretty often
* during development when a build of the SDK failed to compile and resulted in an AAR file with no
* shared libraries (*.so files) inside. In theory, this should never be seen by an end user but if
* it does occur it is better to show a clean message explaining the situation. Nothing can be done
* other than rebuilding the SDK or switching to a functional version.
* As a developer, this error probably means that you need to comment out mavenLocal() as a repo.
*/
fun Context.showSharedLibraryCriticalError(e: Throwable): Dialog = showCriticalMessage(
titleResId = R.string.dialog_error_critical_link_title,
messageResId = R.string.dialog_error_critical_link_message,
onDismiss = { throw e }
)

View File

@@ -0,0 +1,83 @@
package cash.z.ecc.android.ext
import android.text.Editable
import android.text.TextWatcher
import android.view.inputmethod.EditorInfo.IME_ACTION_DONE
import android.widget.EditText
import android.widget.TextView
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
import cash.z.ecc.android.sdk.ext.safelyConvertToBigDecimal
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.util.twig
fun EditText.onEditorActionDone(block: (EditText) -> Unit) {
this.setOnEditorActionListener { _, actionId, _ ->
if (actionId == IME_ACTION_DONE) {
block(this)
true
} else {
false
}
}
}
inline fun EditText.limitDecimalPlaces(max: Int) {
val editText = this
addTextChangedListener(object : TextWatcher {
var previousValue = ""
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Cache the previous value
previousValue = text.toString()
}
override fun afterTextChanged(s: Editable?) {
var textStr = text.toString()
if (textStr.isNotEmpty()) {
val oldText = text.toString()
val number = textStr.safelyConvertToBigDecimal()
if (number != null && number.scale() > 8) {
// Prevent the user from adding a new decimal place somewhere in the middle if we're already at the limit
if (editText.selectionStart == editText.selectionEnd && editText.selectionStart != textStr.length) {
textStr = previousValue
} else {
textStr = WalletZecFormmatter.formatFull(number)
}
}
// Trim leading zeroes
textStr = textStr.trimStart('0')
// Append a zero if this results in an empty string or if the first symbol is not a digit
if (textStr.isEmpty() || !textStr.first().isDigit()) {
textStr = "0$textStr"
}
// Restore the cursor position
if (oldText != textStr) {
val cursorPosition = editText.selectionEnd
editText.setText(textStr)
editText.setSelection(
(cursorPosition - (oldText.length - textStr.length)).coerceIn(
0,
editText.text.toString().length
)
)
}
}
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
})
}
fun TextView.convertZecToZatoshi(): Zatoshi? {
return try {
text.toString().safelyConvertToBigDecimal()?.convertZecToZatoshi()
} catch (t: Throwable) {
twig("Failed to convert text to Zatoshi: $text")
null
}
}

View File

@@ -0,0 +1,69 @@
package cash.z.ecc.android.ext
import android.content.Context
import android.os.Build
import androidx.fragment.app.Fragment
import cash.z.ecc.android.sdk.model.WalletBalance
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.util.Bush
import cash.z.ecc.android.util.Twig
import cash.z.ecc.android.util.twig
import java.util.*
import kotlin.math.roundToInt
/**
* Distribute a string into evenly-sized chunks and then execute a function with each chunk.
*
* @param chunks the number of chunks to create
* @param block a function to be applied to each zero-indexed chunk.
*/
fun <T> String.distribute(chunks: Int, block: (Int, String) -> T) {
val charsPerChunk = length / chunks.toFloat()
val wholeCharsPerChunk = charsPerChunk.toInt()
val chunksWithExtra = ((charsPerChunk - wholeCharsPerChunk) * chunks).roundToInt()
repeat(chunks) { i ->
val part = if (i < chunksWithExtra) {
substring(i * (wholeCharsPerChunk + 1), (i + 1) * (wholeCharsPerChunk + 1))
} else {
substring(i * wholeCharsPerChunk + chunksWithExtra, (i + 1) * wholeCharsPerChunk + chunksWithExtra)
}
block(i, part)
}
}
inline val WalletBalance.pending: Zatoshi
get() = (this.total - this.available)
inline fun <R> tryWithWarning(message: String = "", block: () -> R): R? {
return try {
block()
} catch (error: Throwable) {
twig("WARNING: $message")
null
}
}
inline fun <E : Throwable, R> failWith(specificErrorType: E, block: () -> R): R {
return try {
block()
} catch (error: Throwable) {
throw specificErrorType
}
}
inline fun Fragment.locale(): Locale = context?.locale() ?: Locale.getDefault()
inline fun Context.locale(): Locale {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resources.configuration.locales.get(0)
} else {
//noinspection deprecation
resources.configuration.locale
}
}
// TODO: add this to the SDK and if the trunk is a CompositeTwig, search through there before returning null
inline fun <reified T> Twig.find(): T? {
return if (Bush.trunk::class.java.isAssignableFrom(T::class.java)) Bush.trunk as T
else null
}

View File

@@ -0,0 +1,9 @@
package cash.z.ecc.android.ext
import androidx.fragment.app.Fragment
/**
* A safer alternative to [Fragment.requireContext], as it avoids leaking Fragment or Activity context
* when Application context is often sufficient.
*/
fun Fragment.requireApplicationContext() = requireContext().applicationContext

View File

@@ -0,0 +1,46 @@
package cash.z.ecc.android.ext
import android.content.res.Resources
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.IntegerRes
import androidx.annotation.StringRes
import androidx.core.content.res.ResourcesCompat
import cash.z.ecc.android.ZcashWalletApp
/**
* Grab a color out of the application resources, using the default theme
*/
@ColorInt
internal inline fun @receiver:ColorRes Int.toAppColor(): Int {
return ResourcesCompat.getColor(ZcashWalletApp.instance.resources, this, ZcashWalletApp.instance.theme)
}
/**
* Grab a string from the application resources
*/
internal inline fun @receiver:StringRes Int.toAppString(lowercase: Boolean = false): String {
return ZcashWalletApp.instance.getString(this).let {
if (lowercase) it.toLowerCase() else it
}
}
/**
* Grab a formatted string from the application resources
*/
internal inline fun @receiver:StringRes Int.toAppStringFormatted(vararg formatArgs: Any): String {
return ZcashWalletApp.instance.getString(this, *formatArgs)
}
/**
* Grab an integer from the application resources
*/
internal inline fun @receiver:IntegerRes Int.toAppInt(): Int {
return ZcashWalletApp.instance.resources.getInteger(this)
}
fun Float.toPx() = this * Resources.getSystem().displayMetrics.density
fun Int.toPx() = (this * Resources.getSystem().displayMetrics.density + 0.5f).toInt()
fun Int.toDp() = (this / Resources.getSystem().displayMetrics.density + 0.5f).toInt()

View File

@@ -0,0 +1,14 @@
package cash.z.ecc.android.ext
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
fun <T : View> LifecycleOwner.onClick(view: T, throttle: Long = 250L, block: (T) -> Unit) {
view.clicks().debounce(throttle).onEach {
block(view)
}.launchIn(this.lifecycleScope)
}

View File

@@ -0,0 +1,20 @@
package cash.z.ecc.android.ext
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.annotation.ColorRes
import androidx.core.text.toSpannable
fun CharSequence.toColoredSpan(@ColorRes colorResId: Int, coloredPortion: String): CharSequence {
return toSpannable().apply {
val start = this@toColoredSpan.indexOf(coloredPortion)
setSpan(ForegroundColorSpan(colorResId.toAppColor()), start, start + coloredPortion.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
fun CharSequence.toSplitColorSpan(@ColorRes startColorResId: Int, @ColorRes endColorResId: Int, startColorLength: Int): CharSequence {
return toSpannable().apply {
setSpan(ForegroundColorSpan(startColorResId.toAppColor()), 0, startColorLength - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(ForegroundColorSpan(endColorResId.toAppColor()), startColorLength, this@toSplitColorSpan.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}

View File

@@ -0,0 +1,81 @@
package cash.z.ecc.android.ext
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import cash.z.ecc.android.ui.MainActivity
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.channelFlow
fun View.gone() {
visibility = GONE
}
fun View.invisible() {
visibility = INVISIBLE
}
fun View.visible() {
visibility = VISIBLE
}
// NOTE: avoid `visibleIf` function because the false case is ambiguous: would it be gone or invisible?
fun View.goneIf(isGone: Boolean) {
visibility = if (isGone) GONE else VISIBLE
}
fun View.invisibleIf(isInvisible: Boolean) {
visibility = if (isInvisible) INVISIBLE else VISIBLE
}
fun View.disabledIf(isDisabled: Boolean) {
isEnabled = !isDisabled
}
fun View.transparentIf(isTransparent: Boolean) {
alpha = if (isTransparent) 0.0f else 1.0f
}
fun View.onClickNavTo(navResId: Int, block: (() -> Any) = {}) {
setOnClickListener {
block()
(context as? MainActivity)?.safeNavigate(navResId)
?: throw IllegalStateException(
"Cannot navigate from this activity. " +
"Expected MainActivity but found ${context.javaClass.simpleName}"
)
}
}
fun View.onClickNavUp(block: (() -> Any) = {}) {
setOnClickListener {
block()
(context as? MainActivity)?.navController?.navigateUp()
?: throw IllegalStateException(
"Cannot navigate from this activity. " +
"Expected MainActivity but found ${context.javaClass.simpleName}"
)
}
}
fun View.onClickNavBack(block: (() -> Any) = {}) {
setOnClickListener {
block()
(context as? MainActivity)?.navController?.popBackStack()
?: throw IllegalStateException(
"Cannot navigate from this activity. " +
"Expected MainActivity but found ${context.javaClass.simpleName}"
)
}
}
fun View.clicks() = channelFlow<View> {
setOnClickListener {
trySend(this@clicks)
}
awaitClose {
setOnClickListener(null)
}
}