Initial commit
This initial commit includes HUSH specific changes starting at this commit:
d14637012c
This commit is contained in:
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
193
app/build.gradle
Normal file
193
app/build.gradle
Normal file
@@ -0,0 +1,193 @@
|
||||
import cash.z.ecc.android.Deps
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: "androidx.navigation.safeargs.kotlin"
|
||||
apply plugin: 'com.github.ben-manes.versions'
|
||||
|
||||
archivesBaseName = 'zcash-android-wallet'
|
||||
group = 'cash.z.ecc.android'
|
||||
version = Deps.versionName
|
||||
|
||||
android {
|
||||
ndkVersion "21.1.6352462"
|
||||
compileSdkVersion Deps.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId Deps.packageName
|
||||
minSdkVersion Deps.minSdkVersion
|
||||
targetSdkVersion Deps.targetSdkVersion
|
||||
versionCode = Deps.versionCode
|
||||
versionName = Deps.versionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
if (Boolean.parseBoolean(isUseTestOrchestrator)) {
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
multiDexEnabled true
|
||||
resValue 'string', 'bugsnag_api_key', "${(project.findProperty('BUGSNAG_API_KEY') ?: System.getenv('BUGSNAG_API_KEY')) ?: ''}"
|
||||
|
||||
// this setting allows using color resources in vector drawables, rather than hardcoded values (note: only works when minApi is 21)
|
||||
// per https://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.VectorDrawablesOptions.html: If set to an empty collection, all special handling of vector drawables will be disabled.
|
||||
vectorDrawables.generatedDensities = []
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
flavorDimensions 'network'
|
||||
productFlavors {
|
||||
// would rather name them "testnet" and "mainnet" but product flavor names cannot start with the word "test"
|
||||
zcashtestnet {
|
||||
dimension 'network'
|
||||
applicationId 'cash.z.ecc.android.testnet'
|
||||
buildConfigField "String", "DEFAULT_SERVER_URL", '"lite2.hushpool.is"'
|
||||
matchingFallbacks = ['zcashtestnet', 'debug']
|
||||
}
|
||||
|
||||
zcashmainnet {
|
||||
dimension 'network'
|
||||
buildConfigField "String", "DEFAULT_SERVER_URL", '"lite2.hushpool.is"'
|
||||
matchingFallbacks = ['zcashmainnet', 'release']
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
placeholder {
|
||||
storeFile file("${rootProject.projectDir}/placeholder.keystore")
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
storePassword "android"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.placeholder
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
// builds for testing only in the wallet team, typically unfinished features
|
||||
// this flavor can be installed alongside the others
|
||||
qa {
|
||||
initWith debug
|
||||
debuggable true
|
||||
applicationIdSuffix ".internal"
|
||||
matchingFallbacks = ['debug']
|
||||
signingConfig signingConfigs.placeholder
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
// enable support for new language APIs but also fix the issue with zxing on API < 24
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
|
||||
freeCompilerArgs += "-opt-in=kotlin.time.ExperimentalTime"
|
||||
// freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.ObsoleteCoroutinesApi"
|
||||
// freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.FlowPreview"
|
||||
}
|
||||
testOptions {
|
||||
if (Boolean.parseBoolean(isUseTestOrchestrator)) {
|
||||
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||
}
|
||||
}
|
||||
kapt {
|
||||
arguments {
|
||||
arg 'dagger.fastInit', 'enabled'
|
||||
arg 'dagger.fullBindingGraphValidation', 'ERROR'
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += ['META-INF/AL2.0', 'META-INF/LGPL2.1']
|
||||
}
|
||||
}
|
||||
namespace 'cash.z.ecc.android'
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all {
|
||||
if (variant.buildType.name == "qa") {
|
||||
it.versionNameOverride = "${Deps.versionName}-QA"
|
||||
}
|
||||
outputFileName = "$archivesBaseName-v${Deps.versionName}-${variant.buildType.name}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation project(':qrecycler')
|
||||
implementation project(':feedback')
|
||||
implementation project(':mnemonic')
|
||||
implementation project(':lockbox')
|
||||
|
||||
// Zcash
|
||||
implementation Deps.Zcash.ANDROID_WALLET_PLUGINS
|
||||
implementation Deps.Zcash.SDK
|
||||
|
||||
// Kotlin
|
||||
implementation Deps.Kotlin.STDLIB
|
||||
|
||||
// Android
|
||||
implementation Deps.AndroidX.ANNOTATION
|
||||
implementation Deps.AndroidX.APPCOMPAT
|
||||
implementation Deps.AndroidX.BIOMETRICS
|
||||
implementation Deps.AndroidX.CONSTRAINT_LAYOUT
|
||||
implementation Deps.AndroidX.CORE_KTX
|
||||
implementation Deps.AndroidX.FRAGMENT_KTX
|
||||
implementation Deps.AndroidX.LEGACY
|
||||
implementation Deps.AndroidX.PAGING
|
||||
implementation Deps.AndroidX.RECYCLER
|
||||
implementation Deps.AndroidX.CameraX.CAMERA2
|
||||
implementation Deps.AndroidX.CameraX.CORE
|
||||
implementation Deps.AndroidX.CameraX.LIFECYCLE
|
||||
implementation Deps.AndroidX.CameraX.View.EXT
|
||||
implementation Deps.AndroidX.CameraX.View.VIEW
|
||||
implementation Deps.AndroidX.Lifecycle.LIFECYCLE_RUNTIME_KTX
|
||||
implementation Deps.AndroidX.Navigation.FRAGMENT_KTX
|
||||
implementation Deps.AndroidX.Navigation.UI_KTX
|
||||
implementation Deps.AndroidX.Room.ROOM_KTX
|
||||
kapt Deps.AndroidX.Room.ROOM_COMPILER
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
|
||||
|
||||
// Google
|
||||
implementation Deps.Google.GUAVA
|
||||
implementation Deps.Google.MATERIAL
|
||||
|
||||
// grpc-java
|
||||
implementation Deps.Grpc.ANDROID
|
||||
implementation Deps.Grpc.OKHTTP
|
||||
implementation Deps.Grpc.PROTOBUG
|
||||
implementation Deps.Grpc.STUB
|
||||
implementation 'com.squareup.okio:okio:2.8.0'
|
||||
implementation Deps.JavaX.JAVA_ANNOTATION
|
||||
|
||||
// Misc.
|
||||
implementation Deps.Misc.LOTTIE
|
||||
implementation Deps.Misc.CHIPS
|
||||
implementation Deps.Misc.Plugins.QR_SCANNER
|
||||
|
||||
// Tests
|
||||
testImplementation Deps.Test.JUNIT
|
||||
testImplementation Deps.Test.MOKITO
|
||||
testImplementation Deps.Test.MOKITO_KOTLIN
|
||||
|
||||
androidTestImplementation Deps.Kotlin.REFLECT
|
||||
androidTestImplementation(Deps.Kotlin.Coroutines.TEST)
|
||||
androidTestImplementation Deps.Test.Android.JUNIT
|
||||
androidTestImplementation Deps.Test.Android.CORE
|
||||
androidTestImplementation Deps.Test.Android.FRAGMENT
|
||||
androidTestImplementation Deps.Test.Android.ESPRESSO
|
||||
androidTestImplementation Deps.Test.Android.ESPRESSO_INTENTS
|
||||
androidTestImplementation Deps.Test.Android.NAVIGATION
|
||||
// androidTestImplementation is preferred, but then the androidx.fragment.app.testing.FragmentScenario$EmptyFragmentActivity isn't available
|
||||
debugImplementation Deps.Test.Android.FRAGMENT
|
||||
}
|
||||
|
||||
defaultTasks 'clean', 'assembleZcashmainnetRelease'
|
||||
12
app/proguard-rules.pro
vendored
Normal file
12
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Reports
|
||||
-printusage build/outputs/logs/R8-removed-code-report.txt
|
||||
-printseeds build/outputs/logs/R8-entry-points-report.txt
|
||||
|
||||
## Okio
|
||||
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.*
|
||||
|
||||
#-keep class cash.z.** { *; }
|
||||
43
app/src/androidTest/java/cash/z/ecc/android/MemoTest.kt
Normal file
43
app/src/androidTest/java/cash/z/ecc/android/MemoTest.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package cash.z.ecc.android
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Ignore
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@Ignore("It'd need additional implementation changes to have this one working.")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
// @RunWith(Parameterized::class)
|
||||
class MemoTest(val input: String, val output: String) {
|
||||
|
||||
// @Test
|
||||
// fun testExtractValidAddress() = runBlocking {
|
||||
// val result = MemoUtil.findAddressInMemo(input, ::validateMemo)
|
||||
// assertEquals(output, result)
|
||||
// }
|
||||
|
||||
suspend fun validateMemo(memo: String): Boolean {
|
||||
delay(20)
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
val validTaddr = "tmWGKMEpxSUf97H12MmGtgiER1drVbGjzWM"
|
||||
val validZaddr = "ztestsapling1ukadr59p0hxcl2pq8mfagnfx3h74nsusdkm59gkys7hxze92whxj54mfdn3n37zusum7w4jlj35"
|
||||
val invalidAddr = "ztestsaplinn9ukadr59p0hxcl2pq8mfagnfx3h74nsusdkm59gkys7hxze92whxj54mfdn3n37zusum7w4jlj35"
|
||||
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters
|
||||
fun data() = listOf(
|
||||
arrayOf(
|
||||
"thanks for the food reply-to: $validZaddr",
|
||||
validZaddr
|
||||
),
|
||||
arrayOf(
|
||||
"thanks for the food reply-to: $validTaddr",
|
||||
validTaddr
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package cash.z.ecc.android.integration
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import cash.z.ecc.android.ext.WalletZecFormmatter
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConversionsTest {
|
||||
|
||||
@Test
|
||||
fun testToZatoshi() {
|
||||
val input = "1"
|
||||
val result = WalletZecFormmatter.toZatoshi(input)
|
||||
Assert.assertEquals(100_000_000L, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_short() {
|
||||
val input = Zatoshi(112_340_000L)
|
||||
val result = WalletZecFormmatter.toZecStringShort(input)
|
||||
Assert.assertEquals("1.1234", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_shortRoundUp() {
|
||||
val input = Zatoshi(112_355_600L)
|
||||
val result = WalletZecFormmatter.toZecStringShort(input)
|
||||
Assert.assertEquals("1.1236", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_shortRoundDown() {
|
||||
val input = Zatoshi(112_343_999L)
|
||||
val result = WalletZecFormmatter.toZecStringShort(input)
|
||||
Assert.assertEquals("1.1234", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_shortRoundHalfEven() {
|
||||
val input = Zatoshi(112_345_000L)
|
||||
val result = WalletZecFormmatter.toZecStringShort(input)
|
||||
Assert.assertEquals("1.1234", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_shortRoundHalfOdd() {
|
||||
val input = Zatoshi(112_355_000L)
|
||||
val result = WalletZecFormmatter.toZecStringShort(input)
|
||||
Assert.assertEquals("1.1236", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_noCommas() {
|
||||
val input = "1000"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1000, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_thousandComma() {
|
||||
val input = "1,000"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1000, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_thousandCommaWithDecimal() {
|
||||
val input = "1,000.00"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1000, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_oneDecimal() {
|
||||
val input = "1.000"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_thousandWithThinSpace() {
|
||||
val input = "1 000"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1000, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_oneWithThinSpace() {
|
||||
val input = "1.000 000"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToBigDecimal_oneDecimalWithComma() {
|
||||
val input = "1.000,00"
|
||||
val result = WalletZecFormmatter.toBigDecimal(input)!!
|
||||
Assert.assertEquals(1, result.longValueExact())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_full() {
|
||||
val input = Zatoshi(112_341_123L)
|
||||
val result = WalletZecFormmatter.toZecStringFull(input)
|
||||
Assert.assertEquals("1.12341123", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_fullRoundUp() {
|
||||
val input = Zatoshi(112_355_678L)
|
||||
val result = WalletZecFormmatter.toZecStringFull(input)
|
||||
Assert.assertEquals("1.12355678", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_fullRoundDown() {
|
||||
val input = Zatoshi(112_349_999L)
|
||||
val result = WalletZecFormmatter.toZecStringFull(input)
|
||||
Assert.assertEquals("1.12349999", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_fullRoundHalfEven() {
|
||||
val input = Zatoshi(112_250_009L)
|
||||
val result = WalletZecFormmatter.toZecStringFull(input)
|
||||
Assert.assertEquals("1.12250009", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToZecString_fullRoundHalfOdd() {
|
||||
val input = Zatoshi(112_350_004L)
|
||||
val result = WalletZecFormmatter.toZecStringFull(input)
|
||||
Assert.assertEquals("1.12350004", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package cash.z.ecc.android.integration
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.Buffer
|
||||
import okio.GzipSink
|
||||
import okio.buffer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IntegrationTest {
|
||||
|
||||
private lateinit var appContext: Context
|
||||
private val network = ZcashNetwork.Testnet
|
||||
private val mnemonics = Mnemonics()
|
||||
private val phrase =
|
||||
"human pulse approve subway climb stairs mind gentle raccoon warfare fog roast sponsor" +
|
||||
" under absorb spirit hurdle animal original honey owner upper empower describe"
|
||||
|
||||
@Before
|
||||
fun start() {
|
||||
appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSeed_generation() {
|
||||
val seed = mnemonics.toSeed(phrase.toCharArray())
|
||||
assertEquals(
|
||||
"Generated incorrect BIP-39 seed!",
|
||||
"f4e3d38d9c244da7d0407e19a93c80429614ee82dcf62c141235751c9f1228905d12a1f275f" +
|
||||
"5c22f6fb7fcd9e0a97f1676e0eec53fdeeeafe8ce8aa39639b9fe",
|
||||
seed.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSeed_storage() {
|
||||
val seed = mnemonics.toSeed(phrase.toCharArray())
|
||||
val lb = LockBox(appContext)
|
||||
lb.setBytes("seed", seed)
|
||||
assertTrue(seed.contentEquals(lb.getBytes("seed")!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPhrase_storage() {
|
||||
val lb = LockBox(appContext)
|
||||
val phraseChars = phrase.toCharArray()
|
||||
lb.setCharsUtf8("phrase", phraseChars)
|
||||
assertTrue(phraseChars.contentEquals(lb.getCharsUtf8("phrase")!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPhrase_maxLengthStorage() {
|
||||
val lb = LockBox(appContext)
|
||||
// find and expose the max length
|
||||
var acceptedSize = 256
|
||||
while (acceptedSize > 0) {
|
||||
try {
|
||||
lb.setCharsUtf8("temp", nextString(acceptedSize).toCharArray())
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
acceptedSize--
|
||||
}
|
||||
|
||||
val maxSeedPhraseLength = 8 * 24 + 23 // 215 (max length of each word is 8)
|
||||
assertTrue(
|
||||
"LockBox does not support the maximum length seed phrase." +
|
||||
" Expected: $maxSeedPhraseLength but was: $acceptedSize",
|
||||
acceptedSize > maxSeedPhraseLength
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("It'd need additional implementation changes to have this one working.")
|
||||
fun testAddress() = runTest {
|
||||
val seed = mnemonics.toSeed(phrase.toCharArray())
|
||||
val initializer = Initializer.new(appContext) { config ->
|
||||
// config.newWallet(seed, network)
|
||||
}
|
||||
assertEquals(
|
||||
"Generated incorrect z-address!",
|
||||
"zs1gn2ah0zqhsxnrqwuvwmgxpl5h3ha033qexhsz8tems53fw877f4gug353eefd6z8z3n4zxty65c",
|
||||
// initializer.rustBackend.getShieldedAddress()
|
||||
)
|
||||
initializer.erase()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String {
|
||||
val sb = StringBuilder(size * 2)
|
||||
for (b in this)
|
||||
sb.append(String.format("%02x", b))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun String.gzip(): ByteArray {
|
||||
val result = Buffer()
|
||||
val sink = GzipSink(result).buffer()
|
||||
sink.use {
|
||||
sink.write(toByteArray())
|
||||
}
|
||||
return result.readByteArray()
|
||||
}
|
||||
|
||||
fun nextString(length: Int): String {
|
||||
val allowedChars = "ACGT"
|
||||
return (1..length)
|
||||
.map { allowedChars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cash.z.ecc.android.integration
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
|
||||
class LockBoxTest {
|
||||
|
||||
lateinit var lockBox: LockBox
|
||||
lateinit var appContext: Context
|
||||
private val hex = ('a'..'f') + ('0'..'9')
|
||||
private val iterations = 50
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
lockBox = LockBox(appContext)
|
||||
lockBox.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
@LargeTest
|
||||
@Ignore("This test is extremely slow")
|
||||
fun testLongString() {
|
||||
var successCount = 0
|
||||
repeat(iterations) {
|
||||
val sampleHex = List(500) { hex.random() }.joinToString("")
|
||||
|
||||
lockBox["longStr"] = sampleHex
|
||||
val actual: String = lockBox["longStr"]!!
|
||||
if (sampleHex == actual) successCount++
|
||||
lockBox.clear()
|
||||
}
|
||||
assertEquals(iterations, successCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@LargeTest
|
||||
@Ignore("This test is extremely slow")
|
||||
fun testShortString() {
|
||||
var successCount = 0
|
||||
repeat(iterations) {
|
||||
val sampleHex = List(50) { hex.random() }.joinToString("")
|
||||
|
||||
lockBox["shortStr"] = sampleHex
|
||||
val actual: String = lockBox["shortStr"]!!
|
||||
if (sampleHex == actual) successCount++
|
||||
lockBox.clear()
|
||||
}
|
||||
assertEquals(iterations, successCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@LargeTest
|
||||
@Ignore("This test is extremely slow")
|
||||
fun testGiantString() {
|
||||
var successCount = 0
|
||||
repeat(iterations) {
|
||||
val sampleHex = List(2500) { hex.random() }.joinToString("")
|
||||
|
||||
lockBox["giantStr"] = sampleHex
|
||||
val actual: String = lockBox["giantStr"]!!
|
||||
if (sampleHex == actual) successCount++
|
||||
lockBox.clear()
|
||||
}
|
||||
assertEquals(iterations, successCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cash.z.ecc.android.preference
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.lang.reflect.Modifier
|
||||
import kotlin.reflect.full.memberProperties
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PreferenceKeysTest {
|
||||
@SmallTest
|
||||
@Test
|
||||
@Throws(IllegalAccessException::class)
|
||||
fun fields_public_static_and_final() {
|
||||
PreferenceKeys::class.java.fields.forEach {
|
||||
val modifiers = it.modifiers
|
||||
assertThat(Modifier.isFinal(modifiers), equalTo(true))
|
||||
assertThat(Modifier.isStatic(modifiers), equalTo(true))
|
||||
assertThat(Modifier.isPublic(modifiers), equalTo(true))
|
||||
}
|
||||
}
|
||||
|
||||
// This test is primary to prevent copy-paste errors in preference keys
|
||||
@SmallTest
|
||||
@Test
|
||||
fun key_values_unique() {
|
||||
val fieldValueSet = mutableSetOf<String>()
|
||||
|
||||
PreferenceKeys::class.memberProperties
|
||||
.map { it.getter.call() }
|
||||
.map { it as String }
|
||||
.forEach {
|
||||
assertThat("Duplicate key $it", fieldValueSet.contains(it), equalTo(false))
|
||||
|
||||
fieldValueSet.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cash.z.ecc.android.preference
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.SmallTest
|
||||
import cash.z.ecc.android.preference.model.DefaultValue
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.lang.reflect.Modifier
|
||||
import kotlin.reflect.full.memberProperties
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PreferencesTest {
|
||||
@SmallTest
|
||||
@Test
|
||||
@Throws(IllegalAccessException::class)
|
||||
fun fields_public_static_and_final() {
|
||||
Preferences::class.java.fields.forEach {
|
||||
val modifiers = it.modifiers
|
||||
assertThat(Modifier.isFinal(modifiers), equalTo(true))
|
||||
assertThat(Modifier.isStatic(modifiers), equalTo(true))
|
||||
assertThat(Modifier.isPublic(modifiers), equalTo(true))
|
||||
}
|
||||
}
|
||||
|
||||
// This test is primary to prevent copy-paste errors in preference keys
|
||||
@SmallTest
|
||||
@Test
|
||||
fun key_values_unique() {
|
||||
val fieldValueSet = mutableSetOf<String>()
|
||||
|
||||
Preferences::class.memberProperties
|
||||
.map { it.getter.call(Preferences) }
|
||||
.map { it as DefaultValue<*> }
|
||||
.forEach {
|
||||
assertThat(
|
||||
"Duplicate key ${it.key}",
|
||||
fieldValueSet.contains(it.key),
|
||||
equalTo(false)
|
||||
)
|
||||
|
||||
fieldValueSet.add(it.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cash.z.ecc.android.test
|
||||
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.testing.TestNavHostController
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
data class FragmentNavigationScenario<T : Fragment>(
|
||||
val fragmentScenario: FragmentScenario<T>,
|
||||
val navigationController: TestNavHostController
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun <T : Fragment> new(
|
||||
fragmentScenario: FragmentScenario<T>,
|
||||
@IdRes currentDestination: Int
|
||||
): FragmentNavigationScenario<T> {
|
||||
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
|
||||
|
||||
fragmentScenario.onFragment {
|
||||
navController.setGraph(cash.z.ecc.android.R.navigation.mobile_navigation)
|
||||
navController.setCurrentDestination(currentDestination)
|
||||
|
||||
Navigation.setViewNavController(it.requireView(), navController)
|
||||
}
|
||||
|
||||
return FragmentNavigationScenario(fragmentScenario, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cash.z.ecc.android.test
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Before
|
||||
import java.lang.AssertionError
|
||||
|
||||
/**
|
||||
* Subclass this for UI tests to ensure they run correctly. This helps when developers run tests
|
||||
* against a physical device that might have gone to sleep.
|
||||
*/
|
||||
open class UiTestPrerequisites {
|
||||
@Before
|
||||
fun verifyScreenOn() {
|
||||
if (!isScreenOn()) {
|
||||
throw AssertionError("Screen must be on for UI tests to run") // $NON-NLS
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
|
||||
private fun isScreenOn(): Boolean {
|
||||
val powerService = ApplicationProvider.getApplicationContext<Context>()
|
||||
.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return powerService.isInteractive
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.Intents.intended
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import cash.z.ecc.android.preference.Preferences
|
||||
import cash.z.ecc.android.preference.SharedPreferenceFactory
|
||||
import cash.z.ecc.android.preference.model.get
|
||||
import cash.z.ecc.android.test.FragmentNavigationScenario
|
||||
import cash.z.ecc.android.test.UiTestPrerequisites
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AutoshieldingInformationFragmentTest : UiTestPrerequisites() {
|
||||
@Test
|
||||
@MediumTest
|
||||
fun dismiss_returns_home_when_autoshield_not_available() {
|
||||
val fragmentNavigationScenario = newScenario(isAutoshieldAvailable = false)
|
||||
|
||||
onView(withId(cash.z.ecc.android.R.id.button_autoshield_dismiss)).also {
|
||||
it.perform(ViewActions.click())
|
||||
}
|
||||
|
||||
assertThat(
|
||||
fragmentNavigationScenario.navigationController.currentDestination?.id,
|
||||
equalTo(cash.z.ecc.android.R.id.nav_home)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun dismiss_starts_autoshield_when_autoshield_available() {
|
||||
val fragmentNavigationScenario = newScenario(isAutoshieldAvailable = true)
|
||||
|
||||
onView(withId(cash.z.ecc.android.R.id.button_autoshield_dismiss)).also {
|
||||
it.perform(ViewActions.click())
|
||||
}
|
||||
|
||||
assertThat(
|
||||
fragmentNavigationScenario.navigationController.currentDestination?.id,
|
||||
equalTo(cash.z.ecc.android.R.id.nav_shield_final)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun clicking_more_info_launches_browser() {
|
||||
val fragmentNavigationScenario = newScenario(isAutoshieldAvailable = false)
|
||||
|
||||
onView(withId(cash.z.ecc.android.R.id.button_autoshield_more_info)).also {
|
||||
it.perform(ViewActions.click())
|
||||
}
|
||||
|
||||
assertThat(
|
||||
fragmentNavigationScenario.navigationController.currentDestination?.id,
|
||||
equalTo(cash.z.ecc.android.R.id.nav_autoshielding_info_details)
|
||||
)
|
||||
|
||||
// Note: it is difficult to verify that the browser is launched, because of how the
|
||||
// navigation component works.
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun starting_fragment_does_not_launch_activities() {
|
||||
Intents.init()
|
||||
try {
|
||||
val fragmentNavigationScenario = newScenario(isAutoshieldAvailable = false)
|
||||
|
||||
// The test framework launches an Activity to host the Fragment under test
|
||||
// Since the class name is not a public API, this could break in the future with newer
|
||||
// versions of the AndroidX Test libraries.
|
||||
intended(
|
||||
hasComponent(
|
||||
ComponentName(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
"androidx.test.core.app.InstrumentationActivityInvoker\$BootstrapActivity"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Verifying that no other Activities (e.g. the link view) are launched without explicit
|
||||
// user interaction
|
||||
Intents.assertNoUnverifiedIntents()
|
||||
|
||||
assertThat(
|
||||
fragmentNavigationScenario.navigationController.currentDestination?.id,
|
||||
equalTo(cash.z.ecc.android.R.id.nav_autoshielding_info)
|
||||
)
|
||||
} finally {
|
||||
Intents.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun display_fragment_sets_preference() {
|
||||
newScenario(isAutoshieldAvailable = false)
|
||||
|
||||
assertThat(
|
||||
Preferences.isAcknowledgedAutoshieldingInformationPrompt.get(ApplicationProvider.getApplicationContext<Context>()),
|
||||
equalTo(true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun back_navigates_home() {
|
||||
val fragmentNavigationScenario = newScenario(isAutoshieldAvailable = false)
|
||||
|
||||
fragmentNavigationScenario.fragmentScenario.onFragment {
|
||||
// Probably closest we can come to simulating back with the navigation test framework
|
||||
fragmentNavigationScenario.navigationController.navigateUp()
|
||||
}
|
||||
|
||||
assertThat(
|
||||
fragmentNavigationScenario.navigationController.currentDestination?.id,
|
||||
equalTo(cash.z.ecc.android.R.id.nav_home)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun newScenario(isAutoshieldAvailable: Boolean): FragmentNavigationScenario<AutoshieldingInformationFragment> {
|
||||
// Clear preferences for each scenario, as this most closely reflects how this fragment
|
||||
// is used in the app, as it is displayed usually on first launch
|
||||
SharedPreferenceFactory.getSharedPreferences(ApplicationProvider.getApplicationContext())
|
||||
.edit().clear().apply()
|
||||
|
||||
val scenario = FragmentScenario.launchInContainer(
|
||||
AutoshieldingInformationFragment::class.java,
|
||||
HomeFragmentDirections.actionNavHomeToAutoshieldingInfo(isAutoshieldAvailable).arguments,
|
||||
cash.z.ecc.android.R.style.ZcashTheme,
|
||||
null
|
||||
)
|
||||
|
||||
return FragmentNavigationScenario.new(
|
||||
scenario,
|
||||
cash.z.ecc.android.R.id.nav_autoshielding_info
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/src/main/AndroidManifest.xml
Normal file
43
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name="cash.z.ecc.android.ZcashWalletApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/ZcashTheme">
|
||||
<activity android:name=".ui.MainActivity" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
android:writePermission="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Mixpanel options -->
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.AutoShowMixpanelUpdates" android:value="false" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.EnableDebugLogging" android:value="false" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableDecideChecker" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableEmulatorBindingUI" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableGestureBindingUI" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableViewCrawler" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.IgnoreInvisibleViewsVisualEditor" android:value="true" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
7
app/src/main/assets/saplingtree/mainnet/1225600.json
Normal file
7
app/src/main/assets/saplingtree/mainnet/1225600.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "main",
|
||||
"height": 1225600,
|
||||
"hash": "0000000000196bafb2472eb7a3b1aa85bccc00904d5650a7952dd437859fc38c",
|
||||
"time": 1619215931,
|
||||
"tree": "0128411e8cb2f543c46ca943736c96ab4fa86cab1e3e2e394ed458d56b395bd5050120303bbaf4f19e37a06c1e9ea815567fc23990cc65494c2be29f8e6e4a9d9a6c130001010e9388fdf9bf49e3adf4adb57d83e0b5ba34f63a2681eceb54d3aaaf236b210001c0920d177f77815c4f643c2b331bd6b86d291d6bc2c1c20f6bc501f49adcdb3b000001b7958828206f53c25465943d4173af16de3cee94ae01b2e17a32c51c06fde3630001b31ed2e29d0d894604f0d7bf4735d4bcf25dc9f859c5e296a5689af7ca8c94720134ca9a7c4309349dfe003f3b4b95898b4303631e9be3a25b4e917a4f3472b52f00000121c25bceccda091622bfac1b7973ffaa638abe1f334b3b56f48dc93dc549c9070001ece344ca21dbd3b681f167163d4792165efe8239390afc13378e50d044fee65a01089a1f9d50a037cc66aba4400b1703bcbb66f5f2993fd0dd3bb726e35940916700000118f64df255c9c43db708255e7bf6bffd481e5c2f38fe9ed8f3d189f7f9cf2644"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/mainnet/1250000.json
Normal file
7
app/src/main/assets/saplingtree/mainnet/1250000.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "main",
|
||||
"height": 1250000,
|
||||
"hash": "0000000000f3d2c352c395d66866032bcb67094228dd4a27e561b1c399ea612e",
|
||||
"time": 1621056898,
|
||||
"tree": "01c9a0dd6f6dfaaafe6ae4b432c2d1c41d2a73e564c8cb6d2c5ab637c7001a2456001300000000017da32b486a8ea9f13afb93b99d2b1de69aa969e7c2fd7b9ee958bece70c08d6b000001b3a4486b176dfcedc0b3d9287c0333ff464ecbd02bac7c89bcda7932e6a0a36100010d451c18b56877b8a11cb401ab7024c82b9669ede862a53e461087f57220035001a1c5260bc4dfe010510b8135209c6f64229965f71717f1e693abdcf88a58f36700012f0bf70e372e536fc3b76ecd7e2b69eebf2fbcf71b828c64b0a8b99390fbf754018e7922ca798cd3e26d3369ca2425ec19baa7d79407a979ec1090ae48fdcd094a01ece344ca21dbd3b681f167163d4792165efe8239390afc13378e50d044fee65a01089a1f9d50a037cc66aba4400b1703bcbb66f5f2993fd0dd3bb726e35940916700000118f64df255c9c43db708255e7bf6bffd481e5c2f38fe9ed8f3d189f7f9cf2644"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/mainnet/1290000.json
Normal file
7
app/src/main/assets/saplingtree/mainnet/1290000.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "main",
|
||||
"height": 1290000,
|
||||
"hash": "00000000014836c3cbc011276cbd3702a76a1fea7eb2c0c2c257321220376450",
|
||||
"time": 1624075741,
|
||||
"tree": "01accf4fc3dc4233bbe757f94e0d4cd23b4aa2e6ac472601f4f53ca4dc86a8a05901fae977171a6103a0338990e073ffe50e29fc8bf0400dcd3378ebfe7a146ed1481300014f7b33dd5159ac66f2670b7db8925065e7154e0199ff7ee7559b276ba56ad1200173e9881f21357e54027a4275114f0f6ad4ca17143554182f63c77f3288a23a20011d65465ab942440e200d429ef892452b4b05c5b21e9a6e6d968a719c67b5e85b000000000000000150926c74975e2d8ff095defb75a4a6d9f17007e87a74230a65a3265d8f45032900012ffde6dccbef68b60cd7b4e7a8fe7989f5954fa4bacad01b247d16b9bfa5084000000125911f4524469c00ccb1ba69e64f0ee7380c8d17bbfc76ecd238421b86eb6e09000118f64df255c9c43db708255e7bf6bffd481e5c2f38fe9ed8f3d189f7f9cf2644"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/mainnet/1300000.json
Normal file
7
app/src/main/assets/saplingtree/mainnet/1300000.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "main",
|
||||
"height": 1300000,
|
||||
"hash": "00000000027222bdbcf9c5f807f851f97312ac6e0dbbc2b93f2be21a69c59d44",
|
||||
"time": 1624830312,
|
||||
"tree": "01f5a97e2679a2bb9103caf37b825f92fcd73fff836234844dfcf1815394522b2c01526587b9b9e8aeb0eb572d81fec1f5127b8278ba0f57e451bd6b796596940a2213000131c7ff90fafff6159b8fb6544a2bcbba6c102903158fce8f9a9d3c6654abb23300013555cb7f4f79badeaca9bf2dca5a8704f0929053d50e95c03002f9a4d5286c3a01ad3557e11c1607ec888dc84f5f8899c3c79fb1f50b613946452ec7dd5e53763c0001c4583f4482b949390dba355fc8fa63019c83acd644ddd633cb50211d236f870600000001088da0d78eefd0c222507927e403b972d0890d0c31e08b02268fbe39ac4a6e170001edf82d4e2b4893ea2028ca8c5149e50a4c358b856d73f2de2b9a22034fa78f22012ffde6dccbef68b60cd7b4e7a8fe7989f5954fa4bacad01b247d16b9bfa5084000000125911f4524469c00ccb1ba69e64f0ee7380c8d17bbfc76ecd238421b86eb6e09000118f64df255c9c43db708255e7bf6bffd481e5c2f38fe9ed8f3d189f7f9cf2644"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/mainnet/1335000.json
Normal file
7
app/src/main/assets/saplingtree/mainnet/1335000.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "main",
|
||||
"height": 1335000,
|
||||
"hash": "00000000001d428474214f2844ac7adacab9c9b706f89ebb24e1e43189edff2d",
|
||||
"time": 1627468889,
|
||||
"tree": "01105d94f868041b1680f862dad6211ab815a30c79a63b839c2b2043ce6530834801e53ee3fef11ddfaef984c8653dffa0354929b79aad7321b00c10cb3b60c8b7111301f5693ff9b17a8fc0b032c192841d1fc08b7ec9fe4fcc2b628a550434af70886a01838a7001b5ed5dcdec7bce1ea4250bbeebe8c22aa27fd69e7baf343458e95c7101030f11dfda75a9e4a63bab19fe3bf92c545a3f58a57ca41ae7609290dad01436018923004af490f5718e834215ef61f2f60aee24685c1c2cffb3c686dff57ab82501eb86680f83fa0f9c47da3875645344a2734d56edcf1d99747ecbf25ea0e86e22000001cf6872911593b4f1af2fd03dce8a48d434af849ad1bc872442e7881bbc04e8610168fbde909e21c25e1a686fac9982ee11fb0d05da3568579bfba8b71f7632d62700012965494015cdab2ce010c1ae4ea88306c286128275de391dcf57d3fa85be7e1b01a090ee174239a34a5d684425d09006d238c6075a61c5842d0fc26043f09ccd7001a2b7ee187c7b8ce18ebda8600bed7695b12f7d35ac971ed6ee67184a7ceebd490001b35fe4a943a47404f68db220c77b0573e13c3378a65c6f2396f93be7609d8f2a000125911f4524469c00ccb1ba69e64f0ee7380c8d17bbfc76ecd238421b86eb6e09000118f64df255c9c43db708255e7bf6bffd481e5c2f38fe9ed8f3d189f7f9cf2644"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/testnet/1380300.json
Normal file
7
app/src/main/assets/saplingtree/testnet/1380300.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "test",
|
||||
"height": 1380300,
|
||||
"hash": "00342c648fb9c5d109df4dd5b7849a4357f27f1dfdb8d3a0071e8254072d1a4a",
|
||||
"time": 1619216615,
|
||||
"tree": "01f5b47ef533c9b6240826210d7e66691f36b21ac1ce1e4a231399ff4f8b1286600198dc26bbe8f037c5dbd8a43e94c482bb513898bd1ee1a734c07c57450b9ec01b1000000001b18e52aa826dcf85a08ae15d1bb4c8559166fcd5cffd74b597a8b50bf32d311100018dc0c02e20384fcdc238a6c01a0e4598da69f546646acc177fd91b86a0f8236200000001ba0d7aa9e68417291c63b835fa64114f5899208238de59ee360f594c8b6c1b72018469338dcbdf2f7e54bca5bc3e1c5fad4a656f206040436d3d0433a901218b5e016d559de7a1a382349cf97fe01a2fba41a49bb5e3b306d9ff8c2bcc301c731c00000001f08f39275112dd8905b854170b7f247cf2df18454d4fa94e6e4f9320cca05f24011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/testnet/1450000.json
Normal file
7
app/src/main/assets/saplingtree/testnet/1450000.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "test",
|
||||
"height": 1450000,
|
||||
"hash": "000008a97bc133de13ca304e0c6a2a1b3f2facdceac2cde5b4141179f2a743cc",
|
||||
"time": 1623815069,
|
||||
"tree": "0175626cf9d8448de98f68fcc585dd7a276c946c11bbc3b192ee08db99c542b86b01acf5a110dc7ab911b534984c46bf56592f0c4cc8cf70dbd6a9cc4a5b47d2c81c1001c91f518ccb74093a217a640c537b69b095de058e0430046c8783f231caa1fa4201f7c982ce76b2c9343fb771e077357322f9a7dabfd7ab93b7adee32806c930d6600000170910ab6355ec614412fae56dad5fdc1747ce1b306a4b8ae03b77513b612b00800000000000000013d2fd009bf8a22d68f720eac19c411c99014ed9c5f85d5942e15d1fc039e28680001f08f39275112dd8905b854170b7f247cf2df18454d4fa94e6e4f9320cca05f24011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
|
||||
}
|
||||
7
app/src/main/assets/saplingtree/testnet/1454000.json
Normal file
7
app/src/main/assets/saplingtree/testnet/1454000.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"network": "test",
|
||||
"height": 1454000,
|
||||
"hash": "003254b452f221d36ba81d051a1a63edeb203de7ab457500d08b4110bcc86620",
|
||||
"time": 1624073536,
|
||||
"tree": "01007501338f9d31446b9c0228b87e81886555100fbb1b5bec7966617559d5400901d830393653a5379f1f071bffa191f9b56d0664859d9b19b9e9ae4e1c76f7d34f1001ca92ad0eeb818c3bb57ca30ed500dd58703fe14c4837f14ac8a1491622f0a8550001a1d6a89c888e46ce950d5af54739e9847fab81f383586ad5dc51dd00f65ed85d0160f01e9c484861b220f5a4650119f192217a89854ada30019fae9ab46ff4c4120001462c8d06a58ddec91ed309dcb041cdedcca73446889496332054d54e1561633b000000000000013d2fd009bf8a22d68f720eac19c411c99014ed9c5f85d5942e15d1fc039e28680001f08f39275112dd8905b854170b7f247cf2df18454d4fa94e6e4f9320cca05f24011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
|
||||
}
|
||||
BIN
app/src/main/assets/sound_receive_small.mp3
Normal file
BIN
app/src/main/assets/sound_receive_small.mp3
Normal file
Binary file not shown.
60
app/src/main/java/cash/z/ecc/android/StrictModeHelper.kt
Normal file
60
app/src/main/java/cash/z/ecc/android/StrictModeHelper.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package cash.z.ecc.android
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.StrictMode
|
||||
|
||||
object StrictModeHelper {
|
||||
|
||||
fun enableStrictMode() {
|
||||
configureStrictMode()
|
||||
|
||||
// Workaround for Android bug
|
||||
// https://issuetracker.google.com/issues/36951662
|
||||
// Not needed if target O_MR1 and running on O_MR1
|
||||
// Don't really need to check target, because of Google Play enforcement on targetSdkVersion for app updates
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
|
||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue { configureStrictMode() }
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun configureStrictMode() {
|
||||
StrictMode.enableDefaults()
|
||||
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder().apply {
|
||||
detectAll()
|
||||
penaltyLog()
|
||||
}.build()
|
||||
)
|
||||
|
||||
// Don't enable missing network tags, because those are noisy.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder().apply {
|
||||
detectActivityLeaks()
|
||||
detectCleartextNetwork()
|
||||
detectContentUriWithoutPermission()
|
||||
detectFileUriExposure()
|
||||
detectLeakedClosableObjects()
|
||||
detectLeakedRegistrationObjects()
|
||||
detectLeakedSqlLiteObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
// Disable because this is mostly flagging Android X and Play Services
|
||||
// builder.detectNonSdkApiUsage();
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
} else {
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder().apply {
|
||||
detectAll()
|
||||
penaltyLog()
|
||||
}.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt
Normal file
117
app/src/main/java/cash/z/ecc/android/ZcashWalletApp.kt
Normal file
@@ -0,0 +1,117 @@
|
||||
package cash.z.ecc.android
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.camera.camera2.Camera2Config
|
||||
import androidx.camera.core.CameraXConfig
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.tryWithWarning
|
||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class ZcashWalletApp : Application(), CameraXConfig.Provider {
|
||||
|
||||
private val coordinator: FeedbackCoordinator
|
||||
get() = DependenciesHolder.feedbackCoordinator
|
||||
|
||||
lateinit var defaultNetwork: ZcashNetwork
|
||||
|
||||
var creationTime: Long = 0
|
||||
private set
|
||||
|
||||
var creationMeasured: Boolean = false
|
||||
|
||||
/** The amount of transparent funds that need to accumulate before autoshielding is triggered */
|
||||
val autoshieldThreshold: Long = Zatoshi.ZATOSHI_PER_ZEC // 1 ZEC
|
||||
|
||||
/**
|
||||
* Intentionally private Scope for use with launching Feedback jobs. The feedback object has the
|
||||
* longest scope in the app because it needs to be around early in order to measure launch times
|
||||
* and stick around late in order to catch crashes. We intentionally don't expose this because
|
||||
* application objects can have odd lifecycles, given that there is no clear onDestroy moment in
|
||||
* many cases.
|
||||
*/
|
||||
private var feedbackScope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
|
||||
// Setting a global reference to the application object is icky; we should try to refactor
|
||||
// this away if possible. Doing this in attachBaseContext instead of onCreate()
|
||||
// to avoid any lifecycle issues, as certain components can run before Application.onCreate()
|
||||
// (like ContentProvider initialization), but attachBaseContext will still run before that.
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Register this before the uncaught exception handler, because we want to make sure the
|
||||
// exception handler also doesn't do disk IO. Since StrictMode only applies for debug builds,
|
||||
// we'll also see the crashes during development right away and won't miss them if they aren't
|
||||
// reported by the crash reporting.
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictModeHelper.enableStrictMode()
|
||||
cash.z.ecc.android.sdk.internal.Twig.enabled(true)
|
||||
cash.z.ecc.android.util.Twig.enabled(true)
|
||||
}
|
||||
|
||||
// Setup handler for uncaught exceptions.
|
||||
Thread.getDefaultUncaughtExceptionHandler()?.let {
|
||||
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(it))
|
||||
}
|
||||
creationTime = System.currentTimeMillis()
|
||||
|
||||
defaultNetwork = ZcashNetwork.from(resources.getInteger(R.integer.zcash_network_id))
|
||||
feedbackScope.launch {
|
||||
coordinator.feedback.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCameraXConfig(): CameraXConfig {
|
||||
return Camera2Config.defaultConfig()
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: ZcashWalletApp
|
||||
}
|
||||
|
||||
/**
|
||||
* @param feedbackCoordinator inject a provider so that if a crash happens before configuration
|
||||
* is complete, we can lazily initialize all the feedback objects at this moment so that we
|
||||
* don't have to add any time to startup.
|
||||
*/
|
||||
inner class ExceptionReporter(private val ogHandler: Thread.UncaughtExceptionHandler) :
|
||||
Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(t: Thread?, e: Throwable?) {
|
||||
twig("Uncaught Exception: $e caused by: ${e?.cause}")
|
||||
// Things can get pretty crazy during a fatal exception
|
||||
// so be cautious here to avoid freezing the app
|
||||
tryWithWarning("Unable to report fatal crash") {
|
||||
// note: these are the only reported crashes that set isFatal=true
|
||||
coordinator.feedback.report(e, true)
|
||||
}
|
||||
tryWithWarning("Unable to flush the feedback coordinator") {
|
||||
coordinator.flush()
|
||||
}
|
||||
|
||||
try {
|
||||
// can do this if necessary but first verify that we need it
|
||||
runBlocking {
|
||||
coordinator.await()
|
||||
coordinator.feedback.stop()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
twig("WARNING: failed to wait for the feedback observers to complete.")
|
||||
} finally {
|
||||
// it's important that this always runs so we use the finally clause here
|
||||
// rather than another tryWithWarning block
|
||||
ogHandler.uncaughtException(t, e)
|
||||
Thread.sleep(2000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cash.z.ecc.android.di
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.ext.Const
|
||||
import cash.z.ecc.android.feedback.*
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.ui.util.DebugFileTwig
|
||||
import cash.z.ecc.android.util.SilentTwig
|
||||
import cash.z.ecc.android.util.Twig
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
|
||||
object DependenciesHolder {
|
||||
|
||||
fun provideAppContext(): Context = ZcashWalletApp.instance
|
||||
|
||||
val initializerComponent by lazy { InitializerComponent() }
|
||||
|
||||
val synchronizer by lazy { Synchronizer.newBlocking(initializerComponent.initializer) }
|
||||
|
||||
val clipboardManager by lazy { provideAppContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
|
||||
|
||||
val lockBox by lazy { LockBox(provideAppContext()) }
|
||||
|
||||
val prefs by lazy { LockBox(provideAppContext()) }
|
||||
|
||||
val feedback by lazy { Feedback() }
|
||||
|
||||
val feedbackCoordinator by lazy {
|
||||
lockBox.getBoolean(Const.Pref.FEEDBACK_ENABLED).let { isEnabled ->
|
||||
// observe nothing unless feedback is enabled
|
||||
Twig.plant(if (isEnabled) DebugFileTwig() else SilentTwig())
|
||||
FeedbackCoordinator(feedback)
|
||||
}
|
||||
}
|
||||
|
||||
val feedbackFile by lazy { FeedbackFile() }
|
||||
|
||||
val feedbackConsole by lazy { FeedbackConsole() }
|
||||
|
||||
val mnemonics by lazy { Mnemonics() }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cash.z.ecc.android.di
|
||||
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
|
||||
class InitializerComponent {
|
||||
|
||||
lateinit var initializer: Initializer
|
||||
private set
|
||||
|
||||
fun createInitializer(config: Initializer.Config) {
|
||||
initializer = Initializer.newBlocking(DependenciesHolder.provideAppContext(), config)
|
||||
}
|
||||
|
||||
}
|
||||
53
app/src/main/java/cash/z/ecc/android/ext/Const.kt
Normal file
53
app/src/main/java/cash/z/ecc/android/ext/Const.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
192
app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt
Normal file
192
app/src/main/java/cash/z/ecc/android/ext/Dialogs.kt
Normal 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 }
|
||||
)
|
||||
83
app/src/main/java/cash/z/ecc/android/ext/EditText.kt
Normal file
83
app/src/main/java/cash/z/ecc/android/ext/EditText.kt
Normal 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
|
||||
}
|
||||
}
|
||||
69
app/src/main/java/cash/z/ecc/android/ext/Extensions.kt
Normal file
69
app/src/main/java/cash/z/ecc/android/ext/Extensions.kt
Normal 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
|
||||
}
|
||||
9
app/src/main/java/cash/z/ecc/android/ext/Fragment.kt
Normal file
9
app/src/main/java/cash/z/ecc/android/ext/Fragment.kt
Normal 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
|
||||
46
app/src/main/java/cash/z/ecc/android/ext/Int.kt
Normal file
46
app/src/main/java/cash/z/ecc/android/ext/Int.kt
Normal 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()
|
||||
14
app/src/main/java/cash/z/ecc/android/ext/LifeCycleOwner.kt
Normal file
14
app/src/main/java/cash/z/ecc/android/ext/LifeCycleOwner.kt
Normal 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)
|
||||
}
|
||||
20
app/src/main/java/cash/z/ecc/android/ext/Spannable.kt
Normal file
20
app/src/main/java/cash/z/ecc/android/ext/Spannable.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
81
app/src/main/java/cash/z/ecc/android/ext/View.kt
Normal file
81
app/src/main/java/cash/z/ecc/android/ext/View.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class FeedbackConsole : FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
log(metric.toString())
|
||||
}
|
||||
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
log(action.toString())
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
// TODO: flush logs (once we have the real logging in place)
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Log.d("@TWIG", message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import okio.appendingSink
|
||||
import okio.buffer
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
class FeedbackFile(fileName: String = "user_log.txt") :
|
||||
FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
val file = File("${ZcashWalletApp.instance.filesDir}/logs", fileName)
|
||||
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
|
||||
|
||||
override fun initialize(): FeedbackCoordinator.FeedbackObserver = apply {
|
||||
file.parentFile?.apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
appendToFile(metric.toString())
|
||||
}
|
||||
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
appendToFile(action.toString())
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
// TODO: be more sophisticated about how we open/close the file. And then flush it here.
|
||||
}
|
||||
|
||||
private fun appendToFile(message: String) {
|
||||
file.appendingSink().buffer().use {
|
||||
it.writeUtf8("${format.format(System.currentTimeMillis())}|\t$message\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
255
app/src/main/java/cash/z/ecc/android/feedback/Report.kt
Normal file
255
app/src/main/java/cash/z/ecc/android/feedback/Report.kt
Normal file
@@ -0,0 +1,255 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
|
||||
object Report {
|
||||
|
||||
object Funnel {
|
||||
sealed class Send(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("send", stepName, step, *properties) {
|
||||
object AddressPageComplete : Send("addresspagecomplete", 10)
|
||||
object MemoPageComplete : Send("memopagecomplete", 20)
|
||||
object ConfirmPageComplete : Send("confirmpagecomplete", 30)
|
||||
|
||||
// Beginning of send
|
||||
object SendSelected : Send("sendselected", 50)
|
||||
object SpendingKeyFound : Send("keyfound", 60)
|
||||
object Creating : Send("creating", 70)
|
||||
object Cancelled : Send("cancelled", 72)
|
||||
class Created(id: Long) : Send("created", 80, "id" to id)
|
||||
object Submitted : Send("submitted", 90)
|
||||
class Mined(minedHeight: Int) : Send("mined", 100, "minedHeight" to minedHeight)
|
||||
|
||||
// Errors
|
||||
abstract class Error(stepName: String, step: Int, val errorCode: Int?, val errorMessage: String?, vararg properties: Pair<String, Any>) : Send("error.$stepName", step, "isError" to true, *properties)
|
||||
object ErrorNotFound : Error("notfound", 51, null, "Key not found")
|
||||
class ErrorEncoding(errorCode: Int? = null, errorMessage: String? = null) : Error(
|
||||
"encode", 71, errorCode, errorMessage,
|
||||
"errorCode" to (errorCode ?: -1),
|
||||
"errorMessage" to (errorMessage ?: "None")
|
||||
)
|
||||
class ErrorSubmitting(errorCode: Int? = null, errorMessage: String? = null) : Error(
|
||||
"submit", 81, errorCode, errorMessage,
|
||||
"errorCode" to (errorCode ?: -1),
|
||||
"errorMessage" to (errorMessage ?: "None")
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Restore(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("restore", stepName, step, *properties) {
|
||||
object Initiated : Restore("initiated", 0)
|
||||
object SeedWordsStarted : Restore("wordsstarted", 10)
|
||||
class SeedWordCount(wordCount: Int) : Restore("wordsmodified", 15, "seedWordCount" to wordCount)
|
||||
object SeedWordsCompleted : Restore("wordscompleted", 20)
|
||||
object Stay : Restore("stay", 21)
|
||||
object Exit : Restore("stay", 22)
|
||||
object Done : Restore("doneselected", 30)
|
||||
object ImportStarted : Restore("importstarted", 40)
|
||||
object ImportCompleted : Restore("importcompleted", 50)
|
||||
object Success : Restore("success", 100)
|
||||
}
|
||||
|
||||
sealed class UserFeedback(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("feedback", stepName, step, *properties) {
|
||||
object Started : UserFeedback("started", 0)
|
||||
object Cancelled : UserFeedback("cancelled", 1)
|
||||
class Submitted(rating: Int, question1: String, question2: String, question3: String, isSolicited: Boolean) : UserFeedback("submitted", 100, "rating" to rating, "question1" to question1, "question2" to question2, "question3" to question3, "isSolicited" to isSolicited)
|
||||
}
|
||||
}
|
||||
|
||||
object Error {
|
||||
object NonFatal {
|
||||
class Reorg(errorBlockHeight: BlockHeight, rewindBlockHeight: BlockHeight) : Feedback.AppError(
|
||||
"reorg",
|
||||
"Chain error detected at height $errorBlockHeight, rewinding to $rewindBlockHeight",
|
||||
false,
|
||||
"errorHeight" to errorBlockHeight,
|
||||
"rewindHeight" to rewindBlockHeight
|
||||
) {
|
||||
val errorHeight: Int by propertyMap
|
||||
val rewindHeight: Int by propertyMap
|
||||
}
|
||||
class TxUpdateFailed(t: Throwable) : Feedback.AppError("txupdate", t, false)
|
||||
abstract class TxError(action: String, val errorCode: Int?, val errorMessage: String?) : Feedback.AppError(
|
||||
"tx.$action",
|
||||
"Failed to $action transaction due to $errorMessage",
|
||||
false,
|
||||
"errorCode" to (errorCode ?: 1)
|
||||
)
|
||||
class TxEncodeError(errorCode: Int?, errorMessage: String?) : TxError("encode", errorCode, errorMessage)
|
||||
class TxSubmitError(errorCode: Int?, errorMessage: String?) : TxError("submit", errorCode, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Performance(name: String, vararg properties: Pair<String, Any>) : Feedback.MappedAction(
|
||||
"metricName" to name,
|
||||
"isPerformanceMetric" to true,
|
||||
*properties
|
||||
) {
|
||||
override val key = "performance.$name"
|
||||
override fun toString() = "$key: ${toMap().let { if (it.size > 1) "${it.entries}" else "" }}"
|
||||
|
||||
class ScanRate(network: String, cumulativeItems: Int, cumulativeTime: Long, cumulativeIps: Float) : Performance("scan.bps", "network" to network, "totalBlocks" to cumulativeItems, "totalTime" to cumulativeTime, "blocksPerSecond" to cumulativeIps)
|
||||
}
|
||||
|
||||
// placeholder for things that we want to monitor
|
||||
sealed class Issue(name: String, vararg properties: Pair<String, Any>) : Feedback.MappedAction(
|
||||
"issueName" to name,
|
||||
"isIssue" to true,
|
||||
*properties
|
||||
) {
|
||||
override val key = "issue.$name"
|
||||
override fun toString() = "occurrence of ${key.replace('.', ' ')}${toMap().let { if (it.size > 1) " with ${it.entries}" else "" }}"
|
||||
|
||||
// Issues with sending worth monitoring
|
||||
object SelfSend : Issue("self.send")
|
||||
object TinyAmount : Issue("tiny.amount")
|
||||
object MicroAmount : Issue("micro.amount")
|
||||
object MinimumAmount : Issue("minimum.amount")
|
||||
class TruncatedMemo(memoSize: Int) : Issue("truncated.memo", "memoSize" to memoSize)
|
||||
class LargeMemo(memoSize: Int) : Issue("large.memo", "memoSize" to memoSize)
|
||||
class MissingViewkey(recovered: Boolean, needle: String, haystack: String, hasKey: Boolean) : Issue(
|
||||
"missing.viewkey", "wasAbleToRecover" to recovered, "needle" to needle, "haystack" to haystack, "hasKey" to hasKey
|
||||
)
|
||||
}
|
||||
|
||||
enum class Screen(val id: String? = null) : Feedback.Action {
|
||||
BACKUP,
|
||||
HOME,
|
||||
HISTORY("wallet.history"),
|
||||
TRANSACTION("wallet.transaction"),
|
||||
LANDING,
|
||||
PROFILE,
|
||||
AWESOME,
|
||||
FEEDBACK,
|
||||
RECEIVE,
|
||||
RESTORE,
|
||||
SCAN,
|
||||
AUTO_SHIELD_FINAL("autoshield.final"),
|
||||
AUTO_SHIELD_AVAILABLE("autoshield.available"),
|
||||
AUTO_SHIELD_INFORMATION("autoshield.information"),
|
||||
SEND_ADDRESS("send.address"),
|
||||
SEND_CONFIRM("send.confirm"),
|
||||
SEND_FINAL("send.final"),
|
||||
SEND_MEMO("send.memo");
|
||||
|
||||
override val key = "screen.${id ?: name.toLowerCase()}"
|
||||
override fun toString() = "viewed the ${key.substring(7).replace('.', ' ')} screen"
|
||||
}
|
||||
|
||||
enum class Tap(val id: String) : Feedback.Action {
|
||||
BACKUP_DONE("backup.done"),
|
||||
BACKUP_VERIFY("backup.verify"),
|
||||
DEVELOPER_WALLET_PROMPT("landing.devwallet.prompt"),
|
||||
DEVELOPER_WALLET_IMPORT("landing.devwallet.import"),
|
||||
DEVELOPER_WALLET_CANCEL("landing.devwallet.cancel"),
|
||||
LANDING_RESTORE("landing.restore"),
|
||||
LANDING_NEW("landing.new"),
|
||||
LANDING_BACKUP("landing.backup"),
|
||||
LANDING_BACKUP_SKIPPED_1("landing.backup.skip.1"),
|
||||
LANDING_BACKUP_SKIPPED_2("landing.backup.skip.2"),
|
||||
LANDING_BACKUP_SKIPPED_3("landing.backup.skip.3"),
|
||||
HOME_PROFILE("home.profile"),
|
||||
HOME_HISTORY("home.history"),
|
||||
HOME_RECEIVE("home.receive"),
|
||||
HOME_BALANCE_DETAIL("home.balance.detail"),
|
||||
TAB_LAYOUT("tab.layout"),
|
||||
HOME_SCAN("home.scan"),
|
||||
HOME_SEND("home.send"),
|
||||
HOME_FUND_NOW("home.fund.now"),
|
||||
HOME_CLEAR_AMOUNT("home.clear.amount"),
|
||||
HISTORY_BACK("history.back"),
|
||||
TRANSACTION_BACK("transaction.back"),
|
||||
PROFILE_CLOSE("profile.close"),
|
||||
AWESOME_OPEN("profile.awesome"),
|
||||
AWESOME_CLOSE("awesome.close"),
|
||||
AWESOME_SHIELD("awesome.shield"),
|
||||
PROFILE_BACKUP("profile.backup"),
|
||||
PROFILE_RESCAN("profile.rescan"),
|
||||
PROFILE_VIEW_USER_LOGS("profile.view.user.logs"),
|
||||
PROFILE_VIEW_DEV_LOGS("profile.view.dev.logs"),
|
||||
PROFILE_SEND_FEEDBACK("profile.send.feedback"),
|
||||
FEEDBACK_CANCEL("feedback.cancel"),
|
||||
FEEDBACK_SUBMIT("feedback.submit"),
|
||||
RECEIVE_BACK("receive.back"),
|
||||
RESTORE_DONE("restore.done"),
|
||||
RESTORE_CLEAR("restore.clear"),
|
||||
RESTORE_SUCCESS("restore.success"),
|
||||
RESTORE_BACK("restore.back"),
|
||||
SCAN_BACK("scan.back"),
|
||||
AUTO_SHIELD_FINAL_CLOSE("autoshield.final.close"),
|
||||
AUTO_SHIELD_FINAL_DONE("autoshield.final.done"),
|
||||
SEND_ADDRESS_MAX("send.address.max"),
|
||||
SEND_ADDRESS_NEXT("send.address.next"),
|
||||
SEND_ADDRESS_PASTE("send.address.paste"),
|
||||
SEND_ADDRESS_REUSE("send.address.reuse"),
|
||||
SEND_ADDRESS_BACK("send.address.back"),
|
||||
SEND_ADDRESS_DONE_ADDRESS("send.address.done.address"),
|
||||
SEND_ADDRESS_DONE_AMOUNT("send.address.done.amount"),
|
||||
SEND_ADDRESS_SCAN("send.address.scan"),
|
||||
SEND_CONFIRM_BACK("send.confirm.back"),
|
||||
SEND_CONFIRM_NEXT("send.confirm.next"),
|
||||
SEND_FINAL_EXIT("send.final.exit"),
|
||||
SEND_FINAL_RETRY("send.final.retry"),
|
||||
SEND_FINAL_CLOSE("send.final.close"),
|
||||
SEND_MEMO_INCLUDE("send.memo.include"),
|
||||
SEND_MEMO_EXCLUDE("send.memo.exclude"),
|
||||
SEND_MEMO_NEXT("send.memo.next"),
|
||||
SEND_MEMO_SKIP("send.memo.skip"),
|
||||
SEND_MEMO_CLEAR("send.memo.clear"),
|
||||
SEND_MEMO_BACK("send.memo.back"),
|
||||
|
||||
SEND_SUBMIT("send.submit"),
|
||||
|
||||
// General events
|
||||
COPY_ADDRESS("copy.address"),
|
||||
COPY_TRANSPARENT_ADDRESS("copy.address.transparent");
|
||||
|
||||
override val key = "tap.$id"
|
||||
override fun toString() = "${key.replace('.', ' ')} button".replace("tap ", "tapped the ")
|
||||
}
|
||||
|
||||
enum class NonUserAction(override val key: String, val description: String) : Feedback.Action {
|
||||
FEEDBACK_STARTED("action.feedback.start", "feedback started"),
|
||||
FEEDBACK_STOPPED("action.feedback.stop", "feedback stopped"),
|
||||
SYNC_START("action.feedback.synchronizer.start", "sync started");
|
||||
|
||||
override fun toString(): String = description
|
||||
}
|
||||
|
||||
enum class MetricType(override val key: String, val description: String) : Feedback.Action {
|
||||
ENTROPY_CREATED("metric.entropy.created", "entropy created"),
|
||||
SEED_CREATED("metric.seed.created", "seed created"),
|
||||
SEED_IMPORTED("metric.seed.imported", "seed imported"),
|
||||
SEED_PHRASE_CREATED("metric.seedphrase.created", "seed phrase created"),
|
||||
SEED_PHRASE_LOADED("metric.seedphrase.loaded", "seed phrase loaded"),
|
||||
WALLET_CREATED("metric.wallet.created", "wallet created"),
|
||||
WALLET_IMPORTED("metric.wallet.imported", "wallet imported"),
|
||||
ACCOUNT_CREATED("metric.account.created", "account created"),
|
||||
|
||||
// Transactions
|
||||
TRANSACTION_INITIALIZED("metric.tx.initialized", "transaction initialized"),
|
||||
TRANSACTION_CREATED("metric.tx.created", "transaction created successfully"),
|
||||
TRANSACTION_SUBMITTED("metric.tx.submitted", "transaction submitted successfully"),
|
||||
TRANSACTION_MINED("metric.tx.mined", "transaction mined")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a metric with a start time of ZcashWalletApp.creationTime and an end time of when this
|
||||
* instance was created. This can then be passed to [Feedback.report].
|
||||
*/
|
||||
class LaunchMetric private constructor(private val metric: Feedback.TimeMetric) :
|
||||
Feedback.Metric by metric {
|
||||
constructor() : this(
|
||||
Feedback
|
||||
.TimeMetric(
|
||||
"metric.app.launch",
|
||||
"app launched",
|
||||
mutableListOf(ZcashWalletApp.instance.creationTime)
|
||||
)
|
||||
.markTime()
|
||||
)
|
||||
override fun toString(): String = metric.toString()
|
||||
}
|
||||
|
||||
inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T =
|
||||
this.measure(type.key, type.description, block)
|
||||
@@ -0,0 +1,6 @@
|
||||
package cash.z.ecc.android.preference
|
||||
|
||||
internal object PreferenceKeys {
|
||||
const val IS_AUTOSHIELDING_INFO_ACKNOWLEDGED = "is_autoshielding_info_acknowledged"
|
||||
const val LAST_AUTOSHIELDING_PROMPT_EPOCH_MILLIS = "last_autoshielding_epoch_millis"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cash.z.ecc.android.preference
|
||||
|
||||
import cash.z.ecc.android.preference.model.BooleanDefaultValue
|
||||
import cash.z.ecc.android.preference.model.LongDefaultValue
|
||||
|
||||
object Preferences {
|
||||
val isAcknowledgedAutoshieldingInformationPrompt =
|
||||
BooleanDefaultValue(PreferenceKeys.IS_AUTOSHIELDING_INFO_ACKNOWLEDGED, false)
|
||||
|
||||
val lastAutoshieldingEpochMillis =
|
||||
LongDefaultValue(PreferenceKeys.LAST_AUTOSHIELDING_PROMPT_EPOCH_MILLIS, 0)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package cash.z.ecc.android.preference
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SharedPreferenceFactory {
|
||||
private const val DEFAULT_SHARED_PREFERENCES = "cash.z.ecc.default"
|
||||
|
||||
fun getSharedPreferences(context: Context) = context.getSharedPreferences(DEFAULT_SHARED_PREFERENCES, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cash.z.ecc.android.preference.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cash.z.ecc.android.preference.SharedPreferenceFactory
|
||||
|
||||
/**
|
||||
* A default value represents a preference key, along with its default value. It does not, by itself,
|
||||
* know how to read or write values from the preference repository.
|
||||
*/
|
||||
data class BooleanDefaultValue(override val key: String, internal val defaultValue: Boolean) :
|
||||
DefaultValue<Boolean> {
|
||||
init {
|
||||
require(key.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun BooleanDefaultValue.get(context: Context) = get(
|
||||
SharedPreferenceFactory.getSharedPreferences(
|
||||
context
|
||||
)
|
||||
)
|
||||
|
||||
internal fun BooleanDefaultValue.get(sharedPreferences: SharedPreferences) =
|
||||
sharedPreferences.getBoolean(key, defaultValue)
|
||||
|
||||
fun BooleanDefaultValue.put(context: Context, newValue: Boolean) = put(
|
||||
SharedPreferenceFactory.getSharedPreferences(
|
||||
context
|
||||
),
|
||||
newValue
|
||||
)
|
||||
|
||||
internal fun BooleanDefaultValue.put(sharedPreferences: SharedPreferences, newValue: Boolean) =
|
||||
sharedPreferences.edit().putBoolean(key, newValue).apply()
|
||||
@@ -0,0 +1,23 @@
|
||||
package cash.z.ecc.android.preference.model
|
||||
|
||||
/**
|
||||
* A key and a default value for a key-value store of preferences.
|
||||
*
|
||||
* Use of this interface avoids duplication or accidental variation in default value, because key
|
||||
* and default are defined together just once.
|
||||
*
|
||||
* Note that T is not fully generic and should be one of the supported types: Boolean, ... (other types to be added in the future)
|
||||
*
|
||||
* @see BooleanDefaultValue
|
||||
*/
|
||||
/*
|
||||
* Although primitives would be nice, Objects don't increase memory usage much
|
||||
* because of the autoboxing cache on the JVM. For example, Boolean's true/false values
|
||||
* are cached.
|
||||
*/
|
||||
interface DefaultValue<T> {
|
||||
// Note: the default value is not available through the public interface in order to prevent
|
||||
// clients from accidentally using the default value instead of the stored value.
|
||||
|
||||
val key: String
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cash.z.ecc.android.preference.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cash.z.ecc.android.preference.SharedPreferenceFactory
|
||||
|
||||
/**
|
||||
* A default value represents a preference key, along with its default value. It does not, by itself,
|
||||
* know how to read or write values from the preference repository.
|
||||
*/
|
||||
data class LongDefaultValue(
|
||||
override val key: String,
|
||||
internal val defaultValue: Long
|
||||
) : DefaultValue<Long> {
|
||||
init {
|
||||
require(key.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun LongDefaultValue.get(context: Context) = get(
|
||||
SharedPreferenceFactory.getSharedPreferences(context)
|
||||
)
|
||||
|
||||
internal fun LongDefaultValue.get(sharedPreferences: SharedPreferences) =
|
||||
sharedPreferences.getLong(key, defaultValue)
|
||||
|
||||
fun LongDefaultValue.put(context: Context, newValue: Long) = put(
|
||||
SharedPreferenceFactory.getSharedPreferences(context),
|
||||
newValue
|
||||
)
|
||||
|
||||
internal fun LongDefaultValue.put(sharedPreferences: SharedPreferences, newValue: Long) =
|
||||
sharedPreferences.edit().putLong(key, newValue).apply()
|
||||
669
app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt
Normal file
669
app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt
Normal file
@@ -0,0 +1,669 @@
|
||||
package cash.z.ecc.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Dialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Vibrator
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.biometric.BiometricPrompt.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.findNavController
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.DialogFirstUseMessageBinding
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Feedback
|
||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||
import cash.z.ecc.android.feedback.LaunchMetric
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Error.NonFatal.Reorg
|
||||
import cash.z.ecc.android.feedback.Report.NonUserAction.FEEDBACK_STOPPED
|
||||
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
|
||||
import cash.z.ecc.android.feedback.Report.Tap.COPY_ADDRESS
|
||||
import cash.z.ecc.android.feedback.Report.Tap.COPY_TRANSPARENT_ADDRESS
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException
|
||||
import cash.z.ecc.android.sdk.ext.BatchMetrics
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.ui.history.HistoryViewModel
|
||||
import cash.z.ecc.android.ui.util.MemoUtil
|
||||
import cash.z.ecc.android.util.twig
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : AppCompatActivity(R.layout.main_activity) {
|
||||
|
||||
val mainViewModel: MainViewModel by viewModels()
|
||||
|
||||
val feedback: Feedback = DependenciesHolder.feedback
|
||||
|
||||
val feedbackCoordinator: FeedbackCoordinator = DependenciesHolder.feedbackCoordinator
|
||||
|
||||
val clipboard: ClipboardManager = DependenciesHolder.clipboardManager
|
||||
|
||||
val historyViewModel: HistoryViewModel by viewModels()
|
||||
|
||||
private var syncStarted = false
|
||||
|
||||
private val mediaPlayer: MediaPlayer = MediaPlayer()
|
||||
private var snackbar: Snackbar? = null
|
||||
private var dialog: Dialog? = null
|
||||
private var ignoreScanFailure: Boolean = false
|
||||
|
||||
var navController: NavController? = null
|
||||
private val navInitListeners: MutableList<() -> Unit> = mutableListOf()
|
||||
|
||||
private val hasCameraPermission
|
||||
get() = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val latestHeight: BlockHeight?
|
||||
get() = DependenciesHolder.synchronizer.latestHeight
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
lifecycleScope.launch {
|
||||
feedback.start()
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
initNavigation()
|
||||
initLoadScreen()
|
||||
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
|
||||
)
|
||||
setWindowFlag(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, false)
|
||||
setWindowFlag(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// keep track of app launch metrics
|
||||
// (how long does it take the app to open when it is not already in the foreground)
|
||||
ZcashWalletApp.instance.let { app ->
|
||||
if (!app.creationMeasured) {
|
||||
app.creationMeasured = true
|
||||
feedback.report(LaunchMetric())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
lifecycleScope.launch {
|
||||
feedback.report(FEEDBACK_STOPPED)
|
||||
feedback.stop()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setWindowFlag(bits: Int, on: Boolean) {
|
||||
val win = window
|
||||
val winParams = win.attributes
|
||||
if (on) {
|
||||
winParams.flags = winParams.flags or bits
|
||||
} else {
|
||||
winParams.flags = winParams.flags and bits.inv()
|
||||
}
|
||||
win.attributes = winParams
|
||||
}
|
||||
|
||||
private fun initNavigation() {
|
||||
navController = findNavController(R.id.nav_host_fragment)
|
||||
navController!!.addOnDestinationChangedListener { _, _, _ ->
|
||||
// hide the keyboard anytime we change destinations
|
||||
getSystemService<InputMethodManager>()?.hideSoftInputFromWindow(
|
||||
this@MainActivity.window.decorView.rootView.windowToken,
|
||||
InputMethodManager.HIDE_NOT_ALWAYS
|
||||
)
|
||||
}
|
||||
|
||||
for (listener in navInitListeners) {
|
||||
listener()
|
||||
}
|
||||
navInitListeners.clear()
|
||||
}
|
||||
|
||||
private fun initLoadScreen() {
|
||||
lifecycleScope.launchWhenResumed {
|
||||
mainViewModel.loadingMessage.collect { message ->
|
||||
onLoadingMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLoadingMessage(message: String?) {
|
||||
twig("Applying loading message: $message")
|
||||
// TODO: replace with view binding
|
||||
findViewById<View>(R.id.container_loading).goneIf(message == null)
|
||||
findViewById<TextView>(R.id.text_message).text = message
|
||||
}
|
||||
|
||||
fun popBackTo(@IdRes destination: Int, inclusive: Boolean = false) {
|
||||
navController?.popBackStack(destination, inclusive)
|
||||
}
|
||||
|
||||
fun safeNavigate(navDirections: NavDirections) =
|
||||
safeNavigate(navDirections.actionId, navDirections.arguments, null)
|
||||
|
||||
fun safeNavigate(
|
||||
@IdRes destination: Int,
|
||||
args: Bundle? = null,
|
||||
extras: Navigator.Extras? = null
|
||||
) {
|
||||
if (navController == null) {
|
||||
navInitListeners.add {
|
||||
try {
|
||||
navController?.navigate(destination, args, null, extras)
|
||||
} catch (t: Throwable) {
|
||||
twig(
|
||||
"WARNING: during callback, did not navigate to destination: R.id.${
|
||||
resources.getResourceEntryName(
|
||||
destination
|
||||
)
|
||||
} due to: $t"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
navController?.navigate(destination, args, null, extras)
|
||||
} catch (t: Throwable) {
|
||||
twig(
|
||||
"WARNING: did not immediately navigate to destination: R.id.${
|
||||
resources.getResourceEntryName(
|
||||
destination
|
||||
)
|
||||
} due to: $t"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startSync(isRestart: Boolean = false) {
|
||||
twig("MainActivity.startSync")
|
||||
if (!syncStarted || isRestart) {
|
||||
syncStarted = true
|
||||
mainViewModel.setLoading(true)
|
||||
feedback.report(SYNC_START)
|
||||
DependenciesHolder.synchronizer.let { synchronizer ->
|
||||
synchronizer.onProcessorErrorHandler = ::onProcessorError
|
||||
synchronizer.onChainErrorHandler = ::onChainError
|
||||
synchronizer.onCriticalErrorHandler = ::onCriticalError
|
||||
(synchronizer as SdkSynchronizer).processor.onScanMetricCompleteListener =
|
||||
::onScanMetricComplete
|
||||
|
||||
synchronizer.start(lifecycleScope)
|
||||
mainViewModel.setSyncReady(true)
|
||||
}
|
||||
} else {
|
||||
twig("Ignoring request to start sync because sync has already been started!")
|
||||
}
|
||||
mainViewModel.setLoading(false)
|
||||
twig("MainActivity.startSync COMPLETE")
|
||||
}
|
||||
|
||||
private fun onScanMetricComplete(batchMetrics: BatchMetrics, isComplete: Boolean) {
|
||||
val reportingThreshold = 100
|
||||
if (isComplete) {
|
||||
if (batchMetrics.cumulativeItems > reportingThreshold) {
|
||||
val network = DependenciesHolder.synchronizer.network.networkName
|
||||
reportAction(
|
||||
Report.Performance.ScanRate(
|
||||
network,
|
||||
batchMetrics.cumulativeItems.toInt(),
|
||||
batchMetrics.cumulativeTime,
|
||||
batchMetrics.cumulativeIps
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCriticalError(error: Throwable?): Boolean {
|
||||
val errorMessage = error?.message
|
||||
?: error?.cause?.message
|
||||
?: error?.toString()
|
||||
?: "A critical error has occurred but no details were provided. Please report and consider submitting logs to help track this one down."
|
||||
showCriticalMessage(
|
||||
title = "Unrecoverable Error",
|
||||
message = errorMessage,
|
||||
) {
|
||||
throw error ?: RuntimeException("A critical error occurred but it was null")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun reportScreen(screen: Report.Screen?) = reportAction(screen)
|
||||
|
||||
fun reportTap(tap: Report.Tap?) = reportAction(tap)
|
||||
|
||||
fun reportFunnel(step: Feedback.Funnel?) = reportAction(step)
|
||||
|
||||
private fun reportAction(action: Feedback.Action?) {
|
||||
action?.let { feedback.report(it) }
|
||||
}
|
||||
|
||||
fun setLoading(isLoading: Boolean, message: String? = null) {
|
||||
mainViewModel.setLoading(isLoading, message)
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
description: String,
|
||||
title: String = getString(R.string.biometric_prompt_title),
|
||||
block: () -> Unit
|
||||
) {
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
twig("Authentication success with type: ${if (result.authenticationType == AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL) "DEVICE_CREDENTIAL" else if (result.authenticationType == AUTHENTICATION_RESULT_TYPE_BIOMETRIC) "BIOMETRIC" else "UNKNOWN"} object: ${result.cryptoObject}")
|
||||
block()
|
||||
twig("Done authentication block")
|
||||
// we probably only need to do this if the type is DEVICE_CREDENTIAL
|
||||
// but it doesn't hurt to hide the keyboard every time
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
twig("Authentication failed!!!!")
|
||||
showMessage("Authentication failed :(")
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
twig("Authentication Error")
|
||||
fun doNothing(message: String, interruptUser: Boolean = true) {
|
||||
if (interruptUser) {
|
||||
showSnackbar(message)
|
||||
} else {
|
||||
showMessage(message, true)
|
||||
}
|
||||
}
|
||||
when (errorCode) {
|
||||
ERROR_HW_NOT_PRESENT, ERROR_HW_UNAVAILABLE,
|
||||
ERROR_NO_BIOMETRICS, ERROR_NO_DEVICE_CREDENTIAL -> {
|
||||
twig("Warning: bypassing authentication because $errString [$errorCode]")
|
||||
showMessage(
|
||||
"Please enable screen lock on this device to add security here!",
|
||||
true
|
||||
)
|
||||
block()
|
||||
}
|
||||
ERROR_LOCKOUT -> doNothing("Too many attempts. Try again in 30s.")
|
||||
ERROR_LOCKOUT_PERMANENT -> doNothing("Whoa. Waaaay too many attempts!")
|
||||
ERROR_CANCELED -> doNothing("I just can't right now. Please try again.")
|
||||
ERROR_NEGATIVE_BUTTON -> doNothing("Authentication cancelled", false)
|
||||
ERROR_USER_CANCELED -> doNothing("Cancelled", false)
|
||||
ERROR_NO_SPACE -> doNothing("Not enough storage space!")
|
||||
ERROR_TIMEOUT -> doNothing("Oops. It timed out.")
|
||||
ERROR_UNABLE_TO_PROCESS -> doNothing(".")
|
||||
ERROR_VENDOR -> doNothing("We got some weird error and you should report this.")
|
||||
else -> {
|
||||
twig("Warning: unrecognized authentication error $errorCode")
|
||||
doNothing("Authentication failed with error code $errorCode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BiometricPrompt(this, ContextCompat.getMainExecutor(this), callback).apply {
|
||||
authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setConfirmationRequired(false)
|
||||
.setDescription(description)
|
||||
.setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun playSound(fileName: String) {
|
||||
mediaPlayer.apply {
|
||||
if (isPlaying) stop()
|
||||
try {
|
||||
reset()
|
||||
assets.openFd(fileName).let { afd ->
|
||||
setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
|
||||
}
|
||||
prepare()
|
||||
start()
|
||||
} catch (t: Throwable) {
|
||||
Log.e("SDK_ERROR", "ERROR: unable to play sound due to $t")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: spruce this up with API 26 stuff
|
||||
fun vibrateSuccess() = vibrate(0, 200, 200, 100, 100, 800)
|
||||
|
||||
fun vibrate(initialDelay: Long, vararg durations: Long) {
|
||||
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
if (vibrator.hasVibrator()) {
|
||||
vibrator.vibrate(longArrayOf(initialDelay, *durations), -1)
|
||||
}
|
||||
}
|
||||
|
||||
fun copyAddress(view: View? = null) {
|
||||
reportTap(COPY_ADDRESS)
|
||||
lifecycleScope.launch {
|
||||
copyText(DependenciesHolder.synchronizer.getAddress(), "Address")
|
||||
}
|
||||
}
|
||||
|
||||
fun copyTransparentAddress(view: View? = null) {
|
||||
reportTap(COPY_TRANSPARENT_ADDRESS)
|
||||
lifecycleScope.launch {
|
||||
copyText(DependenciesHolder.synchronizer.getTransparentAddress(), "T-Address")
|
||||
}
|
||||
}
|
||||
|
||||
fun copyText(textToCopy: String, label: String = "ECC Wallet Text") {
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText(label, textToCopy)
|
||||
)
|
||||
showMessage("$label copied!")
|
||||
vibrate(0, 50)
|
||||
}
|
||||
|
||||
fun shareText(textToShare: String) {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, textToShare)
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
}
|
||||
|
||||
suspend fun isValidAddress(address: String): Boolean {
|
||||
try {
|
||||
return !DependenciesHolder.synchronizer.validateAddress(address).isNotValid
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun preventBackPress(fragment: Fragment) {
|
||||
onFragmentBackPressed(fragment) {}
|
||||
}
|
||||
|
||||
fun onFragmentBackPressed(fragment: Fragment, block: () -> Unit) {
|
||||
onBackPressedDispatcher.addCallback(
|
||||
fragment,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
block()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMessage(message: String, linger: Boolean = false) {
|
||||
twig("toast: $message")
|
||||
Toast.makeText(this, message, if (linger) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
fun showSnackbar(
|
||||
message: String,
|
||||
actionLabel: String = getString(android.R.string.ok),
|
||||
action: () -> Unit = {}
|
||||
): Snackbar {
|
||||
return if (snackbar == null) {
|
||||
val view = findViewById<View>(R.id.main_activity_container)
|
||||
val snacks = Snackbar
|
||||
.make(view, "$message", Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(actionLabel) { action() }
|
||||
|
||||
val snackBarView = snacks.view as ViewGroup
|
||||
val navigationBarHeight = resources.getDimensionPixelSize(
|
||||
resources.getIdentifier(
|
||||
"navigation_bar_height",
|
||||
"dimen",
|
||||
"android"
|
||||
)
|
||||
)
|
||||
val params = snackBarView.getChildAt(0).layoutParams as ViewGroup.MarginLayoutParams
|
||||
params.setMargins(
|
||||
params.leftMargin,
|
||||
params.topMargin,
|
||||
params.rightMargin,
|
||||
navigationBarHeight
|
||||
)
|
||||
|
||||
snackBarView.getChildAt(0).setLayoutParams(params)
|
||||
snacks
|
||||
} else {
|
||||
snackbar!!.setText(message).setAction(actionLabel) { action() }
|
||||
}.also {
|
||||
if (!it.isShownOrQueued) it.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun showKeyboard(focusedView: View) {
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
|
||||
}
|
||||
|
||||
fun hideKeyboard() {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param popUpToInclusive the destination to remove from the stack before opening the camera.
|
||||
* This only takes effect in the common case where the permission is granted.
|
||||
*/
|
||||
fun maybeOpenScan(popUpToInclusive: Int? = null) {
|
||||
if (hasCameraPermission) {
|
||||
openCamera(popUpToInclusive)
|
||||
} else {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
requestPermissions(arrayOf(Manifest.permission.CAMERA), 101)
|
||||
} else {
|
||||
onNoCamera()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 101) {
|
||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
openCamera()
|
||||
} else {
|
||||
onNoCamera()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCamera(popUpToInclusive: Int? = null) {
|
||||
navController?.navigate(popUpToInclusive ?: R.id.action_global_nav_scan)
|
||||
}
|
||||
|
||||
private fun onNoCamera() {
|
||||
showSnackbar(getString(R.string.camera_permission_denied))
|
||||
}
|
||||
|
||||
// TODO: clean up this error handling
|
||||
private var ignoredErrors = 0
|
||||
private fun onProcessorError(error: Throwable?): Boolean {
|
||||
var notified = false
|
||||
when (error) {
|
||||
is CompactBlockProcessorException.Uninitialized -> {
|
||||
if (dialog == null) {
|
||||
notified = true
|
||||
runOnUiThread {
|
||||
dialog = showUninitializedError(error) {
|
||||
dialog = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is CompactBlockProcessorException.FailedScan -> {
|
||||
if (dialog == null && !ignoreScanFailure) throttle("scanFailure", 20_000L) {
|
||||
notified = true
|
||||
runOnUiThread {
|
||||
dialog = showScanFailure(
|
||||
error,
|
||||
onCancel = { dialog = null },
|
||||
onDismiss = { dialog = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!notified) {
|
||||
ignoredErrors++
|
||||
if (ignoredErrors >= ZcashSdk.RETRIES) {
|
||||
if (dialog == null) {
|
||||
notified = true
|
||||
runOnUiThread {
|
||||
dialog = showCriticalProcessorError(error) {
|
||||
dialog = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("MainActivity has received an error${if (notified) " and notified the user" else ""} and reported it to bugsnag and mixpanel.")
|
||||
feedback.report(error)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onChainError(errorHeight: BlockHeight, rewindHeight: BlockHeight) {
|
||||
feedback.report(Reorg(errorHeight, rewindHeight))
|
||||
}
|
||||
|
||||
// TODO: maybe move this quick helper code somewhere general or throttle the dialogs differently (like with a flow and stream operators, instead)
|
||||
|
||||
private val throttles = mutableMapOf<String, () -> Any>()
|
||||
private val noWork = {}
|
||||
private fun throttle(key: String, delay: Long, block: () -> Any) {
|
||||
// if the key exists, just add the block to run later and exit
|
||||
if (throttles.containsKey(key)) {
|
||||
throttles[key] = block
|
||||
return
|
||||
}
|
||||
block()
|
||||
|
||||
// after doing the work, check back in later and if another request came in, throttle it, otherwise exit
|
||||
throttles[key] = noWork
|
||||
findViewById<View>(android.R.id.content).postDelayed(
|
||||
{
|
||||
throttles[key]?.let { pendingWork ->
|
||||
throttles.remove(key)
|
||||
if (pendingWork !== noWork) throttle(key, delay, pendingWork)
|
||||
}
|
||||
},
|
||||
delay
|
||||
)
|
||||
}
|
||||
|
||||
/* Memo functions that might possibly get moved to MemoUtils */
|
||||
|
||||
suspend fun getSender(transaction: ConfirmedTransaction?): String {
|
||||
if (transaction == null) return getString(R.string.unknown)
|
||||
return MemoUtil.findAddressInMemo(transaction, ::isValidAddress)?.toAbbreviatedAddress()
|
||||
?: getString(R.string.unknown)
|
||||
}
|
||||
|
||||
suspend fun String?.validateAddress(): String? {
|
||||
if (this == null) return null
|
||||
return if (isValidAddress(this)) this else null
|
||||
}
|
||||
|
||||
fun showFirstUseWarning(
|
||||
prefKey: String,
|
||||
@StringRes titleResId: Int = R.string.blank,
|
||||
@StringRes msgResId: Int = R.string.blank,
|
||||
@StringRes positiveResId: Int = android.R.string.ok,
|
||||
@StringRes negativeResId: Int = android.R.string.cancel,
|
||||
action: MainActivity.() -> Unit = {}
|
||||
) {
|
||||
historyViewModel.prefs.getBoolean(prefKey).let { doNotWarnAgain ->
|
||||
if (doNotWarnAgain) {
|
||||
action()
|
||||
return@showFirstUseWarning
|
||||
}
|
||||
}
|
||||
|
||||
val dialogViewBinding = DialogFirstUseMessageBinding.inflate(layoutInflater)
|
||||
|
||||
fun savePref() {
|
||||
dialogViewBinding.dialogFirstUseCheckbox.isChecked.let { wasChecked ->
|
||||
historyViewModel.prefs.setBoolean(prefKey, wasChecked)
|
||||
}
|
||||
}
|
||||
|
||||
dialogViewBinding.dialogMessage.setText(msgResId)
|
||||
if (dialog != null) dialog?.dismiss()
|
||||
// TODO: This should be moved to a DialogFragment, otherwise unmanaged dialogs go away during Activity configuration changes
|
||||
dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(titleResId)
|
||||
.setView(dialogViewBinding.root)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(positiveResId) { d, _ ->
|
||||
d.dismiss()
|
||||
dialog = null
|
||||
savePref()
|
||||
action()
|
||||
}
|
||||
.setNegativeButton(negativeResId) { d, _ ->
|
||||
d.dismiss()
|
||||
dialog = null
|
||||
savePref()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun onLaunchUrl(url: String) {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
} catch (t: Throwable) {
|
||||
showMessage(getString(R.string.error_launch_url))
|
||||
twig("Warning: failed to open browser due to $t")
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/cash/z/ecc/android/ui/MainViewModel.kt
Normal file
36
app/src/main/java/cash/z/ecc/android/ui/MainViewModel.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package cash.z.ecc.android.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _loadingMessage = MutableStateFlow<String?>("\u23F3 Loading...")
|
||||
private val _syncReady = MutableStateFlow(false)
|
||||
val loadingMessage: StateFlow<String?> get() = _loadingMessage
|
||||
val isLoading get() = loadingMessage.value != null
|
||||
|
||||
/**
|
||||
* A flow of booleans representing whether or not the synchronizer has been started. This is
|
||||
* useful for views that want to monitor the status of the wallet but don't want to access the
|
||||
* synchronizer before it is ready to be used. This is also helpful for race conditions where
|
||||
* the status of the synchronizer is needed before it is created.
|
||||
*/
|
||||
val syncReady = _syncReady.asStateFlow()
|
||||
|
||||
fun setLoading(isLoading: Boolean = false, message: String? = null) {
|
||||
twig("MainViewModel.setLoading: $isLoading")
|
||||
_loadingMessage.value = if (!isLoading) {
|
||||
null
|
||||
} else {
|
||||
message ?: "\u23F3 Loading..."
|
||||
}
|
||||
}
|
||||
|
||||
fun setSyncReady(isReady: Boolean) {
|
||||
twig("MainViewModel.setSyncReady: $isReady")
|
||||
_syncReady.value = isReady
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/cash/z/ecc/android/ui/base/BaseFragment.kt
Normal file
95
app/src/main/java/cash/z/ecc/android/ui/base/BaseFragment.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
package cash.z.ecc.android.ui.base
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
abstract class BaseFragment<T : ViewBinding> : Fragment() {
|
||||
// Normally will be of type MainActivity, but will be null when run under automated tests.
|
||||
// A future enhancement would be to move analytics. For example, refactor it out of the Activity
|
||||
// so that we don't have to cast. Or at least put analytics into an interface, so that we're more
|
||||
// explicitly casting to Analytics rather than MainActivity.
|
||||
val mainActivity: MainActivity? get() = if (activity is MainActivity) {
|
||||
activity as MainActivity
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
lateinit var binding: T
|
||||
|
||||
lateinit var resumedScope: CoroutineScope
|
||||
|
||||
open val screen: Report.Screen? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
binding = inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainActivity?.reportScreen(screen)
|
||||
resumedScope = lifecycleScope.coroutineContext.let {
|
||||
CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job]))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
resumedScope.cancel()
|
||||
}
|
||||
|
||||
// inflate is static in the ViewBinding class so we can't handle this ourselves
|
||||
// each fragment must call FragmentMyLayoutBinding.inflate(inflater)
|
||||
abstract fun inflate(@NonNull inflater: LayoutInflater): T
|
||||
|
||||
fun onBackPressNavTo(navResId: Int, block: (() -> Unit) = {}) {
|
||||
mainActivity?.onFragmentBackPressed(this) {
|
||||
block()
|
||||
mainActivity?.safeNavigate(navResId)
|
||||
}
|
||||
}
|
||||
|
||||
fun tapped(tap: Report.Tap) {
|
||||
mainActivity?.reportTap(tap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the given block once, within the 'resumedScope', once the Synchronizer is ready. This
|
||||
* utility function helps solve the problem of taking action with the synchronizer before it
|
||||
* is created. This surfaced while loading keys from secure storage: the HomeFragment would
|
||||
* resume and start monitoring the synchronizer for changes BEFORE the onAttach function
|
||||
* returned, meaning before the synchronizerComponent is created. So a state variable needed to
|
||||
* exist with a longer lifecycle than the synchronizer. This function just takes care of all the
|
||||
* boilerplate of monitoring that state variable until it returns true.
|
||||
*/
|
||||
fun launchWhenSyncReady(block: () -> Unit) {
|
||||
resumedScope.launch {
|
||||
mainActivity?.let {
|
||||
it.mainViewModel.syncReady.filter { isReady -> isReady }.onEach {
|
||||
block()
|
||||
}.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.PagedList
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentHistoryBinding
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.HISTORY_BACK
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HistoryFragment : BaseFragment<FragmentHistoryBinding>() {
|
||||
override val screen = Report.Screen.HISTORY
|
||||
|
||||
private val viewModel: HistoryViewModel by activityViewModels()
|
||||
|
||||
private lateinit var transactionAdapter: TransactionAdapter<ConfirmedTransaction>
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentHistoryBinding =
|
||||
FragmentHistoryBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
twig("HistoryFragment.onViewCreated")
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
initTransactionUI()
|
||||
binding.backButtonHitArea.onClickNavUp { tapped(HISTORY_BACK) }
|
||||
lifecycleScope.launch {
|
||||
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress(10, 10)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
twig("HistoryFragment.onResume")
|
||||
super.onResume()
|
||||
viewModel.balance.filterNotNull().collectWith(resumedScope) {
|
||||
onBalanceUpdated(it)
|
||||
}
|
||||
viewModel.transactions.collectWith(resumedScope) { onTransactionsUpdated(it) }
|
||||
}
|
||||
|
||||
private fun onBalanceUpdated(balance: WalletBalance) {
|
||||
if (balance.available.value < 0) {
|
||||
binding.textBalanceAvailable.text = "Updating"
|
||||
return
|
||||
}
|
||||
|
||||
binding.textBalanceAvailable.text = WalletZecFormmatter.toZecStringShort(balance.available)
|
||||
val change = balance.pending
|
||||
binding.textBalanceDescription.apply {
|
||||
goneIf(change.value <= 0L)
|
||||
val changeString = WalletZecFormmatter.toZecStringFull(change)
|
||||
val expecting = R.string.home_banner_expecting.toAppString(true)
|
||||
val symbol = getString(R.string.symbol)
|
||||
text = "($expecting +$changeString $symbol)".toColoredSpan(R.color.text_light, "+$changeString")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initTransactionUI() {
|
||||
twig("HistoryFragment.initTransactionUI")
|
||||
transactionAdapter = TransactionAdapter()
|
||||
transactionAdapter.stateRestorationPolicy =
|
||||
RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
|
||||
binding.recyclerTransactions.apply {
|
||||
layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
|
||||
adapter = transactionAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTransactionsUpdated(transactions: List<ConfirmedTransaction>) {
|
||||
twig("HistoryFragment.onTransactionsUpdated")
|
||||
transactions.size.let { newCount ->
|
||||
twig("got a new paged list of transactions of length $newCount")
|
||||
binding.groupEmptyViews.goneIf(newCount > 0)
|
||||
|
||||
// tricky: we handle two types of lists, empty and PagedLists. It's not easy to construct an empty PagedList so the SDK currently returns an emptyList() but that will not cast to a PagedList
|
||||
if (newCount == 0) {
|
||||
transactionAdapter.submitList(null)
|
||||
} else {
|
||||
// tricky: for now, explicitly fail (cast exception) if the transactions are not in a PagedList. Otherwise, this would silently fail to show items and be hard to debug if we're ever passed a non-empty list that isn't an instance of PagedList. This awkwardness will go away when we switch to Paging3
|
||||
transactionAdapter.submitList(transactions as PagedList<ConfirmedTransaction>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: maybe implement this for better fade behavior. Or do an actual scroll behavior instead, yeah do that. Or an item decoration.
|
||||
fun onLastItemShown(item: ConfirmedTransaction, position: Int) {
|
||||
binding.footerFade.alpha = position.toFloat() / (binding.recyclerTransactions.adapter?.itemCount ?: 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.WalletZecFormmatter
|
||||
import cash.z.ecc.android.ext.toAppString
|
||||
import cash.z.ecc.android.ext.toAppStringFormatted
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import cash.z.ecc.android.sdk.db.entity.valueInZatoshi
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.ui.util.MemoUtil
|
||||
import cash.z.ecc.android.ui.util.toUtf8Memo
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class HistoryViewModel : ViewModel() {
|
||||
|
||||
private val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
val prefs: LockBox = DependenciesHolder.prefs
|
||||
|
||||
val selectedTransaction = MutableStateFlow<ConfirmedTransaction?>(null)
|
||||
val uiModels = selectedTransaction.map { it.toUiModel() }
|
||||
|
||||
val transactions get() = synchronizer.clearedTransactions
|
||||
val balance get() = synchronizer.saplingBalances
|
||||
val latestHeight get() = synchronizer.latestHeight
|
||||
|
||||
suspend fun getAddress() = synchronizer.getAddress()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("HistoryViewModel cleared!")
|
||||
}
|
||||
|
||||
//
|
||||
// History Item UiModel
|
||||
//
|
||||
|
||||
data class UiModel(
|
||||
var topLabel: String = "",
|
||||
var topValue: String = "",
|
||||
var bottomLabel: String = "",
|
||||
var bottomValue: String = "",
|
||||
var minedHeight: String = "",
|
||||
var timestamp: String = "",
|
||||
var iconRotation: Float = -1f,
|
||||
|
||||
var fee: String? = null,
|
||||
var source: String? = null,
|
||||
var memo: String? = null,
|
||||
var address: String? = null,
|
||||
var isInbound: Boolean? = null,
|
||||
var isMined: Boolean = false,
|
||||
var confirmation: String? = null,
|
||||
var txId: String? = null
|
||||
)
|
||||
|
||||
private suspend fun ConfirmedTransaction?.toUiModel(latestHeight: Int? = null): UiModel =
|
||||
UiModel().apply {
|
||||
this@toUiModel.let { tx ->
|
||||
txId = toTxId(tx?.rawTransactionId)
|
||||
isInbound = when {
|
||||
!(tx?.toAddress.isNullOrEmpty()) -> false
|
||||
tx != null && tx.toAddress.isNullOrEmpty() && tx.value > 0L && tx.minedHeight > 0 -> true
|
||||
else -> null
|
||||
}
|
||||
isMined =
|
||||
tx?.minedHeight != null && tx.minedHeight > synchronizer.network.saplingActivationHeight.value
|
||||
topValue =
|
||||
if (tx == null) "" else "\$${WalletZecFormmatter.toZecStringFull(tx.valueInZatoshi)}"
|
||||
minedHeight = String.format("%,d", tx?.minedHeight ?: 0)
|
||||
val flags =
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_ABBREV_MONTH
|
||||
timestamp =
|
||||
if (tx == null || tx.blockTimeInSeconds <= 0) getString(R.string.transaction_timestamp_unavailable) else DateUtils.getRelativeDateTimeString(
|
||||
ZcashWalletApp.instance,
|
||||
tx.blockTimeInSeconds * 1000,
|
||||
DateUtils.SECOND_IN_MILLIS,
|
||||
DateUtils.WEEK_IN_MILLIS,
|
||||
flags
|
||||
).toString()
|
||||
|
||||
// memo logic
|
||||
val txMemo = tx?.memo.toUtf8Memo()
|
||||
if (!txMemo.isEmpty()) {
|
||||
memo = txMemo
|
||||
}
|
||||
|
||||
// confirmation logic
|
||||
// TODO: clean all of this up and remove/improve reliance on `isSufficientlyOld` function. Also, add a constant for the number of confirmations we expect.
|
||||
tx?.let {
|
||||
val isMined = it.blockTimeInSeconds != 0L
|
||||
if (isMined) {
|
||||
val hasLatestHeight =
|
||||
latestHeight != null && latestHeight > synchronizer.network.saplingActivationHeight.value
|
||||
if (it.minedHeight > 0 && hasLatestHeight) {
|
||||
val confirmations = latestHeight!! - it.minedHeight + 1
|
||||
confirmation =
|
||||
if (confirmations >= 10) getString(R.string.transaction_status_confirmed) else "$confirmations ${
|
||||
getString(
|
||||
R.string.transaction_status_confirming
|
||||
)
|
||||
}"
|
||||
} else {
|
||||
if (!hasLatestHeight && isSufficientlyOld(tx)) {
|
||||
twig("Warning: could not load latestheight from server to determine confirmations but this transaction is mined and old enough to be considered confirmed")
|
||||
confirmation = getString(R.string.transaction_status_confirmed)
|
||||
} else {
|
||||
twig("Warning: could not determine confirmation text value so it will be left null!")
|
||||
confirmation =
|
||||
getString(R.string.transaction_confirmation_count_unavailable)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
confirmation = getString(R.string.transaction_status_pending)
|
||||
}
|
||||
}
|
||||
|
||||
when (isInbound) {
|
||||
true -> {
|
||||
topLabel = getString(R.string.transaction_story_inbound)
|
||||
bottomLabel = getString(R.string.transaction_story_inbound_total)
|
||||
bottomValue = "\$${WalletZecFormmatter.toZecStringFull(tx?.valueInZatoshi)}"
|
||||
iconRotation = 315f
|
||||
source = getString(R.string.transaction_story_to_shielded)
|
||||
address = MemoUtil.findAddressInMemo(
|
||||
tx,
|
||||
(synchronizer as SdkSynchronizer)::isValidAddress
|
||||
)
|
||||
}
|
||||
false -> {
|
||||
topLabel = getString(R.string.transaction_story_outbound)
|
||||
bottomLabel = getString(R.string.transaction_story_outbound_total)
|
||||
bottomValue =
|
||||
"\$${WalletZecFormmatter.toZecStringFull(Zatoshi((tx?.valueInZatoshi?.value ?: 0) + ZcashSdk.MINERS_FEE.value))}"
|
||||
iconRotation = 135f
|
||||
fee = "+ 0.00001 network fee"
|
||||
source = getString(R.string.transaction_story_from_shielded)
|
||||
address = tx?.toAddress
|
||||
}
|
||||
null -> {
|
||||
twig("Error: transaction appears to be invalid.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getString(@StringRes id: Int) = id.toAppString()
|
||||
private fun getString(@StringRes id: Int, vararg args: Any) = id.toAppStringFormatted(args)
|
||||
|
||||
private fun toTxId(tx: ByteArray?): String? {
|
||||
if (tx == null) return null
|
||||
val sb = StringBuilder(tx.size * 2)
|
||||
for (i in (tx.size - 1) downTo 0) {
|
||||
sb.append(String.format("%02x", tx[i]))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
// TODO: determine this in a more generic and technically correct way. For now, this is good enough.
|
||||
// the goal is just to improve the edge cases where the latest height isn't known but other
|
||||
// information suggests that the TX is confirmed. We can improve this, later.
|
||||
private fun isSufficientlyOld(tx: ConfirmedTransaction): Boolean {
|
||||
val threshold = 75 * 1000 * 25 // approx 25 blocks
|
||||
val delta = System.currentTimeMillis() / 1000L - tx.blockTimeInSeconds
|
||||
return tx.minedHeight > synchronizer.network.saplingActivationHeight.value &&
|
||||
delta < threshold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
|
||||
class TransactionAdapter<T : ConfirmedTransaction> :
|
||||
PagedListAdapter<T, TransactionViewHolder<T>>(
|
||||
object : DiffUtil.ItemCallback<T>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: T,
|
||||
newItem: T
|
||||
) = oldItem.minedHeight == newItem.minedHeight && oldItem.noteId == newItem.noteId &&
|
||||
// bugfix: distinguish between self-transactions so they don't overwrite each other in the UI // TODO confirm that this is working, as intended
|
||||
((oldItem.raw == null && newItem.raw == null) || (oldItem.raw != null && newItem.raw != null && oldItem.raw!!.contentEquals(newItem.raw!!)))
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: T,
|
||||
newItem: T
|
||||
) = oldItem == newItem
|
||||
}
|
||||
) {
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
) = TransactionViewHolder<T>(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.item_transaction, parent, false)
|
||||
)
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: TransactionViewHolder<T>,
|
||||
position: Int
|
||||
) = holder.bindTo(getItem(position))
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return getItem(position)?.id ?: -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.os.Bundle
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.transition.*
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentTransactionBinding
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.history.HistoryViewModel.UiModel
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TransactionFragment : BaseFragment<FragmentTransactionBinding>() {
|
||||
override val screen = Report.Screen.TRANSACTION
|
||||
private val viewModel: HistoryViewModel by activityViewModels()
|
||||
|
||||
var isMemoExpanded: Boolean = false
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentTransactionBinding =
|
||||
FragmentTransactionBinding.inflate(inflater)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// val transition = TransitionInflater.from(requireContext()).inflateTransition(android.R.transition.move)
|
||||
// sharedElementEnterTransition = transition
|
||||
// sharedElementReturnTransition = transition
|
||||
|
||||
// sharedElementEnterTransition = createSharedElementTransition()
|
||||
// sharedElementReturnTransition = createSharedElementTransition()
|
||||
|
||||
// sharedElementEnterTransition = ChangeBounds().apply { duration = 1500 }
|
||||
// sharedElementReturnTransition = ChangeBounds().apply { duration = 1500 }
|
||||
// enterTransition = Fade().apply {
|
||||
// duration = 1800
|
||||
// // slideEdge = Gravity.END
|
||||
// }
|
||||
}
|
||||
|
||||
private fun createSharedElementTransition(duration: Long = 800L): Transition {
|
||||
return TransitionSet().apply {
|
||||
ordering = TransitionSet.ORDERING_TOGETHER
|
||||
this.duration = duration
|
||||
// interpolator = PathInterpolatorCompat.create(0.4f, 0f, 0.2f, 1f)
|
||||
addTransition(ChangeBounds())
|
||||
addTransition(ChangeClipBounds())
|
||||
addTransition(ChangeTransform())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.apply {
|
||||
ViewCompat.setTransitionName(
|
||||
topBoxValue,
|
||||
"test_amount_anim_${viewModel.selectedTransaction.value?.id}"
|
||||
)
|
||||
ViewCompat.setTransitionName(
|
||||
topBoxBackground,
|
||||
"test_bg_anim_${viewModel.selectedTransaction.value?.id}"
|
||||
)
|
||||
backButtonHitArea.onClickNavBack { tapped(Report.Tap.TRANSACTION_BACK) }
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.uiModels.stateIn(lifecycleScope).collect { uiModel ->
|
||||
topBoxLabel.text = uiModel.topLabel
|
||||
topBoxValue.text = uiModel.topValue
|
||||
bottomBoxLabel.text = uiModel.bottomLabel
|
||||
bottomBoxValue.text = uiModel.bottomValue
|
||||
textBlockHeight.text = uiModel.minedHeight
|
||||
textTimestamp.text = uiModel.timestamp
|
||||
if (uiModel.iconRotation < 0) {
|
||||
topBoxIcon.gone()
|
||||
} else {
|
||||
topBoxIcon.rotation = uiModel.iconRotation
|
||||
topBoxIcon.visible()
|
||||
}
|
||||
|
||||
if (!uiModel.isMined) {
|
||||
textBlockHeight.invisible()
|
||||
textBlockHeightPrefix.invisible()
|
||||
}
|
||||
|
||||
val exploreOnClick = View.OnClickListener {
|
||||
uiModel.txId?.let { txId ->
|
||||
mainActivity?.showFirstUseWarning(
|
||||
Const.Pref.FIRST_USE_VIEW_TX,
|
||||
titleResId = R.string.dialog_first_use_view_tx_title,
|
||||
msgResId = R.string.dialog_first_use_view_tx_message,
|
||||
positiveResId = R.string.dialog_first_use_view_tx_positive,
|
||||
negativeResId = R.string.dialog_first_use_view_tx_negative
|
||||
) {
|
||||
onLaunchUrl(txId.toTransactionUrl())
|
||||
}
|
||||
}
|
||||
}
|
||||
buttonExplore.setOnClickListener(exploreOnClick)
|
||||
textBlockHeight.setOnClickListener(exploreOnClick)
|
||||
|
||||
uiModel.fee?.let {
|
||||
subwaySpotFee.visible(); subwayLabelFee.visible(); subwayLabelFee.text = it
|
||||
}
|
||||
uiModel.source?.let {
|
||||
subwaySpotSource.visible(); subwayLabelSource.visible(); subwayLabelSource.text =
|
||||
it
|
||||
}
|
||||
uiModel.toAddressLabel()?.let {
|
||||
subwaySpotAddress.visible(); subwayLabelAddress.visible(); subwayLabelAddress.text =
|
||||
it
|
||||
}
|
||||
uiModel.toAddressClickListener()
|
||||
?.let { subwayLabelAddress.setOnClickListener(it) }
|
||||
|
||||
// TODO: remove logic from sections below and add more fields or extension functions to UiModel
|
||||
uiModel.confirmation?.let {
|
||||
subwaySpotConfirmations.visible(); subwayLabelConfirmations.visible()
|
||||
subwayLabelConfirmations.text = it
|
||||
if (it.equals(getString(R.string.transaction_status_confirmed), true)) {
|
||||
subwayLabelConfirmations.setTextColor(R.color.tx_primary.toAppColor())
|
||||
} else {
|
||||
subwayLabelConfirmations.setTextColor(R.color.tx_text_light_dimmed.toAppColor())
|
||||
}
|
||||
}
|
||||
|
||||
uiModel.memo?.let {
|
||||
hitAreaMemoSubway.setOnClickListener { _ ->
|
||||
onToggleMemo(
|
||||
!isMemoExpanded,
|
||||
it
|
||||
)
|
||||
}
|
||||
hitAreaMemoIcon.setOnClickListener { _ ->
|
||||
onToggleMemo(
|
||||
!isMemoExpanded,
|
||||
it
|
||||
)
|
||||
}
|
||||
subwayLabelMemo.setOnClickListener { _ ->
|
||||
onToggleMemo(
|
||||
!isMemoExpanded,
|
||||
it
|
||||
)
|
||||
}
|
||||
subwayLabelMemo.setOnLongClickListener { _ ->
|
||||
mainActivity?.copyText(it, "Memo")
|
||||
true
|
||||
}
|
||||
subwayLabelMemo.movementMethod = ScrollingMovementMethod()
|
||||
subwaySpotMemoContent.visible()
|
||||
subwayLabelMemo.visible()
|
||||
hitAreaMemoSubway.visible()
|
||||
onToggleMemo(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val invertingMatrix = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
|
||||
private fun onToggleMemo(isExpanded: Boolean, memo: String = "") {
|
||||
twig("onToggleMemo($isExpanded, $memo)")
|
||||
if (isExpanded) {
|
||||
twig("setting memo text to: $memo")
|
||||
binding.subwayLabelMemo.setText(memo)
|
||||
binding.subwayLabelMemo.invalidate()
|
||||
// don't impede the ability to scroll
|
||||
binding.groupMemoIcon.gone()
|
||||
binding.subwayLabelMemo.backgroundTintList =
|
||||
ColorStateList.valueOf(R.color.tx_text_light_dimmed.toAppColor())
|
||||
binding.subwaySpotMemoContent.colorFilter = invertingMatrix
|
||||
binding.subwaySpotMemoContent.rotation = 90.0f
|
||||
} else {
|
||||
binding.subwayLabelMemo.setText(getString(R.string.transaction_with_memo))
|
||||
binding.subwayLabelMemo.scrollTo(0, 0)
|
||||
binding.subwayLabelMemo.invalidate()
|
||||
twig("setting memo text to: with a memo")
|
||||
binding.groupMemoIcon.visible()
|
||||
binding.subwayLabelMemo.backgroundTintList =
|
||||
ColorStateList.valueOf(R.color.tx_primary.toAppColor())
|
||||
binding.subwaySpotMemoContent.colorFilter = null
|
||||
binding.subwaySpotMemoContent.rotation = 0.0f
|
||||
}
|
||||
isMemoExpanded = isExpanded
|
||||
}
|
||||
|
||||
private fun String.toTransactionUrl(): String {
|
||||
return getString(R.string.api_block_explorer, this)
|
||||
}
|
||||
|
||||
private fun UiModel?.toAddressClickListener(): View.OnClickListener? {
|
||||
return this?.address?.let { addr ->
|
||||
View.OnClickListener { mainActivity?.copyText(addr, "Address") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UiModel?.toAddressLabel(): CharSequence? {
|
||||
if (this == null || this.address == null || this.isInbound == null) return null
|
||||
val prefix = getString(
|
||||
if (isInbound == true) {
|
||||
R.string.transaction_prefix_from
|
||||
} else {
|
||||
R.string.transaction_prefix_to
|
||||
}
|
||||
)
|
||||
return "$prefix ${address?.toAbbreviatedAddress() ?: "Unknown"}".let {
|
||||
it.toColoredSpan(R.color.tx_text_light_dimmed, if (address == null) it else prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ext.WalletZecFormmatter
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.locale
|
||||
import cash.z.ecc.android.ext.toAppColor
|
||||
import cash.z.ecc.android.ext.toAppInt
|
||||
import cash.z.ecc.android.ext.toColoredSpan
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import cash.z.ecc.android.sdk.db.entity.valueInZatoshi
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.isShielded
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import cash.z.ecc.android.ui.util.toUtf8Memo
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val indicator = itemView.findViewById<View>(R.id.indicator)
|
||||
private val amountText = itemView.findViewById<TextView>(R.id.text_transaction_amount)
|
||||
private val topText = itemView.findViewById<TextView>(R.id.text_transaction_top)
|
||||
private val bottomText = itemView.findViewById<TextView>(R.id.text_transaction_bottom)
|
||||
private val transactionArrow = itemView.findViewById<ImageView>(R.id.image_transaction_arrow)
|
||||
private val formatter = SimpleDateFormat(itemView.context.getString(R.string.format_transaction_history_date_time), itemView.context.locale())
|
||||
private val iconMemo = itemView.findViewById<ImageView>(R.id.image_memo)
|
||||
|
||||
fun bindTo(transaction: T?) {
|
||||
val mainActivity = itemView.context as MainActivity
|
||||
mainActivity.lifecycleScope.launch {
|
||||
// update view
|
||||
var lineOne: CharSequence = ""
|
||||
var lineTwo = ""
|
||||
var amountZec = ""
|
||||
var amountDisplay = ""
|
||||
var amountColor: Int = R.color.text_light
|
||||
var lineOneColor: Int = R.color.text_light
|
||||
var lineTwoColor: Int = R.color.text_light_dimmed
|
||||
var indicatorBackground: Int = R.color.text_light_dimmed
|
||||
var arrowRotation: Int = R.integer.transaction_arrow_rotation_send
|
||||
var arrowBackgroundTint: Int = R.color.text_light
|
||||
var isLineOneSpanned = false
|
||||
|
||||
try {
|
||||
transaction?.apply {
|
||||
itemView.setOnClickListener {
|
||||
onTransactionClicked(this)
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
onTransactionLongPressed(this)
|
||||
true
|
||||
}
|
||||
amountZec = WalletZecFormmatter.toZecStringShort(valueInZatoshi)
|
||||
// TODO: these might be good extension functions
|
||||
val timestamp = formatter.format(blockTimeInSeconds * 1000L)
|
||||
val isMined = blockTimeInSeconds != 0L
|
||||
when {
|
||||
!toAddress.isNullOrEmpty() -> {
|
||||
indicatorBackground =
|
||||
if (isMined) R.color.zcashRed else R.color.zcashGray
|
||||
lineOne = "${
|
||||
if (isMined) str(R.string.transaction_address_you_paid) else str(R.string.transaction_address_paying)
|
||||
} ${toAddress?.toAbbreviatedAddress()}"
|
||||
lineTwo =
|
||||
if (isMined) "${str(R.string.transaction_status_sent)} $timestamp" else str(
|
||||
R.string.transaction_status_pending
|
||||
)
|
||||
// TODO: this logic works but is sloppy. Find a more robust solution to displaying information about expiration (such as expires in 1 block, etc). Then if it is way beyond expired, remove it entirely. Perhaps give the user a button for that (swipe to dismiss?)
|
||||
if (!isMined && (expiryHeight != null) && (expiryHeight!! < mainActivity.latestHeight?.value ?: -1)) lineTwo =
|
||||
str(R.string.transaction_status_expired)
|
||||
amountDisplay = "- $amountZec"
|
||||
if (isMined) {
|
||||
arrowRotation = R.integer.transaction_arrow_rotation_send
|
||||
amountColor = R.color.transaction_sent
|
||||
if (toAddress.isShielded()) {
|
||||
lineOneColor = R.color.zcashYellow
|
||||
} else {
|
||||
toAddress?.toAbbreviatedAddress()?.let {
|
||||
lineOne = lineOne.toColoredSpan(R.color.zcashBlueDark, it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
arrowRotation = R.integer.transaction_arrow_rotation_pending
|
||||
}
|
||||
}
|
||||
toAddress.isNullOrEmpty() && value > 0L && minedHeight > 0 -> {
|
||||
indicatorBackground = R.color.zcashGreen
|
||||
val senderAddress = mainActivity.getSender(transaction)
|
||||
lineOne = "${str(R.string.transaction_received_from)} $senderAddress"
|
||||
lineTwo = "${str(R.string.transaction_received)} $timestamp"
|
||||
amountDisplay = "+ $amountZec"
|
||||
if (senderAddress.isShielded()) {
|
||||
amountColor = R.color.zcashYellow
|
||||
lineOneColor = R.color.zcashYellow
|
||||
} else {
|
||||
senderAddress.toAbbreviatedAddress().let {
|
||||
lineOne =
|
||||
if (senderAddress.equals(str(R.string.unknown), true)) {
|
||||
lineOne.toColoredSpan(R.color.zcashYellow, it)
|
||||
} else {
|
||||
lineOne.toColoredSpan(R.color.zcashBlueDark, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
arrowRotation = R.integer.transaction_arrow_rotation_received
|
||||
}
|
||||
else -> {
|
||||
lineOne = str(R.string.unknown)
|
||||
lineTwo = str(R.string.unknown)
|
||||
amountDisplay = amountZec
|
||||
amountColor = R.color.text_light
|
||||
arrowRotation = R.integer.transaction_arrow_rotation_received
|
||||
}
|
||||
}
|
||||
// sanitize amount
|
||||
if (value < ZcashSdk.MINERS_FEE.value * 10) amountDisplay = "< 0.0001"
|
||||
else if (amountZec.length > 10) { // 10 allows 3 digits to the left and 6 to the right of the decimal
|
||||
amountDisplay = str(R.string.transaction_instruction_tap)
|
||||
}
|
||||
}
|
||||
|
||||
topText.text = lineOne
|
||||
bottomText.text = lineTwo
|
||||
amountText.text = amountDisplay
|
||||
amountText.setTextColor(amountColor.toAppColor())
|
||||
if (!isLineOneSpanned) {
|
||||
topText.setTextColor(lineOneColor.toAppColor())
|
||||
}
|
||||
bottomText.setTextColor(lineTwoColor.toAppColor())
|
||||
indicator.setBackgroundColor(indicatorBackground.toAppColor())
|
||||
transactionArrow.setColorFilter(arrowBackgroundTint.toAppColor())
|
||||
transactionArrow.rotation = arrowRotation.toAppInt().toFloat()
|
||||
|
||||
var bottomTextRightDrawable: Drawable? = null
|
||||
iconMemo.goneIf(!transaction?.memo.toUtf8Memo().isNotEmpty())
|
||||
bottomText.setCompoundDrawablesWithIntrinsicBounds(null, null, bottomTextRightDrawable, null)
|
||||
} catch (t: Throwable) {
|
||||
twig("Failed to parse the transaction due to $t")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTransactionClicked(transaction: ConfirmedTransaction) {
|
||||
(itemView.context as MainActivity).apply {
|
||||
historyViewModel.selectedTransaction.value = transaction
|
||||
safeNavigate(R.id.action_nav_history_to_nav_transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTransactionLongPressed(transaction: ConfirmedTransaction) {
|
||||
val mainActivity = itemView.context as MainActivity
|
||||
transaction.toAddress?.let {
|
||||
mainActivity.copyText(it, "Transaction Address")
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun str(@StringRes resourceId: Int) = itemView.context.getString(resourceId)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
//
|
||||
// import android.content.Context
|
||||
// import android.graphics.Canvas
|
||||
// import android.graphics.Rect
|
||||
// import android.view.LayoutInflater
|
||||
// import android.view.View
|
||||
// import androidx.recyclerview.widget.RecyclerView
|
||||
// import cash.z.ecc.android.R
|
||||
//
|
||||
//
|
||||
// class TransactionsDrawableFooter(context: Context) : RecyclerView.ItemDecoration() {
|
||||
//
|
||||
// private var footer: View =
|
||||
// LayoutInflater.from(context).inflate(R.layout.footer_transactions, null, false)
|
||||
//
|
||||
// override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
// super.onDraw(c, parent, state!!)
|
||||
// footer.measure(
|
||||
// View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.AT_MOST),
|
||||
// View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
// )
|
||||
// // layout basically just gets drawn on the reserved space on top of the first view
|
||||
// footer.layout(parent.left, 0, parent.right, footer.measuredHeight)
|
||||
// for (i in 0 until parent.childCount) {
|
||||
// val view: View = parent.getChildAt(i)
|
||||
// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
|
||||
// c.save()
|
||||
// val height: Int = footer.measuredHeight
|
||||
// val top: Int = view.top - height
|
||||
// c.translate(0.0f, top.toFloat())
|
||||
// footer.draw(c)
|
||||
// c.restore()
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun getItemOffsets(
|
||||
// outRect: Rect,
|
||||
// view: View,
|
||||
// parent: RecyclerView,
|
||||
// state: RecyclerView.State
|
||||
// ) {
|
||||
// super.getItemOffsets(outRect, view, parent, state)
|
||||
// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
|
||||
// outRect.set(0, 0, 0, 150)
|
||||
// } else {
|
||||
// outRect.setEmpty()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,48 @@
|
||||
package cash.z.ecc.android.ui.history
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
|
||||
class TransactionsFooter(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private var footer: Drawable = context.resources.getDrawable(R.drawable.background_footer)
|
||||
val bounds = Rect()
|
||||
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
c.save()
|
||||
val left: Int = 0
|
||||
val right: Int = parent.width
|
||||
val childCount = parent.childCount
|
||||
val adapterItemCount = parent.adapter!!.itemCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = parent.getChildAt(i)
|
||||
if (parent.getChildAdapterPosition(child) == adapterItemCount - 1) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
val bottom: Int = bounds.bottom + Math.round(child.translationY)
|
||||
val top: Int = bottom - footer.intrinsicHeight
|
||||
footer.setBounds(left, top, right, bottom)
|
||||
footer.draw(c)
|
||||
}
|
||||
}
|
||||
c.restore()
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
super.getItemOffsets(outRect, view, parent, state)
|
||||
if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
|
||||
outRect.set(0, 0, 0, footer.intrinsicHeight)
|
||||
} else {
|
||||
outRect.setEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import cash.z.ecc.android.databinding.FragmentAutoShieldInformationBinding
|
||||
import cash.z.ecc.android.ext.requireApplicationContext
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.preference.Preferences
|
||||
import cash.z.ecc.android.preference.model.put
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
class AutoshieldingInformationFragment : BaseFragment<FragmentAutoShieldInformationBinding>() {
|
||||
override val screen = Report.Screen.AUTO_SHIELD_INFORMATION
|
||||
|
||||
private val args: AutoshieldingInformationFragmentArgs by navArgs()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentAutoShieldInformationBinding =
|
||||
FragmentAutoShieldInformationBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
/*
|
||||
* Once the fragment is displayed, acknowledge it was presented to the user. While it might
|
||||
* be better to have explicit user interaction (positive/negative button or back),
|
||||
* this implementation is simpler. Hooking into the positive/negative button is easy, but
|
||||
* hooking into the back button from a Fragment ends up being gross.
|
||||
*
|
||||
* Always acknowledging is necessary, because the HomeFragment will otherwise almost immediately
|
||||
* re-launch this Fragment when it refreshes the UI (and therefore re-runs the
|
||||
* check as to whether the preference to display this fragment has been set).
|
||||
*/
|
||||
acknowledge()
|
||||
|
||||
binding.buttonAutoshieldDismiss.setOnClickListener {
|
||||
if (args.isStartAutoshield) {
|
||||
findNavController().navigate(AutoshieldingInformationFragmentDirections.actionNavAutoshieldingInfoToAutoshield())
|
||||
} else {
|
||||
findNavController().navigate(AutoshieldingInformationFragmentDirections.actionNavAutoshieldingInfoToHome())
|
||||
}
|
||||
}
|
||||
binding.buttonAutoshieldMoreInfo.setOnClickListener {
|
||||
try {
|
||||
findNavController().navigate(AutoshieldingInformationFragmentDirections.actionNavAutoshieldingInfoToBrowser())
|
||||
} catch (e: Exception) {
|
||||
// ActivityNotFoundException could happen on certain devices, like Android TV, Android Things, etc.
|
||||
|
||||
// SecurityException shouldn't occur, but just in case we catch all exceptions to
|
||||
// prevent another package on the device from crashing us if that package tries to be malicious
|
||||
// by adding permissions or changing export status dynamically.
|
||||
|
||||
// In the future, it might also be desirable to display a Toast or Snackbar indicating
|
||||
// that the browser couldn't be launched
|
||||
|
||||
findNavController().navigate(AutoshieldingInformationFragmentDirections.actionNavAutoshieldingInfoToHome())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun acknowledge() {
|
||||
Preferences.isAcknowledgedAutoshieldingInformationPrompt.put(
|
||||
requireApplicationContext(),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentBalanceDetailBinding
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.ext.toAppColor
|
||||
import cash.z.ecc.android.ext.toSplitColorSpan
|
||||
import cash.z.ecc.android.feedback.Report.Tap.RECEIVE_BACK
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.home.BalanceDetailViewModel.StatusModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BalanceDetailFragment : BaseFragment<FragmentBalanceDetailBinding>() {
|
||||
|
||||
private val viewModel: BalanceDetailViewModel by viewModels()
|
||||
private var lastSignal: BlockHeight? = null
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentBalanceDetailBinding =
|
||||
FragmentBalanceDetailBinding.inflate(inflater)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.balances.onEach { onBalanceUpdated(it) }.launchIn(this)
|
||||
viewModel.statuses.onEach { onStatusUpdated(it) }.launchIn(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hitAreaExit.onClickNavBack() { tapped(RECEIVE_BACK) }
|
||||
binding.textShieldedHushTitle.text = "SHIELDED ${getString(R.string.symbol)}"
|
||||
}
|
||||
|
||||
private fun onBalanceUpdated(balanceModel: BalanceDetailViewModel.BalanceModel) {
|
||||
balanceModel.apply {
|
||||
if (balanceModel.hasData()) {
|
||||
setBalances(paddedShielded, paddedTransparent, paddedTotal)
|
||||
} else {
|
||||
setBalances(" --", " --", " --")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStatusUpdated(status: StatusModel) {
|
||||
binding.textStatus.text = status.toStatus()
|
||||
if (status.missingBlocks > 100) {
|
||||
binding.textBlockHeightPrefix.text = "Processing "
|
||||
binding.textBlockHeight.text = String.format(
|
||||
"%,d",
|
||||
status.info.lastScannedHeight?.value ?: 0
|
||||
) + " of " + String.format("%,d", status.info.networkBlockHeight?.value ?: 0)
|
||||
} else {
|
||||
status.info.lastScannedHeight.let { height ->
|
||||
if (height == null) {
|
||||
binding.textBlockHeightPrefix.text = "Processing..."
|
||||
binding.textBlockHeight.text = ""
|
||||
} else {
|
||||
binding.textBlockHeightPrefix.text = "Balances as of block "
|
||||
binding.textBlockHeight.text =
|
||||
String.format("%,d", status.info.lastScannedHeight?.value ?: 0)
|
||||
sendNewBlockSignal(status.info.lastScannedHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNewBlockSignal(currentHeight: BlockHeight?) {
|
||||
// prevent a flood of signals while scanning blocks
|
||||
if (lastSignal != null && (currentHeight?.value ?: 0) > lastSignal!!.value) {
|
||||
mainActivity?.vibrate(0, 100, 100, 300)
|
||||
Toast.makeText(mainActivity, "New block!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
lastSignal = currentHeight
|
||||
}
|
||||
|
||||
fun setBalances(shielded: String, transparent: String, total: String) {
|
||||
binding.textShieldAmount.text = shielded.colorize()
|
||||
}
|
||||
|
||||
private fun String.colorize(): CharSequence {
|
||||
val dotIndex = indexOf('.')
|
||||
return if (dotIndex < 0 || length < (dotIndex + 4)) {
|
||||
this
|
||||
} else {
|
||||
toSplitColorSpan(R.color.text_light, R.color.zcashWhite_24, indexOf('.') + 4)
|
||||
}
|
||||
}
|
||||
|
||||
private fun StatusModel.toStatus(): String {
|
||||
fun String.plural(count: Int) = if (count > 1) "${this}s" else this
|
||||
|
||||
if (viewModel.latestBalance?.hasData() == false) {
|
||||
return "Balance info is not yet available"
|
||||
}
|
||||
|
||||
var status = ""
|
||||
if (hasUnmined) {
|
||||
val count = pendingUnmined.count()
|
||||
status += "Balance excludes $count unconfirmed ${"transaction".plural(count)}. "
|
||||
}
|
||||
|
||||
status += when {
|
||||
hasPendingTransparentBalance && hasPendingShieldedBalance -> {
|
||||
"Awaiting ${pendingShieldedBalance.convertZatoshiToZecString(8)} ${
|
||||
ZcashWalletApp.instance.getString(
|
||||
R.string.symbol
|
||||
)
|
||||
} in shielded funds and {pendingTransparentBalance.convertZatoshiToZecString(8)} ${
|
||||
ZcashWalletApp.instance.getString(
|
||||
R.string.symbol
|
||||
)
|
||||
} in transparent funds"
|
||||
}
|
||||
hasPendingShieldedBalance -> {
|
||||
"Awaiting ${pendingShieldedBalance.convertZatoshiToZecString(8)} ${
|
||||
ZcashWalletApp.instance.getString(
|
||||
R.string.symbol
|
||||
)
|
||||
} in shielded funds"
|
||||
}
|
||||
hasPendingTransparentBalance -> {
|
||||
"Awaiting ${pendingTransparentBalance.convertZatoshiToZecString(8)} ${
|
||||
ZcashWalletApp.instance.getString(
|
||||
R.string.symbol
|
||||
)
|
||||
} in transparent funds"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
|
||||
pendingUnconfirmed.count().takeUnless { it == 0 }?.let { count ->
|
||||
if (status.contains("Awaiting")) status += " and "
|
||||
status += "$count outbound ${"transaction".plural(count)}"
|
||||
remainingConfirmations().firstOrNull()?.let { remaining ->
|
||||
status += " with $remaining ${"confirmation".plural(remaining.toInt())} remaining"
|
||||
}
|
||||
}
|
||||
|
||||
return if (status.isEmpty()) "All funds are available!" else status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
||||
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
|
||||
import cash.z.ecc.android.sdk.db.entity.isMined
|
||||
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
|
||||
class BalanceDetailViewModel : ViewModel() {
|
||||
|
||||
private val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
private val lockBox: LockBox = DependenciesHolder.lockBox
|
||||
|
||||
var showAvailable: Boolean = true
|
||||
set(value) {
|
||||
field = value
|
||||
latestBalance?.showAvailable = value
|
||||
}
|
||||
|
||||
var latestBalance: BalanceModel? = null
|
||||
|
||||
val balances: Flow<BalanceModel>
|
||||
get() = combineTransform(
|
||||
synchronizer.saplingBalances,
|
||||
synchronizer.transparentBalances
|
||||
) { saplingBalance, transparentBalance ->
|
||||
BalanceModel(saplingBalance, transparentBalance, showAvailable).let {
|
||||
latestBalance = it
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
val statuses: Flow<StatusModel>
|
||||
get() = combineTransform(
|
||||
balances,
|
||||
synchronizer.pendingTransactions,
|
||||
synchronizer.processorInfo
|
||||
) { balances, pending, info ->
|
||||
emit(StatusModel(balances, pending, info))
|
||||
}
|
||||
|
||||
data class BalanceModel(
|
||||
val shieldedBalance: WalletBalance?,
|
||||
val transparentBalance: WalletBalance?,
|
||||
var showAvailable: Boolean = false
|
||||
) {
|
||||
/** Whether to make calculations based on total or available zatoshi */
|
||||
|
||||
val canAutoShield: Boolean =
|
||||
(transparentBalance?.available?.value ?: 0L) > ZcashSdk.MINERS_FEE.value
|
||||
|
||||
val balanceShielded: String
|
||||
get() {
|
||||
return if (showAvailable) shieldedBalance?.available.toDisplay()
|
||||
else shieldedBalance?.total.toDisplay()
|
||||
}
|
||||
|
||||
val balanceTransparent: String
|
||||
get() {
|
||||
return if (showAvailable) transparentBalance?.available.toDisplay()
|
||||
else transparentBalance?.total.toDisplay()
|
||||
}
|
||||
|
||||
val balanceTotal: String
|
||||
get() {
|
||||
return if (showAvailable) ((shieldedBalance?.available
|
||||
?: Zatoshi(0)) + (transparentBalance?.available ?: Zatoshi(0))).toDisplay()
|
||||
else ((shieldedBalance?.total ?: Zatoshi(0)) + (transparentBalance?.total
|
||||
?: Zatoshi(0))).toDisplay()
|
||||
}
|
||||
|
||||
val paddedShielded get() = pad(balanceShielded)
|
||||
val paddedTransparent get() = pad(balanceTransparent)
|
||||
val paddedTotal get() = pad(balanceTotal)
|
||||
val maxLength
|
||||
get() = maxOf(
|
||||
balanceShielded.length,
|
||||
balanceTransparent.length,
|
||||
balanceTotal.length
|
||||
)
|
||||
val hasPending =
|
||||
(null != shieldedBalance && shieldedBalance.available != shieldedBalance.total) ||
|
||||
(null != transparentBalance && transparentBalance.available != transparentBalance.total)
|
||||
|
||||
private fun Zatoshi?.toDisplay(): String {
|
||||
return this?.convertZatoshiToZecString(8, 8) ?: "0"
|
||||
}
|
||||
|
||||
private fun pad(balance: String): String {
|
||||
var diffLength = maxLength - balance.length
|
||||
return buildString {
|
||||
repeat(diffLength) {
|
||||
append(' ')
|
||||
}
|
||||
append(balance)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasData(): Boolean {
|
||||
return shieldedBalance != null || transparentBalance != null
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusModel(
|
||||
val balances: BalanceModel,
|
||||
val pending: List<PendingTransaction>,
|
||||
val info: CompactBlockProcessor.ProcessorInfo,
|
||||
) {
|
||||
val pendingUnconfirmed =
|
||||
pending.filter { it.isSubmitSuccess() && it.isMined() && !it.isConfirmed(info.lastScannedHeight) }
|
||||
val pendingUnmined = pending.filter { it.isSubmitSuccess() && !it.isMined() }
|
||||
val pendingShieldedBalance = balances.shieldedBalance?.pending
|
||||
val pendingTransparentBalance = balances.transparentBalance?.pending
|
||||
val hasUnconfirmed = pendingUnconfirmed.isNotEmpty()
|
||||
val hasUnmined = pendingUnmined.isNotEmpty()
|
||||
val hasPendingShieldedBalance = (pendingShieldedBalance?.value ?: 0L) > 0L
|
||||
val hasPendingTransparentBalance = (pendingTransparentBalance?.value ?: 0L) > 0L
|
||||
val missingBlocks = ((info.networkBlockHeight?.value ?: 0) - (info.lastScannedHeight?.value
|
||||
?: 0)).coerceAtLeast(0)
|
||||
|
||||
private fun PendingTransaction.isConfirmed(networkBlockHeight: BlockHeight?): Boolean {
|
||||
return networkBlockHeight?.let {
|
||||
isMined() && (it.value - minedHeight + 1) > 10 // fix: plus 1 because the mined block counts as the FIRST confirmation
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun remainingConfirmations(confirmationsRequired: Int = 10) =
|
||||
pendingUnconfirmed
|
||||
.map {
|
||||
confirmationsRequired - ((info.lastScannedHeight?.value
|
||||
?: -1) - it.minedHeight + 1)
|
||||
} // fix: plus 1 because the mined block counts as the FIRST confirmation
|
||||
.filter { it > 0 }
|
||||
.sortedDescending()
|
||||
}
|
||||
}
|
||||
596
app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt
Normal file
596
app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt
Normal file
@@ -0,0 +1,596 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.DialogSolicitFeedbackRatingBinding
|
||||
import cash.z.ecc.android.databinding.FragmentHomeBinding
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.preference.Preferences
|
||||
import cash.z.ecc.android.preference.model.get
|
||||
import cash.z.ecc.android.sdk.Synchronizer.Status.*
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
|
||||
import cash.z.ecc.android.sdk.ext.onFirstWith
|
||||
import cash.z.ecc.android.sdk.ext.safelyConvertToBigDecimal
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
|
||||
import cash.z.ecc.android.ui.send.AutoShieldFragment
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
|
||||
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.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.runningReduce
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// There are deprecations with the use of BroadcastChannel
|
||||
@kotlinx.coroutines.ObsoleteCoroutinesApi
|
||||
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
override val screen = Report.Screen.HOME
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels()
|
||||
private val sendViewModel: SendViewModel by activityViewModels()
|
||||
private val viewModel: HomeViewModel by viewModels()
|
||||
|
||||
private lateinit var numberPad: List<TextView>
|
||||
private lateinit var uiModel: HomeViewModel.UiModel
|
||||
|
||||
lateinit var snake: MagicSnakeLoader
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
|
||||
FragmentHomeBinding.inflate(inflater)
|
||||
|
||||
//
|
||||
// LifeCycle
|
||||
//
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
twig("HomeFragment.onAttach")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ")
|
||||
twig("ZZZ ===================== HOME FRAGMENT CREATED ==================================")
|
||||
super.onAttach(context)
|
||||
|
||||
walletSetup.checkSeed().onFirstWith(lifecycleScope) {
|
||||
if (it == NO_SEED) {
|
||||
// interact with user to create, backup and verify seed
|
||||
// leads to a call to startSync(), later (after accounts are created from seed)
|
||||
twig("Previous wallet not found, therefore, launching seed creation flow")
|
||||
mainActivity?.setLoading(false)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_create_wallet)
|
||||
} else {
|
||||
twig("Previous wallet found. Re-opening it.")
|
||||
mainActivity?.setLoading(true)
|
||||
try {
|
||||
walletSetup.openStoredWallet()
|
||||
mainActivity?.startSync()
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
mainActivity?.showSharedLibraryCriticalError(e)
|
||||
}
|
||||
twig("Done reopening wallet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
twig("HomeFragment.onViewCreated uiModel: ${::uiModel.isInitialized} saved: ${savedInstanceState != null}")
|
||||
with(binding) {
|
||||
numberPad = arrayListOf(
|
||||
buttonNumberPad0.asKey(),
|
||||
buttonNumberPad1.asKey(),
|
||||
buttonNumberPad2.asKey(),
|
||||
buttonNumberPad3.asKey(),
|
||||
buttonNumberPad4.asKey(),
|
||||
buttonNumberPad5.asKey(),
|
||||
buttonNumberPad6.asKey(),
|
||||
buttonNumberPad7.asKey(),
|
||||
buttonNumberPad8.asKey(),
|
||||
buttonNumberPad9.asKey(),
|
||||
buttonNumberPadDecimal.asKey(),
|
||||
buttonNumberPadBack.asKey()
|
||||
)
|
||||
hitAreaProfile.onClickNavTo(R.id.action_nav_home_to_nav_profile) { tapped(HOME_PROFILE) }
|
||||
textHistory.onClickNavTo(R.id.action_nav_home_to_nav_history) { tapped(HOME_HISTORY) }
|
||||
textSendAmount.onClickNavTo(R.id.action_nav_home_to_nav_balance_detail) {
|
||||
tapped(
|
||||
HOME_BALANCE_DETAIL
|
||||
)
|
||||
}
|
||||
hitAreaBalance.onClickNavTo(R.id.action_nav_home_to_nav_balance_detail) {
|
||||
tapped(
|
||||
HOME_BALANCE_DETAIL
|
||||
)
|
||||
}
|
||||
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_receive) { tapped(HOME_RECEIVE) }
|
||||
|
||||
textBannerAction.setOnClickListener {
|
||||
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
||||
}
|
||||
buttonSendAmount.setOnClickListener {
|
||||
onSend().also { tapped(HOME_SEND) }
|
||||
}
|
||||
setSendAmount("0", false)
|
||||
|
||||
snake = MagicSnakeLoader(binding.lottieButtonLoading)
|
||||
|
||||
// fix: don't start up with just a black screen
|
||||
buttonSendAmount.text = getString(R.string.home_button_send_disconnected)
|
||||
buttonSendAmount.setTextColor(R.color.text_light.toAppColor())
|
||||
}
|
||||
|
||||
binding.buttonNumberPadBack.setOnLongClickListener {
|
||||
onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
|
||||
true
|
||||
}
|
||||
|
||||
if (::uiModel.isInitialized) {
|
||||
twig("uiModel exists! it has pendingSend=${uiModel.pendingSend} ZEC while the sendViewModel=${sendViewModel.zatoshiAmount} zats")
|
||||
// if the model already existed, cool but let the sendViewModel be the source of truth for the amount
|
||||
onModelUpdated(
|
||||
null,
|
||||
uiModel.copy(
|
||||
pendingSend = WalletZecFormmatter.toZecStringFull(
|
||||
sendViewModel.zatoshiAmount ?: Zatoshi(0L)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClearAmount() {
|
||||
twig("onClearAmount()")
|
||||
if (::uiModel.isInitialized) {
|
||||
resumedScope.launch {
|
||||
binding.textSendAmount.text.apply {
|
||||
while (uiModel.pendingSend != "0") {
|
||||
viewModel.onChar('<')
|
||||
delay(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope")
|
||||
|
||||
launchWhenSyncReady(::onSyncReady)
|
||||
}
|
||||
|
||||
private fun onSyncReady() {
|
||||
twig("Sync ready! Monitoring synchronizer state...")
|
||||
monitorUiModelChanges()
|
||||
|
||||
twig("HomeFragment.onSyncReady COMPLETE")
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
// if (::uiModel.isInitialized) {
|
||||
// outState.putParcelable("uiModel", uiModel)
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
savedInstanceState?.let { inState ->
|
||||
// onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Public UI API
|
||||
//
|
||||
|
||||
var isSendEnabled = false
|
||||
fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
|
||||
isSendEnabled = enabled
|
||||
binding.buttonSendAmount.apply {
|
||||
if (enabled || !isSynced) {
|
||||
isEnabled = true
|
||||
isClickable = isSynced
|
||||
binding.lottieButtonLoading.alpha = 1.0f
|
||||
} else {
|
||||
isEnabled = false
|
||||
isClickable = false
|
||||
binding.lottieButtonLoading.alpha = 0.32f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(uiModel: HomeViewModel.UiModel) {
|
||||
if (!uiModel.processorInfo.hasData && !uiModel.isDisconnected) {
|
||||
twig("Warning: ignoring progress update because the processor is still starting.")
|
||||
return
|
||||
}
|
||||
|
||||
snake.isSynced = uiModel.isSynced
|
||||
if (!uiModel.isSynced) {
|
||||
snake.downloadProgress = uiModel.downloadProgress
|
||||
snake.scanProgress = uiModel.scanProgress
|
||||
}
|
||||
|
||||
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(
|
||||
R.string.home_button_send_no_funds
|
||||
)
|
||||
uiModel.status == STOPPED -> getString(R.string.home_button_send_idle)
|
||||
uiModel.isDownloading -> {
|
||||
when (snake.downloadProgress) {
|
||||
0 -> "Preparing to download..."
|
||||
else -> getString(R.string.home_button_send_downloading, snake.downloadProgress)
|
||||
}
|
||||
}
|
||||
uiModel.isValidating -> getString(R.string.home_button_send_validating)
|
||||
uiModel.isScanning -> {
|
||||
when (snake.scanProgress) {
|
||||
0 -> "Preparing to scan..."
|
||||
100 -> "Finalizing..."
|
||||
else -> getString(R.string.home_button_send_scanning, snake.scanProgress)
|
||||
}
|
||||
}
|
||||
else -> getString(R.string.home_button_send_updating)
|
||||
}
|
||||
|
||||
binding.buttonSendAmount.text = sendText
|
||||
twig("Send button set to: $sendText")
|
||||
|
||||
val resId =
|
||||
if (uiModel.isSynced) R.color.selector_button_text_dark else R.color.selector_button_text_light
|
||||
context?.let {
|
||||
binding.buttonSendAmount.setTextColor(
|
||||
AppCompatResources.getColorStateList(
|
||||
it,
|
||||
resId
|
||||
)
|
||||
)
|
||||
}
|
||||
binding.lottieButtonLoading.invisibleIf(uiModel.isDisconnected)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param amount the amount to send represented as ZEC, without the dollar sign.
|
||||
*/
|
||||
fun setSendAmount(amount: String, updateModel: Boolean = true) {
|
||||
twig("setSendAmount($amount, $updateModel)")
|
||||
binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$")
|
||||
if (updateModel) {
|
||||
sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
|
||||
twig(
|
||||
"dBUG: updating model. converting: $amount\tresult: ${sendViewModel.zatoshiAmount}\tprint: ${
|
||||
WalletZecFormmatter.toZecStringFull(
|
||||
sendViewModel.zatoshiAmount
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
binding.buttonSendAmount.disabledIf(amount == "0")
|
||||
}
|
||||
|
||||
fun setAvailable(
|
||||
availableBalance: Zatoshi?,
|
||||
totalBalance: Zatoshi?,
|
||||
availableTransparentBalance: Zatoshi?,
|
||||
unminedCount: Int = 0
|
||||
) {
|
||||
val missingBalance = availableBalance == null
|
||||
val availableString =
|
||||
if (missingBalance) getString(R.string.home_button_send_updating) else WalletZecFormmatter.toZecStringFull(
|
||||
availableBalance
|
||||
)
|
||||
binding.textBalanceAvailable.text = availableString
|
||||
binding.textBalanceAvailable.transparentIf(missingBalance)
|
||||
binding.labelBalance.transparentIf(missingBalance)
|
||||
binding.textBalanceDescription.apply {
|
||||
goneIf(missingBalance)
|
||||
text = when {
|
||||
unminedCount > 0 -> "(excludes $unminedCount unconfirmed ${if (unminedCount > 1) "transactions" else "transaction"})"
|
||||
availableBalance != null && totalBalance != null && (availableBalance.value < totalBalance.value) -> {
|
||||
val change =
|
||||
WalletZecFormmatter.toZecStringFull(totalBalance - availableBalance)
|
||||
val symbol = getString(R.string.symbol)
|
||||
"(${getString(R.string.home_banner_expecting)} +$change $symbol)".toColoredSpan(
|
||||
R.color.text_light,
|
||||
"+$change"
|
||||
)
|
||||
}
|
||||
else -> getString(R.string.home_instruction_enter_amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBanner(message: String = "", action: BannerAction = CLEAR) {
|
||||
with(binding) {
|
||||
val hasMessage = !message.isEmpty() || action != CLEAR
|
||||
groupBalance.goneIf(hasMessage)
|
||||
groupBanner.goneIf(!hasMessage)
|
||||
//layerLock.goneIf(!hasMessage)
|
||||
|
||||
textBannerMessage.text = message
|
||||
textBannerAction.text = action.action
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Private UI Events
|
||||
//
|
||||
|
||||
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||
logUpdate(old, new)
|
||||
uiModel = new
|
||||
if (old?.pendingSend != new.pendingSend) {
|
||||
setSendAmount(new.pendingSend)
|
||||
}
|
||||
setProgress(new) // TODO: we may not need to separate anymore
|
||||
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
|
||||
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
|
||||
setSendEnabled(new.isSendEnabled, new.status == SYNCED)
|
||||
}
|
||||
|
||||
private fun logUpdate(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||
var message = ""
|
||||
fun maybeComma() = if (message.length > "UiModel(".length) ", " else ""
|
||||
message = when {
|
||||
old == null -> "$new"
|
||||
new == null -> "null"
|
||||
else -> {
|
||||
buildString {
|
||||
append("UiModel(")
|
||||
if (old.status != new.status) append("status=${new.status}")
|
||||
if (old.processorInfo != new.processorInfo) {
|
||||
append("${maybeComma()}processorInfo=ProcessorInfo(")
|
||||
val startLength = length
|
||||
fun innerComma() = if (length > startLength) ", " else ""
|
||||
if (old.processorInfo.networkBlockHeight != new.processorInfo.networkBlockHeight) append(
|
||||
"networkBlockHeight=${new.processorInfo.networkBlockHeight}"
|
||||
)
|
||||
if (old.processorInfo.lastScannedHeight != new.processorInfo.lastScannedHeight) append(
|
||||
"${innerComma()}lastScannedHeight=${new.processorInfo.lastScannedHeight}"
|
||||
)
|
||||
if (old.processorInfo.lastDownloadedHeight != new.processorInfo.lastDownloadedHeight) append(
|
||||
"${innerComma()}lastDownloadedHeight=${new.processorInfo.lastDownloadedHeight}"
|
||||
)
|
||||
if (old.processorInfo.lastDownloadRange != new.processorInfo.lastDownloadRange) append(
|
||||
"${innerComma()}lastDownloadRange=${new.processorInfo.lastDownloadRange}"
|
||||
)
|
||||
if (old.processorInfo.lastScanRange != new.processorInfo.lastScanRange) append(
|
||||
"${innerComma()}lastScanRange=${new.processorInfo.lastScanRange}"
|
||||
)
|
||||
append(")")
|
||||
}
|
||||
if (old.saplingBalance?.available != new.saplingBalance?.available) append("${maybeComma()}availableBalance=${new.saplingBalance?.available}")
|
||||
if (old.saplingBalance?.total != new.saplingBalance?.total) append("${maybeComma()}totalBalance=${new.saplingBalance?.total}")
|
||||
if (old.pendingSend != new.pendingSend) append("${maybeComma()}pendingSend=${new.pendingSend}")
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("onModelUpdated: $message")
|
||||
}
|
||||
|
||||
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
|
||||
setAvailable(null, null, null)
|
||||
}
|
||||
|
||||
private fun onSynced(uiModel: HomeViewModel.UiModel) {
|
||||
snake.isSynced = true
|
||||
if (!uiModel.hasSaplingBalance) {
|
||||
onNoFunds()
|
||||
} else {
|
||||
setBanner("")
|
||||
setAvailable(
|
||||
uiModel.saplingBalance?.available,
|
||||
uiModel.saplingBalance?.total,
|
||||
uiModel.transparentBalance?.available,
|
||||
uiModel.unminedCount
|
||||
)
|
||||
}
|
||||
autoShield(uiModel)
|
||||
}
|
||||
|
||||
private fun autoShield(uiModel: HomeViewModel.UiModel) {
|
||||
// TODO: Move the preference read to a suspending function
|
||||
// First time SharedPreferences are hit, it'll perform disk IO
|
||||
val isAutoshieldingAcknowledged =
|
||||
Preferences.isAcknowledgedAutoshieldingInformationPrompt.get(requireApplicationContext())
|
||||
val canAutoshield = AutoShieldFragment.canAutoshield(requireApplicationContext())
|
||||
|
||||
if (uiModel.hasAutoshieldFunds && canAutoshield) {
|
||||
if (!isAutoshieldingAcknowledged) {
|
||||
mainActivity?.safeNavigate(
|
||||
HomeFragmentDirections.actionNavHomeToAutoshieldingInfo(
|
||||
true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
twig("Autoshielding is available! Let's do this!!!")
|
||||
mainActivity?.safeNavigate(HomeFragmentDirections.actionNavHomeToNavFundsAvailable())
|
||||
}
|
||||
} else {
|
||||
if (!isAutoshieldingAcknowledged) {
|
||||
mainActivity?.safeNavigate(
|
||||
HomeFragmentDirections.actionNavHomeToAutoshieldingInfo(
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// troubleshooting logs
|
||||
if ((uiModel.transparentBalance?.available?.value ?: 0) > 0) {
|
||||
twig(
|
||||
"Transparent funds are available but not enough to autoshield. Available: ${
|
||||
uiModel.transparentBalance?.available.convertZatoshiToZecString(
|
||||
10
|
||||
)
|
||||
} Required: ${
|
||||
Zatoshi(ZcashWalletApp.instance.autoshieldThreshold).convertZatoshiToZecString(
|
||||
8
|
||||
)
|
||||
}"
|
||||
)
|
||||
} else if ((uiModel.transparentBalance?.total?.value ?: 0) > 0) {
|
||||
twig("Transparent funds have been received but they require 10 confirmations for autoshielding.")
|
||||
} else if (!canAutoshield) {
|
||||
twig("Could not autoshield probably because the last one occurred too recently")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSend() {
|
||||
if (isSendEnabled) mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
|
||||
}
|
||||
|
||||
private fun onBannerAction(action: BannerAction) {
|
||||
when (action) {
|
||||
FUND_NOW -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.home_dialog_no_balance_message)
|
||||
.setTitle(R.string.home_dialog_no_balance_title)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.home_dialog_no_balance_button_positive) { dialog, _ ->
|
||||
tapped(HOME_FUND_NOW)
|
||||
dialog.dismiss()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||
}
|
||||
.show()
|
||||
// MaterialAlertDialogBuilder(activity)
|
||||
// .setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
|
||||
// .setTitle("No Balance")
|
||||
// .setCancelable(true)
|
||||
// .setPositiveButton("Tap Faucet") { dialog, _ ->
|
||||
// dialog.dismiss()
|
||||
// setBanner("Tapping faucet...", CANCEL)
|
||||
// }
|
||||
// .setNegativeButton("View Address") { dialog, _ ->
|
||||
// dialog.dismiss()
|
||||
// mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||
// }
|
||||
// .show()
|
||||
}
|
||||
CANCEL -> {
|
||||
// TODO: trigger banner / balance update
|
||||
onNoFunds()
|
||||
}
|
||||
BannerAction.NONE -> TODO()
|
||||
CLEAR -> TODO()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNoFunds() {
|
||||
setBanner(getString(R.string.home_no_balance), FUND_NOW)
|
||||
}
|
||||
|
||||
private fun monitorUiModelChanges() {
|
||||
val existingAmount = sendViewModel.zatoshiAmount ?: Zatoshi(0)
|
||||
viewModel.initializeMaybe(WalletZecFormmatter.toZecStringFull(existingAmount))
|
||||
if (existingAmount.value == 0L) onClearAmount()
|
||||
viewModel.uiModels.runningReduce { old, new ->
|
||||
onModelUpdated(old, new)
|
||||
new
|
||||
}.onCompletion {
|
||||
twig("uiModel.scanReduce completed.")
|
||||
}.catch { e ->
|
||||
twig("exception while processing uiModels $e")
|
||||
throw e
|
||||
}.launchIn(resumedScope)
|
||||
}
|
||||
|
||||
//
|
||||
// Inner classes and extensions
|
||||
//
|
||||
|
||||
enum class BannerAction(val action: String) {
|
||||
FUND_NOW(""),
|
||||
CANCEL("Cancel"),
|
||||
NONE(""),
|
||||
CLEAR("clear");
|
||||
|
||||
companion object {
|
||||
fun from(action: String?): BannerAction {
|
||||
values().forEach {
|
||||
if (it.action == action) return it
|
||||
}
|
||||
throw IllegalArgumentException("Invalid BannerAction: $action")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextView.asKey(): TextView {
|
||||
val c = text[0]
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
viewModel.onChar(c)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
//
|
||||
// User Interruptions
|
||||
//
|
||||
|
||||
// TODO: Expand this placeholder logic around when to interrupt the user.
|
||||
// For now, we just need to get this in the app so that we can BEGIN capturing ECC feedback.
|
||||
var hasInterrupted = false
|
||||
private fun canInterruptUser(): Boolean {
|
||||
// requirements:
|
||||
// - we want occasional random feedback that does not occur too often
|
||||
return !hasInterrupted && Math.random() < 0.01
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
twig("HomeFragment.onStart")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
}
|
||||
}
|
||||
158
app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt
Normal file
158
app/src/main/java/cash/z/ecc/android/ui/home/HomeViewModel.kt
Normal file
@@ -0,0 +1,158 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.toAppString
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer.Status.*
|
||||
import cash.z.ecc.android.sdk.block.CompactBlockProcessor
|
||||
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
|
||||
import cash.z.ecc.android.sdk.db.entity.isMined
|
||||
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk.MINERS_FEE
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// There are deprecations with the use of BroadcastChannel
|
||||
@kotlinx.coroutines.ObsoleteCoroutinesApi
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
lateinit var uiModels: Flow<UiModel>
|
||||
|
||||
lateinit var _typedChars: ConflatedBroadcastChannel<Char>
|
||||
|
||||
var initialized = false
|
||||
|
||||
fun initializeMaybe(preTypedChars: String = "0") {
|
||||
twig("init called")
|
||||
if (initialized) {
|
||||
twig("Warning already initialized HomeViewModel. Ignoring call to initialize.")
|
||||
return
|
||||
}
|
||||
|
||||
if (::_typedChars.isInitialized) {
|
||||
_typedChars.close()
|
||||
}
|
||||
_typedChars = ConflatedBroadcastChannel()
|
||||
val typedChars = _typedChars.asFlow()
|
||||
val decimal = '.' // R.string.key_decimal.toAppString()[0]
|
||||
val backspace = R.string.key_backspace.toAppString()[0]
|
||||
val zec = typedChars.scan(preTypedChars) { acc, c ->
|
||||
when {
|
||||
// no-op cases
|
||||
acc == "0" && c == '0' ||
|
||||
(c == backspace && acc == "0")
|
||||
|| (c == decimal && acc.contains(decimal)) -> {
|
||||
acc
|
||||
}
|
||||
c == backspace && acc.length <= 1 -> {
|
||||
"0"
|
||||
}
|
||||
c == backspace -> {
|
||||
acc.substring(0, acc.length - 1)
|
||||
}
|
||||
acc == "0" && c != decimal -> {
|
||||
c.toString()
|
||||
}
|
||||
acc.contains(decimal) && acc.length - acc.indexOf(decimal) > 8 -> {
|
||||
acc
|
||||
}
|
||||
else -> {
|
||||
"$acc$c"
|
||||
}
|
||||
}
|
||||
}
|
||||
twig("initializing view models stream")
|
||||
uiModels = DependenciesHolder.synchronizer.run {
|
||||
combine(
|
||||
status,
|
||||
processorInfo,
|
||||
orchardBalances,
|
||||
saplingBalances,
|
||||
transparentBalances,
|
||||
zec,
|
||||
pendingTransactions.distinctUntilChanged()
|
||||
// unfortunately we have to use an untyped array here rather than typed parameters because combine only supports up to 5 typed params
|
||||
) { flows ->
|
||||
val unminedCount = (flows[6] as List<PendingTransaction>).count {
|
||||
it.isSubmitSuccess() && !it.isMined()
|
||||
}
|
||||
UiModel(
|
||||
status = flows[0] as Synchronizer.Status,
|
||||
processorInfo = flows[1] as CompactBlockProcessor.ProcessorInfo,
|
||||
orchardBalance = flows[2] as WalletBalance?,
|
||||
saplingBalance = flows[3] as WalletBalance?,
|
||||
transparentBalance = flows[4] as WalletBalance?,
|
||||
pendingSend = flows[5] as String,
|
||||
unminedCount = unminedCount
|
||||
)
|
||||
}.onStart { emit(UiModel(orchardBalance = null, saplingBalance = null, transparentBalance = null)) }
|
||||
}.conflate()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("HomeViewModel cleared!")
|
||||
}
|
||||
|
||||
suspend fun onChar(c: Char) {
|
||||
_typedChars.send(c)
|
||||
}
|
||||
|
||||
data class UiModel(
|
||||
val status: Synchronizer.Status = DISCONNECTED,
|
||||
val processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(null, null, null, null, null),
|
||||
val orchardBalance: WalletBalance?,
|
||||
val saplingBalance: WalletBalance?,
|
||||
val transparentBalance: WalletBalance?,
|
||||
val pendingSend: String = "0",
|
||||
val unminedCount: Int = 0
|
||||
) {
|
||||
// Note: the wallet is effectively empty if it cannot cover the miner's fee
|
||||
val hasFunds: Boolean get() = (saplingBalance?.available?.value ?: 0) > (MINERS_FEE.value.toDouble() / Zatoshi.ZATOSHI_PER_ZEC) // 0.00001
|
||||
val hasSaplingBalance: Boolean get() = (saplingBalance?.total?.value ?: 0) > 0L
|
||||
val hasAutoshieldFunds: Boolean get() = (transparentBalance?.available?.value ?: 0) >= ZcashWalletApp.instance.autoshieldThreshold
|
||||
val isSynced: Boolean get() = status == SYNCED
|
||||
val isSendEnabled: Boolean get() = isSynced && hasFunds
|
||||
|
||||
// Processor Info
|
||||
val isDownloading = status == DOWNLOADING
|
||||
val isScanning = status == SCANNING
|
||||
val isValidating = status == VALIDATING
|
||||
val isDisconnected = status == DISCONNECTED
|
||||
val downloadProgress: Int get() {
|
||||
return processorInfo.run {
|
||||
if (lastDownloadRange?.isEmpty() == true) {
|
||||
100
|
||||
} else {
|
||||
val progress =
|
||||
((((lastDownloadedHeight?.value ?: 0) - (lastDownloadRange?.start?.value ?: 0) + 1).coerceAtLeast(0).toFloat() / ((lastDownloadRange?.endInclusive?.value ?: 0) - (lastDownloadRange?.start?.value ?: 0) + 1)) * 100.0f).coerceAtMost(
|
||||
100.0f
|
||||
).roundToInt()
|
||||
progress
|
||||
}
|
||||
}
|
||||
}
|
||||
val scanProgress: Int get() {
|
||||
return processorInfo.run {
|
||||
if (lastScanRange?.isEmpty() == true) {
|
||||
100
|
||||
} else {
|
||||
val progress = ((((lastScannedHeight?.value ?: 0) - (lastScanRange?.start?.value ?: 0) + 1).coerceAtLeast(0).toFloat() / ((lastScanRange?.endInclusive?.value ?: 0) - (lastScanRange?.start?.value ?: 0) + 1)) * 100.0f).coerceAtMost(100.0f).roundToInt()
|
||||
progress
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
return downloadWeighted.coerceAtLeast(0.0f) + scanWeighted.coerceAtLeast(0.0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt
Normal file
155
app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt
Normal file
@@ -0,0 +1,155 @@
|
||||
package cash.z.ecc.android.ui.home
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
|
||||
class MagicSnakeLoader(
|
||||
val lottie: LottieAnimationView,
|
||||
private val scanningStartFrame: Int = 100,
|
||||
private val scanningEndFrame: Int = 187,
|
||||
val totalFrames: Int = 200
|
||||
) : ValueAnimator.AnimatorUpdateListener {
|
||||
private var isPaused: Boolean = true
|
||||
private var isStarted: Boolean = false
|
||||
|
||||
var isSynced: Boolean = false
|
||||
set(value) {
|
||||
if (value && !isStarted) {
|
||||
lottie.progress = 1.0f
|
||||
field = value
|
||||
return
|
||||
}
|
||||
|
||||
// it is started but it hadn't reached the synced state yet
|
||||
if (value && !field) {
|
||||
field = value
|
||||
playToCompletion()
|
||||
} else {
|
||||
field = value
|
||||
}
|
||||
}
|
||||
|
||||
var scanProgress: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
if (value > 0) {
|
||||
startMaybe()
|
||||
onScanUpdated()
|
||||
}
|
||||
}
|
||||
|
||||
var downloadProgress: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
if (value > 0) {
|
||||
startMaybe()
|
||||
} else {
|
||||
// if (!isSynced) {
|
||||
// lottie.progress = 0.0f
|
||||
// if(!isStarted) startMaybe()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMaybe() {
|
||||
|
||||
if (!isSynced && !isStarted) lottie.postDelayed(
|
||||
{
|
||||
// after some delay, if we're still not synced then we better start animating (unless we already are)!
|
||||
if (!isSynced && isPaused) {
|
||||
lottie.resumeAnimation()
|
||||
isPaused = false
|
||||
isStarted = true
|
||||
}
|
||||
},
|
||||
200L
|
||||
)
|
||||
}
|
||||
|
||||
private val isDownloading get() = downloadProgress in 1..99
|
||||
private val isScanning get() = scanProgress in 1..99
|
||||
|
||||
init {
|
||||
lottie.addAnimatorUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onAnimationUpdate(animation: ValueAnimator) {
|
||||
if (isSynced || isPaused) {
|
||||
// playToCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
// if we are scanning, then set the animation progress, based on the scan progress
|
||||
// if we're not scanning, then we're looping
|
||||
animation.currentFrame().let { frame ->
|
||||
if (isDownloading) allowLoop(frame) else applyScanProgress(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private val acceptablePauseFrames = arrayOf(33, 34, 67, 68, 99)
|
||||
private fun applyScanProgress(frame: Int) {
|
||||
// don't hardcode the progress until the loop animation has completed, cleanly
|
||||
if (isPaused) {
|
||||
onScanUpdated()
|
||||
} else {
|
||||
// once we're ready to show scan progress, do it! Don't do extra loops.
|
||||
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScanUpdated() {
|
||||
if (isSynced) {
|
||||
// playToCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if (isPaused && isStarted) {
|
||||
// move forward within the scan range, proportionate to how much scanning is complete
|
||||
val scanRange = scanningEndFrame - scanningStartFrame
|
||||
val scanRangeProgress = scanProgress.toFloat() / 100.0f * scanRange.toFloat()
|
||||
lottie.progress = (scanningStartFrame.toFloat() + scanRangeProgress) / totalFrames
|
||||
}
|
||||
}
|
||||
|
||||
private fun playToCompletion() {
|
||||
removeLoops()
|
||||
unpause()
|
||||
}
|
||||
|
||||
private fun removeLoops() {
|
||||
lottie.frame.let { frame ->
|
||||
if (frame in 33..67) {
|
||||
lottie.frame = frame + 34
|
||||
} else if (frame in 0..33) {
|
||||
lottie.frame = frame + 67
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun allowLoop(frame: Int) {
|
||||
unpause()
|
||||
if (frame >= scanningStartFrame) {
|
||||
lottie.progress = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun unpause() {
|
||||
if (isPaused) {
|
||||
lottie.resumeAnimation()
|
||||
isPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
if (!isPaused) {
|
||||
lottie.pauseAnimation()
|
||||
isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ValueAnimator.currentFrame(): Int {
|
||||
return ((animatedValue as Float) * totalFrames).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package cash.z.ecc.android.ui.profile
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentAwesomeBinding
|
||||
import cash.z.ecc.android.ext.distribute
|
||||
import cash.z.ecc.android.ext.invisibleIf
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.sdk.db.entity.*
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.util.AddressPartNumberSpan
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AwesomeFragment : BaseFragment<FragmentAwesomeBinding>() {
|
||||
override val screen = Report.Screen.AWESOME
|
||||
|
||||
private val viewModel: ProfileViewModel by viewModels()
|
||||
|
||||
private var lastBalance: WalletBalance? = null
|
||||
|
||||
private var initialized: Boolean = false
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentAwesomeBinding =
|
||||
FragmentAwesomeBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hitAreaExit.onClickNavBack() { tapped(AWESOME_CLOSE) }
|
||||
binding.hitAreaAddress.setOnClickListener {
|
||||
tapped(COPY_TRANSPARENT_ADDRESS)
|
||||
onCopyTransparentAddress()
|
||||
}
|
||||
binding.buttonAction.setOnClickListener {
|
||||
onShieldFundsAction()
|
||||
}
|
||||
binding.lottieShielding.visibility = View.GONE
|
||||
setStatus("Checking balance...")
|
||||
}
|
||||
|
||||
private fun onCopyTransparentAddress() {
|
||||
resumedScope.launch {
|
||||
mainActivity?.copyText(viewModel.getTransparentAddress(), "T-Address")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!initialized) {
|
||||
resumedScope.launch {
|
||||
onAddressLoaded(viewModel.getTransparentAddress())
|
||||
updateBalance()
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStatus(status: String) {
|
||||
binding.textStatus.text = status
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun appendStatus(status: String) {
|
||||
binding.textStatus.text = "${binding.textStatus.text}$status"
|
||||
}
|
||||
|
||||
private suspend fun updateBalance() {
|
||||
val utxoCount = viewModel.fetchUtxos()
|
||||
|
||||
viewModel.getTransparentBalance().let { balance ->
|
||||
onBalanceUpdated(balance, utxoCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddressLoaded(address: String) {
|
||||
twig("t-address loaded: $address length: ${address.length}")
|
||||
// qrecycler.load(address)
|
||||
// .withQuietZoneSize(3)
|
||||
// .withCorrectionLevel(QRecycler.CorrectionLevel.MEDIUM)
|
||||
// .into(binding.receiveQrCode)
|
||||
|
||||
address.distribute(2) { i, part ->
|
||||
setAddressPart(i, part)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAddressPart(index: Int, addressPart: String) {
|
||||
twig("setting t-address for part $index) $addressPart")
|
||||
|
||||
val address = when (index) {
|
||||
0 -> binding.textAddressPart1
|
||||
1 -> binding.textAddressPart2
|
||||
else -> throw IllegalArgumentException(
|
||||
"Unexpected address index $index. Unable to split the t-addr into two parts." +
|
||||
" Ensure that the address is valid."
|
||||
)
|
||||
}
|
||||
|
||||
val thinSpace = "\u2005" // 0.25 em space
|
||||
val textSpan = SpannableString("${index + 1}$thinSpace$addressPart")
|
||||
|
||||
textSpan.setSpan(AddressPartNumberSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
|
||||
address.text = textSpan
|
||||
}
|
||||
|
||||
private fun onShieldFundsAction() {
|
||||
if (binding.buttonAction.isActivated) {
|
||||
tapped(AWESOME_SHIELD)
|
||||
mainActivity?.let { main ->
|
||||
main.authenticate(
|
||||
"Shield transparent funds",
|
||||
getString(R.string.biometric_backup_phrase_title)
|
||||
) {
|
||||
onShieldFunds()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "No balance to shield!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDoneAction() {
|
||||
viewModel.setEasterEggTriggered()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_awesome_to_nav_history)
|
||||
}
|
||||
|
||||
private fun onShieldFunds() {
|
||||
twig("onShieldFunds")
|
||||
lifecycleScope.launchWhenResumed {
|
||||
twig("launching shield funds job")
|
||||
viewModel.shieldFunds().onEach {
|
||||
onPendingTxUpdated(it)
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPendingTxUpdated(tx: PendingTransaction) {
|
||||
twig("shielding transaction updated: $tx")
|
||||
if (tx == null) return // TODO: maybe log this
|
||||
|
||||
try {
|
||||
tx.toUiModel().let { model ->
|
||||
binding.apply {
|
||||
lottieShielding.invisibleIf(!model.showProgress)
|
||||
buttonAction.isActivated = !model.showProgress || model.canCancel
|
||||
buttonAction.isEnabled = true
|
||||
buttonAction.refreshDrawableState()
|
||||
setStatus(model.status)
|
||||
appendStatus(model.details.joinToString("\n", "\n\n"))
|
||||
buttonAction.apply {
|
||||
text = model.primaryButtonText
|
||||
setOnClickListener { model.primaryAction() }
|
||||
}
|
||||
}
|
||||
if (model.updateBalance) {
|
||||
resumedScope.launch {
|
||||
delay(1000L)
|
||||
updateBalance()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
val message = "ERROR: error while handling pending transaction update! $t"
|
||||
twig(message)
|
||||
mainActivity?.feedback?.report(Report.Error.NonFatal.TxUpdateFailed(t))
|
||||
mainActivity?.feedback?.report(t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onShieldComplete(isSuccess: Boolean) {
|
||||
binding.lottieShielding.visibility = View.GONE
|
||||
|
||||
if (isSuccess) {
|
||||
Toast.makeText(mainActivity, "Funds shielded successfully!", Toast.LENGTH_SHORT).show()
|
||||
binding.buttonAction.isEnabled = true
|
||||
binding.buttonAction.isActivated = true
|
||||
binding.buttonAction.text = "See Details"
|
||||
binding.textStatus.text = "Success!\n\nIt may take a while to show up."
|
||||
binding.buttonAction.setOnClickListener {
|
||||
mainActivity?.popBackTo(R.id.nav_home)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(mainActivity, "Failed to shield funds :(", Toast.LENGTH_SHORT).show()
|
||||
binding.buttonAction.isEnabled = true
|
||||
binding.buttonAction.text = "Shield Transparent Funds"
|
||||
binding.textStatus.text = "Failed!"
|
||||
binding.buttonAction.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBalanceUpdated(
|
||||
balance: WalletBalance = WalletBalance(Zatoshi(0), Zatoshi(0)),
|
||||
utxoCount: Int = 0
|
||||
) {
|
||||
lastBalance = balance
|
||||
twig("TRANSPARENT BALANCE: ${balance.available} / ${balance.total}")
|
||||
binding.textStatus.text = if (balance.available.value > 0L) {
|
||||
binding.buttonAction.isActivated = true
|
||||
binding.buttonAction.isEnabled = true
|
||||
"Balance: ᙇ${balance.available.convertZatoshiToZecString(8)}"
|
||||
} else {
|
||||
binding.buttonAction.isActivated = false
|
||||
binding.buttonAction.isEnabled = true
|
||||
"No available balance found"
|
||||
}
|
||||
|
||||
if (utxoCount > 0) {
|
||||
appendStatus("\n\nDownloaded $utxoCount ")
|
||||
appendStatus(if (utxoCount == 1) "transaction!" else "transactions!")
|
||||
}
|
||||
|
||||
balance.pending.takeIf { it.value > 0 }?.let {
|
||||
appendStatus("\n\n(ᙇ${it.convertZatoshiToZecString()} pending confirmation)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun PendingTransaction.toUiModel() = UiModel().also { model ->
|
||||
when {
|
||||
isCancelled() -> {
|
||||
model.status = "Shielding Cancelled!"
|
||||
model.updateBalance = true
|
||||
model.primaryAction = { onShieldFundsAction() }
|
||||
model.details.add("Cancelled!")
|
||||
}
|
||||
isSubmitSuccess() -> {
|
||||
model.status = "Shielding Success!"
|
||||
model.primaryButtonText = "Done"
|
||||
model.primaryAction = { onDoneAction() }
|
||||
}
|
||||
isFailure() -> {
|
||||
model.status = if (isFailedEncoding()) {
|
||||
"${getString(R.string.send_final_error_encoding)}\n\nPlease note:\nShielding requires funds\nto have 10 confirmations."
|
||||
} else {
|
||||
"${getString(R.string.send_final_error_submitting)}\n\n${this.errorMessage}"
|
||||
}
|
||||
|
||||
model.primaryAction = { onShieldFundsAction() }
|
||||
}
|
||||
else -> {
|
||||
model.status = "Shielding ᙇ${lastBalance?.available.convertZatoshiToZecString()}\n\nPlease do not exit this screen!"
|
||||
model.showProgress = true
|
||||
if (isCreating()) {
|
||||
model.canCancel = true
|
||||
model.details.add("Creating transaction...")
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_cancel)
|
||||
model.primaryAction = { onCancel(this) }
|
||||
} else {
|
||||
model.primaryButtonText = "Shielding Funds..."
|
||||
if (isCreated()) model.details.add("Submitting transaction...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCancel(tx: PendingTransaction) {
|
||||
resumedScope.launch {
|
||||
viewModel.cancel(tx.id)
|
||||
}
|
||||
}
|
||||
|
||||
// fields are ordered, as they appear, top-to-bottom in the UI because that makes it easier to reason about each screen state
|
||||
data class UiModel(
|
||||
var status: String = "",
|
||||
val details: MutableSet<String> = linkedSetOf(),
|
||||
var showProgress: Boolean = false,
|
||||
var primaryButtonText: String = "Shield Transparent Funds",
|
||||
var primaryAction: () -> Unit = {},
|
||||
var canCancel: Boolean = false,
|
||||
var updateBalance: Boolean = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package cash.z.ecc.android.ui.profile
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider.getUriForFile
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.BuildConfig
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentProfileBinding
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.FeedbackFile
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.UserFeedback
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.ui.MainActivity
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.util.DebugFileTwig
|
||||
import cash.z.ecc.android.util.Bush
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
|
||||
override val screen = Report.Screen.PROFILE
|
||||
|
||||
private val viewModel: ProfileViewModel by viewModels()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentProfileBinding =
|
||||
FragmentProfileBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hitAreaSettings.onClickNavTo(R.id.action_nav_profile_to_nav_settings)
|
||||
binding.hitAreaExit.onClickNavBack() { tapped(PROFILE_CLOSE) }
|
||||
binding.buttonBackup.setOnClickListener {
|
||||
tapped(PROFILE_BACKUP)
|
||||
mainActivity?.let { main ->
|
||||
main.authenticate(
|
||||
getString(R.string.biometric_backup_phrase_description),
|
||||
getString(R.string.biometric_backup_phrase_title)
|
||||
) {
|
||||
main.safeNavigate(R.id.action_nav_profile_to_nav_backup)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.buttonRescan.setOnClickListener {
|
||||
tapped(PROFILE_RESCAN)
|
||||
onRescanWallet()
|
||||
}
|
||||
binding.textVersion.text = BuildConfig.VERSION_NAME
|
||||
onClick(binding.buttonLogs) {
|
||||
tapped(PROFILE_VIEW_USER_LOGS)
|
||||
onViewLogs()
|
||||
}
|
||||
binding.buttonLogs.setOnLongClickListener {
|
||||
tapped(PROFILE_VIEW_DEV_LOGS)
|
||||
onViewDevLogs()
|
||||
true
|
||||
}
|
||||
binding.iconProfile.setOnLongClickListener {
|
||||
tapped(AWESOME_OPEN)
|
||||
onEnterAwesomeMode()
|
||||
true
|
||||
}
|
||||
binding.textBannerMessage.setOnClickListener {
|
||||
openPlayStoreLink()
|
||||
}
|
||||
|
||||
if (viewModel.isEasterEggTriggered()) {
|
||||
binding.iconProfile.setImageResource(R.drawable.ic_profile_zebra_02)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPlayStoreLink() {
|
||||
getString(R.string.play_store_url).takeUnless { it.isBlank() }?.let { url ->
|
||||
mainActivity?.onLaunchUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEnterAwesomeMode() {
|
||||
(context as? MainActivity)?.safeNavigate(R.id.action_nav_profile_to_nav_awesome)
|
||||
?: throw IllegalStateException(
|
||||
"Cannot navigate from this activity. " +
|
||||
"Expected MainActivity but found ${context?.javaClass?.simpleName}"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumedScope.launch {
|
||||
binding.textAddress.text = viewModel.getShieldedAddress().toAbbreviatedAddress(12, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: reduce these to one function
|
||||
private fun onFullRescan() {
|
||||
twig("TMP: onFullRescan: CALLED")
|
||||
(viewModel.synchronizer as SdkSynchronizer).coroutineScope.launch {
|
||||
try {
|
||||
twig("TMP: onFullRescan: START")
|
||||
viewModel.fullRescan()
|
||||
Toast.makeText(ZcashWalletApp.instance, "Performing full rescan!", Toast.LENGTH_LONG).show()
|
||||
mainActivity?.navController?.popBackStack()
|
||||
} catch (t: Throwable) {
|
||||
mainActivity?.showCriticalMessage(
|
||||
"Full Rescan Failed",
|
||||
"Unable to perform full rescan due to error:\n\n${t.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onQuickRescan() {
|
||||
twig("TMP: onQuickRescan: CALLED")
|
||||
viewModel.viewModelScope.launch {
|
||||
try {
|
||||
twig("TMP: onQuickRescan: START")
|
||||
viewModel.quickRescan()
|
||||
Toast.makeText(ZcashWalletApp.instance, "Performing quick rescan!", Toast.LENGTH_LONG).show()
|
||||
mainActivity?.navController?.popBackStack()
|
||||
} catch (t: Throwable) {
|
||||
mainActivity?.showCriticalMessage("Quick Rescan Failed", "Unable to perform quick rescan due to error:\n\n${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onWipe() {
|
||||
mainActivity?.showConfirmation(
|
||||
"Are you sure?",
|
||||
"Wiping your data will close the app. Since your seed is preserved, " +
|
||||
"this operation is probably safe but please backup your seed anyway." +
|
||||
"\n\nContinue?",
|
||||
"Wipe"
|
||||
) {
|
||||
viewModel.wipe()
|
||||
mainActivity?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRescanWallet() {
|
||||
val quickDistance = viewModel.quickScanDistance()
|
||||
val fullDistance = viewModel.fullScanDistance()
|
||||
mainActivity?.showRescanWalletDialog(
|
||||
String.format("%,d", quickDistance),
|
||||
viewModel.blocksToMinutesString(quickDistance),
|
||||
String.format("%,d", fullDistance),
|
||||
viewModel.blocksToMinutesString(fullDistance),
|
||||
onFullRescan = ::onFullRescan,
|
||||
onQuickRescan = ::onQuickRescan,
|
||||
onWipe = ::onWipe
|
||||
)
|
||||
}
|
||||
|
||||
private fun onViewLogs() {
|
||||
shareFile(userLogFile())
|
||||
}
|
||||
|
||||
private fun onViewDevLogs() {
|
||||
developerLogFile().let {
|
||||
if (it == null) {
|
||||
mainActivity?.showSnackbar("Error: No developer log found!")
|
||||
} else {
|
||||
shareFile(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareFiles(vararg files: File?) {
|
||||
val uris = arrayListOf<Uri>().apply {
|
||||
files.filterNotNull().mapNotNull {
|
||||
getUriForFile(ZcashWalletApp.instance, "${BuildConfig.APPLICATION_ID}.fileprovider", it)
|
||||
}.forEach {
|
||||
add(it)
|
||||
}
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
type = "text/*"
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.profile_share_log_title)))
|
||||
}
|
||||
|
||||
fun shareFile(file: File?) {
|
||||
file ?: return
|
||||
val uri = getUriForFile(ZcashWalletApp.instance, "${BuildConfig.APPLICATION_ID}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = "text/plain"
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.profile_share_log_title)))
|
||||
}
|
||||
|
||||
private fun userLogFile(): File? {
|
||||
return mainActivity?.feedbackCoordinator?.findObserver<FeedbackFile>()?.file
|
||||
}
|
||||
|
||||
private fun developerLogFile(): File? {
|
||||
return Bush.trunk.find<DebugFileTwig>()?.file
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cash.z.ecc.android.ui.profile
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.Const
|
||||
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.db.entity.PendingTransaction
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
class ProfileViewModel : ViewModel() {
|
||||
|
||||
val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
private val lockBox: LockBox = DependenciesHolder.lockBox
|
||||
|
||||
private val prefs: LockBox = DependenciesHolder.prefs
|
||||
|
||||
// TODO: track this in the app and then fetch. For now, just estimate the blocks per second.
|
||||
val bps = 40
|
||||
|
||||
suspend fun getShieldedAddress(): String = synchronizer.getAddress()
|
||||
|
||||
suspend fun getTransparentAddress(): String {
|
||||
return synchronizer.getTransparentAddress()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("ProfileViewModel cleared!")
|
||||
}
|
||||
|
||||
suspend fun fetchUtxos(): Int {
|
||||
val address = getTransparentAddress()
|
||||
val height: Long = lockBox[Const.Backup.BIRTHDAY_HEIGHT]
|
||||
?: synchronizer.network.saplingActivationHeight.value
|
||||
return synchronizer.refreshUtxos(address, BlockHeight.new(synchronizer.network, height))
|
||||
?: 0
|
||||
}
|
||||
|
||||
suspend fun getTransparentBalance(): WalletBalance {
|
||||
val address = getTransparentAddress()
|
||||
return synchronizer.getTransparentBalance(address)
|
||||
}
|
||||
|
||||
fun shieldFunds(): Flow<PendingTransaction> {
|
||||
return lockBox.getBytes(Const.Backup.SEED)?.let {
|
||||
val sk = runBlocking { DerivationTool.deriveSpendingKeys(it, synchronizer.network)[0] }
|
||||
val tsk =
|
||||
runBlocking { DerivationTool.deriveTransparentSecretKey(it, synchronizer.network) }
|
||||
val addr = runBlocking {
|
||||
DerivationTool.deriveTransparentAddressFromPrivateKey(
|
||||
tsk,
|
||||
synchronizer.network
|
||||
)
|
||||
}
|
||||
synchronizer.shieldFunds(
|
||||
sk,
|
||||
tsk,
|
||||
"${ZcashSdk.DEFAULT_SHIELD_FUNDS_MEMO_PREFIX}\nAll UTXOs from $addr"
|
||||
).onEach {
|
||||
twig("Received shielding txUpdate: ${it?.toString()}")
|
||||
// updateMetrics(it)
|
||||
// reportFailures(it)
|
||||
}
|
||||
} ?: throw IllegalStateException("Seed was expected but it was not found!")
|
||||
}
|
||||
|
||||
fun setEasterEggTriggered() {
|
||||
lockBox.setBoolean(Const.Pref.EASTER_EGG_TRIGGERED_SHIELDING, true)
|
||||
}
|
||||
|
||||
fun isEasterEggTriggered(): Boolean {
|
||||
return lockBox.getBoolean(Const.Pref.EASTER_EGG_TRIGGERED_SHIELDING)
|
||||
}
|
||||
|
||||
suspend fun cancel(id: Long) {
|
||||
synchronizer.cancelSpend(id)
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
synchronizer.stop()
|
||||
Toast.makeText(
|
||||
ZcashWalletApp.instance,
|
||||
"SUCCESS! Wallet data cleared. Please relaunch to rescan!",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
runBlocking {
|
||||
Initializer.erase(
|
||||
ZcashWalletApp.instance,
|
||||
ZcashWalletApp.instance.defaultNetwork
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fullRescan() {
|
||||
synchronizer.latestBirthdayHeight?.let {
|
||||
rewindTo(it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickRescan() {
|
||||
synchronizer.latestHeight?.let {
|
||||
val newHeightValue =
|
||||
(it.value - 8064L).coerceAtLeast(synchronizer.network.saplingActivationHeight.value)
|
||||
rewindTo(BlockHeight.new(synchronizer.network, newHeightValue))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rewindTo(targetHeight: BlockHeight) {
|
||||
twig("TMP: rewinding to targetHeight $targetHeight")
|
||||
synchronizer.rewindToNearestHeight(targetHeight, true)
|
||||
}
|
||||
|
||||
fun fullScanDistance(): Long {
|
||||
synchronizer.latestHeight?.let { latestHeight ->
|
||||
synchronizer.latestBirthdayHeight?.let { latestBirthdayHeight ->
|
||||
return (latestHeight.value - latestBirthdayHeight.value).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun quickScanDistance(): Int {
|
||||
val latest = synchronizer.latestHeight
|
||||
val oneWeek = 60 * 60 * 24 / 75 * 7 // a week's worth of blocks
|
||||
val height = BlockHeight.new(
|
||||
synchronizer.network,
|
||||
((latest?.value ?: synchronizer.network.saplingActivationHeight.value) - oneWeek)
|
||||
.coerceAtLeast(synchronizer.network.saplingActivationHeight.value)
|
||||
)
|
||||
val foo = runBlocking {
|
||||
synchronizer.getNearestRewindHeight(height)
|
||||
}
|
||||
return ((latest?.value ?: 0) - foo.value).toInt().coerceAtLeast(0)
|
||||
}
|
||||
|
||||
fun blocksToMinutesString(blocks: BlockHeight): String {
|
||||
val duration = (blocks.value / bps.toDouble()).toDuration(DurationUnit.SECONDS)
|
||||
return duration.toString(DurationUnit.MINUTES).replace("m", " minutes")
|
||||
}
|
||||
|
||||
fun blocksToMinutesString(blocks: Int): String {
|
||||
val duration = (blocks / bps.toDouble()).toDuration(DurationUnit.SECONDS)
|
||||
return duration.toString(DurationUnit.MINUTES).replace("m", " minutes")
|
||||
}
|
||||
|
||||
fun blocksToMinutesString(blocks: Long): String {
|
||||
val duration = (blocks / bps.toDouble()).toDuration(DurationUnit.SECONDS)
|
||||
return duration.toString(DurationUnit.MINUTES).replace("m", " minutes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cash.z.ecc.android.ui.receive
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.viewModels
|
||||
import cash.z.android.qrecycler.QRecycler
|
||||
import cash.z.ecc.android.BuildConfig
|
||||
import cash.z.ecc.android.databinding.FragmentTabReceiveShieldedBinding
|
||||
import cash.z.ecc.android.ext.distribute
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.util.AddressPartNumberSpan
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ReceiveTabFragment :
|
||||
BaseFragment<FragmentTabReceiveShieldedBinding>() {
|
||||
override val screen = Report.Screen.RECEIVE
|
||||
|
||||
private val viewModel: ReceiveViewModel by viewModels()
|
||||
|
||||
lateinit var qrecycler: QRecycler
|
||||
|
||||
lateinit var addressParts: Array<TextView>
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentTabReceiveShieldedBinding =
|
||||
FragmentTabReceiveShieldedBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
addressParts = arrayOf(
|
||||
binding.textAddressPart1,
|
||||
binding.textAddressPart2,
|
||||
binding.textAddressPart3,
|
||||
binding.textAddressPart4,
|
||||
binding.textAddressPart5,
|
||||
binding.textAddressPart6,
|
||||
binding.textAddressPart7,
|
||||
binding.textAddressPart8
|
||||
)
|
||||
binding.iconQrLogo.setOnLongClickListener {
|
||||
mainActivity?.takeIf { BuildConfig.FLAVOR.lowercase().contains("testnet") }?.let {
|
||||
it.copyAddress(null)
|
||||
it.onLaunchUrl("https://faucet.testnet.z.cash/")
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
qrecycler = QRecycler() // inject! :)
|
||||
super.onAttach(context)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumedScope.launch {
|
||||
onAddressLoaded(viewModel.getAddress())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddressLoaded(address: String) {
|
||||
twig("address loaded: $address length: ${address.length}")
|
||||
qrecycler.load(address)
|
||||
.withQuietZoneSize(3)
|
||||
.withCorrectionLevel(QRecycler.CorrectionLevel.MEDIUM)
|
||||
.into(binding.receiveQrCode)
|
||||
|
||||
address.distribute(8) { i, part ->
|
||||
setAddressPart(i, part)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAddressPart(index: Int, addressPart: String) {
|
||||
twig("setting address for part $index) $addressPart")
|
||||
val thinSpace = "\u2005" // 0.25 em space
|
||||
val textSpan = SpannableString("${index + 1}$thinSpace$addressPart")
|
||||
|
||||
textSpan.setSpan(AddressPartNumberSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
|
||||
addressParts[index].text = textSpan
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cash.z.ecc.android.ui.receive
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.util.twig
|
||||
|
||||
class ReceiveViewModel : ViewModel() {
|
||||
|
||||
private val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
suspend fun getAddress(): String = synchronizer.getAddress()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("ReceiveViewModel cleared!")
|
||||
}
|
||||
}
|
||||
66
app/src/main/java/cash/z/ecc/android/ui/scan/QrAnalyzer.kt
Normal file
66
app/src/main/java/cash/z/ecc/android/ui/scan/QrAnalyzer.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package cash.z.ecc.android.ui.scan
|
||||
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import cash.z.ecc.android.util.twig
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.Reader
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
|
||||
class QrAnalyzer(val scanCallback: (qrContent: String, image: ImageProxy) -> Unit) :
|
||||
ImageAnalysis.Analyzer {
|
||||
|
||||
private val reader = QRCodeReader()
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
image.toBinaryBitmap().let { bitmap ->
|
||||
val qrContent = bitmap.decodeWith(reader) ?: bitmap.flip().decodeWith(reader)
|
||||
if (qrContent == null) {
|
||||
image.close()
|
||||
} else {
|
||||
onImageScan(qrContent, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImageProxy.toBinaryBitmap(): BinaryBitmap {
|
||||
return planes[0].buffer.let { buffer ->
|
||||
ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
}.let { bytes ->
|
||||
PlanarYUVLuminanceSource(bytes, width, height, 0, 0, width, height, false)
|
||||
}.let { source ->
|
||||
BinaryBitmap(HybridBinarizer(source))
|
||||
}
|
||||
}
|
||||
|
||||
private fun BinaryBitmap.decodeWith(reader: Reader): String? {
|
||||
return try {
|
||||
reader.decode(this).toString()
|
||||
} catch (e: NotFoundException) {
|
||||
// these happen frequently. Whenever no QR code is found in the frame. No need to log.
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
twig("Error while scanning QR: $e")
|
||||
twig(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun BinaryBitmap.flip(): BinaryBitmap {
|
||||
blackMatrix.apply {
|
||||
repeat(width) { w ->
|
||||
repeat(height) { h ->
|
||||
flip(w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
private fun onImageScan(result: String, image: ImageProxy) {
|
||||
scanCallback(result, image)
|
||||
}
|
||||
}
|
||||
216
app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt
Normal file
216
app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt
Normal file
@@ -0,0 +1,216 @@
|
||||
package cash.z.ecc.android.ui.scan
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentScanBinding
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.SCAN_BACK
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.send.SendViewModel
|
||||
import cash.z.ecc.android.util.twig
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class ScanFragment : BaseFragment<FragmentScanBinding>() {
|
||||
|
||||
override val screen = Report.Screen.SCAN
|
||||
|
||||
private val viewModel: ScanViewModel by viewModels()
|
||||
|
||||
private val sendViewModel: SendViewModel by activityViewModels()
|
||||
|
||||
private lateinit var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>
|
||||
|
||||
private var cameraExecutor: ExecutorService? = null
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentScanBinding =
|
||||
FragmentScanBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
if (cameraExecutor != null) cameraExecutor?.shutdown()
|
||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!allPermissionsGranted()) getRuntimePermissions()
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener(
|
||||
Runnable {
|
||||
bindPreview(cameraProviderFuture.get())
|
||||
},
|
||||
ContextCompat.getMainExecutor(context)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
cameraExecutor?.shutdown()
|
||||
cameraExecutor = null
|
||||
}
|
||||
|
||||
private fun bindPreview(cameraProvider: ProcessCameraProvider) {
|
||||
// Most of the code here is adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt
|
||||
// it's worth keeping tabs on that implementation because they keep making breaking changes to these APIs!
|
||||
|
||||
// Get screen metrics used to setup camera for full screen resolution
|
||||
val metrics = DisplayMetrics().also { binding.preview.display.getRealMetrics(it) }
|
||||
val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels)
|
||||
val rotation = binding.preview.display.rotation
|
||||
|
||||
val preview =
|
||||
Preview.Builder().setTargetName("Preview").setTargetAspectRatio(screenAspectRatio)
|
||||
.setTargetRotation(rotation).build()
|
||||
|
||||
val cameraSelector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder().setTargetAspectRatio(screenAspectRatio)
|
||||
.setTargetRotation(rotation)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
imageAnalysis.setAnalyzer(
|
||||
cameraExecutor!!,
|
||||
QrAnalyzer { q, i ->
|
||||
onQrScanned(q, i)
|
||||
}
|
||||
)
|
||||
|
||||
// Must unbind the use-cases before rebinding them
|
||||
cameraProvider.unbindAll()
|
||||
|
||||
try {
|
||||
cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis)
|
||||
preview.setSurfaceProvider(binding.preview.surfaceProvider)
|
||||
} catch (t: Throwable) {
|
||||
// TODO: consider bubbling this up to the user
|
||||
mainActivity?.feedback?.report(t)
|
||||
twig("Error while opening the camera: $t")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt#L350
|
||||
*/
|
||||
private fun aspectRatio(width: Int, height: Int): Int {
|
||||
val previewRatio = kotlin.math.max(width, height).toDouble() / kotlin.math.min(
|
||||
width,
|
||||
height
|
||||
)
|
||||
if (kotlin.math.abs(previewRatio - (4.0 / 3.0))
|
||||
<= kotlin.math.abs(previewRatio - (16.0 / 9.0))
|
||||
) {
|
||||
return AspectRatio.RATIO_4_3
|
||||
}
|
||||
return AspectRatio.RATIO_16_9
|
||||
}
|
||||
|
||||
private fun onQrScanned(qrContent: String, image: ImageProxy) {
|
||||
resumedScope.launch {
|
||||
val parsed = viewModel.parse(qrContent)
|
||||
if (parsed == null) {
|
||||
val network = viewModel.networkName
|
||||
binding.textScanError.text = getString(R.string.scan_invalid_address, network, qrContent)
|
||||
image.close()
|
||||
} else { /* continue scanning*/
|
||||
binding.textScanError.text = ""
|
||||
sendViewModel.toAddress = parsed
|
||||
mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// private fun updateOverlay(detectedObjects: DetectedObjects) {
|
||||
// if (detectedObjects.objects.isEmpty()) {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// overlay.setSize(detectedObjects.imageWidth, detectedObjects.imageHeight)
|
||||
// val list = mutableListOf<BoxData>()
|
||||
// for (obj in detectedObjects.objects) {
|
||||
// val box = obj.boundingBox
|
||||
// val name = "${categoryNames[obj.classificationCategory]}"
|
||||
// val confidence =
|
||||
// if (obj.classificationCategory != FirebaseVisionObject.CATEGORY_UNKNOWN) {
|
||||
// val confidence: Int = obj.classificationConfidence!!.times(100).toInt()
|
||||
// "$confidence%"
|
||||
// } else {
|
||||
// ""
|
||||
// }
|
||||
// list.add(BoxData("$name $confidence", box))
|
||||
// }
|
||||
// overlay.set(list)
|
||||
// }
|
||||
|
||||
//
|
||||
// Permissions
|
||||
//
|
||||
|
||||
private val requiredPermissions: Array<String?>
|
||||
get() {
|
||||
return try {
|
||||
val info = mainActivity?.packageManager
|
||||
?.getPackageInfo(mainActivity?.packageName ?: "", PackageManager.GET_PERMISSIONS)
|
||||
val ps = info?.requestedPermissions
|
||||
if (ps != null && ps.isNotEmpty()) {
|
||||
ps
|
||||
} else {
|
||||
arrayOfNulls(0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
arrayOfNulls(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun allPermissionsGranted(): Boolean {
|
||||
for (permission in requiredPermissions) {
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getRuntimePermissions() {
|
||||
val allNeededPermissions = arrayListOf<String>()
|
||||
for (permission in requiredPermissions) {
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) {
|
||||
allNeededPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
|
||||
if (allNeededPermissions.isNotEmpty()) {
|
||||
requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CAMERA_PERMISSION_REQUEST = 1002
|
||||
|
||||
private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cash.z.ecc.android.ui.scan
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.util.twig
|
||||
|
||||
class ScanViewModel : ViewModel() {
|
||||
|
||||
private val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
val networkName get() = synchronizer.network.networkName
|
||||
|
||||
suspend fun parse(qrCode: String): String? {
|
||||
// temporary parse code to allow both plain addresses and those that start with zcash:
|
||||
// TODO: replace with more robust ZIP-321 handling of QR codes
|
||||
val address = if (qrCode.startsWith("zcash:")) {
|
||||
qrCode.substring(6, qrCode.indexOf("?").takeUnless { it == -1 } ?: qrCode.length)
|
||||
} else {
|
||||
qrCode
|
||||
}
|
||||
return if (synchronizer.validateAddress(address).isNotValid) null else address
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
twig("${javaClass.simpleName} cleared!")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentAutoShieldBinding
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.invisibleIf
|
||||
import cash.z.ecc.android.ext.requireApplicationContext
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.preference.Preferences
|
||||
import cash.z.ecc.android.preference.model.get
|
||||
import cash.z.ecc.android.preference.model.put
|
||||
import cash.z.ecc.android.sdk.db.entity.*
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.time.Clock
|
||||
|
||||
class AutoShieldFragment : BaseFragment<FragmentAutoShieldBinding>() {
|
||||
override val screen = Report.Screen.AUTO_SHIELD_FINAL
|
||||
|
||||
private val viewModel: AutoShieldViewModel by viewModels()
|
||||
|
||||
private val uiModels = MutableStateFlow(UiModel())
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentAutoShieldBinding =
|
||||
FragmentAutoShieldBinding.inflate(inflater)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (null == savedInstanceState) {
|
||||
setAutoshield(requireApplicationContext())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.backButtonHitArea.setOnClickListener {
|
||||
onExit().also { tapped(Report.Tap.AUTO_SHIELD_FINAL_CLOSE) }
|
||||
}
|
||||
mainActivity?.preventBackPress(this)
|
||||
uiModels.collectWith(lifecycleScope, ::updateUi)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
mainActivity?.apply {
|
||||
viewModel.shieldFunds().onEach { p: PendingTransaction ->
|
||||
try {
|
||||
uiModels.value = p.toUiModel()
|
||||
} catch (t: Throwable) {
|
||||
val message = "ERROR: error while handling pending transaction update! $t"
|
||||
twig(message)
|
||||
mainActivity?.feedback?.report(Report.Error.NonFatal.TxUpdateFailed(t))
|
||||
mainActivity?.feedback?.report(t)
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUi(uiModel: UiModel) = uiModel.apply {
|
||||
if (isResumed) {
|
||||
// if this is the first success
|
||||
if (!binding.lottieSuccess.isVisible && showSuccess) {
|
||||
mainActivity?.vibrateSuccess()
|
||||
}
|
||||
|
||||
binding.backButton.goneIf(!showCloseIcon)
|
||||
binding.textTitle.text = title
|
||||
binding.lottieShielding.invisibleIf(!showShielding)
|
||||
if (pauseShielding) binding.lottieShielding.pauseAnimation()
|
||||
binding.lottieSuccess.invisibleIf(!showSuccess)
|
||||
binding.imageFailed.invisibleIf(!isFailure)
|
||||
binding.textStatus.text = statusMessage
|
||||
|
||||
binding.textStatus.text = when {
|
||||
showStatusDetails && showStatusMessage -> statusDetails
|
||||
showStatusDetails -> statusDetails
|
||||
showStatusMessage -> statusMessage
|
||||
else -> ""
|
||||
}
|
||||
|
||||
binding.buttonPrimary.text = primaryButtonText
|
||||
binding.buttonPrimary.setOnClickListener { primaryAction() }
|
||||
binding.buttonMoreInfo.text = moreInfoButtonText
|
||||
binding.buttonMoreInfo.goneIf(!showMoreInfoButton)
|
||||
binding.buttonMoreInfo.setOnClickListener { moreInfoAction() }
|
||||
|
||||
if (showSuccess) {
|
||||
if (viewModel.updateAutoshieldAchievement()) {
|
||||
mainActivity?.showSnackbar("Achievement unlocked! Golden Zebra.", "View") {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_shield_final_to_profile)
|
||||
Toast.makeText(mainActivity, "Your Zebra is now yellow because you are great", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onExit() {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_shield_final_to_nav_home)
|
||||
}
|
||||
|
||||
private fun onCancel(tx: PendingTransaction) {
|
||||
viewModel.cancel(tx.id)
|
||||
}
|
||||
|
||||
private fun onSeeDetails() {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_shield_final_to_nav_history)
|
||||
}
|
||||
|
||||
private fun PendingTransaction.toUiModel() = UiModel().also { model ->
|
||||
when {
|
||||
isCancelled() -> {
|
||||
model.title = getString(R.string.send_final_result_cancelled)
|
||||
model.pauseShielding = true
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_back)
|
||||
model.primaryAction = { mainActivity?.navController?.popBackStack() }
|
||||
}
|
||||
isSubmitSuccess() -> {
|
||||
model.showCloseIcon = true
|
||||
model.title = getString(R.string.send_final_button_primary_sent)
|
||||
model.showShielding = false
|
||||
model.showSuccess = true
|
||||
model.primaryButtonText = getString(R.string.done)
|
||||
model.primaryAction = ::onExit
|
||||
model.showMoreInfoButton = true
|
||||
model.moreInfoButtonText = getString(R.string.send_final_button_primary_details)
|
||||
model.moreInfoAction = ::onSeeDetails
|
||||
}
|
||||
isFailure() -> {
|
||||
model.showCloseIcon = true
|
||||
model.title =
|
||||
if (isFailedEncoding()) getString(R.string.send_final_error_encoding) else getString(
|
||||
R.string.send_final_error_submitting
|
||||
)
|
||||
model.showShielding = false
|
||||
model.showSuccess = false
|
||||
model.isFailure = true
|
||||
model.showStatusDetails = false
|
||||
model.primaryButtonText = getString(R.string.translated_button_back)
|
||||
model.primaryAction = { mainActivity?.navController?.popBackStack() }
|
||||
model.showMoreInfoButton = errorMessage != null
|
||||
model.moreInfoButtonText = getString(R.string.send_more_info)
|
||||
model.moreInfoAction = {
|
||||
showMoreInfo(errorMessage ?: "No details available")
|
||||
}
|
||||
}
|
||||
isCreating() -> {
|
||||
model.showCloseIcon = false
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_cancel)
|
||||
model.showStatusMessage = true
|
||||
model.statusMessage = "Creating transaction..."
|
||||
model.primaryAction = { onCancel(this) }
|
||||
}
|
||||
isCreated() -> {
|
||||
model.showStatusMessage = true
|
||||
model.statusMessage = "Submitting transaction..."
|
||||
model.primaryButtonText = getString(R.string.translated_button_back)
|
||||
model.primaryAction = { mainActivity?.navController?.popBackStack() }
|
||||
}
|
||||
else -> {
|
||||
model.primaryButtonText = getString(R.string.translated_button_back)
|
||||
model.primaryAction = { mainActivity?.navController?.popBackStack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMoreInfo(info: String) {
|
||||
val current = uiModels.value
|
||||
uiModels.value = current.copy(
|
||||
showMoreInfoButton = true,
|
||||
moreInfoButtonText = getString(R.string.done),
|
||||
moreInfoAction = ::onExit,
|
||||
showStatusMessage = false,
|
||||
showStatusDetails = true,
|
||||
statusDetails = info
|
||||
)
|
||||
}
|
||||
|
||||
// fields are ordered, as they appear, top-to-bottom in the UI because that makes it easier to reason about each screen state
|
||||
data class UiModel(
|
||||
var showCloseIcon: Boolean = false,
|
||||
var title: String = "Shielding Now!",
|
||||
var showShielding: Boolean = true,
|
||||
var pauseShielding: Boolean = false,
|
||||
var showSuccess: Boolean = false,
|
||||
var isFailure: Boolean = false,
|
||||
var statusMessage: String = "",
|
||||
var statusDetails: String = "",
|
||||
var showStatusDetails: Boolean = false,
|
||||
var showStatusMessage: Boolean = true,
|
||||
var primaryButtonText: String = "Cancel",
|
||||
var primaryAction: () -> Unit = {},
|
||||
var moreInfoButtonText: String = "",
|
||||
var showMoreInfoButton: Boolean = false,
|
||||
var moreInfoAction: () -> Unit = {},
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val maxAutoshieldFrequency: Long = 30 * DateUtils.MINUTE_IN_MILLIS
|
||||
|
||||
/**
|
||||
* @param clock Optionally allows injecting a clock, in order to make this testable.
|
||||
*/
|
||||
fun canAutoshield(context: Context, clock: Clock = Clock.systemUTC()): Boolean {
|
||||
val currentEpochMillis = clock.millis()
|
||||
val lastAutoshieldEpochMillis = Preferences.lastAutoshieldingEpochMillis.get(context)
|
||||
|
||||
val isLastAutoshieldOld = (currentEpochMillis - lastAutoshieldEpochMillis) > maxAutoshieldFrequency
|
||||
// Prevent a corner case where a user with a clock in the future during one autoshielding prompt
|
||||
// could prevent all subsequent autoshielding prompts.
|
||||
val isTimeTraveling = lastAutoshieldEpochMillis > currentEpochMillis
|
||||
|
||||
return isLastAutoshieldOld || isTimeTraveling
|
||||
}
|
||||
|
||||
/**
|
||||
* @param clock Optionally allows injecting a clock, in order to make this testable.
|
||||
*/
|
||||
private fun setAutoshield(context: Context, clock: Clock = Clock.systemUTC()) =
|
||||
Preferences.lastAutoshieldingEpochMillis.put(context, clock.millis())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.Const
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.db.entity.PendingTransaction
|
||||
import cash.z.ecc.android.sdk.db.entity.isMined
|
||||
import cash.z.ecc.android.sdk.db.entity.isSubmitSuccess
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.convertZatoshiToZecString
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class AutoShieldViewModel : ViewModel() {
|
||||
|
||||
private val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
private val lockBox: LockBox = DependenciesHolder.lockBox
|
||||
|
||||
var latestBalance: BalanceModel? = null
|
||||
|
||||
val balances
|
||||
get() = combineTransform(
|
||||
synchronizer.orchardBalances,
|
||||
synchronizer.saplingBalances,
|
||||
synchronizer.transparentBalances,
|
||||
) { o, s, t ->
|
||||
BalanceModel(o, s, t).let {
|
||||
latestBalance = it
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
val statuses
|
||||
get() = combineTransform(
|
||||
synchronizer.saplingBalances,
|
||||
synchronizer.pendingTransactions,
|
||||
synchronizer.processorInfo
|
||||
) { balance, pending, info ->
|
||||
val unconfirmed = pending.filter { !it.isConfirmed(info.networkBlockHeight) }
|
||||
val unmined = pending.filter { it.isSubmitSuccess() && !it.isMined() }
|
||||
val pending = balance?.pending?.value ?: 0
|
||||
emit(StatusModel(unmined, unconfirmed, pending, info.networkBlockHeight))
|
||||
}
|
||||
|
||||
private fun PendingTransaction.isConfirmed(networkBlockHeight: BlockHeight?): Boolean {
|
||||
return networkBlockHeight?.let { height ->
|
||||
isMined() && (height.value - minedHeight + 1) > 10
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun cancel(id: Long) {
|
||||
viewModelScope.launch {
|
||||
synchronizer.cancelSpend(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the autoshielding achievement and return true if this is the first time.
|
||||
*/
|
||||
fun updateAutoshieldAchievement(): Boolean {
|
||||
val existingValue = lockBox.getBoolean(Const.Pref.EASTER_EGG_TRIGGERED_SHIELDING)
|
||||
return if (!existingValue) {
|
||||
lockBox.setBoolean(Const.Pref.EASTER_EGG_TRIGGERED_SHIELDING, true)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun shieldFunds(): Flow<PendingTransaction> {
|
||||
return lockBox.getBytes(Const.Backup.SEED)?.let {
|
||||
val sk = runBlocking { DerivationTool.deriveSpendingKeys(it, synchronizer.network)[0] }
|
||||
val tsk = runBlocking {
|
||||
DerivationTool.deriveTransparentSecretKey(
|
||||
it,
|
||||
synchronizer.network
|
||||
)
|
||||
}
|
||||
val addr = runBlocking {
|
||||
DerivationTool.deriveTransparentAddressFromPrivateKey(
|
||||
tsk,
|
||||
synchronizer.network
|
||||
)
|
||||
}
|
||||
synchronizer.shieldFunds(
|
||||
sk,
|
||||
tsk,
|
||||
"${ZcashSdk.DEFAULT_SHIELD_FUNDS_MEMO_PREFIX}\nAll UTXOs from $addr"
|
||||
).onEach { tx ->
|
||||
twig("Received shielding txUpdate: ${tx?.toString()}")
|
||||
// updateMetrics(it)
|
||||
// reportFailures(it)
|
||||
}
|
||||
} ?: throw IllegalStateException("Seed was expected but it was not found!")
|
||||
}
|
||||
|
||||
data class BalanceModel(
|
||||
val orchardBalance: WalletBalance?,
|
||||
val saplingBalance: WalletBalance?,
|
||||
val transparentBalance: WalletBalance?,
|
||||
) {
|
||||
val balanceShielded: String = saplingBalance?.available.toDisplay()
|
||||
val balanceTransparent: String = transparentBalance?.available.toDisplay()
|
||||
val balanceTotal: String =
|
||||
((saplingBalance?.available ?: Zatoshi(0)) + (transparentBalance?.available
|
||||
?: Zatoshi(0))).toDisplay()
|
||||
val canAutoShield: Boolean = (transparentBalance?.available?.value ?: 0) > 0L
|
||||
|
||||
val maxLength =
|
||||
maxOf(balanceShielded.length, balanceTransparent.length, balanceTotal.length)
|
||||
val paddedShielded = pad(balanceShielded)
|
||||
val paddedTransparent = pad(balanceTransparent)
|
||||
val paddedTotal = pad(balanceTotal)
|
||||
|
||||
private fun Zatoshi?.toDisplay(): String {
|
||||
return convertZatoshiToZecString(8, 8)
|
||||
}
|
||||
|
||||
private fun pad(balance: String): String {
|
||||
var diffLength = maxLength - balance.length
|
||||
return buildString {
|
||||
repeat(diffLength) {
|
||||
append(' ')
|
||||
}
|
||||
append(balance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusModel(
|
||||
val pendingUnconfirmed: List<PendingTransaction> = listOf(),
|
||||
val pendingUnmined: List<PendingTransaction> = listOf(),
|
||||
val pendingBalance: Long = 0L,
|
||||
val latestHeight: BlockHeight? = null,
|
||||
) {
|
||||
val hasUnconfirmed = pendingUnconfirmed.isNotEmpty()
|
||||
val hasUnmined = pendingUnmined.isNotEmpty()
|
||||
val hasPendingBalance = pendingBalance > 0L
|
||||
|
||||
fun remainingConfirmations(latestHeight: Int, confirmationsRequired: Int = 10) =
|
||||
pendingUnconfirmed
|
||||
.map { confirmationsRequired - (latestHeight - it.minedHeight + 1) }
|
||||
.filter { it > 0 }
|
||||
.sortedDescending()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentFundsAvailableBinding
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
|
||||
class FundsAvailableFragment : BaseFragment<FragmentFundsAvailableBinding>() {
|
||||
override val screen = Report.Screen.AUTO_SHIELD_AVAILABLE
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentFundsAvailableBinding =
|
||||
FragmentFundsAvailableBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonAction.setOnClickListener {
|
||||
onProceedWithAutoshielding()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function probably serves no purpose other than to click through to the next screen
|
||||
*/
|
||||
private fun onProceedWithAutoshielding() {
|
||||
mainActivity?.let { main ->
|
||||
main.authenticate(
|
||||
"Shield transparent funds",
|
||||
getString(R.string.biometric_backup_phrase_title)
|
||||
) {
|
||||
main.safeNavigate(R.id.action_nav_funds_available_to_nav_shield_final)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendFinalBinding
|
||||
import cash.z.ecc.android.ext.WalletZecFormmatter
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.SEND_FINAL_CLOSE
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.db.entity.*
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
|
||||
override val screen = Report.Screen.SEND_FINAL
|
||||
|
||||
private val sendViewModel: SendViewModel by activityViewModels()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendFinalBinding =
|
||||
FragmentSendFinalBinding.inflate(inflater)
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonPrimary.setOnClickListener {
|
||||
onReturnToSend()
|
||||
}
|
||||
binding.backButtonHitArea.setOnClickListener {
|
||||
onExit().also { tapped(SEND_FINAL_CLOSE) }
|
||||
}
|
||||
binding.textConfirmation.text =
|
||||
"${getString(R.string.send_final_sending)} ${WalletZecFormmatter.toZecStringFull(sendViewModel.zatoshiAmount)} ${getString(R.string.symbol)}\n${getString(R.string.send_final_to)}\n${sendViewModel.toAddress.toAbbreviatedAddress()}"
|
||||
mainActivity?.preventBackPress(this)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
mainActivity?.apply {
|
||||
sendViewModel.send().onEach {
|
||||
onPendingTxUpdated(it)
|
||||
}.launchIn((sendViewModel.synchronizer as SdkSynchronizer).coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPendingTxUpdated(tx: PendingTransaction?) {
|
||||
if (tx == null || !isResumed) return // TODO: maybe log this
|
||||
|
||||
try {
|
||||
tx.toUiModel().let { model ->
|
||||
updateUi(model)
|
||||
}
|
||||
|
||||
// only hold onto the view model if the transaction failed so that the user can retry
|
||||
if (tx.isSubmitSuccess()) {
|
||||
sendViewModel.reset()
|
||||
// celebrate
|
||||
mainActivity?.vibrate(0, 100, 100, 200, 200, 400)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
val message = "ERROR: error while handling pending transaction update! $t"
|
||||
twig(message)
|
||||
mainActivity?.feedback?.report(Report.Error.NonFatal.TxUpdateFailed(t))
|
||||
mainActivity?.feedback?.report(t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onExit() {
|
||||
sendViewModel.reset()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_final_to_nav_home)
|
||||
}
|
||||
|
||||
private fun onCancel(tx: PendingTransaction) {
|
||||
sendViewModel.cancel(tx.id)
|
||||
}
|
||||
|
||||
private fun onReturnToSend() {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_final_to_nav_send)
|
||||
}
|
||||
|
||||
private fun onSeeDetails() {
|
||||
sendViewModel.reset()
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_final_to_nav_history)
|
||||
}
|
||||
|
||||
private fun updateUi(model: UiModel) {
|
||||
binding.apply {
|
||||
backButton.goneIf(!model.showCloseIcon)
|
||||
backButtonHitArea.goneIf(!model.showCloseIcon)
|
||||
|
||||
textConfirmation.text = model.title
|
||||
lottieSending.goneIf(!model.showProgress)
|
||||
if (!model.showProgress) lottieSending.pauseAnimation() else lottieSending.playAnimation()
|
||||
errorMessage.text = model.errorMessage
|
||||
buttonPrimary.apply {
|
||||
text = model.primaryButtonText
|
||||
setOnClickListener { model.primaryAction() }
|
||||
}
|
||||
buttonMoreInfo.apply {
|
||||
goneIf(!model.showSecondaryButton)
|
||||
text = getString(R.string.send_more_info)
|
||||
setOnClickListener {
|
||||
binding.textMoreInfo.text = model.errorDescription
|
||||
text = getString(R.string.done)
|
||||
setOnClickListener { onExit() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PendingTransaction.toUiModel() = UiModel().also { model ->
|
||||
when {
|
||||
isCancelled() -> {
|
||||
model.title = getString(R.string.send_final_result_cancelled)
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_back)
|
||||
model.primaryAction = { onReturnToSend() }
|
||||
}
|
||||
isSubmitSuccess() -> {
|
||||
model.title = getString(R.string.send_final_button_primary_sent)
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_details)
|
||||
model.primaryAction = { onSeeDetails() }
|
||||
}
|
||||
isFailure() -> {
|
||||
model.title = getString(R.string.send_final_button_primary_failed)
|
||||
model.errorMessage = if (isFailedEncoding()) getString(R.string.send_final_error_encoding) else getString(
|
||||
R.string.send_final_error_submitting
|
||||
)
|
||||
model.errorDescription = errorMessage.toString()
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_retry)
|
||||
model.primaryAction = { onReturnToSend() }
|
||||
model.showSecondaryButton = true
|
||||
}
|
||||
else -> {
|
||||
model.title = "${getString(R.string.send_final_sending)} ${WalletZecFormmatter.toZecStringFull(
|
||||
Zatoshi(value))} ${getString(R.string.symbol)} ${getString(R.string.send_final_to)}\n${toAddress.toAbbreviatedAddress()}"
|
||||
model.showProgress = true
|
||||
if (isCreating()) {
|
||||
model.showCloseIcon = false
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_cancel)
|
||||
model.primaryAction = { onCancel(this) }
|
||||
} else {
|
||||
model.primaryButtonText = getString(R.string.send_final_button_primary_details)
|
||||
model.primaryAction = { onSeeDetails() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fields are ordered, as they appear, top-to-bottom in the UI because that makes it easier to reason about each screen state
|
||||
data class UiModel(
|
||||
var showCloseIcon: Boolean = true,
|
||||
var title: String = "",
|
||||
var errorDescription: String = "",
|
||||
var showProgress: Boolean = false,
|
||||
var errorMessage: String = "",
|
||||
var primaryButtonText: String = "See Details",
|
||||
var primaryAction: () -> Unit = {},
|
||||
var showSecondaryButton: Boolean = false,
|
||||
)
|
||||
}
|
||||
373
app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt
Normal file
373
app/src/main/java/cash/z/ecc/android/ui/send/SendFragment.kt
Normal file
@@ -0,0 +1,373 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendBinding
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.android.sdk.ext.onFirstWith
|
||||
import cash.z.ecc.android.sdk.ext.toAbbreviatedAddress
|
||||
import cash.z.ecc.android.sdk.model.WalletBalance
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SendFragment :
|
||||
BaseFragment<FragmentSendBinding>(),
|
||||
ClipboardManager.OnPrimaryClipChangedListener {
|
||||
override val screen = Report.Screen.SEND_ADDRESS
|
||||
|
||||
private var maxZatoshi: Long? = null
|
||||
private var availableZatoshi: Long? = null
|
||||
|
||||
val sendViewModel: SendViewModel by activityViewModels()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendBinding =
|
||||
FragmentSendBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Apply View Model
|
||||
applyViewModel(sendViewModel)
|
||||
updateAddressUi(false)
|
||||
|
||||
// Apply behaviors
|
||||
|
||||
binding.buttonSend.setOnClickListener {
|
||||
onSubmit().also { tapped(SEND_SUBMIT) }
|
||||
}
|
||||
|
||||
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _ ->
|
||||
onIncludeMemo(binding.checkIncludeAddress.isChecked)
|
||||
}
|
||||
|
||||
binding.inputZcashAddress.apply {
|
||||
doAfterTextChanged {
|
||||
val textStr = text.toString()
|
||||
val trim = textStr.trim()
|
||||
// bugfix: prevent cursor from moving while backspacing and deleting whitespace
|
||||
if (text.toString() != trim) {
|
||||
setText(trim)
|
||||
setSelection(selectionEnd - (textStr.length - trim.length))
|
||||
}
|
||||
onAddressChanged(trim)
|
||||
}
|
||||
}
|
||||
|
||||
binding.backButtonHitArea.onClickNavUp { tapped(SEND_ADDRESS_BACK) }
|
||||
//
|
||||
// binding.clearMemo.setOnClickListener {
|
||||
// onClearMemo().also { tapped(SEND_MEMO_CLEAR) }
|
||||
// }
|
||||
|
||||
binding.inputZcashMemo.doAfterTextChanged {
|
||||
sendViewModel.memo = binding.inputZcashMemo.text?.toString() ?: ""
|
||||
onMemoUpdated()
|
||||
}
|
||||
|
||||
binding.textLayoutAddress.setEndIconOnClickListener {
|
||||
mainActivity?.maybeOpenScan().also { tapped(SEND_ADDRESS_SCAN) }
|
||||
}
|
||||
|
||||
// banners
|
||||
|
||||
binding.backgroundClipboard.setOnClickListener {
|
||||
onPaste().also { tapped(SEND_ADDRESS_PASTE) }
|
||||
}
|
||||
binding.containerClipboard.setOnClickListener {
|
||||
onPaste().also { tapped(SEND_ADDRESS_PASTE) }
|
||||
}
|
||||
binding.backgroundLastUsed.setOnClickListener {
|
||||
onReuse().also { tapped(SEND_ADDRESS_REUSE) }
|
||||
}
|
||||
binding.containerLastUsed.setOnClickListener {
|
||||
onReuse().also { tapped(SEND_ADDRESS_REUSE) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyViewModel(model: SendViewModel) {
|
||||
// apply amount
|
||||
val roundedAmount =
|
||||
WalletZecFormmatter.toZecStringFull(model.zatoshiAmount)
|
||||
binding.textSendAmount.text = "\$$roundedAmount"
|
||||
// apply address
|
||||
binding.inputZcashAddress.setText(model.toAddress)
|
||||
// apply memo
|
||||
binding.inputZcashMemo.setText(model.memo)
|
||||
binding.checkIncludeAddress.isChecked = model.includeFromAddress
|
||||
onMemoUpdated()
|
||||
}
|
||||
|
||||
private fun onMemoUpdated() {
|
||||
val totalLength = sendViewModel.createMemoToSend().length
|
||||
binding.textLayoutMemo.helperText =
|
||||
"$totalLength/${ZcashSdk.MAX_MEMO_SIZE} ${getString(R.string.send_memo_chars_abbreviation)}"
|
||||
val color =
|
||||
if (totalLength > ZcashSdk.MAX_MEMO_SIZE) R.color.zcashRed else R.color.text_light_dimmed
|
||||
binding.textLayoutMemo.setHelperTextColor(ColorStateList.valueOf(color.toAppColor()))
|
||||
}
|
||||
|
||||
private fun onClearMemo() {
|
||||
binding.inputZcashMemo.setText("")
|
||||
}
|
||||
|
||||
private fun onIncludeMemo(checked: Boolean) {
|
||||
sendViewModel.afterInitFromAddress {
|
||||
sendViewModel.includeFromAddress = checked
|
||||
onMemoUpdated()
|
||||
tapped(if (checked) SEND_MEMO_INCLUDE else SEND_MEMO_EXCLUDE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddressChanged(address: String) {
|
||||
lifecycleScope.launchWhenResumed {
|
||||
val validation = sendViewModel.validateAddress(address)
|
||||
binding.buttonSend.isActivated = !validation.isNotValid
|
||||
var type = when (validation) {
|
||||
is AddressType.Transparent -> R.string.send_validation_address_valid_taddr to R.color.zcashGreen
|
||||
is AddressType.Shielded -> R.string.send_validation_address_valid_zaddr to R.color.zcashGreen
|
||||
else -> R.string.send_validation_address_invalid to R.color.zcashRed
|
||||
}
|
||||
updateAddressUi(validation is AddressType.Transparent)
|
||||
if (address == sendViewModel.synchronizer.getAddress() || address == sendViewModel.synchronizer.getTransparentAddress()) {
|
||||
type = R.string.send_validation_address_self to R.color.zcashRed
|
||||
}
|
||||
binding.textLayoutAddress.helperText = getString(type.first)
|
||||
binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(type.second.toAppColor()))
|
||||
|
||||
// if we have the clipboard address but we're changing it, then clear the selection
|
||||
if (binding.imageClipboardAddressSelected.isVisible) {
|
||||
loadAddressFromClipboard().let { clipboardAddress ->
|
||||
if (address != clipboardAddress) {
|
||||
updateClipboardBanner(clipboardAddress, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we have the last used address but we're changing it, then clear the selection
|
||||
if (binding.imageLastUsedAddressSelected.isVisible) {
|
||||
loadLastUsedAddress().let { lastAddress ->
|
||||
if (address != lastAddress) {
|
||||
updateLastUsedBanner(lastAddress, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To hide input Memo and reply-to option for T type address and show a info message about memo option availability */
|
||||
private fun updateAddressUi(isMemoHidden: Boolean) {
|
||||
if (isMemoHidden) {
|
||||
binding.textLayoutMemo.gone()
|
||||
binding.checkIncludeAddress.gone()
|
||||
binding.textNoZAddress.visible()
|
||||
} else {
|
||||
binding.textLayoutMemo.visible()
|
||||
binding.checkIncludeAddress.visible()
|
||||
binding.textNoZAddress.gone()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubmit(unused: EditText? = null) {
|
||||
sendViewModel.toAddress = binding.inputZcashAddress.text.toString()
|
||||
sendViewModel.validate(requireContext(), availableZatoshi, maxZatoshi)
|
||||
.onFirstWith(resumedScope) { errorMessage ->
|
||||
if (errorMessage == null) {
|
||||
val symbol = getString(R.string.symbol)
|
||||
mainActivity?.authenticate(
|
||||
"${getString(R.string.send_confirmation_prompt)}\n${
|
||||
WalletZecFormmatter.toZecStringFull(
|
||||
sendViewModel.zatoshiAmount
|
||||
)
|
||||
} $symbol ${getString(R.string.send_final_to)}\n${sendViewModel.toAddress.toAbbreviatedAddress()}"
|
||||
) {
|
||||
// sendViewModel.funnel(Send.AddressPageComplete)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_send_to_nav_send_final)
|
||||
}
|
||||
} else {
|
||||
resumedScope.launch {
|
||||
binding.textAddressError.text = errorMessage
|
||||
delay(2500L)
|
||||
binding.textAddressError.text = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMax() {
|
||||
if (maxZatoshi != null) {
|
||||
// binding.inputZcashAmount.apply {
|
||||
// setText(WalletZecFormmatter.toZecStringFull(maxZatoshi))
|
||||
// postDelayed({
|
||||
// requestFocus()
|
||||
// setSelection(text?.length ?: 0)
|
||||
// }, 10L)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
mainActivity?.clipboard?.addPrimaryClipChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onDetach() {
|
||||
super.onDetach()
|
||||
mainActivity?.clipboard?.removePrimaryClipChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
onPrimaryClipChanged()
|
||||
sendViewModel.synchronizer.saplingBalances.filterNotNull().collectWith(resumedScope) {
|
||||
onBalanceUpdated(it)
|
||||
}
|
||||
binding.inputZcashAddress.text.toString().let {
|
||||
if (!it.isNullOrEmpty()) onAddressChanged(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBalanceUpdated(balance: WalletBalance) {
|
||||
// binding.textLayoutAmount.helperText =
|
||||
// "You have ${WalletZecFormmatter.toZecStringFull(balance.availableZatoshi.coerceAtLeast(0L))} available"
|
||||
maxZatoshi = (balance.available - ZcashSdk.MINERS_FEE).value
|
||||
availableZatoshi = balance.available.value
|
||||
}
|
||||
|
||||
override fun onPrimaryClipChanged() {
|
||||
resumedScope.launch {
|
||||
updateClipboardBanner(loadAddressFromClipboard())
|
||||
updateLastUsedBanner(loadLastUsedAddress())
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateClipboardBanner(address: String?, selected: Boolean = false) {
|
||||
binding.apply {
|
||||
updateAddressBanner(
|
||||
groupClipboard,
|
||||
clipboardAddress,
|
||||
imageClipboardAddressSelected,
|
||||
imageShield,
|
||||
clipboardAddressLabel,
|
||||
selected,
|
||||
address
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateLastUsedBanner(
|
||||
address: String? = null,
|
||||
selected: Boolean = false
|
||||
) {
|
||||
val isBoth = address == loadAddressFromClipboard()
|
||||
binding.apply {
|
||||
updateAddressBanner(
|
||||
groupLastUsed,
|
||||
lastUsedAddress,
|
||||
imageLastUsedAddressSelected,
|
||||
imageLastUsedShield,
|
||||
lastUsedAddressLabel,
|
||||
selected,
|
||||
address.takeUnless { isBoth }
|
||||
)
|
||||
}
|
||||
binding.dividerClipboard.setText(if (isBoth) R.string.send_history_last_and_clipboard else R.string.send_history_clipboard)
|
||||
}
|
||||
|
||||
private fun updateAddressBanner(
|
||||
group: Group,
|
||||
addressTextView: TextView,
|
||||
checkIcon: ImageView,
|
||||
shieldIcon: ImageView,
|
||||
addressLabel: TextView,
|
||||
selected: Boolean = false,
|
||||
address: String? = null
|
||||
) {
|
||||
resumedScope.launch {
|
||||
if (address == null) {
|
||||
group.gone()
|
||||
} else {
|
||||
val userShieldedAddr = sendViewModel.synchronizer.getAddress()
|
||||
val userTransparentAddr = sendViewModel.synchronizer.getTransparentAddress()
|
||||
group.visible()
|
||||
addressTextView.text = address.toAbbreviatedAddress(16, 16)
|
||||
checkIcon.goneIf(!selected)
|
||||
ImageViewCompat.setImageTintList(
|
||||
shieldIcon,
|
||||
ColorStateList.valueOf(if (selected) R.color.colorPrimary.toAppColor() else R.color.zcashWhite_12.toAppColor())
|
||||
)
|
||||
addressLabel.setText(if (address == userShieldedAddr) R.string.send_banner_address_user else R.string.send_banner_address_unknown)
|
||||
if (address == userTransparentAddr) addressLabel.setText("Your Auto-Shielding Address")
|
||||
addressLabel.setTextColor(if (selected) R.color.colorPrimary.toAppColor() else R.color.text_light.toAppColor())
|
||||
addressTextView.setTextColor(if (selected) R.color.text_light.toAppColor() else R.color.text_light_dimmed.toAppColor())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPaste() {
|
||||
mainActivity?.clipboard?.let { clipboard ->
|
||||
if (clipboard.hasPrimaryClip()) {
|
||||
val address = clipboard.text().toString()
|
||||
val applyValue = binding.imageClipboardAddressSelected.isGone
|
||||
updateClipboardBanner(address, applyValue)
|
||||
binding.inputZcashAddress.setText(address.takeUnless { !applyValue })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onReuse() {
|
||||
sendViewModel.viewModelScope.launch {
|
||||
val address = loadLastUsedAddress()
|
||||
val applyValue = binding.imageLastUsedAddressSelected.isGone
|
||||
updateLastUsedBanner(address, applyValue)
|
||||
binding.inputZcashAddress.setText(address.takeUnless { !applyValue })
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadAddressFromClipboard(): String? {
|
||||
mainActivity?.clipboard?.apply {
|
||||
if (hasPrimaryClip()) {
|
||||
text().toString().let { text ->
|
||||
if (sendViewModel.isValidAddress(text)) return@loadAddressFromClipboard text
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private var lastUsedAddress: String? = null
|
||||
private suspend fun loadLastUsedAddress(): String? {
|
||||
if (lastUsedAddress == null) {
|
||||
lastUsedAddress = sendViewModel.synchronizer.sentTransactions.first()
|
||||
.firstOrNull { !it.toAddress.isNullOrEmpty() }?.toAddress
|
||||
updateLastUsedBanner(lastUsedAddress, binding.imageLastUsedAddressSelected.isVisible)
|
||||
}
|
||||
return lastUsedAddress
|
||||
}
|
||||
|
||||
private fun ClipboardManager.text(): CharSequence =
|
||||
primaryClip!!.getItemAt(0).coerceToText(mainActivity)
|
||||
}
|
||||
121
app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt
Normal file
121
app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt
Normal file
@@ -0,0 +1,121 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentSendMemoBinding
|
||||
import cash.z.ecc.android.ext.gone
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.onEditorActionDone
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX_STANDARD
|
||||
|
||||
class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||
override val screen = Report.Screen.SEND_MEMO
|
||||
|
||||
val sendViewModel: SendViewModel by activityViewModels()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSendMemoBinding =
|
||||
FragmentSendMemoBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonNext.setOnClickListener {
|
||||
onTopButton().also { tapped(SEND_MEMO_NEXT) }
|
||||
}
|
||||
binding.buttonSkip.setOnClickListener {
|
||||
onBottomButton().also { tapped(SEND_MEMO_SKIP) }
|
||||
}
|
||||
binding.clearMemo.setOnClickListener {
|
||||
onClearMemo().also { tapped(SEND_MEMO_CLEAR) }
|
||||
}
|
||||
|
||||
// R.id.action_nav_send_memo_to_nav_send_address.let {
|
||||
// binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) }
|
||||
// onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) }
|
||||
// }
|
||||
|
||||
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _ ->
|
||||
onIncludeMemo(binding.checkIncludeAddress.isChecked)
|
||||
}
|
||||
|
||||
binding.inputMemo.let { memo ->
|
||||
memo.onEditorActionDone {
|
||||
onTopButton().also { tapped(SEND_MEMO_NEXT) }
|
||||
}
|
||||
memo.doAfterTextChanged {
|
||||
binding.clearMemo.goneIf(memo.text.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
sendViewModel.afterInitFromAddress {
|
||||
binding.textIncludedAddress.text =
|
||||
"$INCLUDE_MEMO_PREFIX_STANDARD ${sendViewModel.fromAddress}"
|
||||
}
|
||||
|
||||
binding.textIncludedAddress.gone()
|
||||
|
||||
applyModel()
|
||||
}
|
||||
|
||||
private fun onClearMemo() {
|
||||
binding.inputMemo.setText("")
|
||||
}
|
||||
|
||||
private fun applyModel() {
|
||||
sendViewModel.isShielded.let { isShielded ->
|
||||
binding.groupShielded.goneIf(!isShielded)
|
||||
binding.groupTransparent.goneIf(isShielded)
|
||||
binding.textIncludedAddress.goneIf(!sendViewModel.includeFromAddress)
|
||||
if (isShielded) {
|
||||
binding.inputMemo.setText(sendViewModel.memo)
|
||||
binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress
|
||||
binding.buttonNext.text = "ADD MEMO"
|
||||
binding.buttonSkip.text = "OMIT MEMO"
|
||||
} else {
|
||||
binding.buttonNext.text = "GO BACK"
|
||||
binding.buttonSkip.text = "PROCEED"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onIncludeMemo(checked: Boolean) {
|
||||
|
||||
binding.textIncludedAddress.goneIf(!checked)
|
||||
sendViewModel.includeFromAddress = checked
|
||||
binding.textInfoShielded.text = if (checked) {
|
||||
tapped(SEND_MEMO_INCLUDE)
|
||||
getString(R.string.send_memo_included_message)
|
||||
} else {
|
||||
tapped(SEND_MEMO_EXCLUDE)
|
||||
getString(R.string.send_memo_excluded_message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onTopButton() {
|
||||
if (sendViewModel.isShielded) {
|
||||
sendViewModel.memo = binding.inputMemo.text.toString()
|
||||
onNext()
|
||||
} else {
|
||||
// mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_nav_send_address)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBottomButton() {
|
||||
binding.inputMemo.setText("")
|
||||
sendViewModel.memo = ""
|
||||
sendViewModel.includeFromAddress = false
|
||||
onNext()
|
||||
}
|
||||
|
||||
private fun onNext() {
|
||||
sendViewModel.funnel(Send.MemoPageComplete)
|
||||
// mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
|
||||
}
|
||||
}
|
||||
274
app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt
Normal file
274
app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package cash.z.ecc.android.ui.send
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.Const
|
||||
import cash.z.ecc.android.ext.WalletZecFormmatter
|
||||
import cash.z.ecc.android.feedback.Feedback
|
||||
import cash.z.ecc.android.feedback.Feedback.Keyed
|
||||
import cash.z.ecc.android.feedback.Feedback.TimeMetric
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send.SendSelected
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Send.SpendingKeyFound
|
||||
import cash.z.ecc.android.feedback.Report.Issue
|
||||
import cash.z.ecc.android.feedback.Report.MetricType
|
||||
import cash.z.ecc.android.feedback.Report.MetricType.*
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.db.entity.*
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import cash.z.ecc.android.ui.util.INCLUDE_MEMO_PREFIX_STANDARD
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SendViewModel : ViewModel() {
|
||||
|
||||
// note used in testing
|
||||
val metrics = mutableMapOf<String, TimeMetric>()
|
||||
|
||||
private val lockBox: LockBox = DependenciesHolder.lockBox
|
||||
|
||||
val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
private val feedback: Feedback = DependenciesHolder.feedback
|
||||
|
||||
var fromAddress: String = ""
|
||||
var toAddress: String = ""
|
||||
var memo: String = ""
|
||||
var zatoshiAmount: Zatoshi? = null
|
||||
var includeFromAddress: Boolean = false
|
||||
set(value) {
|
||||
require(!value || (value && !fromAddress.isNullOrEmpty())) {
|
||||
"Error: fromAddress was empty while attempting to include it in the memo. Verify" +
|
||||
" that initFromAddress() has previously been called on this viewmodel."
|
||||
}
|
||||
field = value
|
||||
}
|
||||
val isShielded get() = toAddress.startsWith("z")
|
||||
|
||||
fun send(): Flow<PendingTransaction> {
|
||||
funnel(SendSelected)
|
||||
val memoToSend = createMemoToSend()
|
||||
val keys = runBlocking {
|
||||
DerivationTool.deriveSpendingKeys(
|
||||
lockBox.getBytes(Const.Backup.SEED)!!,
|
||||
synchronizer.network
|
||||
)
|
||||
}
|
||||
funnel(SpendingKeyFound)
|
||||
reportUserInputIssues(memoToSend)
|
||||
return synchronizer.sendToAddress(
|
||||
keys[0],
|
||||
zatoshiAmount!!,
|
||||
toAddress,
|
||||
memoToSend.chunked(ZcashSdk.MAX_MEMO_SIZE).firstOrNull() ?: ""
|
||||
).onEach {
|
||||
twig("Received pending txUpdate: ${it?.toString()}")
|
||||
updateMetrics(it)
|
||||
reportFailures(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(pendingId: Long) {
|
||||
viewModelScope.launch {
|
||||
synchronizer.cancelSpend(pendingId)
|
||||
}
|
||||
}
|
||||
|
||||
fun createMemoToSend() =
|
||||
if (includeFromAddress) "$memo\n$INCLUDE_MEMO_PREFIX_STANDARD\n$fromAddress" else memo
|
||||
|
||||
suspend fun validateAddress(address: String): AddressType =
|
||||
synchronizer.validateAddress(address)
|
||||
|
||||
suspend fun isValidAddress(address: String): Boolean = when (validateAddress(address)) {
|
||||
is AddressType.Shielded, is AddressType.Transparent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun validate(context: Context, availableZatoshi: Long?, maxZatoshi: Long?) = flow<String?> {
|
||||
|
||||
when {
|
||||
synchronizer.validateAddress(toAddress).isNotValid -> {
|
||||
emit(context.getString(R.string.send_validation_error_address_invalid))
|
||||
}
|
||||
zatoshiAmount?.let { it.value < 1L } ?: false -> {
|
||||
emit(context.getString(R.string.send_validation_error_amount_minimum))
|
||||
}
|
||||
availableZatoshi == null -> {
|
||||
emit(context.getString(R.string.send_validation_error_unknown_funds))
|
||||
}
|
||||
availableZatoshi == 0L -> {
|
||||
emit(context.getString(R.string.send_validation_error_no_available_funds))
|
||||
}
|
||||
availableZatoshi > 0 && availableZatoshi.let { it < ZcashSdk.MINERS_FEE.value } ?: false -> {
|
||||
emit(context.getString(R.string.send_validation_error_dust))
|
||||
}
|
||||
maxZatoshi != null && zatoshiAmount?.let { it.value > maxZatoshi } ?: false -> {
|
||||
emit(
|
||||
context.getString(
|
||||
R.string.send_validation_error_too_much,
|
||||
WalletZecFormmatter.toZecStringFull(Zatoshi((maxZatoshi))),
|
||||
ZcashWalletApp.instance.getString(R.string.symbol)
|
||||
)
|
||||
)
|
||||
}
|
||||
createMemoToSend().length > ZcashSdk.MAX_MEMO_SIZE -> {
|
||||
emit(
|
||||
context.getString(
|
||||
R.string.send_validation_error_memo_length,
|
||||
ZcashSdk.MAX_MEMO_SIZE
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> emit(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun afterInitFromAddress(block: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
fromAddress = synchronizer.getAddress()
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
fromAddress = ""
|
||||
toAddress = ""
|
||||
memo = ""
|
||||
zatoshiAmount = null
|
||||
includeFromAddress = false
|
||||
}
|
||||
|
||||
//
|
||||
// Analytics
|
||||
//
|
||||
|
||||
private fun reportFailures(tx: PendingTransaction?) {
|
||||
if (tx == null) {
|
||||
// put a stack trace in the logs
|
||||
twig(IllegalArgumentException("Warning: Could not report failures because tx was null"))
|
||||
return
|
||||
}
|
||||
when {
|
||||
tx.isCancelled() -> funnel(Send.Cancelled)
|
||||
tx.isFailedEncoding() -> {
|
||||
// report that the funnel leaked and also capture a non-fatal app error
|
||||
funnel(Send.ErrorEncoding(tx.errorCode, tx.errorMessage))
|
||||
feedback.report(Report.Error.NonFatal.TxEncodeError(tx.errorCode, tx.errorMessage))
|
||||
}
|
||||
tx.isFailedSubmit() -> {
|
||||
// report that the funnel leaked and also capture a non-fatal app error
|
||||
funnel(Send.ErrorSubmitting(tx.errorCode, tx.errorMessage))
|
||||
feedback.report(Report.Error.NonFatal.TxSubmitError(tx.errorCode, tx.errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportUserInputIssues(memoToSend: String) {
|
||||
if (toAddress == fromAddress) feedback.report(Issue.SelfSend)
|
||||
when {
|
||||
(zatoshiAmount?.value
|
||||
?: 0L) < ZcashSdk.MINERS_FEE.value -> feedback.report(Issue.TinyAmount)
|
||||
(zatoshiAmount?.value ?: 0L) < 100L -> feedback.report(Issue.MicroAmount)
|
||||
(zatoshiAmount ?: 0L) == 1L -> feedback.report(Issue.MinimumAmount)
|
||||
}
|
||||
memoToSend.length.also {
|
||||
when {
|
||||
it > ZcashSdk.MAX_MEMO_SIZE -> feedback.report(Issue.TruncatedMemo(it))
|
||||
it > (ZcashSdk.MAX_MEMO_SIZE * 0.96) -> feedback.report(Issue.LargeMemo(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMetrics(tx: PendingTransaction?) {
|
||||
if (tx == null) {
|
||||
// put a stack trace in the logs
|
||||
twig(IllegalArgumentException("Warning: Could not update metrics because tx was null"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
when {
|
||||
tx.isMined() -> TRANSACTION_SUBMITTED to TRANSACTION_MINED by tx.id
|
||||
tx.isSubmitSuccess() -> TRANSACTION_CREATED to TRANSACTION_SUBMITTED by tx.id
|
||||
tx.isCreated() -> TRANSACTION_INITIALIZED to TRANSACTION_CREATED by tx.id
|
||||
tx.isCreating() -> +TRANSACTION_INITIALIZED by tx.id
|
||||
else -> null
|
||||
}?.let { metricId ->
|
||||
report(metricId)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
feedback.report(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun report(metricId: String?) {
|
||||
metrics[metricId]?.let { metric ->
|
||||
metric.takeUnless { (it.elapsedTime ?: 0) <= 0L }?.let {
|
||||
viewModelScope.launch {
|
||||
withContext(IO) {
|
||||
feedback.report(metric)
|
||||
|
||||
// does this metric complete another metric?
|
||||
metricId!!.toRelatedMetricId().let { relatedId ->
|
||||
metrics[relatedId]?.let { relatedMetric ->
|
||||
// then remove the related metric, itself. And the relation.
|
||||
metrics.remove(relatedMetric.toMetricIdFor(metricId!!.toTxId()))
|
||||
metrics.remove(relatedId)
|
||||
}
|
||||
}
|
||||
|
||||
// remove all top-level metrics
|
||||
if (metric.key == Report.MetricType.TRANSACTION_MINED.key) metrics.remove(
|
||||
metricId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun funnel(step: Send?) {
|
||||
step ?: return
|
||||
feedback.report(step)
|
||||
}
|
||||
|
||||
private operator fun MetricType.unaryPlus(): TimeMetric =
|
||||
TimeMetric(key, description).markTime()
|
||||
|
||||
private infix fun TimeMetric.by(txId: Long) =
|
||||
this.toMetricIdFor(txId).also { metrics[it] = this }
|
||||
|
||||
private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? {
|
||||
val startMetric = first.toMetricIdFor(txId).let { metricId ->
|
||||
metrics[metricId].also { if (it == null) println("Warning no start metric for id: $metricId") }
|
||||
}
|
||||
return startMetric?.endTime?.let { startMetricEndTime ->
|
||||
TimeMetric(second.key, second.description, mutableListOf(startMetricEndTime))
|
||||
.markTime().let { endMetric ->
|
||||
endMetric.toMetricIdFor(txId).also { metricId ->
|
||||
metrics[metricId] = endMetric
|
||||
metrics[metricId.toRelatedMetricId()] = startMetric
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key"
|
||||
private fun String.toRelatedMetricId(): String = "$this.related"
|
||||
private fun String.toTxId(): Long = split('.').first().toLong()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cash.z.ecc.android.ui.settings
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.fragment.app.viewModels
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentSettingsBinding
|
||||
import cash.z.ecc.android.ext.*
|
||||
import cash.z.ecc.android.sdk.exception.LightWalletException
|
||||
import cash.z.ecc.android.sdk.ext.collectWith
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsFragment : BaseFragment<FragmentSettingsBinding>() {
|
||||
|
||||
private val viewModel: SettingsViewModel by viewModels()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentSettingsBinding =
|
||||
FragmentSettingsBinding.inflate(inflater)
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
//
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
mainActivity?.preventBackPress(this)
|
||||
viewModel.init()
|
||||
binding.apply {
|
||||
groupLoading.gone()
|
||||
hitAreaExit.onClickNavBack()
|
||||
buttonReset.setOnClickListener(::onResetClicked)
|
||||
buttonUpdate.setOnClickListener(::onUpdateClicked)
|
||||
buttonUpdate.isActivated = true
|
||||
buttonReset.isActivated = true
|
||||
inputHost.doAfterTextChanged {
|
||||
viewModel.pendingHost = it.toString()
|
||||
}
|
||||
inputPort.doAfterTextChanged {
|
||||
viewModel.pendingPortText = it.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.uiModels.collectWith(resumedScope, ::onUiModelUpdated)
|
||||
}
|
||||
|
||||
//
|
||||
// Event handlers
|
||||
//
|
||||
|
||||
private fun onResetClicked(unused: View?) {
|
||||
mainActivity?.hideKeyboard()
|
||||
context?.showUpdateServerDialog(R.string.settings_buttons_restore) {
|
||||
resumedScope.launch {
|
||||
binding.groupLoading.visible()
|
||||
binding.loadingView.requestFocus()
|
||||
viewModel.resetServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUpdateClicked(unused: View?) {
|
||||
mainActivity?.hideKeyboard()
|
||||
context?.showUpdateServerDialog {
|
||||
resumedScope.launch {
|
||||
binding.groupLoading.visible()
|
||||
binding.loadingView.requestFocus()
|
||||
viewModel.submit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUiModelUpdated(uiModel: SettingsViewModel.UiModel) {
|
||||
twig("onUiModelUpdated:::::$uiModel")
|
||||
binding.apply {
|
||||
if (handleCompletion(uiModel)) return@onUiModelUpdated
|
||||
|
||||
// avoid moving the cursor on instances where the change originated from the UI
|
||||
if (inputHost.text.toString() != uiModel.host) inputHost.setText(uiModel.host)
|
||||
if (inputPort.text.toString() != uiModel.portText) inputPort.setText(uiModel.portText)
|
||||
|
||||
buttonReset.isEnabled = uiModel.submitEnabled
|
||||
buttonUpdate.isEnabled = uiModel.submitEnabled && !uiModel.hasError
|
||||
|
||||
uiModel.hostErrorMessage.let { it ->
|
||||
textInputLayoutHost.helperText = it
|
||||
?: R.string.settings_host_helper_text.toAppString()
|
||||
textInputLayoutHost.setHelperTextColor(it.toHelperTextColor())
|
||||
}
|
||||
uiModel.portErrorMessage.let { it ->
|
||||
textInputLayoutPort.helperText = it
|
||||
?: R.string.settings_port_helper_text.toAppString()
|
||||
textInputLayoutPort.setHelperTextColor(it.toHelperTextColor())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the exit conditions and return true if we're done here.
|
||||
*/
|
||||
private fun handleCompletion(uiModel: SettingsViewModel.UiModel): Boolean {
|
||||
return if (uiModel.changeError != null) {
|
||||
binding.groupLoading.gone()
|
||||
onCriticalError(uiModel.changeError)
|
||||
true
|
||||
} else {
|
||||
if (uiModel.complete) {
|
||||
binding.groupLoading.gone()
|
||||
mainActivity?.safeNavigate(R.id.nav_home)
|
||||
Toast.makeText(ZcashWalletApp.instance, getString(R.string.settings_toast_change_server_success), Toast.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCriticalError(error: Throwable) {
|
||||
val details = if (error is LightWalletException.ChangeServerException.StatusException) {
|
||||
error.status.description
|
||||
} else {
|
||||
error.javaClass.simpleName
|
||||
}
|
||||
val message = "An error occured while changing servers. Please verify the info" +
|
||||
" and try again.\n\nError: $details"
|
||||
twig(message)
|
||||
Toast.makeText(ZcashWalletApp.instance, getString(R.string.settings_toast_change_server_failure), Toast.LENGTH_SHORT).show()
|
||||
context?.showUpdateServerCriticalError(message)
|
||||
}
|
||||
|
||||
//
|
||||
// Utilities
|
||||
//
|
||||
|
||||
private fun String?.toHelperTextColor(): ColorStateList {
|
||||
val color = if (this == null) {
|
||||
R.color.text_light_dimmed
|
||||
} else {
|
||||
R.color.zcashRed
|
||||
}
|
||||
return ColorStateList.valueOf(color.toAppColor())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cash.z.ecc.android.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.Const
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.util.twig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlin.properties.Delegates.observable
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class SettingsViewModel : ViewModel() {
|
||||
|
||||
private val synchronizer: Synchronizer = DependenciesHolder.synchronizer
|
||||
|
||||
private val prefs: LockBox = DependenciesHolder.prefs
|
||||
|
||||
lateinit var uiModels: MutableStateFlow<UiModel>
|
||||
|
||||
private lateinit var initialServer: UiModel
|
||||
|
||||
var pendingHost by observable("", ::onUpdateModel)
|
||||
var pendingPortText by observable("", ::onUpdateModel)
|
||||
|
||||
private fun getHost(): String {
|
||||
return prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST
|
||||
}
|
||||
|
||||
private fun getPort(): Int {
|
||||
return prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT
|
||||
}
|
||||
|
||||
fun init() {
|
||||
initialServer = UiModel(getHost(), getPort().toString())
|
||||
uiModels = MutableStateFlow(initialServer)
|
||||
}
|
||||
|
||||
suspend fun resetServer() {
|
||||
UiModel(
|
||||
Const.Default.Server.HOST,
|
||||
Const.Default.Server.PORT.toString()
|
||||
).let { default ->
|
||||
uiModels.value = default
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun submit() {
|
||||
// Note: this only takes effect after the app is relaunched
|
||||
val host = uiModels.value.host
|
||||
val port = uiModels.value.portInt
|
||||
prefs[Const.Pref.SERVER_HOST] = host
|
||||
prefs[Const.Pref.SERVER_PORT] = port
|
||||
uiModels.value = uiModels.value.copy(changeError = null, complete = true)
|
||||
}
|
||||
|
||||
private fun onUpdateModel(kProperty: KProperty<*>, old: String, new: String) {
|
||||
val pendingPort = pendingPortText.toIntOrNull() ?: -1
|
||||
uiModels.value = UiModel(
|
||||
pendingHost,
|
||||
pendingPortText,
|
||||
pendingHost != initialServer.host || pendingPortText != initialServer.portText,
|
||||
if (!pendingHost.isValidHost()) "Please enter a valid host name or IP" else null,
|
||||
if (pendingPort >= 65535) "Please enter a valid port number below 65535" else null
|
||||
).also {
|
||||
twig("updated model with $it")
|
||||
}
|
||||
}
|
||||
|
||||
data class UiModel(
|
||||
val host: String = "",
|
||||
val portText: String = "",
|
||||
val submitEnabled: Boolean = false,
|
||||
val hostErrorMessage: String? = null,
|
||||
val portErrorMessage: String? = null,
|
||||
val changeError: Throwable? = null,
|
||||
val complete: Boolean = false
|
||||
) {
|
||||
val portInt get() = portText.toIntOrNull() ?: -1
|
||||
val hasError get() = hostErrorMessage != null || portErrorMessage != null
|
||||
}
|
||||
|
||||
// we can beef this up later if we want to but this is enough for now
|
||||
private fun String.isValidHost(): Boolean {
|
||||
return !contains("://")
|
||||
}
|
||||
}
|
||||
158
app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt
Normal file
158
app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt
Normal file
@@ -0,0 +1,158 @@
|
||||
package cash.z.ecc.android.ui.setup
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentBackupBinding
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.Const
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.MetricType.SEED_PHRASE_LOADED
|
||||
import cash.z.ecc.android.feedback.Report.Tap.BACKUP_DONE
|
||||
import cash.z.ecc.android.feedback.Report.Tap.BACKUP_VERIFY
|
||||
import cash.z.ecc.android.feedback.measure
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
|
||||
import cash.z.ecc.android.ui.util.AddressPartNumberSpan
|
||||
import cash.z.ecc.android.util.twig
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
||||
override val screen = Report.Screen.BACKUP
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels()
|
||||
|
||||
private var hasBackUp: Boolean = true // TODO: implement backup and then check for it here-ish
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentBackupBinding =
|
||||
FragmentBackupBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
with(binding) {
|
||||
applySpan(
|
||||
textAddressPart1, textAddressPart2, textAddressPart3,
|
||||
textAddressPart4, textAddressPart5, textAddressPart6,
|
||||
textAddressPart7, textAddressPart8, textAddressPart9,
|
||||
textAddressPart10, textAddressPart11, textAddressPart12,
|
||||
textAddressPart13, textAddressPart14, textAddressPart15,
|
||||
textAddressPart16, textAddressPart17, textAddressPart18,
|
||||
textAddressPart19, textAddressPart20, textAddressPart21,
|
||||
textAddressPart22, textAddressPart23, textAddressPart24
|
||||
)
|
||||
}
|
||||
binding.buttonPositive.setOnClickListener {
|
||||
onEnterWallet().also { if (hasBackUp) tapped(BACKUP_DONE) else tapped(BACKUP_VERIFY) }
|
||||
}
|
||||
if (hasBackUp) {
|
||||
binding.buttonPositive.text = getString(R.string.backup_button_done)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.onBackPressedDispatcher?.addCallback(this) {
|
||||
onEnterWallet(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
walletSetup.checkSeed().onEach {
|
||||
hasBackUp = when (it) {
|
||||
SEED_WITH_BACKUP -> true
|
||||
else -> false
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumedScope.launch {
|
||||
binding.textBirtdate.text =
|
||||
getString(R.string.backup_format_birthday_height, calculateBirthday().value)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: move this into the SDK
|
||||
private suspend fun calculateBirthday(): BlockHeight {
|
||||
var storedBirthday: BlockHeight? = null
|
||||
var oldestTransactionHeight: BlockHeight? = null
|
||||
var activationHeight: BlockHeight? = null
|
||||
try {
|
||||
activationHeight = DependenciesHolder.synchronizer.network.saplingActivationHeight
|
||||
storedBirthday = walletSetup.loadBirthdayHeight()
|
||||
oldestTransactionHeight = DependenciesHolder.synchronizer.receivedTransactions.first()
|
||||
?.last()?.minedHeight?.let {
|
||||
BlockHeight.new(ZcashWalletApp.instance.defaultNetwork, it)
|
||||
}
|
||||
// to be safe adjust for reorgs (and generally a little cushion is good for privacy)
|
||||
// so we round down to the nearest 100 and then subtract 100 to ensure that the result is always at least 100 blocks away
|
||||
oldestTransactionHeight = ZcashSdk.MAX_REORG_SIZE.let { boundary ->
|
||||
oldestTransactionHeight?.let { it.value - it.value.rem(boundary) - boundary }
|
||||
?.let { BlockHeight.new(ZcashWalletApp.instance.defaultNetwork, it) }
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
twig("failed to calculate birthday due to: $t")
|
||||
}
|
||||
return listOfNotNull(
|
||||
storedBirthday,
|
||||
oldestTransactionHeight,
|
||||
activationHeight
|
||||
).maxBy { it.value }
|
||||
}
|
||||
|
||||
private fun onEnterWallet(showMessage: Boolean = !this.hasBackUp) {
|
||||
if (showMessage) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
R.string.backup_verification_not_implemented,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
mainActivity?.navController?.popBackStack()
|
||||
}
|
||||
|
||||
private fun applySpan(vararg textViews: TextView) = lifecycleScope.launch {
|
||||
val words = loadSeedWords()
|
||||
val thinSpace = "\u2005" // 0.25 em space
|
||||
textViews.forEachIndexed { index, textView ->
|
||||
val numLength = "$index".length
|
||||
val word = words[index]
|
||||
// TODO: work with a charsequence here, rather than constructing a String
|
||||
textView.text = SpannableString("${index + 1}$thinSpace${String(word)}").apply {
|
||||
setSpan(AddressPartNumberSpan(), 0, 1 + numLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSeedWords(): List<CharArray> = withContext(Dispatchers.IO) {
|
||||
mainActivity!!.feedback.measure(SEED_PHRASE_LOADED) {
|
||||
val lockBox = LockBox(ZcashWalletApp.instance)
|
||||
val mnemonics = Mnemonics()
|
||||
val seedPhrase = lockBox.getCharsUtf8(Const.Backup.SEED_PHRASE)
|
||||
?: throw RuntimeException("Seed Phrase expected but not found in storage!!")
|
||||
val result = mnemonics.toWordList(seedPhrase)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
216
app/src/main/java/cash/z/ecc/android/ui/setup/LandingFragment.kt
Normal file
216
app/src/main/java/cash/z/ecc/android/ui/setup/LandingFragment.kt
Normal file
@@ -0,0 +1,216 @@
|
||||
package cash.z.ecc.android.ui.setup
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentLandingBinding
|
||||
import cash.z.ecc.android.ext.locale
|
||||
import cash.z.ecc.android.ext.showSharedLibraryCriticalError
|
||||
import cash.z.ecc.android.ext.toAppString
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Restore
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITHOUT_BACKUP
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
|
||||
import cash.z.ecc.android.util.twig
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
||||
override val screen = Report.Screen.LANDING
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels()
|
||||
|
||||
private var skipCount: Int = 0
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentLandingBinding =
|
||||
FragmentLandingBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.buttonPositive.setOnClickListener {
|
||||
when (binding.buttonPositive.text.toString().toLowerCase(locale())) {
|
||||
R.string.landing_button_primary.toAppString(true) -> onNewWallet().also {
|
||||
tapped(
|
||||
LANDING_NEW
|
||||
)
|
||||
}
|
||||
R.string.landing_button_primary_create_success.toAppString(true) -> onBackupWallet().also {
|
||||
tapped(
|
||||
LANDING_BACKUP
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.buttonNegative.setOnLongClickListener {
|
||||
tapped(DEVELOPER_WALLET_PROMPT)
|
||||
if (binding.buttonNegative.text.toString().toLowerCase(locale()) == "restore") {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage("Would you like to import the dev wallet?\n\nIf so, please only send 1000 zatoshis at a time and return some later so that the account remains funded.")
|
||||
.setTitle("Import Dev Wallet?")
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("Import") { dialog, _ ->
|
||||
tapped(DEVELOPER_WALLET_IMPORT)
|
||||
dialog.dismiss()
|
||||
onUseDevWallet()
|
||||
}
|
||||
.setNegativeButton("Cancel") { dialog, _ ->
|
||||
tapped(DEVELOPER_WALLET_CANCEL)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
binding.buttonNegative.setOnClickListener {
|
||||
when (binding.buttonNegative.text.toString().toLowerCase(locale())) {
|
||||
R.string.landing_button_secondary.toAppString(true) -> onRestoreWallet().also {
|
||||
mainActivity?.reportFunnel(Restore.Initiated)
|
||||
tapped(LANDING_RESTORE)
|
||||
}
|
||||
else -> onSkip(++skipCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
walletSetup.checkSeed().onEach {
|
||||
when (it) {
|
||||
SEED_WITHOUT_BACKUP, SEED_WITH_BACKUP -> {
|
||||
mainActivity?.safeNavigate(R.id.nav_backup)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
view?.postDelayed(
|
||||
{
|
||||
mainActivity?.hideKeyboard()
|
||||
},
|
||||
25L
|
||||
)
|
||||
}
|
||||
|
||||
private fun onSkip(count: Int) {
|
||||
when (count) {
|
||||
1 -> {
|
||||
tapped(LANDING_BACKUP_SKIPPED_1)
|
||||
binding.textMessage.setText(R.string.landing_backup_skipped_message_1)
|
||||
binding.buttonNegative.setText(R.string.landing_button_backup_skipped_1)
|
||||
}
|
||||
2 -> {
|
||||
tapped(LANDING_BACKUP_SKIPPED_2)
|
||||
binding.textMessage.setText(R.string.landing_backup_skipped_message_2)
|
||||
binding.buttonNegative.setText(R.string.landing_button_backup_skipped_2)
|
||||
}
|
||||
else -> {
|
||||
tapped(LANDING_BACKUP_SKIPPED_3)
|
||||
onEnterWallet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreWallet() {
|
||||
mainActivity?.safeNavigate(R.id.action_nav_landing_to_nav_restore)
|
||||
}
|
||||
|
||||
// AKA import wallet
|
||||
private fun onUseDevWallet() {
|
||||
val seedPhrase: String
|
||||
val birthday: BlockHeight
|
||||
|
||||
// new testnet dev wallet
|
||||
when (ZcashWalletApp.instance.defaultNetwork) {
|
||||
ZcashNetwork.Mainnet -> {
|
||||
seedPhrase =
|
||||
"still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread"
|
||||
birthday = BlockHeight.new(ZcashNetwork.Mainnet, 991645) // 663174
|
||||
}
|
||||
ZcashNetwork.Testnet -> {
|
||||
seedPhrase =
|
||||
"quantum whisper lion route fury lunar pelican image job client hundred sauce chimney barely life cliff spirit admit weekend message recipe trumpet impact kitten"
|
||||
birthday = BlockHeight.new(ZcashNetwork.Testnet, 1330190)
|
||||
}
|
||||
else -> throw RuntimeException("No developer wallet exists for network ${ZcashWalletApp.instance.defaultNetwork}")
|
||||
}
|
||||
|
||||
mainActivity?.apply {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
walletSetup.importWallet(seedPhrase, birthday)
|
||||
mainActivity?.startSync()
|
||||
binding.buttonPositive.isEnabled = true
|
||||
binding.textMessage.setText(R.string.landing_import_success_message)
|
||||
binding.buttonNegative.setText(R.string.landing_button_secondary_import_success)
|
||||
binding.buttonPositive.setText(R.string.landing_import_success_primary_button)
|
||||
playSound("sound_receive_small.mp3")
|
||||
vibrateSuccess()
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
mainActivity?.showSharedLibraryCriticalError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewWallet() {
|
||||
lifecycleScope.launch {
|
||||
binding.buttonPositive.setText(R.string.landing_button_progress_create)
|
||||
binding.buttonPositive.isEnabled = false
|
||||
|
||||
try {
|
||||
walletSetup.newWallet()
|
||||
mainActivity?.startSync()
|
||||
|
||||
binding.buttonPositive.isEnabled = true
|
||||
binding.textMessage.setText(R.string.landing_create_success_message)
|
||||
binding.buttonNegative.setText(R.string.landing_button_secondary_create_success)
|
||||
binding.buttonPositive.setText(R.string.landing_button_primary_create_success)
|
||||
mainActivity?.playSound("sound_receive_small.mp3")
|
||||
mainActivity?.vibrateSuccess()
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
// For developer sanity:
|
||||
// show a nice dialog, rather than a toast, when the rust didn't get compile
|
||||
// which can happen often when working from a local SDK build
|
||||
mainActivity?.showSharedLibraryCriticalError(e)
|
||||
} catch (t: Throwable) {
|
||||
twig("Failed to create wallet due to: $t")
|
||||
mainActivity?.feedback?.report(t)
|
||||
binding.buttonPositive.isEnabled = true
|
||||
binding.buttonPositive.setText(R.string.landing_button_primary)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to create wallet. See logs for details. Try restarting the app.\n\nMessage: \n${t.message}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBackupWallet() {
|
||||
skipCount = 0
|
||||
mainActivity?.safeNavigate(R.id.action_nav_landing_to_nav_backup)
|
||||
}
|
||||
|
||||
private fun onEnterWallet() {
|
||||
skipCount = 0
|
||||
mainActivity?.navController?.popBackStack()
|
||||
}
|
||||
}
|
||||
246
app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt
Normal file
246
app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt
Normal file
@@ -0,0 +1,246 @@
|
||||
package cash.z.ecc.android.ui.setup
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.text.InputType
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.MotionEvent.ACTION_DOWN
|
||||
import android.view.MotionEvent.ACTION_UP
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.databinding.FragmentRestoreBinding
|
||||
import cash.z.ecc.android.ext.goneIf
|
||||
import cash.z.ecc.android.ext.showConfirmation
|
||||
import cash.z.ecc.android.ext.showInvalidSeedPhraseError
|
||||
import cash.z.ecc.android.ext.showSharedLibraryCriticalError
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.feedback.Report.Funnel.Restore
|
||||
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.tylersuehr.chips.Chip
|
||||
import com.tylersuehr.chips.ChipsAdapter
|
||||
import com.tylersuehr.chips.SeedWordAdapter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListener {
|
||||
override val screen = Report.Screen.RESTORE
|
||||
|
||||
private val walletSetup: WalletSetupViewModel by activityViewModels()
|
||||
|
||||
private lateinit var seedWordRecycler: RecyclerView
|
||||
private var seedWordAdapter: SeedWordAdapter? = null
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentRestoreBinding =
|
||||
FragmentRestoreBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
seedWordRecycler = binding.chipsInput.findViewById<RecyclerView>(R.id.chips_recycler)
|
||||
seedWordAdapter = SeedWordAdapter(seedWordRecycler.adapter as ChipsAdapter).onDataSetChanged {
|
||||
onChipsModified()
|
||||
}.also { onChipsModified() }
|
||||
seedWordRecycler.adapter = seedWordAdapter
|
||||
|
||||
binding.chipsInput.apply {
|
||||
setFilterableChipList(getChips())
|
||||
setDelimiter("[ ;,]", true)
|
||||
}
|
||||
|
||||
binding.buttonDone.setOnClickListener {
|
||||
onDone().also { tapped(RESTORE_DONE) }
|
||||
}
|
||||
|
||||
binding.buttonSuccess.setOnClickListener {
|
||||
onEnterWallet().also { tapped(RESTORE_SUCCESS) }
|
||||
}
|
||||
|
||||
binding.buttonClear.setOnClickListener {
|
||||
onClearSeedWords().also { tapped(RESTORE_CLEAR) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClearSeedWords() {
|
||||
mainActivity?.showConfirmation(
|
||||
"Clear All Words",
|
||||
"Are you sure you would like to clear all the seed words and type them again?",
|
||||
"Clear",
|
||||
onPositive = {
|
||||
binding.chipsInput.clearSelectedChips()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
mainActivity?.onFragmentBackPressed(this) {
|
||||
tapped(RESTORE_BACK)
|
||||
if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) {
|
||||
onExit()
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage("Are you sure? For security, the words that you have entered will be cleared!")
|
||||
.setTitle("Abort?")
|
||||
.setPositiveButton("Stay") { dialog, _ ->
|
||||
mainActivity?.reportFunnel(Restore.Stay)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton("Exit") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
onExit()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Require one less tap to enter the seed words
|
||||
touchScreenForUser()
|
||||
}
|
||||
|
||||
private fun onExit() {
|
||||
mainActivity?.reportFunnel(Restore.Exit)
|
||||
hideAutoCompleteWords()
|
||||
mainActivity?.hideKeyboard()
|
||||
mainActivity?.navController?.popBackStack()
|
||||
}
|
||||
|
||||
private fun onEnterWallet() {
|
||||
mainActivity?.reportFunnel(Restore.Success)
|
||||
mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home)
|
||||
}
|
||||
|
||||
private fun onDone() {
|
||||
mainActivity?.reportFunnel(Restore.Done)
|
||||
mainActivity?.hideKeyboard()
|
||||
val activation = ZcashWalletApp.instance.defaultNetwork.saplingActivationHeight
|
||||
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
|
||||
it.title
|
||||
}
|
||||
var birthday = binding.root.findViewById<TextView>(R.id.input_birthdate).text.toString()
|
||||
.let { birthdateString ->
|
||||
if (birthdateString.isNullOrEmpty()) activation.value else birthdateString.toLong()
|
||||
}.coerceAtLeast(activation.value)
|
||||
|
||||
try {
|
||||
walletSetup.validatePhrase(seedPhrase)
|
||||
importWallet(seedPhrase, BlockHeight.new(ZcashWalletApp.instance.defaultNetwork, birthday))
|
||||
} catch (t: Throwable) {
|
||||
mainActivity?.showInvalidSeedPhraseError(t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importWallet(seedPhrase: String, birthday: BlockHeight?) {
|
||||
mainActivity?.reportFunnel(Restore.ImportStarted)
|
||||
mainActivity?.hideKeyboard()
|
||||
mainActivity?.apply {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
walletSetup.importWallet(seedPhrase, birthday)
|
||||
mainActivity?.startSync()
|
||||
// bugfix: if the user proceeds before the synchronizer is created the app will crash!
|
||||
binding.buttonSuccess.isEnabled = true
|
||||
mainActivity?.reportFunnel(Restore.ImportCompleted)
|
||||
playSound("sound_receive_small.mp3")
|
||||
vibrateSuccess()
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
mainActivity?.showSharedLibraryCriticalError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.groupDone.visibility = View.GONE
|
||||
binding.groupStart.visibility = View.GONE
|
||||
binding.groupSuccess.visibility = View.VISIBLE
|
||||
binding.buttonSuccess.isEnabled = false
|
||||
}
|
||||
|
||||
private fun onChipsModified() {
|
||||
updateDoneViews()
|
||||
forceShowKeyboard()
|
||||
}
|
||||
|
||||
private fun updateDoneViews(): Boolean {
|
||||
val count = seedWordAdapter?.itemCount ?: 0
|
||||
reportWords(count - 1) // subtract 1 for the editText
|
||||
val isDone = count > 24
|
||||
binding.groupDone.goneIf(!isDone)
|
||||
return !isDone
|
||||
}
|
||||
|
||||
// forcefully show the keyboard as a hack to fix odd behavior where the keyboard
|
||||
// sometimes closes randomly and inexplicably in between seed word entries
|
||||
private fun forceShowKeyboard() {
|
||||
requireView().postDelayed(
|
||||
{
|
||||
val isDone = (seedWordAdapter?.itemCount ?: 0) > 24
|
||||
val focusedView = if (isDone) binding.inputBirthdate else seedWordAdapter!!.editText
|
||||
mainActivity!!.showKeyboard(focusedView)
|
||||
focusedView.requestFocus()
|
||||
},
|
||||
500L
|
||||
)
|
||||
}
|
||||
|
||||
private fun reportWords(count: Int) {
|
||||
mainActivity?.run {
|
||||
// reportFunnel(Restore.SeedWordCount(count))
|
||||
if (count == 1) {
|
||||
reportFunnel(Restore.SeedWordsStarted)
|
||||
} else if (count == 24) {
|
||||
reportFunnel(Restore.SeedWordsCompleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideAutoCompleteWords() {
|
||||
seedWordAdapter?.editText?.setText("")
|
||||
}
|
||||
|
||||
private fun getChips(): List<Chip> {
|
||||
return resources.getStringArray(R.array.word_list).map {
|
||||
SeedWordChip(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun touchScreenForUser() {
|
||||
seedWordAdapter?.editText?.apply {
|
||||
postDelayed(
|
||||
{
|
||||
seedWordAdapter?.editText?.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
dispatchTouchEvent(motionEvent(ACTION_DOWN))
|
||||
dispatchTouchEvent(motionEvent(ACTION_UP))
|
||||
},
|
||||
100L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun motionEvent(action: Int) = SystemClock.uptimeMillis().let { now ->
|
||||
MotionEvent.obtain(now, now, action, 0f, 0f, 0)
|
||||
}
|
||||
|
||||
override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
class SeedWordChip(val word: String, var index: Int = -1) : Chip() {
|
||||
override fun getSubtitle(): String? = null // "subtitle for $word"
|
||||
override fun getAvatarDrawable(): Drawable? = null
|
||||
override fun getId() = index
|
||||
override fun getTitle() = word
|
||||
override fun getAvatarUri() = null
|
||||
}
|
||||
103
app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt
Normal file
103
app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.tylersuehr.chips
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ext.toAppColor
|
||||
import cash.z.ecc.android.ui.setup.SeedWordChip
|
||||
|
||||
class SeedWordAdapter : ChipsAdapter {
|
||||
|
||||
constructor(existingAdapter: ChipsAdapter) : super(existingAdapter.mDataSource, existingAdapter.mEditText, existingAdapter.mOptions)
|
||||
|
||||
val editText = mEditText
|
||||
private var onDataSetChangedListener: (() -> Unit)? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == CHIP) SeedWordHolder(SeedWordChipView(parent.context))
|
||||
else object : RecyclerView.ViewHolder(mEditText) {}
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (getItemViewType(position) == CHIP) { // Chips
|
||||
// Display the chip information on the chip view
|
||||
(holder as SeedWordHolder).seedChipView.bind(mDataSource.getSelectedChip(position), position)
|
||||
} else {
|
||||
val size = mDataSource.selectedChips.size
|
||||
|
||||
// tricky bugfix:
|
||||
// keep this always enabled otherwise older versions of android crash when this
|
||||
// view is given focus. As a work around, just hide the cursor when the user is done
|
||||
// editing. This is not ideal but it's better than a crash during wallet restore!
|
||||
mEditText.isEnabled = true
|
||||
mEditText.hint = if (size < 3) {
|
||||
mEditText.isCursorVisible = true
|
||||
mEditText.setHintTextColor(R.color.text_light_dimmed.toAppColor())
|
||||
val ordinal = when (size) { 2 -> "3rd"; 1 -> "2nd"; else -> "1st" }
|
||||
"Enter $ordinal seed word"
|
||||
} else if (size >= 24) {
|
||||
mEditText.setHintTextColor(R.color.zcashGreen.toAppColor())
|
||||
mEditText.isCursorVisible = false
|
||||
"done"
|
||||
} else {
|
||||
mEditText.isCursorVisible = true
|
||||
mEditText.setHintTextColor(R.color.zcashYellow.toAppColor())
|
||||
"${size + 1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChipDataSourceChanged() {
|
||||
super.onChipDataSourceChanged()
|
||||
onDataSetChangedListener?.invoke()
|
||||
}
|
||||
|
||||
fun onDataSetChanged(block: () -> Unit): SeedWordAdapter {
|
||||
onDataSetChangedListener = block
|
||||
return this
|
||||
}
|
||||
|
||||
override fun onKeyboardActionDone(text: String?) {
|
||||
if (TextUtils.isEmpty(text)) return
|
||||
|
||||
if (mDataSource.originalChips.firstOrNull { it.title == text } != null) {
|
||||
mDataSource.addSelectedChip(DefaultCustomChip(text))
|
||||
mEditText.apply {
|
||||
postDelayed(
|
||||
{
|
||||
setText("")
|
||||
requestFocus()
|
||||
},
|
||||
50L
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
} else {
|
||||
onKeyboardActionDone(text)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) {
|
||||
val seedChipView = super.chipView as SeedWordChipView
|
||||
}
|
||||
|
||||
private inner class SeedWordChipView(context: Context) : ChipView(context) {
|
||||
private val indexView: TextView = findViewById(R.id.chip_index)
|
||||
|
||||
fun bind(chip: Chip, index: Int) {
|
||||
super.inflateFromChip(chip)
|
||||
indexView.text = (index + 1).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package cash.z.ecc.android.ui.setup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.DependenciesHolder
|
||||
import cash.z.ecc.android.ext.Const
|
||||
import cash.z.ecc.android.ext.failWith
|
||||
import cash.z.ecc.android.feedback.Feedback
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.lockbox.LockBox
|
||||
import cash.z.ecc.android.sdk.Initializer
|
||||
import cash.z.ecc.android.sdk.exception.InitializerException
|
||||
import cash.z.ecc.android.sdk.model.BlockHeight
|
||||
import cash.z.ecc.android.sdk.model.LightWalletEndpoint
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.sdk.tool.DerivationTool
|
||||
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
|
||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
|
||||
import cash.z.ecc.android.util.twig
|
||||
import cash.z.ecc.kotlin.mnemonic.Mnemonics
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class WalletSetupViewModel : ViewModel() {
|
||||
|
||||
private val mnemonics: Mnemonics = DependenciesHolder.mnemonics
|
||||
|
||||
private val lockBox: LockBox = DependenciesHolder.lockBox
|
||||
|
||||
private val prefs: LockBox = DependenciesHolder.prefs
|
||||
|
||||
private val feedback: Feedback = DependenciesHolder.feedback
|
||||
|
||||
enum class WalletSetupState {
|
||||
SEED_WITH_BACKUP, SEED_WITHOUT_BACKUP, NO_SEED
|
||||
}
|
||||
|
||||
fun checkSeed(): Flow<WalletSetupState> = flow {
|
||||
when {
|
||||
lockBox.getBoolean(Const.Backup.HAS_BACKUP) -> emit(SEED_WITH_BACKUP)
|
||||
lockBox.getBoolean(Const.Backup.HAS_SEED) -> emit(SEED_WITHOUT_BACKUP)
|
||||
else -> emit(NO_SEED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an exception if the seed phrase is bad.
|
||||
*/
|
||||
fun validatePhrase(seedPhrase: String) {
|
||||
mnemonics.validate(seedPhrase.toCharArray())
|
||||
}
|
||||
|
||||
fun loadBirthdayHeight(): BlockHeight? {
|
||||
val h: Int? = lockBox[Const.Backup.BIRTHDAY_HEIGHT]
|
||||
twig("Loaded birthday with key ${Const.Backup.BIRTHDAY_HEIGHT} and found $h")
|
||||
h?.let {
|
||||
return BlockHeight.new(ZcashWalletApp.instance.defaultNetwork, it.toLong())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun newWallet() {
|
||||
val network = ZcashWalletApp.instance.defaultNetwork
|
||||
twig("Initializing new ${network.networkName} wallet")
|
||||
with(mnemonics) {
|
||||
storeWallet(nextMnemonic(nextEntropy()), network, loadNearestBirthday(network))
|
||||
}
|
||||
openStoredWallet()
|
||||
}
|
||||
|
||||
suspend fun importWallet(seedPhrase: String, birthdayHeight: BlockHeight?) {
|
||||
val network = ZcashWalletApp.instance.defaultNetwork
|
||||
twig("Importing ${network.networkName} wallet. Requested birthday: $birthdayHeight")
|
||||
storeWallet(
|
||||
seedPhrase.toCharArray(),
|
||||
network,
|
||||
birthdayHeight ?: loadNearestBirthday(network)
|
||||
)
|
||||
openStoredWallet()
|
||||
}
|
||||
|
||||
suspend fun openStoredWallet() {
|
||||
DependenciesHolder.initializerComponent.createInitializer(loadConfig())
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a config object by loading in the viewingKey, birthday and server info which is already
|
||||
* known by this point.
|
||||
*/
|
||||
private suspend fun loadConfig(): Initializer.Config {
|
||||
twig("Loading config variables")
|
||||
var overwriteVks = false
|
||||
val network = ZcashWalletApp.instance.defaultNetwork
|
||||
val vk =
|
||||
loadUnifiedViewingKey() ?: onMissingViewingKey(network).also { overwriteVks = true }
|
||||
val birthdayHeight = loadBirthdayHeight() ?: onMissingBirthday(network)
|
||||
val host = prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST
|
||||
val port = prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT
|
||||
|
||||
twig("Done loading config variables")
|
||||
return Initializer.Config {
|
||||
it.importWallet(vk, birthdayHeight, network, LightWalletEndpoint(host, port, true))
|
||||
it.setOverwriteKeys(overwriteVks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUnifiedViewingKey(): UnifiedViewingKey? {
|
||||
val extfvk = lockBox.getCharsUtf8(Const.Backup.VIEWING_KEY)
|
||||
val extpub = lockBox.getCharsUtf8(Const.Backup.PUBLIC_KEY)
|
||||
return when {
|
||||
extfvk == null || extpub == null -> {
|
||||
if (extfvk == null) {
|
||||
twig("Warning: Shielded key was missing")
|
||||
}
|
||||
if (extpub == null) {
|
||||
twig("Warning: Transparent key was missing")
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> UnifiedViewingKey(extfvk = String(extfvk), extpub = String(extpub))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onMissingViewingKey(network: ZcashNetwork): UnifiedViewingKey {
|
||||
twig("Recover VK: Viewing key was missing")
|
||||
// add some temporary logic to help us troubleshoot this problem.
|
||||
ZcashWalletApp.instance.getSharedPreferences("SecurePreferences", Context.MODE_PRIVATE)
|
||||
.all.map { it.key }.joinToString().let { keyNames ->
|
||||
"${Const.Backup.VIEWING_KEY}, ${Const.Backup.PUBLIC_KEY}".let { missingKeys ->
|
||||
// is there a typo or change in how the value is labelled?
|
||||
// for troubleshooting purposes, let's see if we CAN derive the vk from the seed in these situations
|
||||
var recoveryViewingKey: UnifiedViewingKey? = null
|
||||
var ableToLoadSeed = false
|
||||
try {
|
||||
val seed = lockBox.getBytes(Const.Backup.SEED)!!
|
||||
ableToLoadSeed = true
|
||||
twig("Recover UVK: Seed found")
|
||||
recoveryViewingKey =
|
||||
DerivationTool.deriveUnifiedViewingKeys(seed, network)[0]
|
||||
twig("Recover UVK: successfully derived UVK from seed")
|
||||
} catch (t: Throwable) {
|
||||
twig("Failed while trying to recover UVK due to: $t")
|
||||
}
|
||||
|
||||
// this will happen during rare upgrade scenarios when the user migrates from a seed-only wallet to this vk-based version
|
||||
// or during more common scenarios where the user migrates from a vk only wallet to a unified vk wallet
|
||||
if (recoveryViewingKey != null) {
|
||||
storeUnifiedViewingKey(recoveryViewingKey)
|
||||
return recoveryViewingKey
|
||||
} else {
|
||||
feedback.report(
|
||||
Report.Issue.MissingViewkey(
|
||||
ableToLoadSeed,
|
||||
missingKeys,
|
||||
keyNames,
|
||||
lockBox.getCharsUtf8(Const.Backup.VIEWING_KEY) != null
|
||||
)
|
||||
)
|
||||
}
|
||||
throw InitializerException.MissingViewingKeyException
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onMissingBirthday(network: ZcashNetwork): BlockHeight =
|
||||
failWith(InitializerException.MissingBirthdayException) {
|
||||
twig("Recover Birthday: falling back to sapling birthday")
|
||||
loadNearestBirthday(network)
|
||||
}
|
||||
|
||||
private suspend fun loadNearestBirthday(network: ZcashNetwork) =
|
||||
BlockHeight.ofLatestCheckpoint(
|
||||
ZcashWalletApp.instance,
|
||||
network,
|
||||
)
|
||||
|
||||
//
|
||||
// Storage Helpers
|
||||
//
|
||||
|
||||
/**
|
||||
* Entry point for all storage. Takes a seed phrase and stores all the parts so that we can
|
||||
* selectively use them, the next time the app is opened. Although we store everything, we
|
||||
* primarily only work with the viewing key and spending key. The seed is only accessed when
|
||||
* presenting backup information to the user.
|
||||
*/
|
||||
private suspend fun storeWallet(
|
||||
seedPhraseChars: CharArray,
|
||||
network: ZcashNetwork,
|
||||
birthday: BlockHeight
|
||||
) {
|
||||
check(!lockBox.getBoolean(Const.Backup.HAS_SEED)) {
|
||||
"Error! Cannot store a seed when one already exists! This would overwrite the" +
|
||||
" existing seed and could lead to a loss of funds if the user has no backup!"
|
||||
}
|
||||
|
||||
storeBirthday(birthday)
|
||||
|
||||
mnemonics.toSeed(seedPhraseChars).let { bip39Seed ->
|
||||
DerivationTool.deriveUnifiedViewingKeys(bip39Seed, network)[0].let { viewingKey ->
|
||||
storeSeedPhrase(seedPhraseChars)
|
||||
storeSeed(bip39Seed)
|
||||
storeUnifiedViewingKey(viewingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeBirthday(birthday: BlockHeight) = withContext(IO) {
|
||||
twig("Storing birthday ${birthday.value} with and key ${Const.Backup.BIRTHDAY_HEIGHT}")
|
||||
lockBox[Const.Backup.BIRTHDAY_HEIGHT] = birthday.value
|
||||
}
|
||||
|
||||
private suspend fun storeSeedPhrase(seedPhrase: CharArray) = withContext(IO) {
|
||||
twig("Storing seedphrase: ${seedPhrase.size}")
|
||||
lockBox[Const.Backup.SEED_PHRASE] = seedPhrase
|
||||
lockBox[Const.Backup.HAS_SEED_PHRASE] = true
|
||||
}
|
||||
|
||||
private suspend fun storeSeed(bip39Seed: ByteArray) = withContext(IO) {
|
||||
twig("Storing seed: ${bip39Seed.size}")
|
||||
lockBox.setBytes(Const.Backup.SEED, bip39Seed)
|
||||
lockBox[Const.Backup.HAS_SEED] = true
|
||||
}
|
||||
|
||||
private suspend fun storeUnifiedViewingKey(vk: UnifiedViewingKey) = withContext(IO) {
|
||||
twig("storeViewingKey vk: ${vk.extfvk.length}")
|
||||
lockBox[Const.Backup.VIEWING_KEY] = vk.extfvk
|
||||
lockBox[Const.Backup.PUBLIC_KEY] = vk.extpub
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package cash.z.ecc.android.ui.tab_layout
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.databinding.FragmentTabLayoutBinding
|
||||
import cash.z.ecc.android.ext.onClickNavBack
|
||||
import cash.z.ecc.android.feedback.Report
|
||||
import cash.z.ecc.android.ui.base.BaseFragment
|
||||
import cash.z.ecc.android.ui.receive.ReceiveTabFragment
|
||||
import cash.z.ecc.android.ui.receive.ReceiveViewModel
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TabLayoutFragment :
|
||||
BaseFragment<FragmentTabLayoutBinding>(),
|
||||
FragmentCreator,
|
||||
TabLayout.OnTabSelectedListener {
|
||||
|
||||
private val viewModel: ReceiveViewModel by viewModels()
|
||||
|
||||
override fun inflate(inflater: LayoutInflater): FragmentTabLayoutBinding =
|
||||
FragmentTabLayoutBinding.inflate(inflater)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hitAreaExit.onClickNavBack { tapped(Report.Tap.RECEIVE_BACK) }
|
||||
binding.textTitle.text = "Receive ${getString(R.string.symbol)}"
|
||||
binding.viewPager.adapter = ViewPagerAdapter(this, this)
|
||||
binding.viewPager.setPageTransformer(ZoomOutPageTransformer())
|
||||
binding.tabLayout.addOnTabSelectedListener(this)
|
||||
/*
|
||||
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
|
||||
tab.text = if (position == 0) "Shielded" else "Transparent"
|
||||
}.attach()
|
||||
*/
|
||||
binding.buttonShareAddress.setOnClickListener {
|
||||
shareActiveAddress()
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareActiveAddress() {
|
||||
mainActivity?.apply {
|
||||
lifecycleScope.launch {
|
||||
val address =
|
||||
viewModel.getAddress()
|
||||
shareText(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// TabLayout.OnTabSelectedListener implementation
|
||||
//
|
||||
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
when (tab.position) {
|
||||
0 -> setSelectedTab(R.color.zcashYellow)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab) {}
|
||||
|
||||
private fun setSelectedTab(@ColorRes color: Int) {
|
||||
binding.tabLayout.setSelectedTabIndicatorColor(
|
||||
ContextCompat.getColor(requireContext(), color)
|
||||
)
|
||||
binding.tabLayout.setTabTextColors(
|
||||
ContextCompat.getColor(requireContext(), R.color.unselected_tab_grey),
|
||||
ContextCompat.getColor(requireContext(), color)
|
||||
)
|
||||
}
|
||||
|
||||
//
|
||||
// FragmentCreator implementation
|
||||
//
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> ReceiveTabFragment()
|
||||
else -> throw IndexOutOfBoundsException("Cannot create a fragment for index $position")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = 2
|
||||
|
||||
interface AddressFragment {
|
||||
suspend fun getAddress(): String
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_SCALE = 0.8f
|
||||
private const val MIN_ALPHA = 0.1f
|
||||
|
||||
class ZoomOutPageTransformer : ViewPager2.PageTransformer {
|
||||
|
||||
override fun transformPage(view: View, position: Float) {
|
||||
view.apply {
|
||||
val pageWidth = width
|
||||
val pageHeight = height
|
||||
when {
|
||||
position < -1 -> { // [-Infinity,-1)
|
||||
// This page is way off-screen to the left.
|
||||
alpha = 0f
|
||||
}
|
||||
position <= 1 -> { // [-1,1]
|
||||
// Modify the default slide transition to shrink the page as well
|
||||
val scaleFactor = Math.max(MIN_SCALE, 1 - Math.abs(position))
|
||||
val vertMargin = pageHeight * (1 - scaleFactor) / 2
|
||||
val horzMargin = pageWidth * (1 - scaleFactor) / 2
|
||||
translationX = if (position < 0) {
|
||||
horzMargin - vertMargin / 2
|
||||
} else {
|
||||
horzMargin + vertMargin / 2
|
||||
}
|
||||
|
||||
// Scale the page down (between MIN_SCALE and 1)
|
||||
scaleX = scaleFactor
|
||||
scaleY = scaleFactor
|
||||
|
||||
// Fade the page relative to its size.
|
||||
alpha = (
|
||||
MIN_ALPHA +
|
||||
(((scaleFactor - MIN_SCALE) / (1 - MIN_SCALE)) * (1 - MIN_ALPHA))
|
||||
)
|
||||
}
|
||||
else -> { // (1,+Infinity]
|
||||
// This page is way off-screen to the right.
|
||||
alpha = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cash.z.ecc.android.ui.tab_layout
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class ViewPagerAdapter(parent: Fragment, creator: FragmentCreator) :
|
||||
FragmentStateAdapter(parent),
|
||||
FragmentCreator by creator
|
||||
|
||||
interface FragmentCreator {
|
||||
fun createFragment(position: Int): Fragment
|
||||
fun getItemCount(): Int
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cash.z.ecc.android.ui.util
|
||||
|
||||
import android.text.TextPaint
|
||||
import android.text.style.MetricAffectingSpan
|
||||
import androidx.core.content.ContextCompat
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
|
||||
/**
|
||||
* A span used for numbering the parts of an address. It combines a [android.text.style.RelativeSizeSpan],
|
||||
* [android.text.style.SuperscriptSpan], and a [android.text.style.ForegroundColorSpan] into one class for efficiency.
|
||||
*/
|
||||
class AddressPartNumberSpan(
|
||||
val proportion: Float = 0.5f,
|
||||
val color: Int = ContextCompat.getColor(ZcashWalletApp.instance, R.color.colorPrimary)
|
||||
) : MetricAffectingSpan() {
|
||||
|
||||
override fun updateMeasureState(textPaint: TextPaint) {
|
||||
textPaint.baselineShift += (textPaint.ascent() / 2).toInt() // from SuperscriptSpan
|
||||
textPaint.textSize = textPaint.textSize * proportion // from RelativeSizeSpan
|
||||
}
|
||||
|
||||
override fun updateDrawState(textPaint: TextPaint) {
|
||||
textPaint.baselineShift += (textPaint.ascent() / 2).toInt() // from SuperscriptSpan (baseline must shift before resizing or else it will not properly align to the top of the text)
|
||||
textPaint.textSize = textPaint.textSize * proportion // from RelativeSizeSpan
|
||||
textPaint.color = color // from ForegroundColorSpan
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cash.z.ecc.android.ui.util
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.util.TroubleshootingTwig
|
||||
import okio.appendingSink
|
||||
import okio.buffer
|
||||
import java.io.File
|
||||
|
||||
class DebugFileTwig(fileName: String = "developer_log.txt") : TroubleshootingTwig(formatter = spiffy(6)) {
|
||||
val file = File("${ZcashWalletApp.instance.filesDir}/logs", fileName)
|
||||
|
||||
override fun twig(logMessage: String, priority: Int) {
|
||||
super.twig(logMessage, priority)
|
||||
appendToFile(formatter(logMessage))
|
||||
}
|
||||
|
||||
private fun appendToFile(message: String) {
|
||||
file.appendingSink().buffer().use {
|
||||
it.writeUtf8("$message\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/cash/z/ecc/android/ui/util/MemoUtil.kt
Normal file
54
app/src/main/java/cash/z/ecc/android/ui/util/MemoUtil.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package cash.z.ecc.android.ui.util
|
||||
|
||||
import cash.z.ecc.android.sdk.db.entity.ConfirmedTransaction
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* The prefix that this wallet uses whenever the user chooses to include their address in the memo.
|
||||
* This is the one we standardize around.
|
||||
*/
|
||||
const val INCLUDE_MEMO_PREFIX_STANDARD = "Reply-To:"
|
||||
|
||||
/**
|
||||
* The non-standard prefixes that we will parse if other wallets send them our way.
|
||||
*/
|
||||
val INCLUDE_MEMO_PREFIXES_RECOGNIZED = arrayOf(
|
||||
INCLUDE_MEMO_PREFIX_STANDARD, // standard
|
||||
"reply-to", // standard w/o colon
|
||||
"reply to:", // space instead of dash
|
||||
"reply to", // space instead of dash w/o colon
|
||||
"sent from:", // previous standard
|
||||
"sent from" // previous standard w/o colon
|
||||
)
|
||||
|
||||
// TODO: move this to the SDK
|
||||
inline fun ByteArray?.toUtf8Memo(): String {
|
||||
// TODO: make this more official but for now, this will do
|
||||
return if (this == null || this.isEmpty() || this[0] >= 0xF5) "" else try {
|
||||
// trim empty and "replacement characters" for codes that can't be represented in unicode
|
||||
String(this, StandardCharsets.UTF_8).trim('\u0000', '\uFFFD')
|
||||
} catch (t: Throwable) {
|
||||
"Unable to parse memo."
|
||||
}
|
||||
}
|
||||
|
||||
object MemoUtil {
|
||||
|
||||
suspend fun findAddressInMemo(tx: ConfirmedTransaction?, addressValidator: suspend (String) -> Boolean): String? {
|
||||
// note: t-addr min length is 35, plus we're expecting prefixes
|
||||
return tx?.memo?.toUtf8Memo()?.takeUnless { it.length < 35 }?.let { memo ->
|
||||
// start with what we accept as prefixes
|
||||
INCLUDE_MEMO_PREFIXES_RECOGNIZED.mapNotNull {
|
||||
val maybeMemo = memo.substringAfterLast(it)
|
||||
if (addressValidator(maybeMemo)) maybeMemo else null
|
||||
}.firstOrNull { !it.isNullOrBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
// note: cannot use substringAfterLast, directly because we want to ignore case. perhaps submit a feature request to kotlin for adding `ignoreCase`
|
||||
private fun String.substringAfterLast(prefix: String): String {
|
||||
return lastIndexOf(prefix, ignoreCase = true).takeUnless { it == -1 }?.let { i ->
|
||||
substring(i + prefix.length).trimStart()
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cash.z.ecc.android.ui.util
|
||||
//
|
||||
// import android.Manifest
|
||||
// import android.content.Context
|
||||
// import android.content.pm.PackageManager
|
||||
// import android.os.Bundle
|
||||
// import android.widget.Toast
|
||||
// import androidx.core.content.ContextCompat
|
||||
// import androidx.fragment.app.Fragment
|
||||
// import cash.z.ecc.android.ui.MainActivity
|
||||
//
|
||||
// class PermissionFragment : Fragment() {
|
||||
//
|
||||
// val activity get() = context as MainActivity
|
||||
//
|
||||
// override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// super.onCreate(savedInstanceState)
|
||||
// if (!hasPermissions(activity)) {
|
||||
// requestPermissions(PERMISSIONS, REQUEST_CODE)
|
||||
// } else {
|
||||
// activity.openCamera()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onRequestPermissionsResult(
|
||||
// requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
// ) {
|
||||
// super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
//
|
||||
// if (requestCode == REQUEST_CODE) {
|
||||
// if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
// activity.openCamera()
|
||||
// } else {
|
||||
// Toast.makeText(context, "Camera request denied", Toast.LENGTH_LONG).show()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// companion object {
|
||||
// private const val REQUEST_CODE = 101
|
||||
// private val PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
|
||||
//
|
||||
// fun hasPermissions(context: Context) = PERMISSIONS.all {
|
||||
// ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
200
app/src/main/java/cash/z/ecc/android/util/Twig.kt
Normal file
200
app/src/main/java/cash/z/ecc/android/util/Twig.kt
Normal file
@@ -0,0 +1,200 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package cash.z.ecc.android.util
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
internal typealias Leaf = String
|
||||
|
||||
/**
|
||||
* A tiny log.
|
||||
*/
|
||||
interface Twig {
|
||||
|
||||
/**
|
||||
* Log the message. Simple.
|
||||
*/
|
||||
fun twig(logMessage: String = "", priority: Int = 0)
|
||||
|
||||
/**
|
||||
* Bundles twigs together.
|
||||
*/
|
||||
operator fun plus(twig: Twig): Twig {
|
||||
// if the other twig is a composite twig, let it handle the addition
|
||||
return if (twig is CompositeTwig) twig.plus(this) else CompositeTwig(mutableListOf(this, twig))
|
||||
}
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Access the trunk corresponding to this twig.
|
||||
*/
|
||||
val trunk get() = Bush.trunk
|
||||
|
||||
/**
|
||||
* Convenience function to just turn this thing on. Twigs are silent by default so this is
|
||||
* most useful to enable developer logging at the right time.
|
||||
*/
|
||||
fun enabled(isEnabled: Boolean) {
|
||||
if (isEnabled) plant(TroubleshootingTwig()) else plant(SilentTwig())
|
||||
}
|
||||
|
||||
/**
|
||||
* Plants the twig, making it the one and only bush. Twigs can be bundled together to create
|
||||
* the appearance of multiple bushes (i.e `Twig.plant(twigA + twigB + twigC)`) even though
|
||||
* there's only ever one bush.
|
||||
*/
|
||||
fun plant(rootTwig: Twig) {
|
||||
Bush.trunk = rootTwig
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a leaf on the bush. Leaves show up in every log message as tags until they are
|
||||
* clipped.
|
||||
*/
|
||||
fun sprout(leaf: Leaf) = Bush.leaves.add(leaf)
|
||||
|
||||
/**
|
||||
* Clip a leaf from the bush. Clipped leaves no longer appear in logs.
|
||||
*/
|
||||
fun clip(leaf: Leaf) = Bush.leaves.remove(leaf)
|
||||
|
||||
/**
|
||||
* Clip all leaves from the bush.
|
||||
*/
|
||||
fun prune() = Bush.leaves.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of tiny logs (twigs) consisting of one trunk and maybe some leaves. There can only
|
||||
* ever be one trunk. Trunks are created by planting a twig. Whenever a leaf sprouts, it will appear
|
||||
* as a tag on every log message until clipped.
|
||||
*
|
||||
* @see [Twig.plant]
|
||||
* @see [Twig.sprout]
|
||||
* @see [Twig.clip]
|
||||
*/
|
||||
object Bush {
|
||||
var trunk: Twig = SilentTwig()
|
||||
val leaves: MutableSet<Leaf> = CopyOnWriteArraySet<Leaf>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a tiny log.
|
||||
*/
|
||||
inline fun twig(message: String, priority: Int = 0) = Bush.trunk.twig(message, priority)
|
||||
|
||||
/**
|
||||
* Makes an exception.
|
||||
*/
|
||||
inline fun twig(t: Throwable) = t.stackTraceToString().lines().forEach {
|
||||
twig(it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Times a tiny log.
|
||||
*/
|
||||
inline fun <R> twig(logMessage: String, priority: Int = 0, block: () -> R): R = Bush.trunk.twig(logMessage, priority, block)
|
||||
|
||||
/**
|
||||
* Meticulously times a tiny task.
|
||||
*/
|
||||
inline fun <R> twigTask(logMessage: String, priority: Int = 0, block: () -> R): R = Bush.trunk.twigTask(logMessage, priority, block)
|
||||
|
||||
/**
|
||||
* A tiny log that does nothing. No one hears this twig fall in the woods.
|
||||
*/
|
||||
class SilentTwig : Twig {
|
||||
|
||||
/**
|
||||
* Shh.
|
||||
*/
|
||||
override fun twig(logMessage: String, priority: Int) {
|
||||
// shh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny log for detecting troubles. Aim at your troubles and pull the twigger.
|
||||
*
|
||||
* @param formatter a formatter for the twigs. The default one is pretty spiffy.
|
||||
* @param printer a printer for the twigs. The default is System.err.println.
|
||||
*/
|
||||
open class TroubleshootingTwig(
|
||||
val formatter: (String) -> String = spiffy(6),
|
||||
val printer: (String) -> Any = System.err::println,
|
||||
val minPriority: Int = 0
|
||||
) : Twig {
|
||||
|
||||
/**
|
||||
* Actually print and format the log message, unlike the SilentTwig, which does nothing.
|
||||
*/
|
||||
override fun twig(logMessage: String, priority: Int) {
|
||||
if (priority >= minPriority) printer(formatter(logMessage))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* A tiny log formatter that makes twigs pretty spiffy.
|
||||
*
|
||||
* @param stackFrame the stack frame from which we try to derive the class. This can vary depending
|
||||
* on how the code is called so we expose it for flexibility. Jiggle the handle on this whenever the
|
||||
* line numbers appear incorrect.
|
||||
*/
|
||||
fun spiffy(stackFrame: Int = 4, tag: String = "@TWIG"): (String) -> String = { logMessage: String ->
|
||||
val stack = Thread.currentThread().stackTrace[stackFrame]
|
||||
val time = String.format("$tag %1\$tD %1\$tI:%1\$tM:%1\$tS.%1\$tN", System.currentTimeMillis())
|
||||
val className = stack.className.split(".").lastOrNull()?.split("\$")?.firstOrNull()
|
||||
val tags = Bush.leaves.joinToString(" #", "#")
|
||||
"$time[$className:${stack.lineNumber}]($tags) $logMessage"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since there can only ever be one trunk on the bush of twigs, this class lets
|
||||
* you cheat and make that trunk be a bundle of twigs.
|
||||
*/
|
||||
open class CompositeTwig(open val twigBundle: MutableList<Twig>) :
|
||||
Twig {
|
||||
override operator fun plus(twig: Twig): Twig {
|
||||
if (twig is CompositeTwig) twigBundle.addAll(twig.twigBundle) else twigBundle.add(twig)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun twig(logMessage: String, priority: Int) {
|
||||
for (twig in twigBundle) {
|
||||
twig.twig(logMessage, priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Times a tiny log. Execute the block of code on the clock.
|
||||
*/
|
||||
inline fun <R> Twig.twig(logMessage: String, priority: Int = 0, block: () -> R): R {
|
||||
val start = System.currentTimeMillis()
|
||||
val result = block()
|
||||
val elapsed = (System.currentTimeMillis() - start)
|
||||
twig("$logMessage | ${elapsed}ms", priority)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny log task. Execute the block of code with some twigging around the outside. For silent
|
||||
* twigs, this adds a small amount of overhead at the call site but still avoids logging.
|
||||
*
|
||||
* note: being an extension function (i.e. static rather than a member of the Twig interface) allows
|
||||
* this function to be inlined and simplifies its use with suspend functions
|
||||
* (otherwise the function and its "block" param would have to suspend)
|
||||
*/
|
||||
inline fun <R> Twig.twigTask(logMessage: String, priority: Int = 0, block: () -> R): R {
|
||||
twig("$logMessage - started | on thread ${Thread.currentThread().name}", priority)
|
||||
val start = System.nanoTime()
|
||||
val result = block()
|
||||
val elapsed = ((System.nanoTime() - start) / 1e5).roundToLong() / 10L
|
||||
twig("$logMessage - completed | in $elapsed ms" + " on thread ${Thread.currentThread().name}", priority)
|
||||
return result
|
||||
}
|
||||
9
app/src/main/res/anim/anim_enter_from_bottom.xml
Normal file
9
app/src/main/res/anim/anim_enter_from_bottom.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:fromXDelta="0%" android:toXDelta="0%"
|
||||
android:fromYDelta="100%" android:toYDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
9
app/src/main/res/anim/anim_enter_from_left.xml
Normal file
9
app/src/main/res/anim/anim_enter_from_left.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:fromXDelta="-100%" android:toXDelta="0%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
9
app/src/main/res/anim/anim_enter_from_right.xml
Normal file
9
app/src/main/res/anim/anim_enter_from_right.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:fromXDelta="100%" android:toXDelta="0%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300" />
|
||||
</set>
|
||||
10
app/src/main/res/anim/anim_exit_to_left.xml
Normal file
10
app/src/main/res/anim/anim_exit_to_left.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:interpolator="@android:interpolator/decelerate_cubic"
|
||||
android:fromXDelta="0%" android:toXDelta="-100%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300"/>
|
||||
</set>
|
||||
10
app/src/main/res/anim/anim_exit_to_right.xml
Normal file
10
app/src/main/res/anim/anim_exit_to_right.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:fillAfter="true">
|
||||
<translate
|
||||
android:interpolator="@android:interpolator/decelerate_cubic"
|
||||
android:fromXDelta="0%" android:toXDelta="100%"
|
||||
android:fromYDelta="0%" android:toYDelta="0%"
|
||||
android:duration="300"/>
|
||||
</set>
|
||||
6
app/src/main/res/anim/anim_fade_in.xml
Normal file
6
app/src/main/res/anim/anim_fade_in.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="0.0"
|
||||
android:toAlpha="1.0"
|
||||
android:duration="200"
|
||||
/>
|
||||
6
app/src/main/res/anim/anim_fade_in_scanner.xml
Normal file
6
app/src/main/res/anim/anim_fade_in_scanner.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="0.0"
|
||||
android:toAlpha="1.0"
|
||||
android:duration="700"
|
||||
/>
|
||||
6
app/src/main/res/anim/anim_fade_out.xml
Normal file
6
app/src/main/res/anim/anim_fade_out.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="200"
|
||||
/>
|
||||
6
app/src/main/res/anim/anim_fade_out_address.xml
Normal file
6
app/src/main/res/anim/anim_fade_out_address.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="250"
|
||||
/>
|
||||
6
app/src/main/res/anim/anim_fade_out_medium.xml
Normal file
6
app/src/main/res/anim/anim_fade_out_medium.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/accelerate_interpolator"
|
||||
android:fromAlpha="1.0"
|
||||
android:toAlpha="0.0"
|
||||
android:duration="300"
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user