Extend analytics to include taps, screen views, and send flow.

This commit is contained in:
Kevin Gorham 2020-02-21 18:52:57 -05:00
parent d5129e44fa
commit 2fc572e434
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
17 changed files with 460 additions and 135 deletions

View File

@ -3,25 +3,154 @@ package cash.z.ecc.android.feedback
import cash.z.ecc.android.ZcashWalletApp import cash.z.ecc.android.ZcashWalletApp
object Report { object Report {
object Send {
class SubmitFailure(private val errorCode: Int?, private val errorMessage: String?) : Feedback.Funnel("send.failure.submit") { object Funnel {
override fun toMap(): MutableMap<String, Any> { sealed class Send(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("send", stepName, step, *properties) {
return super.toMap().apply { object AddressPageComplete : Send("addresspagecomplete", 10)
put("error.code", errorCode ?: -1) object MemoPageComplete : Send("memopagecomplete", 20)
put("error.message", errorMessage ?: "None") object ConfirmPageComplete : Send("confirmpagecomplete", 30)
}
} // Beginning of send
object SendSelected : Send("sendselected", 50)
object SpendingKeyFound : Send("keyfound", 60)
object Creating : Send("creating", 70)
class Created(id: Long) : Send("created", 80, "id" to id)
object Submitted : Send("submitted", 90)
class Mined(minedHeight: Int) : Send("mined", 100, "minedHeight" to minedHeight)
// Errors
abstract class Error(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Send("error.$stepName", step, "isError" to true, *properties)
object ErrorNotFound : Error("notfound", 51)
class ErrorEncoding(errorCode: Int? = null, errorMessage: String? = null) : Error("encode", 71,
"errorCode" to (errorCode ?: -1),
"errorMessage" to (errorMessage ?: "None")
)
class ErrorSubmitting(errorCode: Int? = null, errorMessage: String? = null) : Error("submit", 81,
"errorCode" to (errorCode ?: -1),
"errorMessage" to (errorMessage ?: "None")
)
} }
class EncodingFailure(private val errorCode: Int?, private val errorMessage: String?) : Feedback.Funnel("send.failure.submit") { sealed class Restore(stepName: String, step: Int, vararg properties: Pair<String, Any>) : Feedback.Funnel("restore", stepName, step, *properties) {
override fun toMap(): MutableMap<String, Any> { object Initiated : Restore("initiated", 0)
return super.toMap().apply { object SeedWordsStarted : Restore("wordsstarted", 10)
put("error.code", errorCode ?: -1) class SeedWordCount(wordCount: Int) : Restore("wordsmodified", 15, "seedWordCount" to wordCount)
put("error.message", errorMessage ?: "None") object SeedWordsCompleted : Restore("wordscompleted", 20)
} object Stay : Restore("stay", 21)
object Exit : Restore("stay", 22)
object Done : Restore("doneselected", 30)
object ImportStarted : Restore("importstarted", 40)
object ImportCompleted : Restore("importcompleted", 50)
object Success : Restore("success", 100)
}
}
object Error {
object NonFatal {
class Reorg(errorBlockHeight: Int, rewindBlockHeight: Int) : Feedback.AppError(
"reorg",
"Chain error detected at height $errorBlockHeight, rewinding to $rewindBlockHeight",
false,
"errorHeight" to errorBlockHeight,
"rewindHeight" to rewindBlockHeight
) {
val errorHeight: Int by propertyMap
val rewindHeight: Int by propertyMap
} }
} }
}
// placeholder for things that we want to monitor
sealed class Issue(name: String, vararg properties: Pair<String, Any>) : Feedback.MappedAction(
"issueName" to name,
"isIssue" to true,
*properties
) {
override val key = "issue.$name"
override fun toString() = "occurrence of ${key.replace('.', ' ')}"
// Issues with sending worth monitoring
object SelfSend : Issue("self.send")
object TinyAmount : Issue("tiny.amount")
object MicroAmount : Issue("micro.amount")
object MinimumAmount : Issue("minimum.amount")
class TruncatedMemo(memoSize: Int) : Issue("truncated.memo", "memoSize" to memoSize)
class LargeMemo(memoSize: Int) : Issue("large.memo", "memoSize" to memoSize)
}
enum class Screen(val id: String? = null) : Feedback.Action {
BACKUP,
HOME,
DETAIL("wallet.detail"),
LANDING,
PROFILE,
RECEIVE,
RESTORE,
SCAN,
SEND_ADDRESS("send.address"),
SEND_CONFIRM("send.confirm"),
SEND_FINAL("send.final"),
SEND_MEMO("send.memo");
override val key = "screen.${id ?: name.toLowerCase()}"
override fun toString() = "viewed the ${key.substring(7).replace('.', ' ')} screen"
}
enum class Tap(val id: String) : Feedback.Action {
BACKUP_DONE("backup.done"),
BACKUP_VERIFY("backup.verify"),
DEVELOPER_WALLET_PROMPT("landing.devwallet.prompt"),
DEVELOPER_WALLET_IMPORT("landing.devwallet.import"),
DEVELOPER_WALLET_CANCEL("landing.devwallet.cancel"),
LANDING_RESTORE("landing.restore"),
LANDING_NEW("landing.new"),
LANDING_BACKUP("landing.backup"),
LANDING_BACKUP_SKIPPED_1("landing.backup.skip.1"),
LANDING_BACKUP_SKIPPED_2("landing.backup.skip.2"),
LANDING_BACKUP_SKIPPED_3("landing.backup.skip.3"),
HOME_PROFILE("home.profile"),
HOME_DETAIL("home.detail"),
HOME_SCAN("home.scan"),
HOME_SEND("home.send"),
HOME_FUND_NOW("home.fund.now"),
HOME_CLEAR_AMOUNT("home.clear.amount"),
DETAIL_BACK("detail.back"),
PROFILE_CLOSE("profile.close"),
PROFILE_BACKUP("profile.backup"),
PROFILE_VIEW_USER_LOGS("profile.view.user.logs"),
PROFILE_VIEW_DEV_LOGS("profile.view.dev.logs"),
PROFILE_SEND_FEEDBACK("profile.send.feedback"),
RECEIVE_SCAN("receive.scan"),
RECEIVE_BACK("receive.back"),
RESTORE_DONE("restore.done"),
RESTORE_SUCCESS("restore.success"),
RESTORE_BACK("restore.back"),
SCAN_RECEIVE("scan.receive"),
SCAN_BACK("scan.back"),
SEND_ADDRESS_MAX("send.address.max"),
SEND_ADDRESS_NEXT("send.address.next"),
SEND_ADDRESS_PASTE("send.address.paste"),
SEND_ADDRESS_BACK("send.address.back"),
SEND_ADDRESS_DONE_ADDRESS("send.address.done.address"),
SEND_ADDRESS_DONE_AMOUNT("send.address.done.amount"),
SEND_ADDRESS_SCAN("send.address.scan"),
SEND_CONFIRM_BACK("send.confirm.back"),
SEND_CONFIRM_NEXT("send.confirm.next"),
SEND_FINAL_EXIT("send.final.exit"),
SEND_FINAL_RETRY("send.final.retry"),
SEND_FINAL_CLOSE("send.final.close"),
SEND_MEMO_INCLUDE("send.memo.include"),
SEND_MEMO_EXCLUDE("send.memo.exclude"),
SEND_MEMO_NEXT("send.memo.next"),
SEND_MEMO_SKIP("send.memo.skip"),
SEND_MEMO_CLEAR("send.memo.clear"),
SEND_MEMO_BACK("send.memo.back"),
// General events
COPY_ADDRESS("copy.address");
override val key = "tap.$id"
override fun toString() = "${key.replace('.', ' ')} button".replace("tap ", "tapped the ")
} }
enum class NonUserAction(override val key: String, val description: String) : Feedback.Action { enum class NonUserAction(override val key: String, val description: String) : Feedback.Action {
@ -68,5 +197,6 @@ class LaunchMetric private constructor(private val metric: Feedback.TimeMetric)
override fun toString(): String = metric.toString() override fun toString(): String = metric.toString()
} }
inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T = inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T =
this.measure(type.key, type.description, block) this.measure(type.key, type.description, block)

View File

@ -8,6 +8,7 @@ import androidx.annotation.NonNull
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.ui.MainActivity import cash.z.ecc.android.ui.MainActivity
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -18,6 +19,8 @@ abstract class BaseFragment<T : ViewBinding> : Fragment() {
lateinit var resumedScope: CoroutineScope lateinit var resumedScope: CoroutineScope
open val screen: Report.Screen? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -29,6 +32,7 @@ abstract class BaseFragment<T : ViewBinding> : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
mainActivity?.reportScreen(screen)
resumedScope = lifecycleScope.coroutineContext.let { resumedScope = lifecycleScope.coroutineContext.let {
CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job])) CoroutineScope(Dispatchers.Main + SupervisorJob(it[Job]))
} }
@ -43,9 +47,15 @@ abstract class BaseFragment<T : ViewBinding> : Fragment() {
// each fragment must call FragmentMyLayoutBinding.inflate(inflater) // each fragment must call FragmentMyLayoutBinding.inflate(inflater)
abstract fun inflate(@NonNull inflater: LayoutInflater): T abstract fun inflate(@NonNull inflater: LayoutInflater): T
fun onBackPressNavTo(navResId: Int) { fun onBackPressNavTo(navResId: Int, block: (() -> Unit) = {}) {
mainActivity?.onFragmentBackPressed(this) { mainActivity?.onFragmentBackPressed(this) {
block()
mainActivity?.safeNavigate(navResId) mainActivity?.safeNavigate(navResId)
} }
} }
fun tapped(tap: Report.Tap) {
mainActivity?.reportTap(tap)
}
} }

View File

@ -12,6 +12,8 @@ import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.onClickNavUp import cash.z.ecc.android.ext.onClickNavUp
import cash.z.ecc.android.ext.toColoredSpan import cash.z.ecc.android.ext.toColoredSpan
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Tap.DETAIL_BACK
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.entity.ConfirmedTransaction import cash.z.wallet.sdk.entity.ConfirmedTransaction
@ -23,7 +25,7 @@ import kotlinx.coroutines.launch
class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() { class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
override val screen = Report.Screen.DETAIL
private val viewModel: WalletDetailViewModel by viewModel() private val viewModel: WalletDetailViewModel by viewModel()
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction> private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
@ -33,7 +35,7 @@ class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.backButtonHitArea.onClickNavUp() binding.backButtonHitArea.onClickNavUp { tapped(DETAIL_BACK) }
lifecycleScope.launch { lifecycleScope.launch {
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress() binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress()
} }

View File

@ -10,10 +10,9 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentHomeBinding import cash.z.ecc.android.databinding.FragmentHomeBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.disabledIf import cash.z.ecc.android.ext.*
import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.ext.onClickNavTo import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ext.toColoredSpan
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.* import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
import cash.z.ecc.android.ui.send.SendViewModel import cash.z.ecc.android.ui.send.SendViewModel
@ -21,7 +20,10 @@ import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
import cash.z.wallet.sdk.Synchronizer import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.Synchronizer.Status.SYNCED import cash.z.wallet.sdk.Synchronizer.Status.SYNCED
import cash.z.wallet.sdk.ext.* import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.convertZecToZatoshi
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
import cash.z.wallet.sdk.ext.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -29,6 +31,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class HomeFragment : BaseFragment<FragmentHomeBinding>() { class HomeFragment : BaseFragment<FragmentHomeBinding>() {
override val screen = Report.Screen.HOME
private lateinit var numberPad: List<TextView> private lateinit var numberPad: List<TextView>
private lateinit var uiModel: HomeViewModel.UiModel private lateinit var uiModel: HomeViewModel.UiModel
@ -88,18 +91,18 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
buttonNumberPadDecimal.asKey(), buttonNumberPadDecimal.asKey(),
buttonNumberPadBack.asKey() buttonNumberPadBack.asKey()
) )
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile) { tapped(HOME_PROFILE) }
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail) { tapped(HOME_DETAIL) }
hitAreaScan.setOnClickListener { hitAreaScan.setOnClickListener {
mainActivity?.maybeOpenScan() mainActivity?.maybeOpenScan().also { tapped(HOME_SCAN) }
} }
textBannerAction.setOnClickListener { textBannerAction.setOnClickListener {
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString())) onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
} }
buttonSendAmount.setOnClickListener { buttonSendAmount.setOnClickListener {
onSend() onSend().also { tapped(HOME_SEND) }
} }
setSendAmount("0", false) setSendAmount("0", false)
@ -107,7 +110,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
} }
binding.buttonNumberPadBack.setOnLongClickListener { binding.buttonNumberPadBack.setOnLongClickListener {
onClearAmount() onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
true true
} }
@ -175,14 +178,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// Public UI API // Public UI API
// //
fun setSendEnabled(enabled: Boolean) { var isSendEnabled = false
fun setSendEnabled(enabled: Boolean, isSynced: Boolean) {
isSendEnabled = enabled
binding.buttonSendAmount.apply { binding.buttonSendAmount.apply {
isEnabled = enabled if (enabled || !isSynced) {
if (enabled) { isEnabled = true
// setTextColor(resources.getColorStateList(R.color.selector_button_text_dark)) isClickable = isSynced
binding.lottieButtonLoading.alpha = 1.0f binding.lottieButtonLoading.alpha = 1.0f
} else { } else {
// setTextColor(R.color.zcashGray.toAppColor()) isEnabled = false
isClickable = false
binding.lottieButtonLoading.alpha = 0.32f binding.lottieButtonLoading.alpha = 0.32f
} }
} }
@ -268,7 +274,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// //
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) { private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
twig("onModelUpdated: $new") logUpdate(old, new)
if (binding.lottieButtonLoading.visibility != View.VISIBLE) binding.lottieButtonLoading.visibility = View.VISIBLE if (binding.lottieButtonLoading.visibility != View.VISIBLE) binding.lottieButtonLoading.visibility = View.VISIBLE
uiModel = new uiModel = new
if (old?.pendingSend != new.pendingSend) { if (old?.pendingSend != new.pendingSend) {
@ -278,7 +284,38 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
setProgress(uiModel) // TODO: we may not need to separate anymore setProgress(uiModel) // TODO: we may not need to separate anymore
// if (new.status = SYNCING) onSyncing(new) else onSynced(new) // if (new.status = SYNCING) onSyncing(new) else onSynced(new)
if (new.status == SYNCED) onSynced(new) else onSyncing(new) if (new.status == SYNCED) onSynced(new) else onSyncing(new)
setSendEnabled(new.isSendEnabled) setSendEnabled(new.isSendEnabled, new.status == SYNCED)
}
private fun logUpdate(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
var message = ""
fun maybeComma() = if (message.length > "UiModel(".length) ", " else ""
message = when {
old == null -> "$new"
new == null -> "null"
else -> {
buildString {
append("UiModel(")
if (old.status != new.status) append ("status=${new.status}")
if (old.processorInfo != new.processorInfo) {
append ("${maybeComma()}processorInfo=ProcessorInfo(")
val startLength = length
fun innerComma() = if (length > startLength) ", " else ""
if (old.processorInfo.networkBlockHeight != new.processorInfo.networkBlockHeight) append("networkBlockHeight=${new.processorInfo.networkBlockHeight}")
if (old.processorInfo.lastScannedHeight != new.processorInfo.lastScannedHeight) append("${innerComma()}lastScannedHeight=${new.processorInfo.lastScannedHeight}")
if (old.processorInfo.lastDownloadedHeight != new.processorInfo.lastDownloadedHeight) append("${innerComma()}lastDownloadedHeight=${new.processorInfo.lastDownloadedHeight}")
if (old.processorInfo.lastDownloadRange != new.processorInfo.lastDownloadRange) append("${innerComma()}lastDownloadRange=${new.processorInfo.lastDownloadRange}")
if (old.processorInfo.lastScanRange != new.processorInfo.lastScanRange) append("${innerComma()}lastScanRange=${new.processorInfo.lastScanRange}")
append(")")
}
if (old.availableBalance != new.availableBalance) append ("${maybeComma()}availableBalance=${new.availableBalance}")
if (old.totalBalance != new.totalBalance) append ("${maybeComma()}totalBalance=${new.totalBalance}")
if (old.pendingSend != new.pendingSend) append ("${maybeComma()}pendingSend=${new.pendingSend}")
append(")")
}
}
}
twig("onModelUpdated: $message")
} }
private fun onSyncing(uiModel: HomeViewModel.UiModel) { private fun onSyncing(uiModel: HomeViewModel.UiModel) {
@ -296,7 +333,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
} }
private fun onSend() { private fun onSend() {
mainActivity?.safeNavigate(R.id.action_nav_home_to_send) if (isSendEnabled) mainActivity?.safeNavigate(R.id.action_nav_home_to_send)
} }
private fun onBannerAction(action: BannerAction) { private fun onBannerAction(action: BannerAction) {
@ -307,6 +344,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
.setTitle("No Balance") .setTitle("No Balance")
.setCancelable(true) .setCancelable(true)
.setPositiveButton("View Address") { dialog, _ -> .setPositiveButton("View Address") { dialog, _ ->
tapped(HOME_FUND_NOW)
dialog.dismiss() dialog.dismiss()
mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive) mainActivity?.safeNavigate(R.id.action_nav_home_to_nav_receive)
} }

View File

@ -10,6 +10,8 @@ import cash.z.ecc.android.databinding.FragmentReceiveNewBinding
import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.onClickNavBack import cash.z.ecc.android.ext.onClickNavBack
import cash.z.ecc.android.ext.onClickNavTo import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.ext.toAbbreviatedAddress import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.twig import cash.z.wallet.sdk.ext.twig
@ -17,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() { class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
override val screen = Report.Screen.RECEIVE
private val viewModel: ReceiveViewModel by viewModel() private val viewModel: ReceiveViewModel by viewModel()
@ -40,9 +43,9 @@ class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
// text_address_part_8 // text_address_part_8
// ) // )
binding.buttonScan.setOnClickListener { binding.buttonScan.setOnClickListener {
mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan) mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan).also { tapped(RECEIVE_SCAN) }
} }
binding.backButtonHitArea.onClickNavBack() binding.backButtonHitArea.onClickNavBack() { tapped(RECEIVE_BACK) }
} }
override fun onAttach(context: Context) { override fun onAttach(context: Context) {

View File

@ -17,6 +17,8 @@ import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.onClickNavBack import cash.z.ecc.android.ext.onClickNavBack
import cash.z.ecc.android.ext.onClickNavTo import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.send.SendViewModel import cash.z.ecc.android.ui.send.SendViewModel
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
@ -24,7 +26,7 @@ import kotlinx.coroutines.launch
import java.util.concurrent.Executors import java.util.concurrent.Executors
class ScanFragment : BaseFragment<FragmentScanBinding>() { class ScanFragment : BaseFragment<FragmentScanBinding>() {
override val screen = Report.Screen.SCAN
private val viewModel: ScanViewModel by viewModel() private val viewModel: ScanViewModel by viewModel()
private val sendViewModel: SendViewModel by activityViewModel() private val sendViewModel: SendViewModel by activityViewModel()
@ -37,8 +39,8 @@ class ScanFragment : BaseFragment<FragmentScanBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive) binding.buttonReceive.onClickNavTo(R.id.action_nav_scan_to_nav_receive) { tapped(SCAN_RECEIVE) }
binding.backButtonHitArea.onClickNavBack() binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) }
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
@ -56,7 +58,7 @@ class ScanFragment : BaseFragment<FragmentScanBinding>() {
private fun bindPreview(cameraProvider: ProcessCameraProvider) { private fun bindPreview(cameraProvider: ProcessCameraProvider) {
Preview.Builder().setTargetName("Preview").build().let { preview -> Preview.Builder().setTargetName("Preview").build().let { preview ->
preview.previewSurfaceProvider = binding.preview.previewSurfaceProvider preview.setSurfaceProvider(binding.preview.previewSurfaceProvider)
val cameraSelector = CameraSelector.Builder() val cameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK) .requireLensFacing(CameraSelector.LENS_FACING_BACK)

View File

@ -4,7 +4,6 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.EditText import android.widget.EditText
@ -13,6 +12,9 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendAddressBinding import cash.z.ecc.android.databinding.FragmentSendAddressBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.* import cash.z.ecc.android.ext.*
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Send
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.Synchronizer import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(), class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
ClipboardManager.OnPrimaryClipChangedListener { ClipboardManager.OnPrimaryClipChangedListener {
override val screen = Report.Screen.SEND_ADDRESS
private var maxZatoshi: Long? = null private var maxZatoshi: Long? = null
@ -32,18 +35,18 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home) binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home) { tapped(SEND_ADDRESS_BACK) }
binding.buttonNext.setOnClickListener { binding.buttonNext.setOnClickListener {
onSubmit() onSubmit().also { tapped(SEND_ADDRESS_NEXT) }
} }
binding.textBannerAction.setOnClickListener { binding.textBannerAction.setOnClickListener {
onPaste() onPaste().also { tapped(SEND_ADDRESS_PASTE) }
} }
binding.textBannerMessage.setOnClickListener { binding.textBannerMessage.setOnClickListener {
onPaste() onPaste().also { tapped(SEND_ADDRESS_PASTE) }
} }
binding.textMax.setOnClickListener { binding.textMax.setOnClickListener {
onMax() onMax().also { tapped(SEND_ADDRESS_MAX) }
} }
// Apply View Model // Apply View Model
@ -60,8 +63,8 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
binding.inputZcashAddress.setText(null) binding.inputZcashAddress.setText(null)
} }
binding.inputZcashAddress.onEditorActionDone(::onSubmit) binding.inputZcashAddress.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_ADDRESS) }
binding.inputZcashAmount.onEditorActionDone(::onSubmit) binding.inputZcashAmount.onEditorActionDone(::onSubmit).also { tapped(SEND_ADDRESS_DONE_AMOUNT) }
binding.inputZcashAddress.apply { binding.inputZcashAddress.apply {
doAfterTextChanged { doAfterTextChanged {
@ -75,7 +78,7 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
} }
binding.textLayoutAddress.setEndIconOnClickListener { binding.textLayoutAddress.setEndIconOnClickListener {
mainActivity?.maybeOpenScan() mainActivity?.maybeOpenScan().also { tapped(SEND_ADDRESS_SCAN) }
} }
} }
@ -99,6 +102,7 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it } binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it }
sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) { sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) {
if (it == null) { if (it == null) {
sendViewModel.funnel(Send.AddressPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo) mainActivity?.safeNavigate(R.id.action_nav_send_address_to_send_memo)
} else { } else {
resumedScope.launch { resumedScope.launch {

View File

@ -8,14 +8,17 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendConfirmBinding import cash.z.ecc.android.databinding.FragmentSendConfirmBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.onClickNavBack
import cash.z.ecc.android.ext.onClickNavTo import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Send
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.ext.toAbbreviatedAddress import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.convertZatoshiToZecString import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() { class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
override val screen = Report.Screen.SEND_CONFIRM
val sendViewModel: SendViewModel by activityViewModel() val sendViewModel: SendViewModel by activityViewModel()
@ -25,11 +28,11 @@ class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener { binding.buttonNext.setOnClickListener {
onSend() onSend().also { tapped(SEND_CONFIRM_NEXT) }
} }
R.id.action_nav_send_confirm_to_nav_send_memo.let { R.id.action_nav_send_confirm_to_nav_send_memo.let {
binding.backButtonHitArea.onClickNavTo(it) binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_CONFIRM_BACK) }
onBackPressNavTo(it) onBackPressNavTo(it) { tapped(SEND_CONFIRM_BACK) }
} }
mainActivity?.lifecycleScope?.launch { mainActivity?.lifecycleScope?.launch {
binding.textConfirmation.text = binding.textConfirmation.text =
@ -42,6 +45,7 @@ class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
} }
private fun onSend() { private fun onSend() {
sendViewModel.funnel(Send.ConfirmPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final) mainActivity?.safeNavigate(R.id.action_nav_send_confirm_to_send_final)
} }
} }

View File

@ -9,13 +9,12 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendFinalBinding import cash.z.ecc.android.databinding.FragmentSendFinalBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.Report import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.MetricType.* import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.entity.* import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.convertZatoshiToZecString import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.twig import cash.z.wallet.sdk.ext.twig
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.onEach
import kotlin.random.Random import kotlin.random.Random
class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() { class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
override val screen = Report.Screen.SEND_FINAL
val sendViewModel: SendViewModel by activityViewModel() val sendViewModel: SendViewModel by activityViewModel()
@ -34,13 +34,13 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener { binding.buttonNext.setOnClickListener {
onExit() onExit().also { tapped(SEND_FINAL_EXIT) }
} }
binding.buttonRetry.setOnClickListener { binding.buttonRetry.setOnClickListener {
onRetry() onRetry().also { tapped(SEND_FINAL_RETRY) }
} }
binding.backButtonHitArea.setOnClickListener { binding.backButtonHitArea.setOnClickListener {
onExit() onExit().also { tapped(SEND_FINAL_CLOSE) }
} }
binding.textConfirmation.text = binding.textConfirmation.text =
"Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}" "Sending ${sendViewModel.zatoshiAmount.convertZatoshiToZecString(8)} ZEC to ${sendViewModel.toAddress.toAbbreviatedAddress()}"
@ -81,24 +81,19 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
val id = pendingTransaction?.id ?: -1 val id = pendingTransaction?.id ?: -1
var isSending = true var isSending = true
var isFailure = false var isFailure = false
var step: Report.Funnel.Send? = null
val message = when { val message = when {
pendingTransaction == null -> "Transaction not found" pendingTransaction == null -> "Transaction not found".also { step = Report.Funnel.Send.ErrorNotFound }
pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false } pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false; step = Report.Funnel.Send.Mined(pendingTransaction.minedHeight) }
pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ." pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . .".also { step = Report.Funnel.Send.Submitted }
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true } pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorEncoding(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true } pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true; step = Report.Funnel.Send.ErrorSubmitting(pendingTransaction?.errorCode, pendingTransaction?.errorMessage) }
pendingTransaction.isCreated() -> "Transaction creation complete!" pendingTransaction.isCreated() -> "Transaction creation complete!".also { step = Report.Funnel.Send.Created(id) }
pendingTransaction.isCreating() -> "Creating transaction . . ." pendingTransaction.isCreating() -> "Creating transaction . . .".also { step = Report.Funnel.Send.Creating }
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") } else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
} }
// TODO: make this error tracking easier to use and more spiffy sendViewModel.funnel(step)
if (pendingTransaction?.isFailedSubmit() == true) {
sendViewModel.feedback.report(Report.Send.SubmitFailure(pendingTransaction?.errorCode, pendingTransaction?.errorMessage))
}
if (pendingTransaction?.isFailedEncoding() == true) {
sendViewModel.feedback.report(Report.Send.EncodingFailure(pendingTransaction?.errorCode, pendingTransaction?.errorMessage))
}
twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message") twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message")
binding.textStatus.apply { binding.textStatus.apply {

View File

@ -3,15 +3,21 @@ package cash.z.ecc.android.ui.send
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import cash.z.ecc.android.R import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendMemoBinding import cash.z.ecc.android.databinding.FragmentSendMemoBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.* import cash.z.ecc.android.ext.gone
import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.ext.onEditorActionDone
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Send
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() { class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
override val screen = Report.Screen.SEND_MEMO
val sendViewModel: SendViewModel by activityViewModel() val sendViewModel: SendViewModel by activityViewModel()
@ -21,18 +27,18 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener { binding.buttonNext.setOnClickListener {
onTopButton() onTopButton().also { tapped(SEND_MEMO_NEXT) }
} }
binding.buttonSkip.setOnClickListener { binding.buttonSkip.setOnClickListener {
onBottomButton() onBottomButton().also { tapped(SEND_MEMO_SKIP) }
} }
binding.clearMemo.setOnClickListener { binding.clearMemo.setOnClickListener {
onClearMemo() onClearMemo().also { tapped(SEND_MEMO_CLEAR) }
} }
R.id.action_nav_send_memo_to_nav_send_address.let { R.id.action_nav_send_memo_to_nav_send_address.let {
binding.backButtonHitArea.onClickNavTo(it) binding.backButtonHitArea.onClickNavTo(it) { tapped(SEND_MEMO_BACK) }
onBackPressNavTo(it) onBackPressNavTo(it) { tapped(SEND_MEMO_BACK) }
} }
binding.checkIncludeAddress.setOnCheckedChangeListener { _, _-> binding.checkIncludeAddress.setOnCheckedChangeListener { _, _->
@ -41,7 +47,7 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
binding.inputMemo.let { memo -> binding.inputMemo.let { memo ->
memo.onEditorActionDone { memo.onEditorActionDone {
onTopButton() onTopButton().also { tapped(SEND_MEMO_NEXT) }
} }
memo.doAfterTextChanged { memo.doAfterTextChanged {
binding.clearMemo.goneIf(memo.text.isEmpty()) binding.clearMemo.goneIf(memo.text.isEmpty())
@ -79,11 +85,14 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
} }
private fun onIncludeMemo(checked: Boolean) { private fun onIncludeMemo(checked: Boolean) {
binding.textIncludedAddress.goneIf(!checked) binding.textIncludedAddress.goneIf(!checked)
sendViewModel.includeFromAddress = checked sendViewModel.includeFromAddress = checked
binding.textInfoShielded.text = if (checked) { binding.textInfoShielded.text = if (checked) {
tapped(SEND_MEMO_INCLUDE)
getString(R.string.send_memo_included_message) getString(R.string.send_memo_included_message)
} else { } else {
tapped(SEND_MEMO_EXCLUDE)
getString(R.string.send_memo_excluded_message) getString(R.string.send_memo_excluded_message)
} }
} }
@ -105,6 +114,7 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
} }
private fun onNext() { private fun onNext() {
sendViewModel.funnel(Send.MemoPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm) mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
} }
} }

View File

@ -1,19 +1,20 @@
package cash.z.ecc.android.ui.send package cash.z.ecc.android.ui.send
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.feedback.Feedback import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.Feedback.Keyed import cash.z.ecc.android.feedback.Feedback.Keyed
import cash.z.ecc.android.feedback.Feedback.TimeMetric import cash.z.ecc.android.feedback.Feedback.TimeMetric
import cash.z.ecc.android.feedback.Report import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Send.SendSelected
import cash.z.ecc.android.feedback.Report.Funnel.Send.SpendingKeyFound
import cash.z.ecc.android.feedback.Report.Issue
import cash.z.ecc.android.feedback.Report.MetricType import cash.z.ecc.android.feedback.Report.MetricType
import cash.z.ecc.android.feedback.Report.MetricType.* import cash.z.ecc.android.feedback.Report.MetricType.*
import cash.z.ecc.android.lockbox.LockBox import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.setup.WalletSetupViewModel import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.wallet.sdk.Initializer import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.Synchronizer import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.annotation.OpenForTesting
import cash.z.wallet.sdk.entity.* import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.ZcashSdk import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.convertZatoshiToZecString import cash.z.wallet.sdk.ext.convertZatoshiToZecString
@ -58,20 +59,38 @@ class SendViewModel @Inject constructor() : ViewModel() {
val isShielded get() = toAddress.startsWith("z") val isShielded get() = toAddress.startsWith("z")
fun send(): Flow<PendingTransaction> { fun send(): Flow<PendingTransaction> {
funnel(SendSelected)
val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo
val keys = initializer.deriveSpendingKeys( val keys = initializer.deriveSpendingKeys(
lockBox.getBytes(WalletSetupViewModel.LockBoxKey.SEED)!! lockBox.getBytes(WalletSetupViewModel.LockBoxKey.SEED)!!
) )
funnel(SpendingKeyFound)
reportIssues(memoToSend)
return synchronizer.sendToAddress( return synchronizer.sendToAddress(
keys[0], keys[0],
zatoshiAmount, zatoshiAmount,
toAddress, toAddress,
memoToSend memoToSend.chunked(ZcashSdk.MAX_MEMO_SIZE).firstOrNull() ?: ""
).onEach { ).onEach {
twig(it.toString()) twig(it.toString())
} }
} }
private fun reportIssues(memoToSend: String) {
if (toAddress == fromAddress) feedback.report(Issue.SelfSend)
when {
zatoshiAmount < ZcashSdk.MINERS_FEE_ZATOSHI -> feedback.report(Issue.TinyAmount)
zatoshiAmount < 100 -> feedback.report(Issue.MicroAmount)
zatoshiAmount == 1L -> feedback.report(Issue.MinimumAmount)
}
memoToSend.length.also {
when {
it > ZcashSdk.MAX_MEMO_SIZE -> feedback.report(Issue.TruncatedMemo(it))
it > (ZcashSdk.MAX_MEMO_SIZE * 0.96) -> feedback.report(Issue.LargeMemo(it))
}
}
}
suspend fun validateAddress(address: String): Synchronizer.AddressType = suspend fun validateAddress(address: String): Synchronizer.AddressType =
synchronizer.validateAddress(address) synchronizer.validateAddress(address)
@ -81,7 +100,7 @@ class SendViewModel @Inject constructor() : ViewModel() {
synchronizer.validateAddress(toAddress).isNotValid -> { synchronizer.validateAddress(toAddress).isNotValid -> {
emit("Please enter a valid address") emit("Please enter a valid address")
} }
zatoshiAmount <= 1 -> { zatoshiAmount < 1 -> {
emit("Too little! Please enter at least 1 Zatoshi.") emit("Too little! Please enter at least 1 Zatoshi.")
} }
maxZatoshi != null && zatoshiAmount > maxZatoshi -> { maxZatoshi != null && zatoshiAmount > maxZatoshi -> {
@ -146,6 +165,11 @@ class SendViewModel @Inject constructor() : ViewModel() {
} }
} }
fun funnel(step: Report.Funnel.Send?) {
step ?: return
feedback.report(step)
}
private operator fun MetricType.unaryPlus(): TimeMetric = TimeMetric(key, description).markTime() private operator fun MetricType.unaryPlus(): TimeMetric = TimeMetric(key, description).markTime()
private infix fun TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this } private infix fun TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this }
private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? { private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? {
@ -167,6 +191,7 @@ class SendViewModel @Inject constructor() : ViewModel() {
private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key" private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key"
private fun String.toRelatedMetricId(): String = "$this.related" private fun String.toRelatedMetricId(): String = "$this.related"
private fun String.toTxId(): Long = split('.').first().toLong() private fun String.toTxId(): Long = split('.').first().toLong()
} }

View File

@ -15,7 +15,10 @@ import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentBackupBinding import cash.z.ecc.android.databinding.FragmentBackupBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.MetricType.SEED_PHRASE_LOADED import cash.z.ecc.android.feedback.Report.MetricType.SEED_PHRASE_LOADED
import cash.z.ecc.android.feedback.Report.Tap.BACKUP_DONE
import cash.z.ecc.android.feedback.Report.Tap.BACKUP_VERIFY
import cash.z.ecc.android.feedback.measure import cash.z.ecc.android.feedback.measure
import cash.z.ecc.android.lockbox.LockBox import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
@ -30,6 +33,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class BackupFragment : BaseFragment<FragmentBackupBinding>() { class BackupFragment : BaseFragment<FragmentBackupBinding>() {
override val screen = Report.Screen.BACKUP
val walletSetup: WalletSetupViewModel by activityViewModel(false) val walletSetup: WalletSetupViewModel by activityViewModel(false)
private var hasBackUp: Boolean = true //TODO: implement backup and then check for it here-ish private var hasBackUp: Boolean = true //TODO: implement backup and then check for it here-ish
@ -52,9 +57,9 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
) )
} }
binding.buttonPositive.setOnClickListener { binding.buttonPositive.setOnClickListener {
onEnterWallet() onEnterWallet().also { if (hasBackUp) tapped(BACKUP_DONE) else tapped(BACKUP_VERIFY) }
} }
if (hasBackUp == true) { if (hasBackUp) {
binding.buttonPositive.text = "Done" binding.buttonPositive.text = "Done"
} }
} }

