diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..72917e1
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
diff --git a/app/build.gradle b/app/build.gradle
index f1336c1..9efe529 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,7 +11,7 @@ apply plugin: 'com.google.firebase.firebase-perf'
archivesBaseName = 'zcash-android-wallet'
group = 'cash.z.ecc.android'
-version = '1.0.0-alpha17'
+version = '1.0.0-alpha23'
android {
compileSdkVersion Deps.compileSdkVersion
@@ -21,8 +21,8 @@ android {
applicationId 'cash.z.ecc.android'
minSdkVersion Deps.minSdkVersion
targetSdkVersion Deps.targetSdkVersion
- versionCode = 1_00_00_017
- // 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.
+ versionCode = 1_00_00_023
+ // 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"
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
testInstrumentationRunnerArguments clearPackageData: 'true'
@@ -87,6 +87,7 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
+
kapt {
arguments {
arg 'dagger.fastInit', 'enabled'
@@ -146,10 +147,10 @@ dependencies {
implementation 'io.github.novacrypto:securestring:2019.01.27'
// grpc-java
- implementation "io.grpc:grpc-okhttp:1.27.0"
- implementation "io.grpc:grpc-android:1.27.0"
- implementation "io.grpc:grpc-protobuf-lite:1.27.0"
- implementation "io.grpc:grpc-stub:1.27.0"
+ implementation "io.grpc:grpc-okhttp:1.25.0"
+ implementation "io.grpc:grpc-android:1.25.0"
+ implementation "io.grpc:grpc-protobuf-lite:1.25.0"
+ implementation "io.grpc:grpc-stub:1.25.0"
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)
// 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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b075b1d..7b5fa4f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,17 @@
+
+
+
+
diff --git a/app/src/main/java/cash/z/ecc/android/di/module/AppModule.kt b/app/src/main/java/cash/z/ecc/android/di/module/AppModule.kt
index bc5fb40..92870ce 100644
--- a/app/src/main/java/cash/z/ecc/android/di/module/AppModule.kt
+++ b/app/src/main/java/cash/z/ecc/android/di/module/AppModule.kt
@@ -57,4 +57,9 @@ class AppModule {
@Singleton
@IntoSet
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
+
+ @Provides
+ @Singleton
+ @IntoSet
+ fun provideFeedbackCrashlytics(): FeedbackCoordinator.FeedbackObserver = FeedbackCrashlytics()
}
diff --git a/app/src/main/java/cash/z/ecc/android/ext/Extensions.kt b/app/src/main/java/cash/z/ecc/android/ext/Extensions.kt
new file mode 100644
index 0000000..2e2b7e1
--- /dev/null
+++ b/app/src/main/java/cash/z/ecc/android/ext/Extensions.kt
@@ -0,0 +1,3 @@
+package cash.z.ecc.android.ext
+
+fun Boolean.asString(ifTrue: String = "", ifFalse: String = "") = if(this) ifTrue else ifFalse
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/ext/View.kt b/app/src/main/java/cash/z/ecc/android/ext/View.kt
index 5005f96..62b9842 100644
--- a/app/src/main/java/cash/z/ecc/android/ext/View.kt
+++ b/app/src/main/java/cash/z/ecc/android/ext/View.kt
@@ -22,16 +22,18 @@ fun View.disabledIf(isDisabled: Boolean) {
isEnabled = !isDisabled
}
-fun View.onClickNavTo(navResId: Int) {
+fun View.onClickNavTo(navResId: Int, block: (() -> Any) = {}) {
setOnClickListener {
+ block()
(context as? MainActivity)?.safeNavigate(navResId)
?: throw IllegalStateException("Cannot navigate from this activity. " +
"Expected MainActivity but found ${context.javaClass.simpleName}")
}
}
-fun View.onClickNavUp() {
+fun View.onClickNavUp(block: (() -> Any) = {}) {
setOnClickListener {
+ block()
(context as? MainActivity)?.navController?.navigateUp()
?: throw IllegalStateException(
"Cannot navigate from this activity. " +
@@ -40,8 +42,9 @@ fun View.onClickNavUp() {
}
}
-fun View.onClickNavBack() {
+fun View.onClickNavBack(block: (() -> Any) = {}) {
setOnClickListener {
+ block()
(context as? MainActivity)?.navController?.popBackStack()
?: throw IllegalStateException(
"Cannot navigate from this activity. " +
diff --git a/app/src/main/java/cash/z/ecc/android/feedback/FeedbackCrashlytics.kt b/app/src/main/java/cash/z/ecc/android/feedback/FeedbackCrashlytics.kt
new file mode 100644
index 0000000..d3e5d30
--- /dev/null
+++ b/app/src/main/java/cash/z/ecc/android/feedback/FeedbackCrashlytics.kt
@@ -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)
+}
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/feedback/FeedbackFile.kt b/app/src/main/java/cash/z/ecc/android/feedback/FeedbackFile.kt
index 339c019..8f31fde 100644
--- a/app/src/main/java/cash/z/ecc/android/feedback/FeedbackFile.kt
+++ b/app/src/main/java/cash/z/ecc/android/feedback/FeedbackFile.kt
@@ -5,12 +5,15 @@ import okio.Okio
import java.io.File
import java.text.SimpleDateFormat
-class FeedbackFile(fileName: String = "feedback.log") :
+class FeedbackFile(fileName: String = "user_log.txt") :
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")
+ init {
+ if (!file.parentFile.exists()) file.parentFile.mkdirs()
+ }
override fun onMetric(metric: Feedback.Metric) {
appendToFile(metric.toString())
diff --git a/app/src/main/java/cash/z/ecc/android/feedback/Report.kt b/app/src/main/java/cash/z/ecc/android/feedback/Report.kt
index 0cd2d68..3ec359a 100644
--- a/app/src/main/java/cash/z/ecc/android/feedback/Report.kt
+++ b/app/src/main/java/cash/z/ecc/android/feedback/Report.kt
@@ -3,25 +3,154 @@ package cash.z.ecc.android.feedback
import cash.z.ecc.android.ZcashWalletApp
object Report {
- object Send {
- class SubmitFailure(private val errorCode: Int?, private val errorMessage: String?) : Feedback.Funnel("send.failure.submit") {
- override fun toMap(): MutableMap {
- return super.toMap().apply {
- put("error.code", errorCode ?: -1)
- put("error.message", errorMessage ?: "None")
- }
- }
+
+ object Funnel {
+ sealed class Send(stepName: String, step: Int, vararg properties: Pair) : Feedback.Funnel("send", stepName, step, *properties) {
+ object AddressPageComplete : Send("addresspagecomplete", 10)
+ object MemoPageComplete : Send("memopagecomplete", 20)
+ object ConfirmPageComplete : Send("confirmpagecomplete", 30)
+
+ // Beginning of send
+ object SendSelected : Send("sendselected", 50)
+ object SpendingKeyFound : Send("keyfound", 60)
+ object Creating : Send("creating", 70)
+ 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) : 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") {
- override fun toMap(): MutableMap {
- return super.toMap().apply {
- put("error.code", errorCode ?: -1)
- put("error.message", errorMessage ?: "None")
- }
+ sealed class Restore(stepName: String, step: Int, vararg properties: Pair) : Feedback.Funnel("restore", stepName, step, *properties) {
+ object Initiated : Restore("initiated", 0)
+ object SeedWordsStarted : Restore("wordsstarted", 10)
+ class SeedWordCount(wordCount: Int) : Restore("wordsmodified", 15, "seedWordCount" to wordCount)
+ object SeedWordsCompleted : Restore("wordscompleted", 20)
+ object Stay : Restore("stay", 21)
+ object Exit : Restore("stay", 22)
+ object Done : Restore("doneselected", 30)
+ object ImportStarted : Restore("importstarted", 40)
+ object ImportCompleted : Restore("importcompleted", 50)
+ object Success : Restore("success", 100)
+ }
+ }
+
+ 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) : 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 {
@@ -68,5 +197,6 @@ class LaunchMetric private constructor(private val metric: Feedback.TimeMetric)
override fun toString(): String = metric.toString()
}
+
inline fun Feedback.measure(type: Report.MetricType, block: () -> T): T =
this.measure(type.key, type.description, block)
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt
index da6745c..8337d21 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/MainActivity.kt
@@ -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.FeedbackCoordinator
import cash.z.ecc.android.feedback.LaunchMetric
+import cash.z.ecc.android.feedback.Report
+import cash.z.ecc.android.feedback.Report.Error.NonFatal.Reorg
import cash.z.ecc.android.feedback.Report.NonUserAction.FEEDBACK_STOPPED
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
+import cash.z.ecc.android.feedback.Report.Tap.COPY_ADDRESS
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.exception.CompactBlockProcessorException
+import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
@@ -59,6 +63,7 @@ class MainActivity : AppCompatActivity() {
private val mediaPlayer: MediaPlayer = MediaPlayer()
private var snackbar: Snackbar? = null
private var dialog: Dialog? = null
+ private var ignoreScanFailure: Boolean = false
lateinit var component: MainActivitySubcomponent
lateinit var synchronizerComponent: SynchronizerSubcomponent
@@ -165,6 +170,7 @@ class MainActivity : AppCompatActivity() {
feedback.report(SYNC_START)
synchronizerComponent.synchronizer().let { synchronizer ->
synchronizer.onProcessorErrorHandler = ::onProcessorError
+ synchronizer.onChainErrorHandler = ::onChainError
synchronizer.start(lifecycleScope)
}
} 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) {
mediaPlayer.apply {
if (isPlaying) stop()
@@ -197,6 +213,7 @@ class MainActivity : AppCompatActivity() {
}
fun copyAddress(view: View? = null) {
+ reportTap(COPY_ADDRESS)
lifecycleScope.launch {
clipboard.setPrimaryClip(
ClipData.newPlainText(
@@ -258,14 +275,12 @@ class MainActivity : AppCompatActivity() {
}
fun showKeyboard(focusedView: View) {
- twig("SHOWING KEYBOARD")
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
}
fun hideKeyboard() {
- twig("HIDING KEYBOARD")
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(findViewById(android.R.id.content).windowToken, 0)
}
@@ -309,10 +324,13 @@ class MainActivity : AppCompatActivity() {
showSnackbar("Well, this is awkward. You denied permission for the camera.")
}
+ private var ignoredErrors = 0
private fun onProcessorError(error: Throwable?): Boolean {
+ var notified = false
when (error) {
is CompactBlockProcessorException.Uninitialized -> {
- if (dialog == null)
+ if (dialog == null) {
+ notified = true
runOnUiThread {
dialog = MaterialAlertDialogBuilder(this)
.setTitle("Wallet Improperly Initialized")
@@ -324,9 +342,83 @@ class MainActivity : AppCompatActivity() {
}
.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)
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 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(android.R.id.content).postDelayed({
+ throttles[key]?.let { pendingWork ->
+ throttles.remove(key)
+ if (pendingWork !== noWork) throttle(key, delay, pendingWork)
+ }
+ }, delay)
+ }
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/base/BaseFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/base/BaseFragment.kt
index 1a08aff..b34b70a 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/base/BaseFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/base/BaseFragment.kt
@@ -8,6 +8,7 @@ import androidx.annotation.NonNull
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
+import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.ui.MainActivity
import kotlinx.coroutines.*
@@ -18,6 +19,8 @@ abstract class BaseFragment : Fragment() {
lateinit var resumedScope: CoroutineScope
+ open val screen: Report.Screen? = null
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -29,6 +32,7 @@ abstract class BaseFragment : Fragment() {
override fun onResume() {
super.onResume()
+ mainActivity?.reportScreen(screen)
resumedScope = lifecycleScope.coroutineContext.let {
CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job]))
}
@@ -43,9 +47,15 @@ abstract class BaseFragment : Fragment() {
// each fragment must call FragmentMyLayoutBinding.inflate(inflater)
abstract fun inflate(@NonNull inflater: LayoutInflater): T
- fun onBackPressNavTo(navResId: Int) {
+ fun onBackPressNavTo(navResId: Int, block: (() -> Unit) = {}) {
mainActivity?.onFragmentBackPressed(this) {
+ block()
mainActivity?.safeNavigate(navResId)
}
}
+
+ fun tapped(tap: Report.Tap) {
+ mainActivity?.reportTap(tap)
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt
index 0d336fc..8c43d56 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/detail/TransactionViewHolder.kt
@@ -8,10 +8,7 @@ import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.toAppColor
import cash.z.ecc.android.ui.MainActivity
import cash.z.wallet.sdk.entity.ConfirmedTransaction
-import cash.z.wallet.sdk.ext.ZcashSdk
-import cash.z.wallet.sdk.ext.convertZatoshiToZecString
-import cash.z.wallet.sdk.ext.isShielded
-import cash.z.wallet.sdk.ext.toAbbreviatedAddress
+import cash.z.wallet.sdk.ext.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.nio.charset.Charset
import java.text.SimpleDateFormat
@@ -30,7 +27,8 @@ class TransactionViewHolder(itemView: View) : Recycler
// update view
var lineOne: String = ""
var lineTwo: String = ""
- var amount: String = ""
+ var amountZec: String = ""
+ var amountDisplay: String = ""
var amountColor: Int = 0
var indicatorBackground: Int = 0
@@ -38,7 +36,7 @@ class TransactionViewHolder(itemView: View) : Recycler
itemView.setOnClickListener {
onTransactionClicked(this)
}
- amount = value.convertZatoshiToZecString()
+ amountZec = value.convertZatoshiToZecString()
// TODO: these might be good extension functions
val timestamp = formatter.format(blockTimeInSeconds * 1000L)
val isMined = blockTimeInSeconds != 0L
@@ -46,14 +44,14 @@ class TransactionViewHolder(itemView: View) : Recycler
!toAddress.isNullOrEmpty() -> {
lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}"
lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation"
- amount = "- $amount"
+ amountDisplay = "- $amountZec"
amountColor = R.color.zcashRed
indicatorBackground = R.drawable.background_indicator_outbound
}
raw == null || raw?.isEmpty() == true -> {
lineOne = "Unknown paid you"
lineTwo = "Received $timestamp"
- amount = "+ $amount"
+ amountDisplay = "+ $amountZec"
amountColor = R.color.zcashGreen
indicatorBackground = R.drawable.background_indicator_inbound
}
@@ -64,14 +62,16 @@ class TransactionViewHolder(itemView: View) : Recycler
}
// sanitize amount
- if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amount = "< 0.001"
- else if (amount.length > 8) amount = "tap to view"
+ if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amountDisplay = "< 0.001"
+ 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
bottomText.text = lineTwo
- amountText.text = amount
+ amountText.text = amountDisplay
amountText.setTextColor(amountColor.toAppColor())
val context = itemView.context
indicator.background = context.resources.getDrawable(indicatorBackground)
diff --git a/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt
index 2409f98..3da050d 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/detail/WalletDetailFragment.kt
@@ -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.onClickNavUp
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.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.entity.ConfirmedTransaction
@@ -23,7 +25,7 @@ import kotlinx.coroutines.launch
class WalletDetailFragment : BaseFragment() {
-
+ override val screen = Report.Screen.DETAIL
private val viewModel: WalletDetailViewModel by viewModel()
private lateinit var adapter: TransactionAdapter
@@ -33,7 +35,7 @@ class WalletDetailFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- binding.backButtonHitArea.onClickNavUp()
+ binding.backButtonHitArea.onClickNavUp { tapped(DETAIL_BACK) }
lifecycleScope.launch {
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress()
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt
index cf1aead..44f62e4 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/home/HomeFragment.kt
@@ -10,10 +10,9 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentHomeBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.di.viewmodel.viewModel
-import cash.z.ecc.android.ext.disabledIf
-import cash.z.ecc.android.ext.goneIf
-import cash.z.ecc.android.ext.onClickNavTo
-import cash.z.ecc.android.ext.toColoredSpan
+import cash.z.ecc.android.ext.*
+import cash.z.ecc.android.feedback.Report
+import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
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.wallet.sdk.Synchronizer
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 kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -29,6 +31,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class HomeFragment : BaseFragment() {
+ override val screen = Report.Screen.HOME
private lateinit var numberPad: List
private lateinit var uiModel: HomeViewModel.UiModel
@@ -88,18 +91,18 @@ class HomeFragment : BaseFragment() {
buttonNumberPadDecimal.asKey(),
buttonNumberPadBack.asKey()
)
- hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile)
- iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
- textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
+ hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) { tapped(HOME_PROFILE) }
+ iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
+ textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
hitAreaScan.setOnClickListener {
- mainActivity?.maybeOpenScan()
+ mainActivity?.maybeOpenScan().also { tapped(HOME_SCAN) }
}
textBannerAction.setOnClickListener {
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
}
buttonSendAmount.setOnClickListener {
- onSend()
+ onSend().also { tapped(HOME_SEND) }
}
setSendAmount("0", false)
@@ -107,7 +110,7 @@ class HomeFragment : BaseFragment() {
}
binding.buttonNumberPadBack.setOnLongClickListener {
- onClearAmount()
+ onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
true
}
@@ -175,14 +178,17 @@ class HomeFragment : BaseFragment() {
// Public UI API
//
- fun setSendEnabled(enabled: Boolean) {
+ var isSendEnabled = false
+ fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
+ isSendEnabled = enabled
binding.buttonSendAmount.apply {
- isEnabled = enabled
- if (enabled) {
-// setTextColor(resources.getColorStateList(R.color.selector_button_text_dark))
+ if (enabled || !isSynced) {
+ isEnabled = true
+ isClickable = isSynced
binding.lottieButtonLoading.alpha = 1.0f
} else {
-// setTextColor(R.color.zcashGray.toAppColor())
+ isEnabled = false
+ isClickable = false
binding.lottieButtonLoading.alpha = 0.32f
}
}
@@ -268,7 +274,7 @@ class HomeFragment : BaseFragment() {
//
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
uiModel = new
if (old?.pendingSend != new.pendingSend) {
@@ -278,7 +284,38 @@ class HomeFragment : BaseFragment() {
setProgress(uiModel) // TODO: we may not need to separate anymore
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
- setSendEnabled(new.isSendEnabled)
+ 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) {
@@ -296,7 +333,7 @@ class HomeFragment : BaseFragment() {
}
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) {
@@ -307,6 +344,7 @@ class HomeFragment : BaseFragment() {
.setTitle("No Balance")
.setCancelable(true)
.setPositiveButton("View Address") { dialog, _ ->
+ tapped(HOME_FUND_NOW)
dialog.dismiss()
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt b/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt
index 2cb2f56..01d54d5 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/home/MagicSnakeLoader.kt
@@ -85,7 +85,6 @@ class MagicSnakeLoader(
} else {
// once we're ready to show scan progress, do it! Don't do extra loops.
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
- twig("ZZZ pausing so we can scan! ${if(frame
if (frame in 33..67) {
- twig("ZZZ removing 1 loop!")
lottie.frame = frame + 34
} else if (frame in 0..33) {
- twig("ZZZ removing 2 loops!")
lottie.frame = frame + 67
}
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt
index 529005f..e59fa5b 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/profile/ProfileFragment.kt
@@ -1,23 +1,34 @@
package cash.z.ecc.android.ui.profile
+import android.content.Context
import android.content.Intent
+import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
+import androidx.core.content.FileProvider.getUriForFile
import cash.z.ecc.android.BuildConfig
import cash.z.ecc.android.R
+import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentProfileBinding
import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.onClick
import cash.z.ecc.android.ext.onClickNavBack
import cash.z.ecc.android.ext.onClickNavTo
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.wallet.sdk.ext.toAbbreviatedAddress
+import cash.z.wallet.sdk.ext.twig
import kotlinx.coroutines.launch
import okio.Okio
+import java.io.File
+import java.io.IOException
+
class ProfileFragment : BaseFragment() {
+ override val screen = Report.Screen.PROFILE
private val viewModel: ProfileViewModel by viewModel()
@@ -26,13 +37,20 @@ class ProfileFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- binding.hitAreaClose.onClickNavBack()
- binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup)
+ binding.hitAreaClose.onClickNavBack() { tapped(PROFILE_CLOSE) }
+ binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup) { tapped(PROFILE_BACKUP) }
binding.textVersion.text = BuildConfig.VERSION_NAME
onClick(binding.buttonLogs) {
+ tapped(PROFILE_VIEW_USER_LOGS)
onViewLogs()
}
+ binding.buttonLogs.setOnLongClickListener {
+ tapped(PROFILE_VIEW_DEV_LOGS)
+ onViewDevLogs()
+ true
+ }
onClick(binding.buttonFeedback) {
+ tapped(PROFILE_SEND_FEEDBACK)
onSendFeedback()
}
}
@@ -45,31 +63,63 @@ class ProfileFragment : BaseFragment() {
}
private fun onViewLogs() {
- loadLogFileAsText().let { logText ->
- 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"
- }
+ shareFile(userLogFile())
+ }
- val shareIntent = Intent.createChooser(sendIntent, "Share Log File")
- startActivity(shareIntent)
+ private fun onViewDevLogs() {
+ shareFile(writeLogcat())
+ }
+
+ private fun shareFiles(vararg files: File?) {
+ val uris = arrayListOf().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() {
mainActivity?.showSnackbar("Feedback feature coming soon!")
}
+ private fun userLogFile(): File? {
+ return mainActivity?.feedbackCoordinator?.findObserver()?.file
+ }
+
private fun loadLogFileAsText(): String? {
- val feedbackFile: FeedbackFile =
- mainActivity?.feedbackCoordinator?.findObserver() ?: return null
- Okio.buffer(Okio.source(feedbackFile.file)).use {
+ val feedbackFile: File = userLogFile() ?: return null
+ Okio.buffer(Okio.source(feedbackFile)).use {
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
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt
index 3c95eb2..68777c4 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/receive/ReceiveFragment.kt
@@ -10,6 +10,8 @@ import cash.z.ecc.android.databinding.FragmentReceiveNewBinding
import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.onClickNavBack
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.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.twig
@@ -17,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class ReceiveFragment : BaseFragment() {
+ override val screen = Report.Screen.RECEIVE
private val viewModel: ReceiveViewModel by viewModel()
@@ -40,9 +43,9 @@ class ReceiveFragment : BaseFragment() {
// text_address_part_8
// )
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) {
diff --git a/app/src/main/java/cash/z/ecc/android/ui/scan/QrAnalyzer.kt b/app/src/main/java/cash/z/ecc/android/ui/scan/QrAnalyzer.kt
index bcad5b4..a7d9c70 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/scan/QrAnalyzer.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/scan/QrAnalyzer.kt
@@ -2,6 +2,8 @@ package cash.z.ecc.android.ui.scan
import androidx.camera.core.ImageAnalysis
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 com.google.android.gms.tasks.Task
import com.google.firebase.ml.vision.FirebaseVision
@@ -27,22 +29,25 @@ class QrAnalyzer(val scanCallback: (qrContent: String, image: ImageProxy) -> Uni
if (rotation < 0) {
rotation += 360
}
- val mediaImage = FirebaseVisionImage.fromMediaImage(
- image.image!!, when (rotation) {
- 0 -> FirebaseVisionImageMetadata.ROTATION_0
- 90 -> FirebaseVisionImageMetadata.ROTATION_90
- 180 -> FirebaseVisionImageMetadata.ROTATION_180
- 270 -> FirebaseVisionImageMetadata.ROTATION_270
- else -> {
- FirebaseVisionImageMetadata.ROTATION_0
+
+ retrySimple {
+ val mediaImage = FirebaseVisionImage.fromMediaImage(
+ image.image!!, when (rotation) {
+ 0 -> FirebaseVisionImageMetadata.ROTATION_0
+ 90 -> FirebaseVisionImageMetadata.ROTATION_90
+ 180 -> FirebaseVisionImageMetadata.ROTATION_180
+ 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)
}
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt
index 8ec701a..8317e1b 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt
@@ -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.ext.onClickNavBack
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.send.SendViewModel
import com.google.common.util.concurrent.ListenableFuture
@@ -24,7 +26,7 @@ import kotlinx.coroutines.launch
import java.util.concurrent.Executors
class ScanFragment : BaseFragment() {
-
+ override val screen = Report.Screen.SCAN
private val viewModel: ScanViewModel by viewModel()
private val sendViewModel: SendViewModel by activityViewModel()
@@ -37,8 +39,8 @@ class ScanFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive)
- binding.backButtonHitArea.onClickNavBack()
+ binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive) { tapped(SCAN_RECEIVE) }
+ binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) }
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
@@ -56,7 +58,7 @@ class ScanFragment : BaseFragment() {
private fun bindPreview(cameraProvider: ProcessCameraProvider) {
Preview.Builder().setTargetName("Preview").build().let { preview ->
- preview.previewSurfaceProvider = binding.preview.previewSurfaceProvider
+ preview.setSurfaceProvider(binding.preview.previewSurfaceProvider)
val cameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt
index 8a41209..484506a 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendAddressFragment.kt
@@ -4,7 +4,6 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.res.ColorStateList
import android.os.Bundle
-import android.util.Log
import android.view.LayoutInflater
import android.view.View
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.di.viewmodel.activityViewModel
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.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
@@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
class SendAddressFragment : BaseFragment(),
ClipboardManager.OnPrimaryClipChangedListener {
+ override val screen = Report.Screen.SEND_ADDRESS
private var maxZatoshi: Long? = null
@@ -32,18 +35,18 @@ class SendAddressFragment : BaseFragment(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
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 {
- onSubmit()
+ onSubmit().also { tapped(SEND_ADDRESS_NEXT) }
}
binding.textBannerAction.setOnClickListener {
- onPaste()
+ onPaste().also { tapped(SEND_ADDRESS_PASTE) }
}
binding.textBannerMessage.setOnClickListener {
- onPaste()
+ onPaste().also { tapped(SEND_ADDRESS_PASTE) }
}
binding.textMax.setOnClickListener {
- onMax()
+ onMax().also { tapped(SEND_ADDRESS_MAX) }
}
// Apply View Model
@@ -60,8 +63,8 @@ class SendAddressFragment : BaseFragment(),
binding.inputZcashAddress.setText(null)
}
- binding.inputZcashAddress.onEditorActionDone(::onSubmit)
- binding.inputZcashAmount.onEditorActionDone(::onSubmit)
+ binding.inputZcashAddress.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_ADDRESS) }
+ binding.inputZcashAmount.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_AMOUNT) }
binding.inputZcashAddress.apply {
doAfterTextChanged {
@@ -75,7 +78,7 @@ class SendAddressFragment : BaseFragment(),
}
binding.textLayoutAddress.setEndIconOnClickListener {
- mainActivity?.maybeOpenScan()
+ mainActivity?.maybeOpenScan().also { tapped(SEND_ADDRESS_SCAN) }
}
}
@@ -99,6 +102,7 @@ class SendAddressFragment : BaseFragment(),
binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it }
sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) {
if (it == null) {
+ sendViewModel.funnel(Send.AddressPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo)
} else {
resumedScope.launch {
diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt
index dc3117d..eca5e2d 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendConfirmFragment.kt
@@ -8,14 +8,17 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendConfirmBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
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.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.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import kotlinx.coroutines.launch
class SendConfirmFragment : BaseFragment() {
+ override val screen = Report.Screen.SEND_CONFIRM
val sendViewModel: SendViewModel by activityViewModel()
@@ -25,11 +28,11 @@ class SendConfirmFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener {
- onSend()
+ onSend().also { tapped(SEND_CONFIRM_NEXT) }
}
R.id.action_nav_send_confirm_to_nav_send_memo.let {
- binding.backButtonHitArea.onClickNavTo(it)
- onBackPressNavTo(it)
+ binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_CONFIRM_BACK) }
+ onBackPressNavTo(it) { tapped(SEND_CONFIRM_BACK) }
}
mainActivity?.lifecycleScope?.launch {
binding.textConfirmation.text =
@@ -42,6 +45,7 @@ class SendConfirmFragment : BaseFragment() {
}
private fun onSend() {
+ sendViewModel.funnel(Send.ConfirmPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt
index 7294ed9..f275f2d 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendFinalFragment.kt
@@ -9,13 +9,12 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendFinalBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
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.MetricType.*
+import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment
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.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.twig
import com.crashlytics.android.Crashlytics
import kotlinx.coroutines.delay
@@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.onEach
import kotlin.random.Random
class SendFinalFragment : BaseFragment() {
+ override val screen = Report.Screen.SEND_FINAL
val sendViewModel: SendViewModel by activityViewModel()
@@ -34,13 +34,13 @@ class SendFinalFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener {
- onExit()
+ onExit().also { tapped(SEND_FINAL_EXIT) }
}
binding.buttonRetry.setOnClickListener {
- onRetry()
+ onRetry().also { tapped(SEND_FINAL_RETRY) }
}
binding.backButtonHitArea.setOnClickListener {
- onExit()
+ onExit().also { tapped(SEND_FINAL_CLOSE) }
}
binding.textConfirmation.text =
"Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}"
@@ -81,24 +81,19 @@ class SendFinalFragment : BaseFragment() {
val id = pendingTransaction?.id ?: -1
var isSending = true
var isFailure = false
+ var step: Report.Funnel.Send? = null
val message = when {
- pendingTransaction == null -> "Transaction not found"
- pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false }
- pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ."
- pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true }
- pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true }
- pendingTransaction.isCreated() -> "Transaction creation complete!"
- pendingTransaction.isCreating() -> "Creating transaction . . ."
+ pendingTransaction == null -> "Transaction not found".also { step = Report.Funnel.Send.ErrorNotFound }
+ pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false; step = Report.Funnel.Send.Mined(pendingTransaction.minedHeight) }
+ 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; step = Report.Funnel.Send.ErrorEncoding(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
+ 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!".also { step = Report.Funnel.Send.Created(id) }
+ pendingTransaction.isCreating() -> "Creating transaction . . .".also { step = Report.Funnel.Send.Creating }
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
}
- // TODO: make this error tracking easier to use and more spiffy
- 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))
- }
+ sendViewModel.funnel(step)
twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message")
binding.textStatus.apply {
diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt
index f8d602c..dbf48b3 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendMemoFragment.kt
@@ -3,15 +3,21 @@ package cash.z.ecc.android.ui.send
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
-import android.view.inputmethod.EditorInfo
import androidx.core.widget.doAfterTextChanged
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendMemoBinding
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
class SendMemoFragment : BaseFragment() {
+ override val screen = Report.Screen.SEND_MEMO
val sendViewModel: SendViewModel by activityViewModel()
@@ -21,18 +27,18 @@ class SendMemoFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener {
- onTopButton()
+ onTopButton().also { tapped(SEND_MEMO_NEXT) }
}
binding.buttonSkip.setOnClickListener {
- onBottomButton()
+ onBottomButton().also { tapped(SEND_MEMO_SKIP) }
}
binding.clearMemo.setOnClickListener {
- onClearMemo()
+ onClearMemo().also { tapped(SEND_MEMO_CLEAR) }
}
R.id.action_nav_send_memo_to_nav_send_address.let {
- binding.backButtonHitArea.onClickNavTo(it)
- onBackPressNavTo(it)
+ binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) }
+ onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) }
}
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _->
@@ -41,7 +47,7 @@ class SendMemoFragment : BaseFragment() {
binding.inputMemo.let { memo ->
memo.onEditorActionDone {
- onTopButton()
+ onTopButton().also { tapped(SEND_MEMO_NEXT) }
}
memo.doAfterTextChanged {
binding.clearMemo.goneIf(memo.text.isEmpty())
@@ -79,11 +85,14 @@ class SendMemoFragment : BaseFragment() {
}
private fun onIncludeMemo(checked: Boolean) {
+
binding.textIncludedAddress.goneIf(!checked)
sendViewModel.includeFromAddress = checked
binding.textInfoShielded.text = if (checked) {
+ tapped(SEND_MEMO_INCLUDE)
getString(R.string.send_memo_included_message)
} else {
+ tapped(SEND_MEMO_EXCLUDE)
getString(R.string.send_memo_excluded_message)
}
}
@@ -105,6 +114,7 @@ class SendMemoFragment : BaseFragment() {
}
private fun onNext() {
+ sendViewModel.funnel(Send.MemoPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt
index 88bb146..986a426 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/send/SendViewModel.kt
@@ -1,19 +1,20 @@
package cash.z.ecc.android.ui.send
-import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.Feedback.Keyed
import cash.z.ecc.android.feedback.Feedback.TimeMetric
import cash.z.ecc.android.feedback.Report
+import cash.z.ecc.android.feedback.Report.Funnel.Send.SendSelected
+import cash.z.ecc.android.feedback.Report.Funnel.Send.SpendingKeyFound
+import cash.z.ecc.android.feedback.Report.Issue
import cash.z.ecc.android.feedback.Report.MetricType
import cash.z.ecc.android.feedback.Report.MetricType.*
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.Synchronizer
-import cash.z.wallet.sdk.annotation.OpenForTesting
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
@@ -58,20 +59,38 @@ class SendViewModel @Inject constructor() : ViewModel() {
val isShielded get() = toAddress.startsWith("z")
fun send(): Flow {
+ funnel(SendSelected)
val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo
val keys = initializer.deriveSpendingKeys(
lockBox.getBytes(WalletSetupViewModel.LockBoxKey.SEED)!!
)
+ funnel(SpendingKeyFound)
+ reportIssues(memoToSend)
return synchronizer.sendToAddress(
keys[0],
zatoshiAmount,
toAddress,
- memoToSend
+ memoToSend.chunked(ZcashSdk.MAX_MEMO_SIZE).firstOrNull() ?: ""
).onEach {
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 =
synchronizer.validateAddress(address)
@@ -81,7 +100,7 @@ class SendViewModel @Inject constructor() : ViewModel() {
synchronizer.validateAddress(toAddress).isNotValid -> {
emit("Please enter a valid address")
}
- zatoshiAmount <= 1 -> {
+ zatoshiAmount < 1 -> {
emit("Too little! Please enter at least 1 Zatoshi.")
}
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 infix fun TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this }
private infix fun Pair.by(txId: Long): String? {
@@ -167,6 +191,7 @@ class SendViewModel @Inject constructor() : ViewModel() {
private fun Keyed.toMetricIdFor(id: Long): String = "$id.$key"
private fun String.toRelatedMetricId(): String = "$this.related"
private fun String.toTxId(): Long = split('.').first().toLong()
+
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt
index 308fe67..9d5952b 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/setup/BackupFragment.kt
@@ -15,7 +15,10 @@ import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentBackupBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
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.Tap.BACKUP_DONE
+import cash.z.ecc.android.feedback.Report.Tap.BACKUP_VERIFY
import cash.z.ecc.android.feedback.measure
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.base.BaseFragment
@@ -30,6 +33,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BackupFragment : BaseFragment() {
+ override val screen = Report.Screen.BACKUP
+
val walletSetup: WalletSetupViewModel by activityViewModel(false)
private var hasBackUp: Boolean = true //TODO: implement backup and then check for it here-ish
@@ -52,9 +57,9 @@ class BackupFragment : BaseFragment() {
)
}
binding.buttonPositive.setOnClickListener {
- onEnterWallet()
+ onEnterWallet().also { if (hasBackUp) tapped(BACKUP_DONE) else tapped(BACKUP_VERIFY) }
}
- if (hasBackUp == true) {
+ if (hasBackUp) {
binding.buttonPositive.text = "Done"
}
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/LandingFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/LandingFragment.kt
index 6c49e2b..c3d743f 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/setup/LandingFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/setup/LandingFragment.kt
@@ -12,6 +12,9 @@ import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentLandingBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
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.setup.WalletSetupViewModel.WalletSetupState.SEED_WITHOUT_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
class LandingFragment : BaseFragment() {
+ override val screen = Report.Screen.LANDING
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
@@ -34,21 +38,24 @@ class LandingFragment : BaseFragment() {
super.onViewCreated(view, savedInstanceState)
binding.buttonPositive.setOnClickListener {
when (binding.buttonPositive.text.toString().toLowerCase()) {
- "new" -> onNewWallet()
- "backup" -> onBackupWallet()
+ "new" -> onNewWallet().also { tapped(LANDING_NEW) }
+ "backup" -> onBackupWallet().also { tapped(LANDING_BACKUP) }
}
}
binding.buttonNegative.setOnLongClickListener {
+ tapped(DEVELOPER_WALLET_PROMPT)
if (binding.buttonNegative.text.toString().toLowerCase() == "restore") {
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.")
.setTitle("Import Dev Wallet?")
.setCancelable(true)
.setPositiveButton("Import") { dialog, _ ->
+ tapped(DEVELOPER_WALLET_IMPORT)
dialog.dismiss()
onUseDevWallet()
}
.setNegativeButton("Cancel") { dialog, _ ->
+ tapped(DEVELOPER_WALLET_CANCEL)
dialog.dismiss()
}
.show()
@@ -58,7 +65,10 @@ class LandingFragment : BaseFragment() {
}
binding.buttonNegative.setOnClickListener {
when (binding.buttonNegative.text.toString().toLowerCase()) {
- "restore" -> onRestoreWallet()
+ "restore" -> onRestoreWallet().also {
+ mainActivity?.reportFunnel(Restore.Initiated)
+ tapped(LANDING_RESTORE)
+ }
else -> onSkip(++skipCount)
}
}
@@ -83,16 +93,19 @@ class LandingFragment : BaseFragment() {
private fun onSkip(count: Int) {
when (count) {
1 -> {
+ tapped(LANDING_BACKUP_SKIPPED_1)
binding.textMessage.text =
"Are you sure? Without a backup, funds can be lost FOREVER!"
binding.buttonNegative.text = "Later"
}
2 -> {
+ tapped(LANDING_BACKUP_SKIPPED_2)
binding.textMessage.text =
"You can't backup later. You're probably going to lose your funds!"
binding.buttonNegative.text = "I've been warned"
}
else -> {
+ tapped(LANDING_BACKUP_SKIPPED_3)
onEnterWallet()
}
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt
index a002198..9011f97 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/setup/RestoreFragment.kt
@@ -17,6 +17,9 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentRestoreBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
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.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig
@@ -28,6 +31,7 @@ import kotlinx.coroutines.launch
class RestoreFragment : BaseFragment(), View.OnKeyListener {
+ override val screen = Report.Screen.RESTORE
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
@@ -53,21 +57,18 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen
}
binding.buttonDone.setOnClickListener {
- onDone()
+ onDone().also { tapped(RESTORE_DONE) }
}
binding.buttonSuccess.setOnClickListener {
- onEnterWallet()
- }
-
- binding.textSubtitle.setOnClickListener {
- seedWordAdapter!!.editText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ onEnterWallet().also { tapped(RESTORE_SUCCESS) }
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
mainActivity?.onFragmentBackPressed(this) {
+ tapped(RESTORE_BACK)
if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) {
onExit()
} else {
@@ -75,6 +76,7 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen
.setMessage("Are you sure? For security, the words that you have entered will be cleared!")
.setTitle("Abort?")
.setPositiveButton("Stay") { dialog, _ ->
+ mainActivity?.reportFunnel(Restore.Stay)
dialog.dismiss()
}
.setNegativeButton("Exit") { dialog, _ ->
@@ -94,16 +96,19 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen
private fun onExit() {
+ mainActivity?.reportFunnel(Restore.Exit)
hideAutoCompleteWords()
mainActivity?.hideKeyboard()
mainActivity?.navController?.popBackStack()
}
private fun onEnterWallet() {
+ mainActivity?.reportFunnel(Restore.Success)
mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home)
}
private fun onDone() {
+ mainActivity?.reportFunnel(Restore.Done)
mainActivity?.hideKeyboard()
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
it.title
@@ -117,12 +122,14 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen
}
private fun importWallet(seedPhrase: String, birthday: Int) {
+ mainActivity?.reportFunnel(Restore.ImportStarted)
mainActivity?.hideKeyboard()
mainActivity?.apply {
lifecycleScope.launch {
mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday))
// bugfix: if the user proceeds before the synchronizer is created the app will crash!
binding.buttonSuccess.isEnabled = true
+ mainActivity?.reportFunnel(Restore.ImportCompleted)
}
playSound("sound_receive_small.mp3")
vibrateSuccess()
@@ -135,7 +142,6 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen
}
private fun onChipsModified() {
- twig("onChipsModified")
seedWordAdapter?.editText?.apply {
postDelayed({
requestFocus()
@@ -151,9 +157,21 @@ class RestoreFragment : BaseFragment(), View.OnKeyListen
private fun setDoneEnabled() {
val count = seedWordAdapter?.itemCount ?: 0
+ reportWords(count - 1) // subtract 1 for the editText
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() {
seedWordAdapter?.editText?.setText("")
}
diff --git a/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt b/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt
index 0673569..26b0057 100644
--- a/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt
+++ b/app/src/main/java/cash/z/ecc/android/ui/setup/SeedWordAdapter.kt
@@ -7,6 +7,9 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import cash.z.ecc.android.R
import cash.z.ecc.android.ext.toAppColor
+import cash.z.ecc.android.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.wallet.sdk.ext.twig
@@ -46,7 +49,6 @@ class SeedWordAdapter : ChipsAdapter {
override fun onChipDataSourceChanged() {
super.onChipDataSourceChanged()
- twig("onChipDataSourceChanged")
onDataSetChangedListener?.invoke()
}
@@ -69,15 +71,12 @@ class SeedWordAdapter : ChipsAdapter {
}
}
-
override fun onKeyboardDelimiter(text: String) {
- twig("onKeyboardDelimiter: $text ${mDataSource.filteredChips.size}")
if (mDataSource.filteredChips.size > 0) {
onKeyboardActionDone((mDataSource.filteredChips.first() as SeedWordChip).word)
}
}
-
private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) {
val seedChipView = super.chipView as SeedWordChipView
}
diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml
index 863d2fb..d49117a 100644
--- a/app/src/main/res/layout/fragment_profile.xml
+++ b/app/src/main/res/layout/fragment_profile.xml
@@ -161,7 +161,7 @@
android:layout_marginTop="16dp"
style="@style/TextAppearance.AppCompat.Body1"
android:textSize="16sp"
- android:text="See Application Log"
+ android:text="See Application Logs"
android:textColor="@color/selector_button_text_light_dimmed"
app:layout_constraintTop_toBottomOf="@id/button_backup"
app:layout_constraintEnd_toEndOf="parent"
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..c66b0a5
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
index e3f94b1..aaff23d 100644
--- a/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
+++ b/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
@@ -1,6 +1,6 @@
package cash.z.ecc.android.feedback
-import android.util.Log
+//import android.util.Log
import cash.z.ecc.android.feedback.util.CompositeJob
import kotlinx.coroutines.*
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.
*/
suspend fun start(): Feedback {
- val callStack = StringBuilder().let { s ->
- Thread.currentThread().stackTrace.forEach {element ->
- s.append("$element\n")
- }
- s.toString()
- }
if(::scope.isInitialized) {
- Log.e("@TWIG","Warning: did not initialize feedback because it has already been initialized. Call stack: $callStack")
return this
- } else {
- Log.e("@TWIG","Debug: Initializing feedback for the first time. Call stack: $callStack")
}
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
invokeOnCompletion {
@@ -143,8 +134,8 @@ class Feedback(capacity: Int = 256) {
*
* @param error the uncaught exception that occurred.
*/
- fun report(error: Throwable?, fatal: Boolean = false): Feedback {
- return report(Crash(error, fatal))
+ fun report(error: Throwable?, isFatal: Boolean = false): Feedback {
+ 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 {
- override fun toMap(): MutableMap {
- return mutableMapOf(
- "key" to key
- )
+ abstract class MappedAction private constructor(protected val propertyMap: MutableMap = mutableMapOf()) : Feedback.Action {
+ constructor(vararg properties: Pair) : this(mutableMapOf(*properties))
+
+ override fun toMap(): Map {
+ return propertyMap.apply { putAll(super.toMap()) }
}
}
+ abstract class Funnel(funnelName: String, stepName: String, step: Int, vararg properties: Pair) : 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 {
val key: T
}
@@ -231,31 +232,52 @@ class Feedback(capacity: Int = 256) {
}
}
- data class Crash(val error: Throwable? = null, val fatal: Boolean = true) : Action {
- override val key: String = "crash"
- override fun toMap(): Map {
- return mutableMapOf(
- "fatal" to fatal,
- "message" to (error?.message ?: "None"),
- "cause" to (error?.cause?.toString() ?: "None"),
- "cause.cause" to (error?.cause?.cause?.toString() ?: "None"),
- "cause.cause.cause" to (error?.cause?.cause?.cause?.toString() ?: "None")
- ).apply { putAll(super.toMap()); putAll(error.stacktraceToMap()) }
+ open class AppError(name: String = "unknown", description: String? = null, isFatal: Boolean = false, vararg properties: Pair) : MappedAction(
+ "isError" to true,
+ "isFatal" to isFatal,
+ "errorName" to name,
+ "message" to (description ?: "None"),
+ "description" to describe(name, description, isFatal),
+ *properties
+ ) {
+ val isFatal: Boolean by propertyMap
+ 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 {
- val properties = mutableMapOf("stacktrace0" to "None")
+ val properties = mutableMapOf("stacktrace.0" to "None")
if (this == null) return properties
val stringWriter = StringWriter()
printStackTrace(PrintWriter(stringWriter))
stringWriter.toString().chunked(chunkSize).forEachIndexed { index, chunk ->
- properties["stacktrace$index"] = chunk
+ properties["stacktrace.$index"] = chunk
}
return properties
}
diff --git a/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt b/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt
index dc0ebbe..b55a992 100644
--- a/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt
+++ b/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt
@@ -6,9 +6,9 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
+import org.junit.Assert.*
import org.junit.Test
+import java.lang.RuntimeException
class FeedbackTest {
@@ -43,6 +43,7 @@ class FeedbackTest {
verifyAction(feedback, simpleAction.key)
feedback.report(simpleAction)
+ Unit
}
@Test
@@ -64,6 +65,50 @@ class FeedbackTest {
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) {
+ for (entry in map) {
+ println("%-20s = %s".format(entry.key, entry.value))
+ }
+ }
+
private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking {
val feedback = Feedback()
var counter = 0