commit
9550cdbbc7
|
@ -0,0 +1,44 @@
|
||||||
|
Change Log
|
||||||
|
==========
|
||||||
|
|
||||||
|
Version 1.0.0-alpha23 *(2020-02-21)*
|
||||||
|
------------------------------------
|
||||||
|
- Fix: reorg improvements, squashing critical bugs that disabled wallets
|
||||||
|
- New: extend analytics to include taps, screen views, and send flow.
|
||||||
|
- New: add crash reporting via Crashlytics.
|
||||||
|
- New: expose user logs and developer logs as files.
|
||||||
|
- New: improve feature for creating checkpoints.
|
||||||
|
- New: added DB schemas to the repository for tracking.
|
||||||
|
- Fix: numerous bug fixes, test fixes and cleanup.
|
||||||
|
- New: improved error handling and user experience
|
||||||
|
|
||||||
|
Version 1.0.0-alpha17 *(2020-02-07)*
|
||||||
|
------------------------------------
|
||||||
|
- New: implemented wallet import
|
||||||
|
- New: display the memo when tapping outbound transactions
|
||||||
|
- Fix: removed the sad zebra and softened wording for sending z->t
|
||||||
|
- Fix: removed restriction on smallest sendable ZEC amount
|
||||||
|
- Fix: removed "fund now"
|
||||||
|
- New: turned on developer logging to help with troubleshooting
|
||||||
|
- New: improved wallet details ability to handle small amounts of ZEC
|
||||||
|
- New: added ability to clear the memo
|
||||||
|
- Fix: changed "SEND WITHOUT MEMO" to "OMIT MEMO"
|
||||||
|
- Fix: corrected wording when the address is included in the memo
|
||||||
|
- New: display the approximate wallet birthday with the backup words
|
||||||
|
- New: improved crash reporting
|
||||||
|
- Fix: fixed bug when returning from the background
|
||||||
|
- New: added logging for failed transactions
|
||||||
|
- New: added logic to verify setup and offer explanation when the wallet is corrupted
|
||||||
|
- New: refactored and improved wallet initialization
|
||||||
|
- New: added ability to contribute 'plugins' to the SDK
|
||||||
|
- New: added tons more checkpoints to reduce startup/import time
|
||||||
|
- New: exposed logic to derive addresses directly from seeds
|
||||||
|
- Fix: fixed several crashes
|
||||||
|
|
||||||
|
Version 1.0.0-alpha11 *(2020-01-15)*
|
||||||
|
------------------------------------
|
||||||
|
- Initial ECC release
|
||||||
|
|
||||||
|
Version 1.0.0-alpha03 *(2019-12-18)*
|
||||||
|
------------------------------------
|
||||||
|
- Initial internal wallet team release
|
|
@ -11,7 +11,7 @@ apply plugin: 'com.google.firebase.firebase-perf'
|
||||||
|
|
||||||
archivesBaseName = 'zcash-android-wallet'
|
archivesBaseName = 'zcash-android-wallet'
|
||||||
group = 'cash.z.ecc.android'
|
group = 'cash.z.ecc.android'
|
||||||
version = '1.0.0-alpha17'
|
version = '1.0.0-alpha23'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion Deps.compileSdkVersion
|
compileSdkVersion Deps.compileSdkVersion
|
||||||
|
@ -21,8 +21,8 @@ android {
|
||||||
applicationId 'cash.z.ecc.android'
|
applicationId 'cash.z.ecc.android'
|
||||||
minSdkVersion Deps.minSdkVersion
|
minSdkVersion Deps.minSdkVersion
|
||||||
targetSdkVersion Deps.targetSdkVersion
|
targetSdkVersion Deps.targetSdkVersion
|
||||||
versionCode = 1_00_00_017
|
versionCode = 1_00_00_023
|
||||||
// last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
|
// last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX) dev(9XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
|
||||||
versionName = "$version"
|
versionName = "$version"
|
||||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||||
|
@ -87,6 +87,7 @@ android {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
kapt {
|
kapt {
|
||||||
arguments {
|
arguments {
|
||||||
arg 'dagger.fastInit', 'enabled'
|
arg 'dagger.fastInit', 'enabled'
|
||||||
|
@ -146,10 +147,10 @@ dependencies {
|
||||||
implementation 'io.github.novacrypto:securestring:2019.01.27'
|
implementation 'io.github.novacrypto:securestring:2019.01.27'
|
||||||
|
|
||||||
// grpc-java
|
// grpc-java
|
||||||
implementation "io.grpc:grpc-okhttp:1.27.0"
|
implementation "io.grpc:grpc-okhttp:1.25.0"
|
||||||
implementation "io.grpc:grpc-android:1.27.0"
|
implementation "io.grpc:grpc-android:1.25.0"
|
||||||
implementation "io.grpc:grpc-protobuf-lite:1.27.0"
|
implementation "io.grpc:grpc-protobuf-lite:1.25.0"
|
||||||
implementation "io.grpc:grpc-stub:1.27.0"
|
implementation "io.grpc:grpc-stub:1.25.0"
|
||||||
implementation 'javax.annotation:javax.annotation-api:1.3.2'
|
implementation 'javax.annotation:javax.annotation-api:1.3.2'
|
||||||
// solves error: Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules jetified-guava-26.0-android.jar (com.google.guava:guava:26.0-android) and listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
|
// solves error: Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules jetified-guava-26.0-android.jar (com.google.guava:guava:26.0-android) and listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
|
||||||
// per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ
|
// per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ
|
||||||
|
|
|
@ -20,6 +20,17 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="cash.z.ecc.android.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>
|
||||||
|
|
||||||
<!-- Firebase options -->
|
<!-- Firebase options -->
|
||||||
<meta-data android:name="com.google.firebase.ml.vision.DEPENDENCIES" android:value="barcode" />
|
<meta-data android:name="com.google.firebase.ml.vision.DEPENDENCIES" android:value="barcode" />
|
||||||
|
|
||||||
|
|
|
@ -57,4 +57,9 @@ class AppModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@IntoSet
|
@IntoSet
|
||||||
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
|
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@IntoSet
|
||||||
|
fun provideFeedbackCrashlytics(): FeedbackCoordinator.FeedbackObserver = FeedbackCrashlytics()
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
package cash.z.ecc.android.ext
|
||||||
|
|
||||||
|
fun Boolean.asString(ifTrue: String = "", ifFalse: String = "") = if(this) ifTrue else ifFalse
|
|
@ -22,16 +22,18 @@ fun View.disabledIf(isDisabled: Boolean) {
|
||||||
isEnabled = !isDisabled
|
isEnabled = !isDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
fun View.onClickNavTo(navResId: Int) {
|
fun View.onClickNavTo(navResId: Int, block: (() -> Any) = {}) {
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
|
block()
|
||||||
(context as? MainActivity)?.safeNavigate(navResId)
|
(context as? MainActivity)?.safeNavigate(navResId)
|
||||||
?: throw IllegalStateException("Cannot navigate from this activity. " +
|
?: throw IllegalStateException("Cannot navigate from this activity. " +
|
||||||
"Expected MainActivity but found ${context.javaClass.simpleName}")
|
"Expected MainActivity but found ${context.javaClass.simpleName}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun View.onClickNavUp() {
|
fun View.onClickNavUp(block: (() -> Any) = {}) {
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
|
block()
|
||||||
(context as? MainActivity)?.navController?.navigateUp()
|
(context as? MainActivity)?.navController?.navigateUp()
|
||||||
?: throw IllegalStateException(
|
?: throw IllegalStateException(
|
||||||
"Cannot navigate from this activity. " +
|
"Cannot navigate from this activity. " +
|
||||||
|
@ -40,8 +42,9 @@ fun View.onClickNavUp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun View.onClickNavBack() {
|
fun View.onClickNavBack(block: (() -> Any) = {}) {
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
|
block()
|
||||||
(context as? MainActivity)?.navController?.popBackStack()
|
(context as? MainActivity)?.navController?.popBackStack()
|
||||||
?: throw IllegalStateException(
|
?: throw IllegalStateException(
|
||||||
"Cannot navigate from this activity. " +
|
"Cannot navigate from this activity. " +
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
|
import com.crashlytics.android.Crashlytics
|
||||||
|
|
||||||
|
class FeedbackCrashlytics : FeedbackCoordinator.FeedbackObserver {
|
||||||
|
/**
|
||||||
|
* Report non-fatal crashes because fatal ones already get reported by default.
|
||||||
|
*/
|
||||||
|
override fun onAction(action: Feedback.Action) {
|
||||||
|
var exception: Throwable? = null
|
||||||
|
exception = when (action) {
|
||||||
|
is Feedback.Crash -> action.exception
|
||||||
|
is Feedback.NonFatal -> action.exception
|
||||||
|
is Report.Error.NonFatal.Reorg -> ReorgException(
|
||||||
|
action.errorHeight,
|
||||||
|
action.rewindHeight,
|
||||||
|
action.toString()
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
exception?.let { Crashlytics.logException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ReorgException(errorHeight: Int, rewindHeight: Int, reorgMesssage: String) :
|
||||||
|
Throwable(reorgMesssage)
|
||||||
|
}
|
|
@ -5,12 +5,15 @@ import okio.Okio
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
|
||||||
class FeedbackFile(fileName: String = "feedback.log") :
|
class FeedbackFile(fileName: String = "user_log.txt") :
|
||||||
FeedbackCoordinator.FeedbackObserver {
|
FeedbackCoordinator.FeedbackObserver {
|
||||||
|
|
||||||
val file = File(ZcashWalletApp.instance.noBackupFilesDir, fileName)
|
val file = File("${ZcashWalletApp.instance.filesDir}/logs", fileName)
|
||||||
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
|
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!file.parentFile.exists()) file.parentFile.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onMetric(metric: Feedback.Metric) {
|
override fun onMetric(metric: Feedback.Metric) {
|
||||||
appendToFile(metric.toString())
|
appendToFile(metric.toString())
|
||||||
|
|
|
@ -3,25 +3,154 @@ package cash.z.ecc.android.feedback
|
||||||
import cash.z.ecc.android.ZcashWalletApp
|
import cash.z.ecc.android.ZcashWalletApp
|
||||||
|
|
||||||
object Report {
|
object Report {
|
||||||
object Send {
|
|
||||||
class SubmitFailure(private val errorCode: Int?, private val errorMessage: String?) : Feedback.Funnel("send.failure.submit") {
|
object Funnel {
|
||||||
override fun toMap(): MutableMap<String, Any> {
|
sealed class Send(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("send", stepName, step, *properties) {
|
||||||
return super.toMap().apply {
|
object AddressPageComplete : Send("addresspagecomplete", 10)
|
||||||
put("error.code", errorCode ?: -1)
|
object MemoPageComplete : Send("memopagecomplete", 20)
|
||||||
put("error.message", errorMessage ?: "None")
|
object ConfirmPageComplete : Send("confirmpagecomplete", 30)
|
||||||
}
|
|
||||||
}
|
// Beginning of send
|
||||||
|
object SendSelected : Send("sendselected", 50)
|
||||||
|
object SpendingKeyFound : Send("keyfound", 60)
|
||||||
|
object Creating : Send("creating", 70)
|
||||||
|
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, vararg properties: Pair<String, Any>) : Send("error.$stepName", step, "isError" to true, *properties)
|
||||||
|
object ErrorNotFound : Error("notfound", 51)
|
||||||
|
class ErrorEncoding(errorCode: Int? = null, errorMessage: String? = null) : Error("encode", 71,
|
||||||
|
"errorCode" to (errorCode ?: -1),
|
||||||
|
"errorMessage" to (errorMessage ?: "None")
|
||||||
|
)
|
||||||
|
class ErrorSubmitting(errorCode: Int? = null, errorMessage: String? = null) : Error("submit", 81,
|
||||||
|
"errorCode" to (errorCode ?: -1),
|
||||||
|
"errorMessage" to (errorMessage ?: "None")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class EncodingFailure(private val errorCode: Int?, private val errorMessage: String?) : Feedback.Funnel("send.failure.submit") {
|
sealed class Restore(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("restore", stepName, step, *properties) {
|
||||||
override fun toMap(): MutableMap<String, Any> {
|
object Initiated : Restore("initiated", 0)
|
||||||
return super.toMap().apply {
|
object SeedWordsStarted : Restore("wordsstarted", 10)
|
||||||
put("error.code", errorCode ?: -1)
|
class SeedWordCount(wordCount: Int) : Restore("wordsmodified", 15, "seedWordCount" to wordCount)
|
||||||
put("error.message", errorMessage ?: "None")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Error {
|
||||||
|
object NonFatal {
|
||||||
|
class Reorg(errorBlockHeight: Int, rewindBlockHeight: Int) : 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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('.', ' ')}"
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Screen(val id: String? = null) : Feedback.Action {
|
||||||
|
BACKUP,
|
||||||
|
HOME,
|
||||||
|
DETAIL("wallet.detail"),
|
||||||
|
LANDING,
|
||||||
|
PROFILE,
|
||||||
|
RECEIVE,
|
||||||
|
RESTORE,
|
||||||
|
SCAN,
|
||||||
|
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_DETAIL("home.detail"),
|
||||||
|
HOME_SCAN("home.scan"),
|
||||||
|
HOME_SEND("home.send"),
|
||||||
|
HOME_FUND_NOW("home.fund.now"),
|
||||||
|
HOME_CLEAR_AMOUNT("home.clear.amount"),
|
||||||
|
DETAIL_BACK("detail.back"),
|
||||||
|
PROFILE_CLOSE("profile.close"),
|
||||||
|
PROFILE_BACKUP("profile.backup"),
|
||||||
|
PROFILE_VIEW_USER_LOGS("profile.view.user.logs"),
|
||||||
|
PROFILE_VIEW_DEV_LOGS("profile.view.dev.logs"),
|
||||||
|
PROFILE_SEND_FEEDBACK("profile.send.feedback"),
|
||||||
|
RECEIVE_SCAN("receive.scan"),
|
||||||
|
RECEIVE_BACK("receive.back"),
|
||||||
|
RESTORE_DONE("restore.done"),
|
||||||
|
RESTORE_SUCCESS("restore.success"),
|
||||||
|
RESTORE_BACK("restore.back"),
|
||||||
|
SCAN_RECEIVE("scan.receive"),
|
||||||
|
SCAN_BACK("scan.back"),
|
||||||
|
SEND_ADDRESS_MAX("send.address.max"),
|
||||||
|
SEND_ADDRESS_NEXT("send.address.next"),
|
||||||
|
SEND_ADDRESS_PASTE("send.address.paste"),
|
||||||
|
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"),
|
||||||
|
|
||||||
|
// General events
|
||||||
|
COPY_ADDRESS("copy.address");
|
||||||
|
|
||||||
|
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 {
|
enum class NonUserAction(override val key: String, val description: String) : Feedback.Action {
|
||||||
|
@ -68,5 +197,6 @@ class LaunchMetric private constructor(private val metric: Feedback.TimeMetric)
|
||||||
override fun toString(): String = metric.toString()
|
override fun toString(): String = metric.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T =
|
inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T =
|
||||||
this.measure(type.key, type.description, block)
|
this.measure(type.key, type.description, block)
|
|
@ -33,10 +33,14 @@ import cash.z.ecc.android.di.component.SynchronizerSubcomponent
|
||||||
import cash.z.ecc.android.feedback.Feedback
|
import cash.z.ecc.android.feedback.Feedback
|
||||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||||
import cash.z.ecc.android.feedback.LaunchMetric
|
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.FEEDBACK_STOPPED
|
||||||
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
|
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
|
||||||
|
import cash.z.ecc.android.feedback.Report.Tap.COPY_ADDRESS
|
||||||
import cash.z.wallet.sdk.Initializer
|
import cash.z.wallet.sdk.Initializer
|
||||||
import cash.z.wallet.sdk.exception.CompactBlockProcessorException
|
import cash.z.wallet.sdk.exception.CompactBlockProcessorException
|
||||||
|
import cash.z.wallet.sdk.ext.ZcashSdk
|
||||||
import cash.z.wallet.sdk.ext.twig
|
import cash.z.wallet.sdk.ext.twig
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
@ -59,6 +63,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private val mediaPlayer: MediaPlayer = MediaPlayer()
|
private val mediaPlayer: MediaPlayer = MediaPlayer()
|
||||||
private var snackbar: Snackbar? = null
|
private var snackbar: Snackbar? = null
|
||||||
private var dialog: Dialog? = null
|
private var dialog: Dialog? = null
|
||||||
|
private var ignoreScanFailure: Boolean = false
|
||||||
|
|
||||||
lateinit var component: MainActivitySubcomponent
|
lateinit var component: MainActivitySubcomponent
|
||||||
lateinit var synchronizerComponent: SynchronizerSubcomponent
|
lateinit var synchronizerComponent: SynchronizerSubcomponent
|
||||||
|
@ -165,6 +170,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
feedback.report(SYNC_START)
|
feedback.report(SYNC_START)
|
||||||
synchronizerComponent.synchronizer().let { synchronizer ->
|
synchronizerComponent.synchronizer().let { synchronizer ->
|
||||||
synchronizer.onProcessorErrorHandler = ::onProcessorError
|
synchronizer.onProcessorErrorHandler = ::onProcessorError
|
||||||
|
synchronizer.onChainErrorHandler = ::onChainError
|
||||||
synchronizer.start(lifecycleScope)
|
synchronizer.start(lifecycleScope)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -172,6 +178,16 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 playSound(fileName: String) {
|
fun playSound(fileName: String) {
|
||||||
mediaPlayer.apply {
|
mediaPlayer.apply {
|
||||||
if (isPlaying) stop()
|
if (isPlaying) stop()
|
||||||
|
@ -197,6 +213,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun copyAddress(view: View? = null) {
|
fun copyAddress(view: View? = null) {
|
||||||
|
reportTap(COPY_ADDRESS)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
clipboard.setPrimaryClip(
|
clipboard.setPrimaryClip(
|
||||||
ClipData.newPlainText(
|
ClipData.newPlainText(
|
||||||
|
@ -258,14 +275,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showKeyboard(focusedView: View) {
|
fun showKeyboard(focusedView: View) {
|
||||||
twig("SHOWING KEYBOARD")
|
|
||||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
|
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideKeyboard() {
|
fun hideKeyboard() {
|
||||||
twig("HIDING KEYBOARD")
|
|
||||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0)
|
imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0)
|
||||||
}
|
}
|
||||||
|
@ -309,10 +324,13 @@ class MainActivity : AppCompatActivity() {
|
||||||
showSnackbar("Well, this is awkward. You denied permission for the camera.")
|
showSnackbar("Well, this is awkward. You denied permission for the camera.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var ignoredErrors = 0
|
||||||
private fun onProcessorError(error: Throwable?): Boolean {
|
private fun onProcessorError(error: Throwable?): Boolean {
|
||||||
|
var notified = false
|
||||||
when (error) {
|
when (error) {
|
||||||
is CompactBlockProcessorException.Uninitialized -> {
|
is CompactBlockProcessorException.Uninitialized -> {
|
||||||
if (dialog == null)
|
if (dialog == null) {
|
||||||
|
notified = true
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
dialog = MaterialAlertDialogBuilder(this)
|
dialog = MaterialAlertDialogBuilder(this)
|
||||||
.setTitle("Wallet Improperly Initialized")
|
.setTitle("Wallet Improperly Initialized")
|
||||||
|
@ -324,9 +342,83 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CompactBlockProcessorException.FailedScan -> {
|
||||||
|
if (dialog == null && !ignoreScanFailure) throttle("scanFailure", 20_000L) {
|
||||||
|
notified = true
|
||||||
|
runOnUiThread {
|
||||||
|
dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle("Scan Failure")
|
||||||
|
.setMessage("${error.message}${if (error.cause != null) "\n\nCaused by: ${error.cause}" else ""}")
|
||||||
|
.setCancelable(true)
|
||||||
|
.setPositiveButton("Retry") { d, _ ->
|
||||||
|
d.dismiss()
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
.setNegativeButton("Ignore") { d, _ ->
|
||||||
|
d.dismiss()
|
||||||
|
ignoreScanFailure = true
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!notified) {
|
||||||
|
ignoredErrors++
|
||||||
|
}
|
||||||
|
if (ignoredErrors >= ZcashSdk.RETRIES) {
|
||||||
|
if (dialog == null) {
|
||||||
|
notified = true
|
||||||
|
runOnUiThread {
|
||||||
|
dialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle("Processor Error")
|
||||||
|
.setMessage(error?.message ?: "Critical error while processing blocks!")
|
||||||
|
.setCancelable(false)
|
||||||
|
.setPositiveButton("Retry") { d, _ ->
|
||||||
|
d.dismiss()
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
.setNegativeButton("Exit") { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
throw error
|
||||||
|
?: RuntimeException("Critical error while processing blocks and the user chose to exit.")
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
twig("MainActivity has received an error${if (notified) " and notified the user" else ""} and reported it to crashlytics and mixpanel.")
|
||||||
feedback.report(error)
|
feedback.report(error)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onChainError(errorHeight: Int, rewindHeight: Int) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import androidx.annotation.NonNull
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import cash.z.ecc.android.feedback.Report
|
||||||
import cash.z.ecc.android.ui.MainActivity
|
import cash.z.ecc.android.ui.MainActivity
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
@ -18,6 +19,8 @@ abstract class BaseFragment<T : ViewBinding> : Fragment() {
|
||||||
|
|
||||||
lateinit var resumedScope: CoroutineScope
|
lateinit var resumedScope: CoroutineScope
|
||||||
|
|
||||||
|
open val screen: Report.Screen? = null
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -29,6 +32,7 @@ abstract class BaseFragment<T : ViewBinding> : Fragment() {
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
mainActivity?.reportScreen(screen)
|
||||||
resumedScope = lifecycleScope.coroutineContext.let {
|
resumedScope = lifecycleScope.coroutineContext.let {
|
||||||
CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job]))
|
CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job]))
|
||||||
}
|
}
|
||||||
|
@ -43,9 +47,15 @@ abstract class BaseFragment<T : ViewBinding> : Fragment() {
|
||||||
// each fragment must call FragmentMyLayoutBinding.inflate(inflater)
|
// each fragment must call FragmentMyLayoutBinding.inflate(inflater)
|
||||||
abstract fun inflate(@NonNull inflater: LayoutInflater): T
|
abstract fun inflate(@NonNull inflater: LayoutInflater): T
|
||||||
|
|
||||||
fun onBackPressNavTo(navResId: Int) {
|
fun onBackPressNavTo(navResId: Int, block: (() -> Unit) = {}) {
|
||||||
mainActivity?.onFragmentBackPressed(this) {
|
mainActivity?.onFragmentBackPressed(this) {
|
||||||
|
block()
|
||||||
mainActivity?.safeNavigate(navResId)
|
mainActivity?.safeNavigate(navResId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun tapped(tap: Report.Tap) {
|
||||||
|
mainActivity?.reportTap(tap)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -8,10 +8,7 @@ import cash.z.ecc.android.ext.goneIf
|
||||||
import cash.z.ecc.android.ext.toAppColor
|
import cash.z.ecc.android.ext.toAppColor
|
||||||
import cash.z.ecc.android.ui.MainActivity
|
import cash.z.ecc.android.ui.MainActivity
|
||||||
import cash.z.wallet.sdk.entity.ConfirmedTransaction
|
import cash.z.wallet.sdk.entity.ConfirmedTransaction
|
||||||
import cash.z.wallet.sdk.ext.ZcashSdk
|
import cash.z.wallet.sdk.ext.*
|
||||||
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
|
||||||
import cash.z.wallet.sdk.ext.isShielded
|
|
||||||
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
@ -30,7 +27,8 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
|
||||||
// update view
|
// update view
|
||||||
var lineOne: String = ""
|
var lineOne: String = ""
|
||||||
var lineTwo: String = ""
|
var lineTwo: String = ""
|
||||||
var amount: String = ""
|
var amountZec: String = ""
|
||||||
|
var amountDisplay: String = ""
|
||||||
var amountColor: Int = 0
|
var amountColor: Int = 0
|
||||||
var indicatorBackground: Int = 0
|
var indicatorBackground: Int = 0
|
||||||
|
|
||||||
|
@ -38,7 +36,7 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
|
||||||
itemView.setOnClickListener {
|
itemView.setOnClickListener {
|
||||||
onTransactionClicked(this)
|
onTransactionClicked(this)
|
||||||
}
|
}
|
||||||
amount = value.convertZatoshiToZecString()
|
amountZec = value.convertZatoshiToZecString()
|
||||||
// TODO: these might be good extension functions
|
// TODO: these might be good extension functions
|
||||||
val timestamp = formatter.format(blockTimeInSeconds * 1000L)
|
val timestamp = formatter.format(blockTimeInSeconds * 1000L)
|
||||||
val isMined = blockTimeInSeconds != 0L
|
val isMined = blockTimeInSeconds != 0L
|
||||||
|
@ -46,14 +44,14 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
|
||||||
!toAddress.isNullOrEmpty() -> {
|
!toAddress.isNullOrEmpty() -> {
|
||||||
lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}"
|
lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}"
|
||||||
lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation"
|
lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation"
|
||||||
amount = "- $amount"
|
amountDisplay = "- $amountZec"
|
||||||
amountColor = R.color.zcashRed
|
amountColor = R.color.zcashRed
|
||||||
indicatorBackground = R.drawable.background_indicator_outbound
|
indicatorBackground = R.drawable.background_indicator_outbound
|
||||||
}
|
}
|
||||||
raw == null || raw?.isEmpty() == true -> {
|
raw == null || raw?.isEmpty() == true -> {
|
||||||
lineOne = "Unknown paid you"
|
lineOne = "Unknown paid you"
|
||||||
lineTwo = "Received $timestamp"
|
lineTwo = "Received $timestamp"
|
||||||
amount = "+ $amount"
|
amountDisplay = "+ $amountZec"
|
||||||
amountColor = R.color.zcashGreen
|
amountColor = R.color.zcashGreen
|
||||||
indicatorBackground = R.drawable.background_indicator_inbound
|
indicatorBackground = R.drawable.background_indicator_inbound
|
||||||
}
|
}
|
||||||
|
@ -64,14 +62,16 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitize amount
|
// sanitize amount
|
||||||
if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amount = "< 0.001"
|
if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amountDisplay = "< 0.001"
|
||||||
else if (amount.length > 8) amount = "tap to view"
|
else if (amountZec.length > 10) { // 10 allows 3 digits to the left and 6 to the right of the decimal
|
||||||
|
amountDisplay = "tap to view"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
topText.text = lineOne
|
topText.text = lineOne
|
||||||
bottomText.text = lineTwo
|
bottomText.text = lineTwo
|
||||||
amountText.text = amount
|
amountText.text = amountDisplay
|
||||||
amountText.setTextColor(amountColor.toAppColor())
|
amountText.setTextColor(amountColor.toAppColor())
|
||||||
val context = itemView.context
|
val context = itemView.context
|
||||||
indicator.background = context.resources.getDrawable(indicatorBackground)
|
indicator.background = context.resources.getDrawable(indicatorBackground)
|
||||||
|
|
|
@ -12,6 +12,8 @@ import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
import cash.z.ecc.android.ext.goneIf
|
import cash.z.ecc.android.ext.goneIf
|
||||||
import cash.z.ecc.android.ext.onClickNavUp
|
import cash.z.ecc.android.ext.onClickNavUp
|
||||||
import cash.z.ecc.android.ext.toColoredSpan
|
import cash.z.ecc.android.ext.toColoredSpan
|
||||||
|
import cash.z.ecc.android.feedback.Report
|
||||||
|
import cash.z.ecc.android.feedback.Report.Tap.DETAIL_BACK
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
|
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
|
||||||
import cash.z.wallet.sdk.entity.ConfirmedTransaction
|
import cash.z.wallet.sdk.entity.ConfirmedTransaction
|
||||||
|
@ -23,7 +25,7 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
|
class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
|
||||||
|
override val screen = Report.Screen.DETAIL
|
||||||
private val viewModel: WalletDetailViewModel by viewModel()
|
private val viewModel: WalletDetailViewModel by viewModel()
|
||||||
|
|
||||||
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
|
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
|
||||||
|
@ -33,7 +35,7 @@ class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.backButtonHitArea.onClickNavUp()
|
binding.backButtonHitArea.onClickNavUp { tapped(DETAIL_BACK) }
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress()
|
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress()
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,9 @@ import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.databinding.FragmentHomeBinding
|
import cash.z.ecc.android.databinding.FragmentHomeBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
import cash.z.ecc.android.ext.disabledIf
|
import cash.z.ecc.android.ext.*
|
||||||
import cash.z.ecc.android.ext.goneIf
|
import cash.z.ecc.android.feedback.Report
|
||||||
import cash.z.ecc.android.ext.onClickNavTo
|
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||||
import cash.z.ecc.android.ext.toColoredSpan
|
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
|
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
|
||||||
import cash.z.ecc.android.ui.send.SendViewModel
|
import cash.z.ecc.android.ui.send.SendViewModel
|
||||||
|
@ -21,7 +20,10 @@ import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
|
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
|
||||||
import cash.z.wallet.sdk.Synchronizer
|
import cash.z.wallet.sdk.Synchronizer
|
||||||
import cash.z.wallet.sdk.Synchronizer.Status.SYNCED
|
import cash.z.wallet.sdk.Synchronizer.Status.SYNCED
|
||||||
import cash.z.wallet.sdk.ext.*
|
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
||||||
|
import cash.z.wallet.sdk.ext.convertZecToZatoshi
|
||||||
|
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
|
||||||
|
import cash.z.wallet.sdk.ext.twig
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
@ -29,6 +31,7 @@ import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
|
override val screen = Report.Screen.HOME
|
||||||
|
|
||||||
private lateinit var numberPad: List<TextView>
|
private lateinit var numberPad: List<TextView>
|
||||||
private lateinit var uiModel: HomeViewModel.UiModel
|
private lateinit var uiModel: HomeViewModel.UiModel
|
||||||
|
@ -88,18 +91,18 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
buttonNumberPadDecimal.asKey(),
|
buttonNumberPadDecimal.asKey(),
|
||||||
buttonNumberPadBack.asKey()
|
buttonNumberPadBack.asKey()
|
||||||
)
|
)
|
||||||
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile)
|
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) { tapped(HOME_PROFILE) }
|
||||||
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
|
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
|
||||||
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
|
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
|
||||||
hitAreaScan.setOnClickListener {
|
hitAreaScan.setOnClickListener {
|
||||||
mainActivity?.maybeOpenScan()
|
mainActivity?.maybeOpenScan().also { tapped(HOME_SCAN) }
|
||||||
}
|
}
|
||||||
|
|
||||||
textBannerAction.setOnClickListener {
|
textBannerAction.setOnClickListener {
|
||||||
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
|
||||||
}
|
}
|
||||||
buttonSendAmount.setOnClickListener {
|
buttonSendAmount.setOnClickListener {
|
||||||
onSend()
|
onSend().also { tapped(HOME_SEND) }
|
||||||
}
|
}
|
||||||
setSendAmount("0", false)
|
setSendAmount("0", false)
|
||||||
|
|
||||||
|
@ -107,7 +110,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.buttonNumberPadBack.setOnLongClickListener {
|
binding.buttonNumberPadBack.setOnLongClickListener {
|
||||||
onClearAmount()
|
onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,14 +178,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
// Public UI API
|
// Public UI API
|
||||||
//
|
//
|
||||||
|
|
||||||
fun setSendEnabled(enabled: Boolean) {
|
var isSendEnabled = false
|
||||||
|
fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
|
||||||
|
isSendEnabled = enabled
|
||||||
binding.buttonSendAmount.apply {
|
binding.buttonSendAmount.apply {
|
||||||
isEnabled = enabled
|
if (enabled || !isSynced) {
|
||||||
if (enabled) {
|
isEnabled = true
|
||||||
// setTextColor(resources.getColorStateList(R.color.selector_button_text_dark))
|
isClickable = isSynced
|
||||||
binding.lottieButtonLoading.alpha = 1.0f
|
binding.lottieButtonLoading.alpha = 1.0f
|
||||||
} else {
|
} else {
|
||||||
// setTextColor(R.color.zcashGray.toAppColor())
|
isEnabled = false
|
||||||
|
isClickable = false
|
||||||
binding.lottieButtonLoading.alpha = 0.32f
|
binding.lottieButtonLoading.alpha = 0.32f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,7 +274,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
//
|
//
|
||||||
|
|
||||||
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
|
||||||
twig("onModelUpdated: $new")
|
logUpdate(old, new)
|
||||||
if (binding.lottieButtonLoading.visibility != View.VISIBLE) binding.lottieButtonLoading.visibility = View.VISIBLE
|
if (binding.lottieButtonLoading.visibility != View.VISIBLE) binding.lottieButtonLoading.visibility = View.VISIBLE
|
||||||
uiModel = new
|
uiModel = new
|
||||||
if (old?.pendingSend != new.pendingSend) {
|
if (old?.pendingSend != new.pendingSend) {
|
||||||
|
@ -278,7 +284,38 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
setProgress(uiModel) // TODO: we may not need to separate anymore
|
setProgress(uiModel) // TODO: we may not need to separate anymore
|
||||||
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
|
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
|
||||||
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
|
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
|
||||||
setSendEnabled(new.isSendEnabled)
|
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.availableBalance != new.availableBalance) append ("${maybeComma()}availableBalance=${new.availableBalance}")
|
||||||
|
if (old.totalBalance != new.totalBalance) append ("${maybeComma()}totalBalance=${new.totalBalance}")
|
||||||
|
if (old.pendingSend != new.pendingSend) append ("${maybeComma()}pendingSend=${new.pendingSend}")
|
||||||
|
append(")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
twig("onModelUpdated: $message")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
|
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
|
||||||
|
@ -296,7 +333,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSend() {
|
private fun onSend() {
|
||||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
|
if (isSendEnabled) mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onBannerAction(action: BannerAction) {
|
private fun onBannerAction(action: BannerAction) {
|
||||||
|
@ -307,6 +344,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||||
.setTitle("No Balance")
|
.setTitle("No Balance")
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setPositiveButton("View Address") { dialog, _ ->
|
.setPositiveButton("View Address") { dialog, _ ->
|
||||||
|
tapped(HOME_FUND_NOW)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,7 +85,6 @@ class MagicSnakeLoader(
|
||||||
} else {
|
} else {
|
||||||
// once we're ready to show scan progress, do it! Don't do extra loops.
|
// once we're ready to show scan progress, do it! Don't do extra loops.
|
||||||
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
|
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
|
||||||
twig("ZZZ pausing so we can scan! ${if(frame<scanningStartFrame) "WE STOPPED EARLY!" else ""}")
|
|
||||||
pause()
|
pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,17 +106,14 @@ class MagicSnakeLoader(
|
||||||
|
|
||||||
private fun playToCompletion() {
|
private fun playToCompletion() {
|
||||||
removeLoops()
|
removeLoops()
|
||||||
twig("ZZZ playing to completion")
|
|
||||||
unpause()
|
unpause()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeLoops() {
|
private fun removeLoops() {
|
||||||
lottie.frame.let {frame ->
|
lottie.frame.let {frame ->
|
||||||
if (frame in 33..67) {
|
if (frame in 33..67) {
|
||||||
twig("ZZZ removing 1 loop!")
|
|
||||||
lottie.frame = frame + 34
|
lottie.frame = frame + 34
|
||||||
} else if (frame in 0..33) {
|
} else if (frame in 0..33) {
|
||||||
twig("ZZZ removing 2 loops!")
|
|
||||||
lottie.frame = frame + 67
|
lottie.frame = frame + 67
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,34 @@
|
||||||
package cash.z.ecc.android.ui.profile
|
package cash.z.ecc.android.ui.profile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.core.content.FileProvider.getUriForFile
|
||||||
import cash.z.ecc.android.BuildConfig
|
import cash.z.ecc.android.BuildConfig
|
||||||
import cash.z.ecc.android.R
|
import cash.z.ecc.android.R
|
||||||
|
import cash.z.ecc.android.ZcashWalletApp
|
||||||
import cash.z.ecc.android.databinding.FragmentProfileBinding
|
import cash.z.ecc.android.databinding.FragmentProfileBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
import cash.z.ecc.android.ext.onClick
|
import cash.z.ecc.android.ext.onClick
|
||||||
import cash.z.ecc.android.ext.onClickNavBack
|
import cash.z.ecc.android.ext.onClickNavBack
|
||||||
import cash.z.ecc.android.ext.onClickNavTo
|
import cash.z.ecc.android.ext.onClickNavTo
|
||||||
import cash.z.ecc.android.feedback.FeedbackFile
|
import cash.z.ecc.android.feedback.FeedbackFile
|
||||||
|
import cash.z.ecc.android.feedback.Report
|
||||||
|
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
||||||
|
import cash.z.wallet.sdk.ext.twig
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okio.Okio
|
import okio.Okio
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
|
||||||
class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
|
class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
|
||||||
|
override val screen = Report.Screen.PROFILE
|
||||||
|
|
||||||
private val viewModel: ProfileViewModel by viewModel()
|
private val viewModel: ProfileViewModel by viewModel()
|
||||||
|
|
||||||
|
@ -26,13 +37,20 @@ class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.hitAreaClose.onClickNavBack()
|
binding.hitAreaClose.onClickNavBack() { tapped(PROFILE_CLOSE) }
|
||||||
binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup)
|
binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup) { tapped(PROFILE_BACKUP) }
|
||||||
binding.textVersion.text = BuildConfig.VERSION_NAME
|
binding.textVersion.text = BuildConfig.VERSION_NAME
|
||||||
onClick(binding.buttonLogs) {
|
onClick(binding.buttonLogs) {
|
||||||
|
tapped(PROFILE_VIEW_USER_LOGS)
|
||||||
onViewLogs()
|
onViewLogs()
|
||||||
}
|
}
|
||||||
|
binding.buttonLogs.setOnLongClickListener {
|
||||||
|
tapped(PROFILE_VIEW_DEV_LOGS)
|
||||||
|
onViewDevLogs()
|
||||||
|
true
|
||||||
|
}
|
||||||
onClick(binding.buttonFeedback) {
|
onClick(binding.buttonFeedback) {
|
||||||
|
tapped(PROFILE_SEND_FEEDBACK)
|
||||||
onSendFeedback()
|
onSendFeedback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,31 +63,63 @@ class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onViewLogs() {
|
private fun onViewLogs() {
|
||||||
loadLogFileAsText().let { logText ->
|
shareFile(userLogFile())
|
||||||
if (logText == null) {
|
}
|
||||||
mainActivity?.showSnackbar("Log file not found!")
|
|
||||||
} else {
|
|
||||||
val sendIntent: Intent = Intent().apply {
|
|
||||||
action = Intent.ACTION_SEND
|
|
||||||
putExtra(Intent.EXTRA_TEXT, logText)
|
|
||||||
type = "text/plain"
|
|
||||||
}
|
|
||||||
|
|
||||||
val shareIntent = Intent.createChooser(sendIntent, "Share Log File")
|
private fun onViewDevLogs() {
|
||||||
startActivity(shareIntent)
|
shareFile(writeLogcat())
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "Share Log Files"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "Share Log File"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSendFeedback() {
|
private fun onSendFeedback() {
|
||||||
mainActivity?.showSnackbar("Feedback feature coming soon!")
|
mainActivity?.showSnackbar("Feedback feature coming soon!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun userLogFile(): File? {
|
||||||
|
return mainActivity?.feedbackCoordinator?.findObserver<FeedbackFile>()?.file
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadLogFileAsText(): String? {
|
private fun loadLogFileAsText(): String? {
|
||||||
val feedbackFile: FeedbackFile =
|
val feedbackFile: File = userLogFile() ?: return null
|
||||||
mainActivity?.feedbackCoordinator?.findObserver() ?: return null
|
Okio.buffer(Okio.source(feedbackFile)).use {
|
||||||
Okio.buffer(Okio.source(feedbackFile.file)).use {
|
|
||||||
return it.readUtf8()
|
return it.readUtf8()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun writeLogcat(): File? {
|
||||||
|
try {
|
||||||
|
val outputFile = File("${ZcashWalletApp.instance.filesDir}/logs", "developer_log.txt")
|
||||||
|
val cmd = arrayOf("/bin/sh", "-c", "logcat -v time -d | grep \"@TWIG\" > ${outputFile.absolutePath}")
|
||||||
|
Runtime.getRuntime().exec(cmd)
|
||||||
|
return outputFile
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
twig("Failed to create log")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -10,6 +10,8 @@ import cash.z.ecc.android.databinding.FragmentReceiveNewBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
import cash.z.ecc.android.ext.onClickNavBack
|
import cash.z.ecc.android.ext.onClickNavBack
|
||||||
import cash.z.ecc.android.ext.onClickNavTo
|
import cash.z.ecc.android.ext.onClickNavTo
|
||||||
|
import cash.z.ecc.android.feedback.Report
|
||||||
|
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
||||||
import cash.z.wallet.sdk.ext.twig
|
import cash.z.wallet.sdk.ext.twig
|
||||||
|
@ -17,6 +19,7 @@ import kotlinx.coroutines.launch
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
|
class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
|
||||||
|
override val screen = Report.Screen.RECEIVE
|
||||||
|
|
||||||
private val viewModel: ReceiveViewModel by viewModel()
|
private val viewModel: ReceiveViewModel by viewModel()
|
||||||
|
|
||||||
|
@ -40,9 +43,9 @@ class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
|
||||||
// text_address_part_8
|
// text_address_part_8
|
||||||
// )
|
// )
|
||||||
binding.buttonScan.setOnClickListener {
|
binding.buttonScan.setOnClickListener {
|
||||||
mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan)
|
mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan).also { tapped(RECEIVE_SCAN) }
|
||||||
}
|
}
|
||||||
binding.backButtonHitArea.onClickNavBack()
|
binding.backButtonHitArea.onClickNavBack() { tapped(RECEIVE_BACK) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
override fun onAttach(context: Context) {
|
||||||
|
|
|
@ -2,6 +2,8 @@ package cash.z.ecc.android.ui.scan
|
||||||
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
|
import cash.z.wallet.sdk.ext.retrySimple
|
||||||
|
import cash.z.wallet.sdk.ext.retryUpTo
|
||||||
import cash.z.wallet.sdk.ext.twig
|
import cash.z.wallet.sdk.ext.twig
|
||||||
import com.google.android.gms.tasks.Task
|
import com.google.android.gms.tasks.Task
|
||||||
import com.google.firebase.ml.vision.FirebaseVision
|
import com.google.firebase.ml.vision.FirebaseVision
|
||||||
|
@ -27,22 +29,25 @@ class QrAnalyzer(val scanCallback: (qrContent: String, image: ImageProxy) -> Uni
|
||||||
if (rotation < 0) {
|
if (rotation < 0) {
|
||||||
rotation += 360
|
rotation += 360
|
||||||
}
|
}
|
||||||
val mediaImage = FirebaseVisionImage.fromMediaImage(
|
|
||||||
image.image!!, when (rotation) {
|
retrySimple {
|
||||||
0 -> FirebaseVisionImageMetadata.ROTATION_0
|
val mediaImage = FirebaseVisionImage.fromMediaImage(
|
||||||
90 -> FirebaseVisionImageMetadata.ROTATION_90
|
image.image!!, when (rotation) {
|
||||||
180 -> FirebaseVisionImageMetadata.ROTATION_180
|
0 -> FirebaseVisionImageMetadata.ROTATION_0
|
||||||
270 -> FirebaseVisionImageMetadata.ROTATION_270
|
90 -> FirebaseVisionImageMetadata.ROTATION_90
|
||||||
else -> {
|
180 -> FirebaseVisionImageMetadata.ROTATION_180
|
||||||
FirebaseVisionImageMetadata.ROTATION_0
|
270 -> FirebaseVisionImageMetadata.ROTATION_270
|
||||||
|
else -> {
|
||||||
|
FirebaseVisionImageMetadata.ROTATION_0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
pendingTask = detector.detectInImage(mediaImage).also {
|
||||||
|
it.addOnSuccessListener { result ->
|
||||||
|
onImageScan(result, image)
|
||||||
|
}
|
||||||
|
it.addOnFailureListener(::onImageScanFailure)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
pendingTask = detector.detectInImage(mediaImage).also {
|
|
||||||
it.addOnSuccessListener { result ->
|
|
||||||
onImageScan(result, image)
|
|
||||||
}
|
|
||||||
it.addOnFailureListener(::onImageScanFailure)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@ import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
import cash.z.ecc.android.ext.onClickNavBack
|
import cash.z.ecc.android.ext.onClickNavBack
|
||||||
import cash.z.ecc.android.ext.onClickNavTo
|
import cash.z.ecc.android.ext.onClickNavTo
|
||||||
|
import cash.z.ecc.android.feedback.Report
|
||||||
|
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.ecc.android.ui.send.SendViewModel
|
import cash.z.ecc.android.ui.send.SendViewModel
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
@ -24,7 +26,7 @@ import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
class ScanFragment : BaseFragment<FragmentScanBinding>() {
|
class ScanFragment : BaseFragment<FragmentScanBinding>() {
|
||||||
|
override val screen = Report.Screen.SCAN
|
||||||
private val viewModel: ScanViewModel by viewModel()
|
private val viewModel: ScanViewModel by viewModel()
|
||||||
|
|
||||||
private val sendViewModel: SendViewModel by activityViewModel()
|
private val sendViewModel: SendViewModel by activityViewModel()
|
||||||
|
@ -37,8 +39,8 @@ class ScanFragment : BaseFragment<FragmentScanBinding>() {
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive)
|
binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive) { tapped(SCAN_RECEIVE) }
|
||||||
binding.backButtonHitArea.onClickNavBack()
|
binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
@ -56,7 +58,7 @@ class ScanFragment : BaseFragment<FragmentScanBinding>() {
|
||||||
|
|
||||||
private fun bindPreview(cameraProvider: ProcessCameraProvider) {
|
private fun bindPreview(cameraProvider: ProcessCameraProvider) {
|
||||||
Preview.Builder().setTargetName("Preview").build().let { preview ->
|
Preview.Builder().setTargetName("Preview").build().let { preview ->
|
||||||
preview.previewSurfaceProvider = binding.preview.previewSurfaceProvider
|
preview.setSurfaceProvider(binding.preview.previewSurfaceProvider)
|
||||||
|
|
||||||
val cameraSelector = CameraSelector.Builder()
|
val cameraSelector = CameraSelector.Builder()
|
||||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||||
|
|
|
@ -4,7 +4,6 @@ import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
|
@ -13,6 +12,9 @@ import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.databinding.FragmentSendAddressBinding
|
import cash.z.ecc.android.databinding.FragmentSendAddressBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.ext.*
|
import cash.z.ecc.android.ext.*
|
||||||
|
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.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.Synchronizer
|
import cash.z.wallet.sdk.Synchronizer
|
||||||
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
|
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
|
||||||
|
@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
||||||
ClipboardManager.OnPrimaryClipChangedListener {
|
ClipboardManager.OnPrimaryClipChangedListener {
|
||||||
|
override val screen = Report.Screen.SEND_ADDRESS
|
||||||
|
|
||||||
private var maxZatoshi: Long? = null
|
private var maxZatoshi: Long? = null
|
||||||
|
|
||||||
|
@ -32,18 +35,18 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home)
|
binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home) { tapped(SEND_ADDRESS_BACK) }
|
||||||
binding.buttonNext.setOnClickListener {
|
binding.buttonNext.setOnClickListener {
|
||||||
onSubmit()
|
onSubmit().also { tapped(SEND_ADDRESS_NEXT) }
|
||||||
}
|
}
|
||||||
binding.textBannerAction.setOnClickListener {
|
binding.textBannerAction.setOnClickListener {
|
||||||
onPaste()
|
onPaste().also { tapped(SEND_ADDRESS_PASTE) }
|
||||||
}
|
}
|
||||||
binding.textBannerMessage.setOnClickListener {
|
binding.textBannerMessage.setOnClickListener {
|
||||||
onPaste()
|
onPaste().also { tapped(SEND_ADDRESS_PASTE) }
|
||||||
}
|
}
|
||||||
binding.textMax.setOnClickListener {
|
binding.textMax.setOnClickListener {
|
||||||
onMax()
|
onMax().also { tapped(SEND_ADDRESS_MAX) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply View Model
|
// Apply View Model
|
||||||
|
@ -60,8 +63,8 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
||||||
binding.inputZcashAddress.setText(null)
|
binding.inputZcashAddress.setText(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.inputZcashAddress.onEditorActionDone(::onSubmit)
|
binding.inputZcashAddress.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_ADDRESS) }
|
||||||
binding.inputZcashAmount.onEditorActionDone(::onSubmit)
|
binding.inputZcashAmount.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_AMOUNT) }
|
||||||
|
|
||||||
binding.inputZcashAddress.apply {
|
binding.inputZcashAddress.apply {
|
||||||
doAfterTextChanged {
|
doAfterTextChanged {
|
||||||
|
@ -75,7 +78,7 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.textLayoutAddress.setEndIconOnClickListener {
|
binding.textLayoutAddress.setEndIconOnClickListener {
|
||||||
mainActivity?.maybeOpenScan()
|
mainActivity?.maybeOpenScan().also { tapped(SEND_ADDRESS_SCAN) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,6 +102,7 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
|
||||||
binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it }
|
binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it }
|
||||||
sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) {
|
sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) {
|
||||||
if (it == null) {
|
if (it == null) {
|
||||||
|
sendViewModel.funnel(Send.AddressPageComplete)
|
||||||
mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo)
|
mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo)
|
||||||
} else {
|
} else {
|
||||||
resumedScope.launch {
|
resumedScope.launch {
|
||||||
|
|
|
@ -8,14 +8,17 @@ import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.databinding.FragmentSendConfirmBinding
|
import cash.z.ecc.android.databinding.FragmentSendConfirmBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.ext.goneIf
|
import cash.z.ecc.android.ext.goneIf
|
||||||
import cash.z.ecc.android.ext.onClickNavBack
|
|
||||||
import cash.z.ecc.android.ext.onClickNavTo
|
import cash.z.ecc.android.ext.onClickNavTo
|
||||||
|
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.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
||||||
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
|
class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
|
||||||
|
override val screen = Report.Screen.SEND_CONFIRM
|
||||||
|
|
||||||
val sendViewModel: SendViewModel by activityViewModel()
|
val sendViewModel: SendViewModel by activityViewModel()
|
||||||
|
|
||||||
|
@ -25,11 +28,11 @@ class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.buttonNext.setOnClickListener {
|
binding.buttonNext.setOnClickListener {
|
||||||
onSend()
|
onSend().also { tapped(SEND_CONFIRM_NEXT) }
|
||||||
}
|
}
|
||||||
R.id.action_nav_send_confirm_to_nav_send_memo.let {
|
R.id.action_nav_send_confirm_to_nav_send_memo.let {
|
||||||
binding.backButtonHitArea.onClickNavTo(it)
|
binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_CONFIRM_BACK) }
|
||||||
onBackPressNavTo(it)
|
onBackPressNavTo(it) { tapped(SEND_CONFIRM_BACK) }
|
||||||
}
|
}
|
||||||
mainActivity?.lifecycleScope?.launch {
|
mainActivity?.lifecycleScope?.launch {
|
||||||
binding.textConfirmation.text =
|
binding.textConfirmation.text =
|
||||||
|
@ -42,6 +45,7 @@ class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSend() {
|
private fun onSend() {
|
||||||
|
sendViewModel.funnel(Send.ConfirmPageComplete)
|
||||||
mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final)
|
mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -9,13 +9,12 @@ import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.databinding.FragmentSendFinalBinding
|
import cash.z.ecc.android.databinding.FragmentSendFinalBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.ext.goneIf
|
import cash.z.ecc.android.ext.goneIf
|
||||||
import cash.z.ecc.android.feedback.Feedback
|
|
||||||
import cash.z.ecc.android.feedback.Report
|
import cash.z.ecc.android.feedback.Report
|
||||||
import cash.z.ecc.android.feedback.Report.MetricType.*
|
import cash.z.ecc.android.feedback.Report.Tap.*
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.entity.*
|
import cash.z.wallet.sdk.entity.*
|
||||||
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
|
||||||
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
||||||
|
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
|
||||||
import cash.z.wallet.sdk.ext.twig
|
import cash.z.wallet.sdk.ext.twig
|
||||||
import com.crashlytics.android.Crashlytics
|
import com.crashlytics.android.Crashlytics
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.onEach
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
|
class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
|
||||||
|
override val screen = Report.Screen.SEND_FINAL
|
||||||
|
|
||||||
val sendViewModel: SendViewModel by activityViewModel()
|
val sendViewModel: SendViewModel by activityViewModel()
|
||||||
|
|
||||||
|
@ -34,13 +34,13 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.buttonNext.setOnClickListener {
|
binding.buttonNext.setOnClickListener {
|
||||||
onExit()
|
onExit().also { tapped(SEND_FINAL_EXIT) }
|
||||||
}
|
}
|
||||||
binding.buttonRetry.setOnClickListener {
|
binding.buttonRetry.setOnClickListener {
|
||||||
onRetry()
|
onRetry().also { tapped(SEND_FINAL_RETRY) }
|
||||||
}
|
}
|
||||||
binding.backButtonHitArea.setOnClickListener {
|
binding.backButtonHitArea.setOnClickListener {
|
||||||
onExit()
|
onExit().also { tapped(SEND_FINAL_CLOSE) }
|
||||||
}
|
}
|
||||||
binding.textConfirmation.text =
|
binding.textConfirmation.text =
|
||||||
"Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}"
|
"Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}"
|
||||||
|
@ -81,24 +81,19 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
|
||||||
val id = pendingTransaction?.id ?: -1
|
val id = pendingTransaction?.id ?: -1
|
||||||
var isSending = true
|
var isSending = true
|
||||||
var isFailure = false
|
var isFailure = false
|
||||||
|
var step: Report.Funnel.Send? = null
|
||||||
val message = when {
|
val message = when {
|
||||||
pendingTransaction == null -> "Transaction not found"
|
pendingTransaction == null -> "Transaction not found".also { step = Report.Funnel.Send.ErrorNotFound }
|
||||||
pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false }
|
pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false; step = Report.Funnel.Send.Mined(pendingTransaction.minedHeight) }
|
||||||
pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ."
|
pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . .".also { step = Report.Funnel.Send.Submitted }
|
||||||
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true }
|
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorEncoding(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
|
||||||
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true }
|
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorSubmitting(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
|
||||||
pendingTransaction.isCreated() -> "Transaction creation complete!"
|
pendingTransaction.isCreated() -> "Transaction creation complete!".also { step = Report.Funnel.Send.Created(id) }
|
||||||
pendingTransaction.isCreating() -> "Creating transaction . . ."
|
pendingTransaction.isCreating() -> "Creating transaction . . .".also { step = Report.Funnel.Send.Creating }
|
||||||
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
|
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make this error tracking easier to use and more spiffy
|
sendViewModel.funnel(step)
|
||||||
if (pendingTransaction?.isFailedSubmit() == true) {
|
|
||||||
sendViewModel.feedback.report(Report.Send.SubmitFailure(pendingTransaction?.errorCode, pendingTransaction?.errorMessage))
|
|
||||||
}
|
|
||||||
if (pendingTransaction?.isFailedEncoding() == true) {
|
|
||||||
sendViewModel.feedback.report(Report.Send.EncodingFailure(pendingTransaction?.errorCode, pendingTransaction?.errorMessage))
|
|
||||||
}
|
|
||||||
|
|
||||||
twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message")
|
twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message")
|
||||||
binding.textStatus.apply {
|
binding.textStatus.apply {
|
||||||
|
|
|
@ -3,15 +3,21 @@ package cash.z.ecc.android.ui.send
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.inputmethod.EditorInfo
|
|
||||||
import androidx.core.widget.doAfterTextChanged
|
import androidx.core.widget.doAfterTextChanged
|
||||||
import cash.z.ecc.android.R
|
import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.databinding.FragmentSendMemoBinding
|
import cash.z.ecc.android.databinding.FragmentSendMemoBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.ext.*
|
import cash.z.ecc.android.ext.gone
|
||||||
|
import cash.z.ecc.android.ext.goneIf
|
||||||
|
import cash.z.ecc.android.ext.onClickNavTo
|
||||||
|
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.base.BaseFragment
|
||||||
|
|
||||||
class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||||
|
override val screen = Report.Screen.SEND_MEMO
|
||||||
|
|
||||||
val sendViewModel: SendViewModel by activityViewModel()
|
val sendViewModel: SendViewModel by activityViewModel()
|
||||||
|
|
||||||
|
@ -21,18 +27,18 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.buttonNext.setOnClickListener {
|
binding.buttonNext.setOnClickListener {
|
||||||
onTopButton()
|
onTopButton().also { tapped(SEND_MEMO_NEXT) }
|
||||||
}
|
}
|
||||||
binding.buttonSkip.setOnClickListener {
|
binding.buttonSkip.setOnClickListener {
|
||||||
onBottomButton()
|
onBottomButton().also { tapped(SEND_MEMO_SKIP) }
|
||||||
}
|
}
|
||||||
binding.clearMemo.setOnClickListener {
|
binding.clearMemo.setOnClickListener {
|
||||||
onClearMemo()
|
onClearMemo().also { tapped(SEND_MEMO_CLEAR) }
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_nav_send_memo_to_nav_send_address.let {
|
R.id.action_nav_send_memo_to_nav_send_address.let {
|
||||||
binding.backButtonHitArea.onClickNavTo(it)
|
binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) }
|
||||||
onBackPressNavTo(it)
|
onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) }
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _->
|
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _->
|
||||||
|
@ -41,7 +47,7 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||||
|
|
||||||
binding.inputMemo.let { memo ->
|
binding.inputMemo.let { memo ->
|
||||||
memo.onEditorActionDone {
|
memo.onEditorActionDone {
|
||||||
onTopButton()
|
onTopButton().also { tapped(SEND_MEMO_NEXT) }
|
||||||
}
|
}
|
||||||
memo.doAfterTextChanged {
|
memo.doAfterTextChanged {
|
||||||
binding.clearMemo.goneIf(memo.text.isEmpty())
|
binding.clearMemo.goneIf(memo.text.isEmpty())
|
||||||
|
@ -79,11 +85,14 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onIncludeMemo(checked: Boolean) {
|
private fun onIncludeMemo(checked: Boolean) {
|
||||||
|
|
||||||
binding.textIncludedAddress.goneIf(!checked)
|
binding.textIncludedAddress.goneIf(!checked)
|
||||||
sendViewModel.includeFromAddress = checked
|
sendViewModel.includeFromAddress = checked
|
||||||
binding.textInfoShielded.text = if (checked) {
|
binding.textInfoShielded.text = if (checked) {
|
||||||
|
tapped(SEND_MEMO_INCLUDE)
|
||||||
getString(R.string.send_memo_included_message)
|
getString(R.string.send_memo_included_message)
|
||||||
} else {
|
} else {
|
||||||
|
tapped(SEND_MEMO_EXCLUDE)
|
||||||
getString(R.string.send_memo_excluded_message)
|
getString(R.string.send_memo_excluded_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -105,6 +114,7 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onNext() {
|
private fun onNext() {
|
||||||
|
sendViewModel.funnel(Send.MemoPageComplete)
|
||||||
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
|
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,19 +1,20 @@
|
||||||
package cash.z.ecc.android.ui.send
|
package cash.z.ecc.android.ui.send
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import cash.z.ecc.android.feedback.Feedback
|
import cash.z.ecc.android.feedback.Feedback
|
||||||
import cash.z.ecc.android.feedback.Feedback.Keyed
|
import cash.z.ecc.android.feedback.Feedback.Keyed
|
||||||
import cash.z.ecc.android.feedback.Feedback.TimeMetric
|
import cash.z.ecc.android.feedback.Feedback.TimeMetric
|
||||||
import cash.z.ecc.android.feedback.Report
|
import cash.z.ecc.android.feedback.Report
|
||||||
|
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.feedback.Report.MetricType.*
|
import cash.z.ecc.android.feedback.Report.MetricType.*
|
||||||
import cash.z.ecc.android.lockbox.LockBox
|
import cash.z.ecc.android.lockbox.LockBox
|
||||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
|
||||||
import cash.z.wallet.sdk.Initializer
|
import cash.z.wallet.sdk.Initializer
|
||||||
import cash.z.wallet.sdk.Synchronizer
|
import cash.z.wallet.sdk.Synchronizer
|
||||||
import cash.z.wallet.sdk.annotation.OpenForTesting
|
|
||||||
import cash.z.wallet.sdk.entity.*
|
import cash.z.wallet.sdk.entity.*
|
||||||
import cash.z.wallet.sdk.ext.ZcashSdk
|
import cash.z.wallet.sdk.ext.ZcashSdk
|
||||||
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
|
||||||
|
@ -58,20 +59,38 @@ class SendViewModel @Inject constructor() : ViewModel() {
|
||||||
val isShielded get() = toAddress.startsWith("z")
|
val isShielded get() = toAddress.startsWith("z")
|
||||||
|
|
||||||
fun send(): Flow<PendingTransaction> {
|
fun send(): Flow<PendingTransaction> {
|
||||||
|
funnel(SendSelected)
|
||||||
val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo
|
val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo
|
||||||
val keys = initializer.deriveSpendingKeys(
|
val keys = initializer.deriveSpendingKeys(
|
||||||
lockBox.getBytes(WalletSetupViewModel.LockBoxKey.SEED)!!
|
lockBox.getBytes(WalletSetupViewModel.LockBoxKey.SEED)!!
|
||||||
)
|
)
|
||||||
|
funnel(SpendingKeyFound)
|
||||||
|
reportIssues(memoToSend)
|
||||||
return synchronizer.sendToAddress(
|
return synchronizer.sendToAddress(
|
||||||
keys[0],
|
keys[0],
|
||||||
zatoshiAmount,
|
zatoshiAmount,
|
||||||
toAddress,
|
toAddress,
|
||||||
memoToSend
|
memoToSend.chunked(ZcashSdk.MAX_MEMO_SIZE).firstOrNull() ?: ""
|
||||||
).onEach {
|
).onEach {
|
||||||
twig(it.toString())
|
twig(it.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun reportIssues(memoToSend: String) {
|
||||||
|
if (toAddress == fromAddress) feedback.report(Issue.SelfSend)
|
||||||
|
when {
|
||||||
|
zatoshiAmount < ZcashSdk.MINERS_FEE_ZATOSHI -> feedback.report(Issue.TinyAmount)
|
||||||
|
zatoshiAmount < 100 -> feedback.report(Issue.MicroAmount)
|
||||||
|
zatoshiAmount == 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun validateAddress(address: String): Synchronizer.AddressType =
|
suspend fun validateAddress(address: String): Synchronizer.AddressType =
|
||||||
synchronizer.validateAddress(address)
|
synchronizer.validateAddress(address)
|
||||||
|
|
||||||
|
@ -81,7 +100,7 @@ class SendViewModel @Inject constructor() : ViewModel() {
|
||||||
synchronizer.validateAddress(toAddress).isNotValid -> {
|
synchronizer.validateAddress(toAddress).isNotValid -> {
|
||||||
emit("Please enter a valid address")
|
emit("Please enter a valid address")
|
||||||
}
|
}
|
||||||
zatoshiAmount <= 1 -> {
|
zatoshiAmount < 1 -> {
|
||||||
emit("Too little! Please enter at least 1 Zatoshi.")
|
emit("Too little! Please enter at least 1 Zatoshi.")
|
||||||
}
|
}
|
||||||
maxZatoshi != null && zatoshiAmount > maxZatoshi -> {
|
maxZatoshi != null && zatoshiAmount > maxZatoshi -> {
|
||||||
|
@ -146,6 +165,11 @@ class SendViewModel @Inject constructor() : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun funnel(step: Report.Funnel.Send?) {
|
||||||
|
step ?: return
|
||||||
|
feedback.report(step)
|
||||||
|
}
|
||||||
|
|
||||||
private operator fun MetricType.unaryPlus(): TimeMetric = TimeMetric(key, description).markTime()
|
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 TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this }
|
||||||
private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? {
|
private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? {
|
||||||
|
@ -167,6 +191,7 @@ class SendViewModel @Inject constructor() : ViewModel() {
|
||||||
private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key"
|
private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key"
|
||||||
private fun String.toRelatedMetricId(): String = "$this.related"
|
private fun String.toRelatedMetricId(): String = "$this.related"
|
||||||
private fun String.toTxId(): Long = split('.').first().toLong()
|
private fun String.toTxId(): Long = split('.').first().toLong()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,10 @@ import cash.z.ecc.android.ZcashWalletApp
|
||||||
import cash.z.ecc.android.databinding.FragmentBackupBinding
|
import cash.z.ecc.android.databinding.FragmentBackupBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
|
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.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.feedback.measure
|
||||||
import cash.z.ecc.android.lockbox.LockBox
|
import cash.z.ecc.android.lockbox.LockBox
|
||||||
import cash.z.ecc.android.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
|
@ -30,6 +33,8 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
||||||
|
override val screen = Report.Screen.BACKUP
|
||||||
|
|
||||||
val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||||
|
|
||||||
private var hasBackUp: Boolean = true //TODO: implement backup and then check for it here-ish
|
private var hasBackUp: Boolean = true //TODO: implement backup and then check for it here-ish
|
||||||
|
@ -52,9 +57,9 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
binding.buttonPositive.setOnClickListener {
|
binding.buttonPositive.setOnClickListener {
|
||||||
onEnterWallet()
|
onEnterWallet().also { if (hasBackUp) tapped(BACKUP_DONE) else tapped(BACKUP_VERIFY) }
|
||||||
}
|
}
|
||||||
if (hasBackUp == true) {
|
if (hasBackUp) {
|
||||||
binding.buttonPositive.text = "Done"
|
binding.buttonPositive.text = "Done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,9 @@ import cash.z.ecc.android.ZcashWalletApp
|
||||||
import cash.z.ecc.android.databinding.FragmentLandingBinding
|
import cash.z.ecc.android.databinding.FragmentLandingBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.di.viewmodel.viewModel
|
import cash.z.ecc.android.di.viewmodel.viewModel
|
||||||
|
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.ui.base.BaseFragment
|
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_WITHOUT_BACKUP
|
||||||
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
|
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
|
||||||
|
@ -22,6 +25,7 @@ import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
||||||
|
override val screen = Report.Screen.LANDING
|
||||||
|
|
||||||
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||||
|
|
||||||
|
@ -34,21 +38,24 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.buttonPositive.setOnClickListener {
|
binding.buttonPositive.setOnClickListener {
|
||||||
when (binding.buttonPositive.text.toString().toLowerCase()) {
|
when (binding.buttonPositive.text.toString().toLowerCase()) {
|
||||||
"new" -> onNewWallet()
|
"new" -> onNewWallet().also { tapped(LANDING_NEW) }
|
||||||
"backup" -> onBackupWallet()
|
"backup" -> onBackupWallet().also { tapped(LANDING_BACKUP) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.buttonNegative.setOnLongClickListener {
|
binding.buttonNegative.setOnLongClickListener {
|
||||||
|
tapped(DEVELOPER_WALLET_PROMPT)
|
||||||
if (binding.buttonNegative.text.toString().toLowerCase() == "restore") {
|
if (binding.buttonNegative.text.toString().toLowerCase() == "restore") {
|
||||||
MaterialAlertDialogBuilder(activity)
|
MaterialAlertDialogBuilder(activity)
|
||||||
.setMessage("Would you like to import the dev wallet?\n\nIf so, please only send 0.0001 ZEC at a time and return some later so that the account remains funded.")
|
.setMessage("Would you like to import the dev wallet?\n\nIf so, please only send 0.0001 ZEC at a time and return some later so that the account remains funded.")
|
||||||
.setTitle("Import Dev Wallet?")
|
.setTitle("Import Dev Wallet?")
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setPositiveButton("Import") { dialog, _ ->
|
.setPositiveButton("Import") { dialog, _ ->
|
||||||
|
tapped(DEVELOPER_WALLET_IMPORT)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
onUseDevWallet()
|
onUseDevWallet()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Cancel") { dialog, _ ->
|
.setNegativeButton("Cancel") { dialog, _ ->
|
||||||
|
tapped(DEVELOPER_WALLET_CANCEL)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
|
@ -58,7 +65,10 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
||||||
}
|
}
|
||||||
binding.buttonNegative.setOnClickListener {
|
binding.buttonNegative.setOnClickListener {
|
||||||
when (binding.buttonNegative.text.toString().toLowerCase()) {
|
when (binding.buttonNegative.text.toString().toLowerCase()) {
|
||||||
"restore" -> onRestoreWallet()
|
"restore" -> onRestoreWallet().also {
|
||||||
|
mainActivity?.reportFunnel(Restore.Initiated)
|
||||||
|
tapped(LANDING_RESTORE)
|
||||||
|
}
|
||||||
else -> onSkip(++skipCount)
|
else -> onSkip(++skipCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -83,16 +93,19 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
|
||||||
private fun onSkip(count: Int) {
|
private fun onSkip(count: Int) {
|
||||||
when (count) {
|
when (count) {
|
||||||
1 -> {
|
1 -> {
|
||||||
|
tapped(LANDING_BACKUP_SKIPPED_1)
|
||||||
binding.textMessage.text =
|
binding.textMessage.text =
|
||||||
"Are you sure? Without a backup, funds can be lost FOREVER!"
|
"Are you sure? Without a backup, funds can be lost FOREVER!"
|
||||||
binding.buttonNegative.text = "Later"
|
binding.buttonNegative.text = "Later"
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
|
tapped(LANDING_BACKUP_SKIPPED_2)
|
||||||
binding.textMessage.text =
|
binding.textMessage.text =
|
||||||
"You can't backup later. You're probably going to lose your funds!"
|
"You can't backup later. You're probably going to lose your funds!"
|
||||||
binding.buttonNegative.text = "I've been warned"
|
binding.buttonNegative.text = "I've been warned"
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
tapped(LANDING_BACKUP_SKIPPED_3)
|
||||||
onEnterWallet()
|
onEnterWallet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,9 @@ import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.databinding.FragmentRestoreBinding
|
import cash.z.ecc.android.databinding.FragmentRestoreBinding
|
||||||
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
import cash.z.ecc.android.di.viewmodel.activityViewModel
|
||||||
import cash.z.ecc.android.ext.goneIf
|
import cash.z.ecc.android.ext.goneIf
|
||||||
|
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.ui.base.BaseFragment
|
import cash.z.ecc.android.ui.base.BaseFragment
|
||||||
import cash.z.wallet.sdk.ext.ZcashSdk
|
import cash.z.wallet.sdk.ext.ZcashSdk
|
||||||
import cash.z.wallet.sdk.ext.twig
|
import cash.z.wallet.sdk.ext.twig
|
||||||
|
@ -28,6 +31,7 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListener {
|
class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListener {
|
||||||
|
override val screen = Report.Screen.RESTORE
|
||||||
|
|
||||||
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
|
||||||
|
|
||||||
|
@ -53,21 +57,18 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.buttonDone.setOnClickListener {
|
binding.buttonDone.setOnClickListener {
|
||||||
onDone()
|
onDone().also { tapped(RESTORE_DONE) }
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.buttonSuccess.setOnClickListener {
|
binding.buttonSuccess.setOnClickListener {
|
||||||
onEnterWallet()
|
onEnterWallet().also { tapped(RESTORE_SUCCESS) }
|
||||||
}
|
|
||||||
|
|
||||||
binding.textSubtitle.setOnClickListener {
|
|
||||||
seedWordAdapter!!.editText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
super.onActivityCreated(savedInstanceState)
|
super.onActivityCreated(savedInstanceState)
|
||||||
mainActivity?.onFragmentBackPressed(this) {
|
mainActivity?.onFragmentBackPressed(this) {
|
||||||
|
tapped(RESTORE_BACK)
|
||||||
if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) {
|
if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) {
|
||||||
onExit()
|
onExit()
|
||||||
} else {
|
} else {
|
||||||
|
@ -75,6 +76,7 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
|
||||||
.setMessage("Are you sure? For security, the words that you have entered will be cleared!")
|
.setMessage("Are you sure? For security, the words that you have entered will be cleared!")
|
||||||
.setTitle("Abort?")
|
.setTitle("Abort?")
|
||||||
.setPositiveButton("Stay") { dialog, _ ->
|
.setPositiveButton("Stay") { dialog, _ ->
|
||||||
|
mainActivity?.reportFunnel(Restore.Stay)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Exit") { dialog, _ ->
|
.setNegativeButton("Exit") { dialog, _ ->
|
||||||
|
@ -94,16 +96,19 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
|
||||||
|
|
||||||
|
|
||||||
private fun onExit() {
|
private fun onExit() {
|
||||||
|
mainActivity?.reportFunnel(Restore.Exit)
|
||||||
hideAutoCompleteWords()
|
hideAutoCompleteWords()
|
||||||
mainActivity?.hideKeyboard()
|
mainActivity?.hideKeyboard()
|
||||||
mainActivity?.navController?.popBackStack()
|
mainActivity?.navController?.popBackStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onEnterWallet() {
|
private fun onEnterWallet() {
|
||||||
|
mainActivity?.reportFunnel(Restore.Success)
|
||||||
mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home)
|
mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onDone() {
|
private fun onDone() {
|
||||||
|
mainActivity?.reportFunnel(Restore.Done)
|
||||||
mainActivity?.hideKeyboard()
|
mainActivity?.hideKeyboard()
|
||||||
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
|
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
|
||||||
it.title
|
it.title
|
||||||
|
@ -117,12 +122,14 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importWallet(seedPhrase: String, birthday: Int) {
|
private fun importWallet(seedPhrase: String, birthday: Int) {
|
||||||
|
mainActivity?.reportFunnel(Restore.ImportStarted)
|
||||||
mainActivity?.hideKeyboard()
|
mainActivity?.hideKeyboard()
|
||||||
mainActivity?.apply {
|
mainActivity?.apply {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday))
|
mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday))
|
||||||
// bugfix: if the user proceeds before the synchronizer is created the app will crash!
|
// bugfix: if the user proceeds before the synchronizer is created the app will crash!
|
||||||
binding.buttonSuccess.isEnabled = true
|
binding.buttonSuccess.isEnabled = true
|
||||||
|
mainActivity?.reportFunnel(Restore.ImportCompleted)
|
||||||
}
|
}
|
||||||
playSound("sound_receive_small.mp3")
|
playSound("sound_receive_small.mp3")
|
||||||
vibrateSuccess()
|
vibrateSuccess()
|
||||||
|
@ -135,7 +142,6 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChipsModified() {
|
private fun onChipsModified() {
|
||||||
twig("onChipsModified")
|
|
||||||
seedWordAdapter?.editText?.apply {
|
seedWordAdapter?.editText?.apply {
|
||||||
postDelayed({
|
postDelayed({
|
||||||
requestFocus()
|
requestFocus()
|
||||||
|
@ -151,9 +157,21 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
|
||||||
|
|
||||||
private fun setDoneEnabled() {
|
private fun setDoneEnabled() {
|
||||||
val count = seedWordAdapter?.itemCount ?: 0
|
val count = seedWordAdapter?.itemCount ?: 0
|
||||||
|
reportWords(count - 1) // subtract 1 for the editText
|
||||||
binding.groupDone.goneIf(count <= 24)
|
binding.groupDone.goneIf(count <= 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
private fun hideAutoCompleteWords() {
|
||||||
seedWordAdapter?.editText?.setText("")
|
seedWordAdapter?.editText?.setText("")
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@ import android.widget.TextView
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import cash.z.ecc.android.R
|
import cash.z.ecc.android.R
|
||||||
import cash.z.ecc.android.ext.toAppColor
|
import cash.z.ecc.android.ext.toAppColor
|
||||||
|
import cash.z.ecc.android.feedback.Report
|
||||||
|
import cash.z.ecc.android.feedback.Report.Funnel.Restore
|
||||||
|
import cash.z.ecc.android.ui.MainActivity
|
||||||
import cash.z.ecc.android.ui.setup.SeedWordChip
|
import cash.z.ecc.android.ui.setup.SeedWordChip
|
||||||
import cash.z.wallet.sdk.ext.twig
|
import cash.z.wallet.sdk.ext.twig
|
||||||
|
|
||||||
|
@ -46,7 +49,6 @@ class SeedWordAdapter : ChipsAdapter {
|
||||||
|
|
||||||
override fun onChipDataSourceChanged() {
|
override fun onChipDataSourceChanged() {
|
||||||
super.onChipDataSourceChanged()
|
super.onChipDataSourceChanged()
|
||||||
twig("onChipDataSourceChanged")
|
|
||||||
onDataSetChangedListener?.invoke()
|
onDataSetChangedListener?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,15 +71,12 @@ class SeedWordAdapter : ChipsAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onKeyboardDelimiter(text: String) {
|
override fun onKeyboardDelimiter(text: String) {
|
||||||
twig("onKeyboardDelimiter: $text ${mDataSource.filteredChips.size}")
|
|
||||||
if (mDataSource.filteredChips.size > 0) {
|
if (mDataSource.filteredChips.size > 0) {
|
||||||
onKeyboardActionDone((mDataSource.filteredChips.first() as SeedWordChip).word)
|
onKeyboardActionDone((mDataSource.filteredChips.first() as SeedWordChip).word)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) {
|
private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) {
|
||||||
val seedChipView = super.chipView as SeedWordChipView
|
val seedChipView = super.chipView as SeedWordChipView
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,7 +161,7 @@
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
style="@style/TextAppearance.AppCompat.Body1"
|
style="@style/TextAppearance.AppCompat.Body1"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:text="See Application Log"
|
android:text="See Application Logs"
|
||||||
android:textColor="@color/selector_button_text_light_dimmed"
|
android:textColor="@color/selector_button_text_light_dimmed"
|
||||||
app:layout_constraintTop_toBottomOf="@id/button_backup"
|
app:layout_constraintTop_toBottomOf="@id/button_backup"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- Share files from a dedicated folder on the internal files storage. -->
|
||||||
|
<files-path name="logs" path="logs/"/>
|
||||||
|
</paths>
|
|
@ -1,6 +1,6 @@
|
||||||
package cash.z.ecc.android.feedback
|
package cash.z.ecc.android.feedback
|
||||||
|
|
||||||
import android.util.Log
|
//import android.util.Log
|
||||||
import cash.z.ecc.android.feedback.util.CompositeJob
|
import cash.z.ecc.android.feedback.util.CompositeJob
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.BroadcastChannel
|
import kotlinx.coroutines.channels.BroadcastChannel
|
||||||
|
@ -35,17 +35,8 @@ class Feedback(capacity: Int = 256) {
|
||||||
* [actions] channels will remain open unless [stop] is also called on this instance.
|
* [actions] channels will remain open unless [stop] is also called on this instance.
|
||||||
*/
|
*/
|
||||||
suspend fun start(): Feedback {
|
suspend fun start(): Feedback {
|
||||||
val callStack = StringBuilder().let { s ->
|
|
||||||
Thread.currentThread().stackTrace.forEach {element ->
|
|
||||||
s.append("$element\n")
|
|
||||||
}
|
|
||||||
s.toString()
|
|
||||||
}
|
|
||||||
if(::scope.isInitialized) {
|
if(::scope.isInitialized) {
|
||||||
Log.e("@TWIG","Warning: did not initialize feedback because it has already been initialized. Call stack: $callStack")
|
|
||||||
return this
|
return this
|
||||||
} else {
|
|
||||||
Log.e("@TWIG","Debug: Initializing feedback for the first time. Call stack: $callStack")
|
|
||||||
}
|
}
|
||||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
|
||||||
invokeOnCompletion {
|
invokeOnCompletion {
|
||||||
|
@ -143,8 +134,8 @@ class Feedback(capacity: Int = 256) {
|
||||||
*
|
*
|
||||||
* @param error the uncaught exception that occurred.
|
* @param error the uncaught exception that occurred.
|
||||||
*/
|
*/
|
||||||
fun report(error: Throwable?, fatal: Boolean = false): Feedback {
|
fun report(error: Throwable?, isFatal: Boolean = false): Feedback {
|
||||||
return report(Crash(error, fatal))
|
return if (isFatal) report(Crash(error)) else report(NonFatal(error, "reported"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -197,14 +188,24 @@ class Feedback(capacity: Int = 256) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Funnel(override val key: String) : Action {
|
abstract class MappedAction private constructor(protected val propertyMap: MutableMap<String, Any> = mutableMapOf()) : Feedback.Action {
|
||||||
override fun toMap(): MutableMap<String, Any> {
|
constructor(vararg properties: Pair<String, Any>) : this(mutableMapOf(*properties))
|
||||||
return mutableMapOf(
|
|
||||||
"key" to key
|
override fun toMap(): Map<String, Any> {
|
||||||
)
|
return propertyMap.apply { putAll(super.toMap()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract class Funnel(funnelName: String, stepName: String, step: Int, vararg properties: Pair<String, Any>) : MappedAction(
|
||||||
|
"funnelName" to funnelName,
|
||||||
|
"stepName" to stepName,
|
||||||
|
"step" to step,
|
||||||
|
*properties
|
||||||
|
) {
|
||||||
|
override fun toString() = key
|
||||||
|
override val key: String = "funnel.$funnelName.$stepName.$step"
|
||||||
|
}
|
||||||
|
|
||||||
interface Keyed<T> {
|
interface Keyed<T> {
|
||||||
val key: T
|
val key: T
|
||||||
}
|
}
|
||||||
|
@ -231,31 +232,52 @@ class Feedback(capacity: Int = 256) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Crash(val error: Throwable? = null, val fatal: Boolean = true) : Action {
|
open class AppError(name: String = "unknown", description: String? = null, isFatal: Boolean = false, vararg properties: Pair<String, Any>) : MappedAction(
|
||||||
override val key: String = "crash"
|
"isError" to true,
|
||||||
override fun toMap(): Map<String, Any> {
|
"isFatal" to isFatal,
|
||||||
return mutableMapOf<String, Any>(
|
"errorName" to name,
|
||||||
"fatal" to fatal,
|
"message" to (description ?: "None"),
|
||||||
"message" to (error?.message ?: "None"),
|
"description" to describe(name, description, isFatal),
|
||||||
"cause" to (error?.cause?.toString() ?: "None"),
|
*properties
|
||||||
"cause.cause" to (error?.cause?.cause?.toString() ?: "None"),
|
) {
|
||||||
"cause.cause.cause" to (error?.cause?.cause?.cause?.toString() ?: "None")
|
val isFatal: Boolean by propertyMap
|
||||||
).apply { putAll(super.toMap()); putAll(error.stacktraceToMap()) }
|
val errorName: String by propertyMap
|
||||||
|
val description: String by propertyMap
|
||||||
|
constructor(name: String, exception: Throwable? = null, isFatal: Boolean = false) : this(
|
||||||
|
name, exception?.toString(), isFatal,
|
||||||
|
"exceptionString" to (exception?.toString() ?: "None"),
|
||||||
|
"message" to (exception?.message ?: "None"),
|
||||||
|
"cause" to (exception?.cause?.toString() ?: "None"),
|
||||||
|
"cause.cause" to (exception?.cause?.cause?.toString() ?: "None"),
|
||||||
|
"cause.cause.cause" to (exception?.cause?.cause?.cause?.toString() ?: "None")
|
||||||
|
) {
|
||||||
|
propertyMap.putAll(exception.stacktraceToMap())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString() = "App ${if (fatal) "crashed due to" else "caught error"}: $error"
|
override val key = "error.${if (isFatal) "fatal" else "nonfatal"}.$name"
|
||||||
|
override fun toString() = description
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun describe(name: String, description: String?, isFatal: Boolean) =
|
||||||
|
"${if (isFatal) "Error: FATAL" else "Error: non-fatal"} $name error due to: ${description ?: "unknown error"}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Crash(val exception: Throwable? = null) : AppError( "crash", exception, true)
|
||||||
|
class NonFatal(val exception: Throwable? = null, name: String) : AppError(name, exception, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun Throwable?.stacktraceToMap(chunkSize: Int = 250): Map<out String, String> {
|
private fun Throwable?.stacktraceToMap(chunkSize: Int = 250): Map<out String, String> {
|
||||||
val properties = mutableMapOf("stacktrace0" to "None")
|
val properties = mutableMapOf("stacktrace.0" to "None")
|
||||||
if (this == null) return properties
|
if (this == null) return properties
|
||||||
val stringWriter = StringWriter()
|
val stringWriter = StringWriter()
|
||||||
|
|
||||||
printStackTrace(PrintWriter(stringWriter))
|
printStackTrace(PrintWriter(stringWriter))
|
||||||
|
|
||||||
stringWriter.toString().chunked(chunkSize).forEachIndexed { index, chunk ->
|
stringWriter.toString().chunked(chunkSize).forEachIndexed { index, chunk ->
|
||||||
properties["stacktrace$index"] = chunk
|
properties["stacktrace.$index"] = chunk
|
||||||
}
|
}
|
||||||
return properties
|
return properties
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,9 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.*
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.lang.RuntimeException
|
||||||
|
|
||||||
class FeedbackTest {
|
class FeedbackTest {
|
||||||
|
|
||||||
|
@ -43,6 +43,7 @@ class FeedbackTest {
|
||||||
verifyAction(feedback, simpleAction.key)
|
verifyAction(feedback, simpleAction.key)
|
||||||
|
|
||||||
feedback.report(simpleAction)
|
feedback.report(simpleAction)
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -64,6 +65,50 @@ class FeedbackTest {
|
||||||
verifyFeedbackCancellation { _, _ -> }
|
verifyFeedbackCancellation { _, _ -> }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCrash() {
|
||||||
|
val rushing = RuntimeException("rushing")
|
||||||
|
val speeding = RuntimeException("speeding", rushing)
|
||||||
|
val runlight = RuntimeException("Run light", speeding)
|
||||||
|
val crash = Feedback.Crash(RuntimeException("BOOM", runlight))
|
||||||
|
val map = crash.toMap()
|
||||||
|
printMap(map)
|
||||||
|
|
||||||
|
assertNotNull(map["cause"])
|
||||||
|
assertNotNull(map["cause.cause"])
|
||||||
|
assertNotNull(map["cause.cause"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppError_exception() {
|
||||||
|
val rushing = RuntimeException("rushing")
|
||||||
|
val speeding = RuntimeException("speeding", rushing)
|
||||||
|
val runlight = RuntimeException("Run light", speeding)
|
||||||
|
val error = Feedback.AppError("reported", RuntimeException("BOOM", runlight))
|
||||||
|
val map = error.toMap()
|
||||||
|
printMap(map)
|
||||||
|
|
||||||
|
assertFalse(error.isFatal)
|
||||||
|
assertNotNull(map["cause"])
|
||||||
|
assertNotNull(map["cause.cause"])
|
||||||
|
assertNotNull(map["cause.cause"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppError_description() {
|
||||||
|
val error = Feedback.AppError("reported", "The server was down while downloading blocks!")
|
||||||
|
val map = error.toMap()
|
||||||
|
printMap(map)
|
||||||
|
|
||||||
|
assertFalse(error.isFatal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printMap(map: Map<String, Any>) {
|
||||||
|
for (entry in map) {
|
||||||
|
println("%-20s = %s".format(entry.key, entry.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking {
|
private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking {
|
||||||
val feedback = Feedback()
|
val feedback = Feedback()
|
||||||
var counter = 0
|
var counter = 0
|
||||||
|
|
Loading…
Reference in New Issue