View File

@ -12,6 +12,9 @@ import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentLandingBinding import cash.z.ecc.android.databinding.FragmentLandingBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Restore
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITHOUT_BACKUP import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITHOUT_BACKUP
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.SEED_WITH_BACKUP
@ -22,6 +25,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class LandingFragment : BaseFragment<FragmentLandingBinding>() { class LandingFragment : BaseFragment<FragmentLandingBinding>() {
override val screen = Report.Screen.LANDING
private val walletSetup: WalletSetupViewModel by activityViewModel(false) private val walletSetup: WalletSetupViewModel by activityViewModel(false)
@ -34,21 +38,24 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.buttonPositive.setOnClickListener { binding.buttonPositive.setOnClickListener {
when (binding.buttonPositive.text.toString().toLowerCase()) { when (binding.buttonPositive.text.toString().toLowerCase()) {
"new" -> onNewWallet() "new" -> onNewWallet().also { tapped(LANDING_NEW) }
"backup" -> onBackupWallet() "backup" -> onBackupWallet().also { tapped(LANDING_BACKUP) }
} }
} }
binding.buttonNegative.setOnLongClickListener { binding.buttonNegative.setOnLongClickListener {
tapped(DEVELOPER_WALLET_PROMPT)
if (binding.buttonNegative.text.toString().toLowerCase() == "restore") { if (binding.buttonNegative.text.toString().toLowerCase() == "restore") {
MaterialAlertDialogBuilder(activity) MaterialAlertDialogBuilder(activity)
.setMessage("Would you like to import the dev wallet?\n\nIf so, please only send 0.0001 ZEC at a time and return some later so that the account remains funded.") .setMessage("Would you like to import the dev wallet?\n\nIf so, please only send 0.0001 ZEC at a time and return some later so that the account remains funded.")
.setTitle("Import Dev Wallet?") .setTitle("Import Dev Wallet?")
.setCancelable(true) .setCancelable(true)
.setPositiveButton("Import") { dialog, _ -> .setPositiveButton("Import") { dialog, _ ->
tapped(DEVELOPER_WALLET_IMPORT)
dialog.dismiss() dialog.dismiss()
onUseDevWallet() onUseDevWallet()
} }
.setNegativeButton("Cancel") { dialog, _ -> .setNegativeButton("Cancel") { dialog, _ ->
tapped(DEVELOPER_WALLET_CANCEL)
dialog.dismiss() dialog.dismiss()
} }
.show() .show()
@ -58,7 +65,10 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
} }
binding.buttonNegative.setOnClickListener { binding.buttonNegative.setOnClickListener {
when (binding.buttonNegative.text.toString().toLowerCase()) { when (binding.buttonNegative.text.toString().toLowerCase()) {
"restore" -> onRestoreWallet() "restore" -> onRestoreWallet().also {
mainActivity?.reportFunnel(Restore.Initiated)
tapped(LANDING_RESTORE)
}
else -> onSkip(++skipCount) else -> onSkip(++skipCount)
} }
} }
@ -83,16 +93,19 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
private fun onSkip(count: Int) { private fun onSkip(count: Int) {
when (count) { when (count) {
1 -> { 1 -> {
tapped(LANDING_BACKUP_SKIPPED_1)
binding.textMessage.text = binding.textMessage.text =
"Are you sure? Without a backup, funds can be lost FOREVER!" "Are you sure? Without a backup, funds can be lost FOREVER!"
binding.buttonNegative.text = "Later" binding.buttonNegative.text = "Later"
} }
2 -> { 2 -> {
tapped(LANDING_BACKUP_SKIPPED_2)
binding.textMessage.text = binding.textMessage.text =
"You can't backup later. You're probably going to lose your funds!" "You can't backup later. You're probably going to lose your funds!"
binding.buttonNegative.text = "I've been warned" binding.buttonNegative.text = "I've been warned"
} }
else -> { else -> {
tapped(LANDING_BACKUP_SKIPPED_3)
onEnterWallet() onEnterWallet()
} }
} }

View File

@ -17,6 +17,9 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentRestoreBinding import cash.z.ecc.android.databinding.FragmentRestoreBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.goneIf import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Restore
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.ext.ZcashSdk import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig import cash.z.wallet.sdk.ext.twig
@ -28,6 +31,7 @@ import kotlinx.coroutines.launch
class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListener { class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListener {
override val screen = Report.Screen.RESTORE
private val walletSetup: WalletSetupViewModel by activityViewModel(false) private val walletSetup: WalletSetupViewModel by activityViewModel(false)
@ -53,21 +57,18 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
} }
binding.buttonDone.setOnClickListener { binding.buttonDone.setOnClickListener {
onDone() onDone().also { tapped(RESTORE_DONE) }
} }
binding.buttonSuccess.setOnClickListener { binding.buttonSuccess.setOnClickListener {
onEnterWallet() onEnterWallet().also { tapped(RESTORE_SUCCESS) }
}
binding.textSubtitle.setOnClickListener {
seedWordAdapter!!.editText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
} }
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState) super.onActivityCreated(savedInstanceState)
mainActivity?.onFragmentBackPressed(this) { mainActivity?.onFragmentBackPressed(this) {
tapped(RESTORE_BACK)
if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) { if (seedWordAdapter == null || seedWordAdapter?.itemCount == 1) {
onExit() onExit()
} else { } else {
@ -75,6 +76,7 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
.setMessage("Are you sure? For security, the words that you have entered will be cleared!") .setMessage("Are you sure? For security, the words that you have entered will be cleared!")
.setTitle("Abort?") .setTitle("Abort?")
.setPositiveButton("Stay") { dialog, _ -> .setPositiveButton("Stay") { dialog, _ ->
mainActivity?.reportFunnel(Restore.Stay)
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton("Exit") { dialog, _ -> .setNegativeButton("Exit") { dialog, _ ->
@ -94,16 +96,19 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
private fun onExit() { private fun onExit() {
mainActivity?.reportFunnel(Restore.Exit)
hideAutoCompleteWords() hideAutoCompleteWords()
mainActivity?.hideKeyboard() mainActivity?.hideKeyboard()
mainActivity?.navController?.popBackStack() mainActivity?.navController?.popBackStack()
} }
private fun onEnterWallet() { private fun onEnterWallet() {
mainActivity?.reportFunnel(Restore.Success)
mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home) mainActivity?.safeNavigate(R.id.action_nav_restore_to_nav_home)
} }
private fun onDone() { private fun onDone() {
mainActivity?.reportFunnel(Restore.Done)
mainActivity?.hideKeyboard() mainActivity?.hideKeyboard()
val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") { val seedPhrase = binding.chipsInput.selectedChips.joinToString(" ") {
it.title it.title
@ -117,12 +122,14 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
} }
private fun importWallet(seedPhrase: String, birthday: Int) { private fun importWallet(seedPhrase: String, birthday: Int) {
mainActivity?.reportFunnel(Restore.ImportStarted)
mainActivity?.hideKeyboard() mainActivity?.hideKeyboard()
mainActivity?.apply { mainActivity?.apply {
lifecycleScope.launch { lifecycleScope.launch {
mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday)) mainActivity?.startSync(walletSetup.importWallet(seedPhrase, birthday))
// bugfix: if the user proceeds before the synchronizer is created the app will crash! // bugfix: if the user proceeds before the synchronizer is created the app will crash!
binding.buttonSuccess.isEnabled = true binding.buttonSuccess.isEnabled = true
mainActivity?.reportFunnel(Restore.ImportCompleted)
} }
playSound("sound_receive_small.mp3") playSound("sound_receive_small.mp3")
vibrateSuccess() vibrateSuccess()
@ -135,7 +142,6 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
} }
private fun onChipsModified() { private fun onChipsModified() {
twig("onChipsModified")
seedWordAdapter?.editText?.apply { seedWordAdapter?.editText?.apply {
postDelayed({ postDelayed({
requestFocus() requestFocus()
@ -151,9 +157,21 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), View.OnKeyListen
private fun setDoneEnabled() { private fun setDoneEnabled() {
val count = seedWordAdapter?.itemCount ?: 0 val count = seedWordAdapter?.itemCount ?: 0
reportWords(count - 1) // subtract 1 for the editText
binding.groupDone.goneIf(count <= 24) binding.groupDone.goneIf(count <= 24)
} }
private fun reportWords(count: Int) {
mainActivity?.run {
// reportFunnel(Restore.SeedWordCount(count))
if (count == 1) {
reportFunnel(Restore.SeedWordsStarted)
} else if (count == 24) {
reportFunnel(Restore.SeedWordsCompleted)
}
}
}
private fun hideAutoCompleteWords() { private fun hideAutoCompleteWords() {
seedWordAdapter?.editText?.setText("") seedWordAdapter?.editText?.setText("")
} }

View File

@ -7,6 +7,9 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import cash.z.ecc.android.R import cash.z.ecc.android.R
import cash.z.ecc.android.ext.toAppColor import cash.z.ecc.android.ext.toAppColor
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Funnel.Restore
import cash.z.ecc.android.ui.MainActivity
import cash.z.ecc.android.ui.setup.SeedWordChip import cash.z.ecc.android.ui.setup.SeedWordChip
import cash.z.wallet.sdk.ext.twig import cash.z.wallet.sdk.ext.twig
@ -46,7 +49,6 @@ class SeedWordAdapter : ChipsAdapter {
override fun onChipDataSourceChanged() { override fun onChipDataSourceChanged() {
super.onChipDataSourceChanged() super.onChipDataSourceChanged()
twig("onChipDataSourceChanged")
onDataSetChangedListener?.invoke() onDataSetChangedListener?.invoke()
} }
@ -69,15 +71,12 @@ class SeedWordAdapter : ChipsAdapter {
} }
} }
override fun onKeyboardDelimiter(text: String) { override fun onKeyboardDelimiter(text: String) {
twig("onKeyboardDelimiter: $text ${mDataSource.filteredChips.size}")
if (mDataSource.filteredChips.size > 0) { if (mDataSource.filteredChips.size > 0) {
onKeyboardActionDone((mDataSource.filteredChips.first() as SeedWordChip).word) onKeyboardActionDone((mDataSource.filteredChips.first() as SeedWordChip).word)
} }
} }
private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) { private inner class SeedWordHolder(chipView: SeedWordChipView) : ChipsAdapter.ChipHolder(chipView) {
val seedChipView = super.chipView as SeedWordChipView val seedChipView = super.chipView as SeedWordChipView
} }

View File

@ -1,6 +1,6 @@
package cash.z.ecc.android.feedback package cash.z.ecc.android.feedback
import android.util.Log //import android.util.Log
import cash.z.ecc.android.feedback.util.CompositeJob import cash.z.ecc.android.feedback.util.CompositeJob
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.BroadcastChannel
@ -35,17 +35,8 @@ class Feedback(capacity: Int = 256) {
* [actions] channels will remain open unless [stop] is also called on this instance. * [actions] channels will remain open unless [stop] is also called on this instance.
*/ */
suspend fun start(): Feedback { suspend fun start(): Feedback {
val callStack = StringBuilder().let { s ->
Thread.currentThread().stackTrace.forEach {element ->
s.append("$element\n")
}
s.toString()
}
if(::scope.isInitialized) { if(::scope.isInitialized) {
Log.e("@TWIG","Warning: did not initialize feedback because it has already been initialized. Call stack: $callStack")
return this return this
} else {
Log.e("@TWIG","Debug: Initializing feedback for the first time. Call stack: $callStack")
} }
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job])) scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
invokeOnCompletion { invokeOnCompletion {
@ -143,8 +134,8 @@ class Feedback(capacity: Int = 256) {
* *
* @param error the uncaught exception that occurred. * @param error the uncaught exception that occurred.
*/ */
fun report(error: Throwable?, fatal: Boolean = false): Feedback { fun report(error: Throwable?, isFatal: Boolean = false): Feedback {
return report(Crash(error, fatal)) return if (isFatal) report(Crash(error)) else report(NonFatal(error, "reported"))
} }
/** /**
@ -197,14 +188,24 @@ class Feedback(capacity: Int = 256) {
} }
} }
abstract class Funnel(override val key: String) : Action { abstract class MappedAction private constructor(protected val propertyMap: MutableMap<String, Any> = mutableMapOf()) : Feedback.Action {
override fun toMap(): MutableMap<String, Any> { constructor(vararg properties: Pair<String, Any>) : this(mutableMapOf(*properties))
return mutableMapOf(
"key" to key override fun toMap(): Map<String, Any> {
) return propertyMap.apply { putAll(super.toMap()) }
} }
} }
abstract class Funnel(funnelName: String, stepName: String, step: Int, vararg properties: Pair<String, Any>) : MappedAction(
"funnelName" to funnelName,
"stepName" to stepName,
"step" to step,
*properties
) {
override fun toString() = key
override val key: String = "funnel.$funnelName.$stepName.$step"
}
interface Keyed<T> { interface Keyed<T> {
val key: T val key: T
} }
@ -231,31 +232,52 @@ class Feedback(capacity: Int = 256) {
} }
} }
data class Crash(val error: Throwable? = null, val fatal: Boolean = true) : Action { open class AppError(name: String = "unknown", description: String? = null, isFatal: Boolean = false, vararg properties: Pair<String, Any>) : MappedAction(
override val key: String = "crash" "isError" to true,
override fun toMap(): Map<String, Any> { "isFatal" to isFatal,
return mutableMapOf<String, Any>( "errorName" to name,
"fatal" to fatal, "message" to (description ?: "None"),
"message" to (error?.message ?: "None"), "description" to describe(name, description, isFatal),
"cause" to (error?.cause?.toString() ?: "None"), *properties
"cause.cause" to (error?.cause?.cause?.toString() ?: "None"), ) {
"cause.cause.cause" to (error?.cause?.cause?.cause?.toString() ?: "None") val isFatal: Boolean by propertyMap
).apply { putAll(super.toMap()); putAll(error.stacktraceToMap()) } val errorName: String by propertyMap
val description: String by propertyMap
constructor(name: String, exception: Throwable? = null, isFatal: Boolean = false) : this(
name, exception?.toString(), isFatal,
"exceptionString" to (exception?.toString() ?: "None"),
"message" to (exception?.message ?: "None"),
"cause" to (exception?.cause?.toString() ?: "None"),
"cause.cause" to (exception?.cause?.cause?.toString() ?: "None"),
"cause.cause.cause" to (exception?.cause?.cause?.cause?.toString() ?: "None")
) {
propertyMap.putAll(exception.stacktraceToMap())
} }
override fun toString() = "App ${if (fatal) "crashed due to" else "caught error"}: $error" override val key = "error.${if (isFatal) "fatal" else "nonfatal"}.$name"
override fun toString() = description
companion object {
fun describe(name: String, description: String?, isFatal: Boolean) =
"${if (isFatal) "Error: FATAL" else "Error: non-fatal"} $name error due to: ${description ?: "unknown error"}"
}
} }
class Crash(val exception: Throwable? = null) : AppError( "crash", exception, true)
class NonFatal(val exception: Throwable? = null, name: String) : AppError(name, exception, false)
} }
private fun Throwable?.stacktraceToMap(chunkSize: Int = 250): Map<out String, String> { private fun Throwable?.stacktraceToMap(chunkSize: Int = 250): Map<out String, String> {
val properties = mutableMapOf("stacktrace0" to "None") val properties = mutableMapOf("stacktrace.0" to "None")
if (this == null) return properties if (this == null) return properties
val stringWriter = StringWriter() val stringWriter = StringWriter()
printStackTrace(PrintWriter(stringWriter)) printStackTrace(PrintWriter(stringWriter))
stringWriter.toString().chunked(chunkSize).forEachIndexed { index, chunk -> stringWriter.toString().chunked(chunkSize).forEachIndexed { index, chunk ->
properties["stacktrace$index"] = chunk properties["stacktrace.$index"] = chunk
} }
return properties return properties
} }

View File

@ -6,9 +6,9 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.*
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.lang.RuntimeException
class FeedbackTest { class FeedbackTest {
@ -43,6 +43,7 @@ class FeedbackTest {
verifyAction(feedback, simpleAction.key) verifyAction(feedback, simpleAction.key)
feedback.report(simpleAction) feedback.report(simpleAction)
Unit
} }
@Test @Test
@ -64,6 +65,50 @@ class FeedbackTest {
verifyFeedbackCancellation { _, _ -> } verifyFeedbackCancellation { _, _ -> }
} }
@Test
fun testCrash() {
val rushing = RuntimeException("rushing")
val speeding = RuntimeException("speeding", rushing)
val runlight = RuntimeException("Run light", speeding)
val crash = Feedback.Crash(RuntimeException("BOOM", runlight))
val map = crash.toMap()
printMap(map)
assertNotNull(map["cause"])
assertNotNull(map["cause.cause"])
assertNotNull(map["cause.cause"])
}
@Test
fun testAppError_exception() {
val rushing = RuntimeException("rushing")
val speeding = RuntimeException("speeding", rushing)
val runlight = RuntimeException("Run light", speeding)
val error = Feedback.AppError("reported", RuntimeException("BOOM", runlight))
val map = error.toMap()
printMap(map)
assertFalse(error.isFatal)
assertNotNull(map["cause"])
assertNotNull(map["cause.cause"])
assertNotNull(map["cause.cause"])
}
@Test
fun testAppError_description() {
val error = Feedback.AppError("reported", "The server was down while downloading blocks!")
val map = error.toMap()
printMap(map)
assertFalse(error.isFatal)
}
private fun printMap(map: Map<String, Any>) {
for (entry in map) {
println("%-20s = %s".format(entry.key, entry.value))
}
}
private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking { private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking {
val feedback = Feedback() val feedback = Feedback()
var counter = 0 var counter = 0