From 8331e8ff06099b390ce825d67748eede8afacb0a Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Fri, 21 Feb 2020 18:49:16 -0500 Subject: [PATCH 1/6] Pulled in error handling improvements from the SDK. --- .../cash/z/ecc/android/ui/MainActivity.kt | 98 ++++++++++++++++++- 1 file changed, 95 insertions(+), 3 deletions(-) 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) + } } From 5803a9dd71505744707ec47fc5942bfdbae6bda5 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Fri, 21 Feb 2020 18:49:45 -0500 Subject: [PATCH 2/6] Add crash reporting via Crashlytics. --- .../cash/z/ecc/android/di/module/AppModule.kt | 5 ++++ .../android/feedback/FeedbackCrashlytics.kt | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 app/src/main/java/cash/z/ecc/android/feedback/FeedbackCrashlytics.kt 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/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 From d5129e44fac1d092077e78ad1f2e03432df99bf0 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Fri, 21 Feb 2020 18:50:57 -0500 Subject: [PATCH 3/6] Download user logs and developer logs as files. --- app/src/main/AndroidManifest.xml | 11 +++ .../z/ecc/android/feedback/FeedbackFile.kt | 7 +- .../ecc/android/ui/profile/ProfileFragment.kt | 82 +++++++++++++++---- app/src/main/res/layout/fragment_profile.xml | 2 +- app/src/main/res/xml/file_paths.xml | 5 ++ 5 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml 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/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/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/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 From 2fc572e43473c03d351d9eba517f180688de6f92 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Fri, 21 Feb 2020 18:52:57 -0500 Subject: [PATCH 4/6] Extend analytics to include taps, screen views, and send flow. --- .../cash/z/ecc/android/feedback/Report.kt | 158 ++++++++++++++++-- .../z/ecc/android/ui/base/BaseFragment.kt | 12 +- .../android/ui/detail/WalletDetailFragment.kt | 6 +- .../z/ecc/android/ui/home/HomeFragment.kt | 76 ++++++--- .../ecc/android/ui/receive/ReceiveFragment.kt | 7 +- .../z/ecc/android/ui/scan/ScanFragment.kt | 10 +- .../android/ui/send/SendAddressFragment.kt | 22 ++- .../android/ui/send/SendConfirmFragment.kt | 12 +- .../ecc/android/ui/send/SendFinalFragment.kt | 35 ++-- .../z/ecc/android/ui/send/SendMemoFragment.kt | 26 ++- .../z/ecc/android/ui/send/SendViewModel.kt | 33 +++- .../z/ecc/android/ui/setup/BackupFragment.kt | 9 +- .../z/ecc/android/ui/setup/LandingFragment.kt | 19 ++- .../z/ecc/android/ui/setup/RestoreFragment.kt | 32 +++- .../z/ecc/android/ui/setup/SeedWordAdapter.kt | 7 +- .../cash/z/ecc/android/feedback/Feedback.kt | 82 +++++---- .../z/ecc/android/feedback/FeedbackTest.kt | 49 +++++- 17 files changed, 460 insertions(+), 135 deletions(-) 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/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/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/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/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/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 From 6d085914529df9316ecec7fc318f2983dbe01319 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Fri, 21 Feb 2020 18:53:29 -0500 Subject: [PATCH 5/6] General bug fixes. --- .../java/cash/z/ecc/android/ext/Extensions.kt | 3 ++ .../main/java/cash/z/ecc/android/ext/View.kt | 9 +++-- .../ui/detail/TransactionViewHolder.kt | 22 ++++++------- .../z/ecc/android/ui/home/MagicSnakeLoader.kt | 4 --- .../cash/z/ecc/android/ui/scan/QrAnalyzer.kt | 33 +++++++++++-------- 5 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/cash/z/ecc/android/ext/Extensions.kt 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/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/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/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) } } From 1367ef6efff9f66ae61e7594811fd24cc3945ea3 Mon Sep 17 00:00:00 2001 From: Kevin Gorham Date: Fri, 21 Feb 2020 19:03:00 -0500 Subject: [PATCH 6/6] Updated build, dependencies and added changelog. --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ app/build.gradle | 15 ++++++++------- 2 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.md 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