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