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
object Report {
object Send {
class SubmitFailure(private val errorCode: Int?, private val errorMessage: String?) : Feedback.Funnel("send.failure.submit") {
override fun toMap(): MutableMap<String, Any> {
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<String, Any>) : 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<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") {
override fun toMap(): MutableMap<String, Any> {
return super.toMap().apply {
put("error.code", errorCode ?: -1)
put("error.message", errorMessage ?: "None")
}
sealed class Restore(stepName: String, step: Int, vararg properties: Pair<String, Any>) : 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<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 {
@ -68,5 +197,6 @@ class LaunchMetric private constructor(private val metric: Feedback.TimeMetric)
override fun toString(): String = metric.toString()
}
inline fun <T> Feedback.measure(type: Report.MetricType, block: () -> T): T =
this.measure(type.key, type.description, block)

View File

@ -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<T : ViewBinding> : 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<T : ViewBinding> : 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<T : ViewBinding> : 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)
}
}

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.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<FragmentDetailBinding>() {
override val screen = Report.Screen.DETAIL
private val viewModel: WalletDetailViewModel by viewModel()
private lateinit var adapter: TransactionAdapter<ConfirmedTransaction>
@ -33,7 +35,7 @@ class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
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()
}

View File

@ -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<FragmentHomeBinding>() {
override val screen = Report.Screen.HOME
private lateinit var numberPad: List<TextView>
private lateinit var uiModel: HomeViewModel.UiModel
@ -88,18 +91,18 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
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<FragmentHomeBinding>() {
}
binding.buttonNumberPadBack.setOnLongClickListener {
onClearAmount()
onClearAmount().also { tapped(HOME_CLEAR_AMOUNT) }
true
}
@ -175,14 +178,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// 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<FragmentHomeBinding>() {
//
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<FragmentHomeBinding>() {
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<FragmentHomeBinding>() {
}
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<FragmentHomeBinding>() {
.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)
}

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.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<FragmentReceiveNewBinding>() {
override val screen = Report.Screen.RECEIVE
private val viewModel: ReceiveViewModel by viewModel()
@ -40,9 +43,9 @@ class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
// 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) {

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.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<FragmentScanBinding>() {
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<FragmentScanBinding>() {
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<FragmentScanBinding>() {
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)

View File

@ -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<FragmentSendAddressBinding>(),
ClipboardManager.OnPrimaryClipChangedListener {
override val screen = Report.Screen.SEND_ADDRESS
private var maxZatoshi: Long? = null
@ -32,18 +35,18 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
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<FragmentSendAddressBinding>(),
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<FragmentSendAddressBinding>(),
}
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 }
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 {

View File

@ -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<FragmentSendConfirmBinding>() {
override val screen = Report.Screen.SEND_CONFIRM
val sendViewModel: SendViewModel by activityViewModel()
@ -25,11 +28,11 @@ class SendConfirmFragment : BaseFragment<FragmentSendConfirmBinding>() {
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<FragmentSendConfirmBinding>() {
}
private fun onSend() {
sendViewModel.funnel(Send.ConfirmPageComplete)
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.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<FragmentSendFinalBinding>() {
override val screen = Report.Screen.SEND_FINAL
val sendViewModel: SendViewModel by activityViewModel()
@ -34,13 +34,13 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
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<FragmentSendFinalBinding>() {
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 {

View File

@ -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<FragmentSendMemoBinding>() {
override val screen = Report.Screen.SEND_MEMO
val sendViewModel: SendViewModel by activityViewModel()
@ -21,18 +27,18 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
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<FragmentSendMemoBinding>() {
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<FragmentSendMemoBinding>() {
}
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<FragmentSendMemoBinding>() {
}
private fun onNext() {
sendViewModel.funnel(Send.MemoPageComplete)
mainActivity?.safeNavigate(R.id.action_nav_send_memo_to_send_confirm)
}
}

View File

@ -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<PendingTransaction> {
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<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 String.toRelatedMetricId(): String = "$this.related"
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.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<FragmentBackupBinding>() {
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<FragmentBackupBinding>() {
)
}
binding.buttonPositive.setOnClickListener {
onEnterWallet()
onEnterWallet().also { if (hasBackUp) tapped(BACKUP_DONE) else tapped(BACKUP_VERIFY) }
}
if (hasBackUp == true) {
if (hasBackUp) {
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.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<FragmentLandingBinding>() {
override val screen = Report.Screen.LANDING
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
@ -34,21 +38,24 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
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<FragmentLandingBinding>() {
}
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<FragmentLandingBinding>() {
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()
}
}

View File

@ -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<FragmentRestoreBinding>(), View.OnKeyListener {
override val screen = Report.Screen.RESTORE
private val walletSetup: WalletSetupViewModel by activityViewModel(false)
@ -53,21 +57,18 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), 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<FragmentRestoreBinding>(), 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<FragmentRestoreBinding>(), 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<FragmentRestoreBinding>(), 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<FragmentRestoreBinding>(), View.OnKeyListen
}
private fun onChipsModified() {
twig("onChipsModified")
seedWordAdapter?.editText?.apply {
postDelayed({
requestFocus()
@ -151,9 +157,21 @@ class RestoreFragment : BaseFragment<FragmentRestoreBinding>(), 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("")
}

View File

@ -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
}

View File

@ -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<String, Any> {
return mutableMapOf(
"key" to key
)
abstract class MappedAction private constructor(protected val propertyMap: MutableMap<String, Any> = mutableMapOf()) : Feedback.Action {
constructor(vararg properties: Pair<String, Any>) : this(mutableMapOf(*properties))
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> {
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<String, Any> {
return mutableMapOf<String, Any>(
"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<String, Any>) : 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<out String, String> {
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
}

View File

@ -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<String, Any>) {
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