Merge pull request #80 from zcash/release/sprint-2

Release/sprint 2
This commit is contained in:
Kevin Gorham 2020-03-10 14:47:30 -04:00 committed by GitHub
commit 9550cdbbc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 765 additions and 196 deletions

44
CHANGELOG.md Normal file
View File

@ -0,0 +1,44 @@
Change Log
==========
Version 1.0.0-alpha23 *(2020-02-21)*
------------------------------------
- Fix: reorg improvements, squashing critical bugs that disabled wallets
- New: extend analytics to include taps, screen views, and send flow.
- New: add crash reporting via Crashlytics.
- New: expose user logs and developer logs as files.
- New: improve feature for creating checkpoints.
- New: added DB schemas to the repository for tracking.
- Fix: numerous bug fixes, test fixes and cleanup.
- New: improved error handling and user experience
Version 1.0.0-alpha17 *(2020-02-07)*
------------------------------------
- New: implemented wallet import
- New: display the memo when tapping outbound transactions
- Fix: removed the sad zebra and softened wording for sending z->t
- Fix: removed restriction on smallest sendable ZEC amount
- Fix: removed "fund now"
- New: turned on developer logging to help with troubleshooting
- New: improved wallet details ability to handle small amounts of ZEC
- New: added ability to clear the memo
- Fix: changed "SEND WITHOUT MEMO" to "OMIT MEMO"
- Fix: corrected wording when the address is included in the memo
- New: display the approximate wallet birthday with the backup words
- New: improved crash reporting
- Fix: fixed bug when returning from the background
- New: added logging for failed transactions
- New: added logic to verify setup and offer explanation when the wallet is corrupted
- New: refactored and improved wallet initialization
- New: added ability to contribute 'plugins' to the SDK
- New: added tons more checkpoints to reduce startup/import time
- New: exposed logic to derive addresses directly from seeds
- Fix: fixed several crashes
Version 1.0.0-alpha11 *(2020-01-15)*
------------------------------------
- Initial ECC release
Version 1.0.0-alpha03 *(2019-12-18)*
------------------------------------
- Initial internal wallet team release

View File

@ -11,7 +11,7 @@ apply plugin: 'com.google.firebase.firebase-perf'
archivesBaseName = 'zcash-android-wallet' archivesBaseName = 'zcash-android-wallet'
group = 'cash.z.ecc.android' group = 'cash.z.ecc.android'
version = '1.0.0-alpha17' version = '1.0.0-alpha23'
android { android {
compileSdkVersion Deps.compileSdkVersion compileSdkVersion Deps.compileSdkVersion
@ -21,8 +21,8 @@ android {
applicationId 'cash.z.ecc.android' applicationId 'cash.z.ecc.android'
minSdkVersion Deps.minSdkVersion minSdkVersion Deps.minSdkVersion
targetSdkVersion Deps.targetSdkVersion targetSdkVersion Deps.targetSdkVersion
versionCode = 1_00_00_017 versionCode = 1_00_00_023
// last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release. // last digits are alpha(0XX) beta(2XX) rc(4XX) release(8XX) dev(9XX). Ex: 1_08_04_401 is an release candidate build of version 1.8.4 and 1_08_04_800 would be the final release.
versionName = "$version" versionName = "$version"
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
testInstrumentationRunnerArguments clearPackageData: 'true' testInstrumentationRunnerArguments clearPackageData: 'true'
@ -87,6 +87,7 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "1.8"
} }
kapt { kapt {
arguments { arguments {
arg 'dagger.fastInit', 'enabled' arg 'dagger.fastInit', 'enabled'
@ -146,10 +147,10 @@ dependencies {
implementation 'io.github.novacrypto:securestring:2019.01.27' implementation 'io.github.novacrypto:securestring:2019.01.27'
// grpc-java // grpc-java
implementation "io.grpc:grpc-okhttp:1.27.0" implementation "io.grpc:grpc-okhttp:1.25.0"
implementation "io.grpc:grpc-android:1.27.0" implementation "io.grpc:grpc-android:1.25.0"
implementation "io.grpc:grpc-protobuf-lite:1.27.0" implementation "io.grpc:grpc-protobuf-lite:1.25.0"
implementation "io.grpc:grpc-stub:1.27.0" implementation "io.grpc:grpc-stub:1.25.0"
implementation 'javax.annotation:javax.annotation-api:1.3.2' implementation 'javax.annotation:javax.annotation-api:1.3.2'
// solves error: Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules jetified-guava-26.0-android.jar (com.google.guava:guava:26.0-android) and listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0) // solves error: Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules jetified-guava-26.0-android.jar (com.google.guava:guava:26.0-android) and listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
// per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ // per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ

View File

@ -20,6 +20,17 @@
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="cash.z.ecc.android.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
android:writePermission="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Firebase options --> <!-- Firebase options -->
<meta-data android:name="com.google.firebase.ml.vision.DEPENDENCIES" android:value="barcode" /> <meta-data android:name="com.google.firebase.ml.vision.DEPENDENCIES" android:value="barcode" />

View File

@ -57,4 +57,9 @@ class AppModule {
@Singleton @Singleton
@IntoSet @IntoSet
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel() fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
@Provides
@Singleton
@IntoSet
fun provideFeedbackCrashlytics(): FeedbackCoordinator.FeedbackObserver = FeedbackCrashlytics()
} }

View File

@ -0,0 +1,3 @@
package cash.z.ecc.android.ext
fun Boolean.asString(ifTrue: String = "", ifFalse: String = "") = if(this) ifTrue else ifFalse

View File

@ -22,16 +22,18 @@ fun View.disabledIf(isDisabled: Boolean) {
isEnabled = !isDisabled isEnabled = !isDisabled
} }
fun View.onClickNavTo(navResId: Int) { fun View.onClickNavTo(navResId: Int, block: (() -> Any) = {}) {
setOnClickListener { setOnClickListener {
block()
(context as? MainActivity)?.safeNavigate(navResId) (context as? MainActivity)?.safeNavigate(navResId)
?: throw IllegalStateException("Cannot navigate from this activity. " + ?: throw IllegalStateException("Cannot navigate from this activity. " +
"Expected MainActivity but found ${context.javaClass.simpleName}") "Expected MainActivity but found ${context.javaClass.simpleName}")
} }
} }
fun View.onClickNavUp() { fun View.onClickNavUp(block: (() -> Any) = {}) {
setOnClickListener { setOnClickListener {
block()
(context as? MainActivity)?.navController?.navigateUp() (context as? MainActivity)?.navController?.navigateUp()
?: throw IllegalStateException( ?: throw IllegalStateException(
"Cannot navigate from this activity. " + "Cannot navigate from this activity. " +
@ -40,8 +42,9 @@ fun View.onClickNavUp() {
} }
} }
fun View.onClickNavBack() { fun View.onClickNavBack(block: (() -> Any) = {}) {
setOnClickListener { setOnClickListener {
block()
(context as? MainActivity)?.navController?.popBackStack() (context as? MainActivity)?.navController?.popBackStack()
?: throw IllegalStateException( ?: throw IllegalStateException(
"Cannot navigate from this activity. " + "Cannot navigate from this activity. " +

View File

@ -0,0 +1,26 @@
package cash.z.ecc.android.feedback
import com.crashlytics.android.Crashlytics
class FeedbackCrashlytics : FeedbackCoordinator.FeedbackObserver {
/**
* Report non-fatal crashes because fatal ones already get reported by default.
*/
override fun onAction(action: Feedback.Action) {
var exception: Throwable? = null
exception = when (action) {
is Feedback.Crash -> action.exception
is Feedback.NonFatal -> action.exception
is Report.Error.NonFatal.Reorg -> ReorgException(
action.errorHeight,
action.rewindHeight,
action.toString()
)
else -> null
}
exception?.let { Crashlytics.logException(it) }
}
private class ReorgException(errorHeight: Int, rewindHeight: Int, reorgMesssage: String) :
Throwable(reorgMesssage)
}

View File

@ -5,12 +5,15 @@ import okio.Okio
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
class FeedbackFile(fileName: String = "feedback.log") : class FeedbackFile(fileName: String = "user_log.txt") :
FeedbackCoordinator.FeedbackObserver { FeedbackCoordinator.FeedbackObserver {
val file = File(ZcashWalletApp.instance.noBackupFilesDir, fileName) val file = File("${ZcashWalletApp.instance.filesDir}/logs", fileName)
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS") private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
init {
if (!file.parentFile.exists()) file.parentFile.mkdirs()
}
override fun onMetric(metric: Feedback.Metric) { override fun onMetric(metric: Feedback.Metric) {
appendToFile(metric.toString()) appendToFile(metric.toString())

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

@ -33,10 +33,14 @@ import cash.z.ecc.android.di.component.SynchronizerSubcomponent
import cash.z.ecc.android.feedback.Feedback import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.FeedbackCoordinator import cash.z.ecc.android.feedback.FeedbackCoordinator
import cash.z.ecc.android.feedback.LaunchMetric import cash.z.ecc.android.feedback.LaunchMetric
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Error.NonFatal.Reorg
import cash.z.ecc.android.feedback.Report.NonUserAction.FEEDBACK_STOPPED import cash.z.ecc.android.feedback.Report.NonUserAction.FEEDBACK_STOPPED
import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START import cash.z.ecc.android.feedback.Report.NonUserAction.SYNC_START
import cash.z.ecc.android.feedback.Report.Tap.COPY_ADDRESS
import cash.z.wallet.sdk.Initializer import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.exception.CompactBlockProcessorException import cash.z.wallet.sdk.exception.CompactBlockProcessorException
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.twig import cash.z.wallet.sdk.ext.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -59,6 +63,7 @@ class MainActivity : AppCompatActivity() {
private val mediaPlayer: MediaPlayer = MediaPlayer() private val mediaPlayer: MediaPlayer = MediaPlayer()
private var snackbar: Snackbar? = null private var snackbar: Snackbar? = null
private var dialog: Dialog? = null private var dialog: Dialog? = null
private var ignoreScanFailure: Boolean = false
lateinit var component: MainActivitySubcomponent lateinit var component: MainActivitySubcomponent
lateinit var synchronizerComponent: SynchronizerSubcomponent lateinit var synchronizerComponent: SynchronizerSubcomponent
@ -165,6 +170,7 @@ class MainActivity : AppCompatActivity() {
feedback.report(SYNC_START) feedback.report(SYNC_START)
synchronizerComponent.synchronizer().let { synchronizer -> synchronizerComponent.synchronizer().let { synchronizer ->
synchronizer.onProcessorErrorHandler = ::onProcessorError synchronizer.onProcessorErrorHandler = ::onProcessorError
synchronizer.onChainErrorHandler = ::onChainError
synchronizer.start(lifecycleScope) synchronizer.start(lifecycleScope)
} }
} else { } else {
@ -172,6 +178,16 @@ class MainActivity : AppCompatActivity() {
} }
} }
fun reportScreen(screen: Report.Screen?) = reportAction(screen)
fun reportTap(tap: Report.Tap?) = reportAction(tap)
fun reportFunnel(step: Feedback.Funnel?) = reportAction(step)
private fun reportAction(action: Feedback.Action?) {
action?.let { feedback.report(it) }
}
fun playSound(fileName: String) { fun playSound(fileName: String) {
mediaPlayer.apply { mediaPlayer.apply {
if (isPlaying) stop() if (isPlaying) stop()
@ -197,6 +213,7 @@ class MainActivity : AppCompatActivity() {
} }
fun copyAddress(view: View? = null) { fun copyAddress(view: View? = null) {
reportTap(COPY_ADDRESS)
lifecycleScope.launch { lifecycleScope.launch {
clipboard.setPrimaryClip( clipboard.setPrimaryClip(
ClipData.newPlainText( ClipData.newPlainText(
@ -258,14 +275,12 @@ class MainActivity : AppCompatActivity() {
} }
fun showKeyboard(focusedView: View) { fun showKeyboard(focusedView: View) {
twig("SHOWING KEYBOARD")
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED) imm.showSoftInput(focusedView, InputMethodManager.SHOW_FORCED)
} }
fun hideKeyboard() { fun hideKeyboard() {
twig("HIDING KEYBOARD")
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0) imm.hideSoftInputFromWindow(findViewById<View>(android.R.id.content).windowToken, 0)
} }
@ -309,10 +324,13 @@ class MainActivity : AppCompatActivity() {
showSnackbar("Well, this is awkward. You denied permission for the camera.") showSnackbar("Well, this is awkward. You denied permission for the camera.")
} }
private var ignoredErrors = 0
private fun onProcessorError(error: Throwable?): Boolean { private fun onProcessorError(error: Throwable?): Boolean {
var notified = false
when (error) { when (error) {
is CompactBlockProcessorException.Uninitialized -> { is CompactBlockProcessorException.Uninitialized -> {
if (dialog == null) if (dialog == null) {
notified = true
runOnUiThread { runOnUiThread {
dialog = MaterialAlertDialogBuilder(this) dialog = MaterialAlertDialogBuilder(this)
.setTitle("Wallet Improperly Initialized") .setTitle("Wallet Improperly Initialized")
@ -324,9 +342,83 @@ class MainActivity : AppCompatActivity() {
} }
.show() .show()
} }
}
}
is CompactBlockProcessorException.FailedScan -> {
if (dialog == null && !ignoreScanFailure) throttle("scanFailure", 20_000L) {
notified = true
runOnUiThread {
dialog = MaterialAlertDialogBuilder(this)
.setTitle("Scan Failure")
.setMessage("${error.message}${if (error.cause != null) "\n\nCaused by: ${error.cause}" else ""}")
.setCancelable(true)
.setPositiveButton("Retry") { d, _ ->
d.dismiss()
dialog = null
}
.setNegativeButton("Ignore") { d, _ ->
d.dismiss()
ignoreScanFailure = true
dialog = null
}
.show()
}
}
} }
} }
if (!notified) {
ignoredErrors++
}
if (ignoredErrors >= ZcashSdk.RETRIES) {
if (dialog == null) {
notified = true
runOnUiThread {
dialog = MaterialAlertDialogBuilder(this)
.setTitle("Processor Error")
.setMessage(error?.message ?: "Critical error while processing blocks!")
.setCancelable(false)
.setPositiveButton("Retry") { d, _ ->
d.dismiss()
dialog = null
}
.setNegativeButton("Exit") { dialog, _ ->
dialog.dismiss()
throw error
?: RuntimeException("Critical error while processing blocks and the user chose to exit.")
}
.show()
}
}
}
twig("MainActivity has received an error${if (notified) " and notified the user" else ""} and reported it to crashlytics and mixpanel.")
feedback.report(error) feedback.report(error)
return true return true
} }
private fun onChainError(errorHeight: Int, rewindHeight: Int) {
feedback.report(Reorg(errorHeight, rewindHeight))
}
// TODO: maybe move this quick helper code somewhere general or throttle the dialogs differently (like with a flow and stream operators, instead)
private val throttles = mutableMapOf<String, () -> Any>()
private val noWork = {}
private fun throttle(key: String, delay: Long, block: () -> Any) {
// if the key exists, just add the block to run later and exit
if (throttles.containsKey(key)) {
throttles[key] = block
return
}
block()
// after doing the work, check back in later and if another request came in, throttle it, otherwise exit
throttles[key] = noWork
findViewById<View>(android.R.id.content).postDelayed({
throttles[key]?.let { pendingWork ->
throttles.remove(key)
if (pendingWork !== noWork) throttle(key, delay, pendingWork)
}
}, delay)
}
} }

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

@ -8,10 +8,7 @@ import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.toAppColor import cash.z.ecc.android.ext.toAppColor
import cash.z.ecc.android.ui.MainActivity import cash.z.ecc.android.ui.MainActivity
import cash.z.wallet.sdk.entity.ConfirmedTransaction import cash.z.wallet.sdk.entity.ConfirmedTransaction
import cash.z.wallet.sdk.ext.ZcashSdk import cash.z.wallet.sdk.ext.*
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.isShielded
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.nio.charset.Charset import java.nio.charset.Charset
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -30,7 +27,8 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
// update view // update view
var lineOne: String = "" var lineOne: String = ""
var lineTwo: String = "" var lineTwo: String = ""
var amount: String = "" var amountZec: String = ""
var amountDisplay: String = ""
var amountColor: Int = 0 var amountColor: Int = 0
var indicatorBackground: Int = 0 var indicatorBackground: Int = 0
@ -38,7 +36,7 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
itemView.setOnClickListener { itemView.setOnClickListener {
onTransactionClicked(this) onTransactionClicked(this)
} }
amount = value.convertZatoshiToZecString() amountZec = value.convertZatoshiToZecString()
// TODO: these might be good extension functions // TODO: these might be good extension functions
val timestamp = formatter.format(blockTimeInSeconds * 1000L) val timestamp = formatter.format(blockTimeInSeconds * 1000L)
val isMined = blockTimeInSeconds != 0L val isMined = blockTimeInSeconds != 0L
@ -46,14 +44,14 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
!toAddress.isNullOrEmpty() -> { !toAddress.isNullOrEmpty() -> {
lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}" lineOne = "You paid ${toAddress?.toAbbreviatedAddress()}"
lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation" lineTwo = if (isMined) "Sent $timestamp" else "Pending confirmation"
amount = "- $amount" amountDisplay = "- $amountZec"
amountColor = R.color.zcashRed amountColor = R.color.zcashRed
indicatorBackground = R.drawable.background_indicator_outbound indicatorBackground = R.drawable.background_indicator_outbound
} }
raw == null || raw?.isEmpty() == true -> { raw == null || raw?.isEmpty() == true -> {
lineOne = "Unknown paid you" lineOne = "Unknown paid you"
lineTwo = "Received $timestamp" lineTwo = "Received $timestamp"
amount = "+ $amount" amountDisplay = "+ $amountZec"
amountColor = R.color.zcashGreen amountColor = R.color.zcashGreen
indicatorBackground = R.drawable.background_indicator_inbound indicatorBackground = R.drawable.background_indicator_inbound
} }
@ -64,14 +62,16 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
} }
// sanitize amount // sanitize amount
if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amount = "< 0.001" if (value < ZcashSdk.MINERS_FEE_ZATOSHI) amountDisplay = "< 0.001"
else if (amount.length > 8) amount = "tap to view" else if (amountZec.length > 10) { // 10 allows 3 digits to the left and 6 to the right of the decimal
amountDisplay = "tap to view"
}
} }
topText.text = lineOne topText.text = lineOne
bottomText.text = lineTwo bottomText.text = lineTwo
amountText.text = amount amountText.text = amountDisplay
amountText.setTextColor(amountColor.toAppColor()) amountText.setTextColor(amountColor.toAppColor())
val context = itemView.context val context = itemView.context
indicator.background = context.resources.getDrawable(indicatorBackground) indicator.background = context.resources.getDrawable(indicatorBackground)

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

@ -85,7 +85,6 @@ class MagicSnakeLoader(
} else { } else {
// once we're ready to show scan progress, do it! Don't do extra loops. // once we're ready to show scan progress, do it! Don't do extra loops.
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) { if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
twig("ZZZ pausing so we can scan! ${if(frame<scanningStartFrame) "WE STOPPED EARLY!" else ""}")
pause() pause()
} }
} }
@ -107,17 +106,14 @@ class MagicSnakeLoader(
private fun playToCompletion() { private fun playToCompletion() {
removeLoops() removeLoops()
twig("ZZZ playing to completion")
unpause() unpause()
} }
private fun removeLoops() { private fun removeLoops() {
lottie.frame.let {frame -> lottie.frame.let {frame ->
if (frame in 33..67) { if (frame in 33..67) {
twig("ZZZ removing 1 loop!")
lottie.frame = frame + 34 lottie.frame = frame + 34
} else if (frame in 0..33) { } else if (frame in 0..33) {
twig("ZZZ removing 2 loops!")
lottie.frame = frame + 67 lottie.frame = frame + 67
} }
} }

View File

@ -1,23 +1,34 @@
package cash.z.ecc.android.ui.profile package cash.z.ecc.android.ui.profile
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
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 androidx.core.content.FileProvider.getUriForFile
import cash.z.ecc.android.BuildConfig import cash.z.ecc.android.BuildConfig
import cash.z.ecc.android.R import cash.z.ecc.android.R
import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentProfileBinding import cash.z.ecc.android.databinding.FragmentProfileBinding
import cash.z.ecc.android.di.viewmodel.viewModel import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.onClick import cash.z.ecc.android.ext.onClick
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.FeedbackFile import cash.z.ecc.android.feedback.FeedbackFile
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.Tap.*
import cash.z.ecc.android.ui.base.BaseFragment import cash.z.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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okio.Okio import okio.Okio
import java.io.File
import java.io.IOException
class ProfileFragment : BaseFragment<FragmentProfileBinding>() { class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
override val screen = Report.Screen.PROFILE
private val viewModel: ProfileViewModel by viewModel() private val viewModel: ProfileViewModel by viewModel()
@ -26,13 +37,20 @@ class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.hitAreaClose.onClickNavBack() binding.hitAreaClose.onClickNavBack() { tapped(PROFILE_CLOSE) }
binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup) binding.buttonBackup.onClickNavTo(R.id.action_nav_profile_to_nav_backup) { tapped(PROFILE_BACKUP) }
binding.textVersion.text = BuildConfig.VERSION_NAME binding.textVersion.text = BuildConfig.VERSION_NAME
onClick(binding.buttonLogs) { onClick(binding.buttonLogs) {
tapped(PROFILE_VIEW_USER_LOGS)
onViewLogs() onViewLogs()
} }
binding.buttonLogs.setOnLongClickListener {
tapped(PROFILE_VIEW_DEV_LOGS)
onViewDevLogs()
true
}
onClick(binding.buttonFeedback) { onClick(binding.buttonFeedback) {
tapped(PROFILE_SEND_FEEDBACK)
onSendFeedback() onSendFeedback()
} }
} }
@ -45,31 +63,63 @@ class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
} }
private fun onViewLogs() { private fun onViewLogs() {
loadLogFileAsText().let { logText -> shareFile(userLogFile())
if (logText == null) { }
mainActivity?.showSnackbar("Log file not found!")
} else {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, logText)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, "Share Log File") private fun onViewDevLogs() {
startActivity(shareIntent) shareFile(writeLogcat())
}
private fun shareFiles(vararg files: File?) {
val uris = arrayListOf<Uri>().apply {
files.filterNotNull().mapNotNull {
getUriForFile(ZcashWalletApp.instance, "${BuildConfig.APPLICATION_ID}.fileprovider", it)
}.forEach {
add(it)
} }
} }
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
type = "text/*"
}
startActivity(Intent.createChooser(intent, "Share Log Files"))
}
fun shareFile(file: File?) {
file ?: return
val uri = getUriForFile(ZcashWalletApp.instance, "${BuildConfig.APPLICATION_ID}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "text/plain"
}
startActivity(Intent.createChooser(intent, "Share Log File"))
} }
private fun onSendFeedback() { private fun onSendFeedback() {
mainActivity?.showSnackbar("Feedback feature coming soon!") mainActivity?.showSnackbar("Feedback feature coming soon!")
} }
private fun userLogFile(): File? {
return mainActivity?.feedbackCoordinator?.findObserver<FeedbackFile>()?.file
}
private fun loadLogFileAsText(): String? { private fun loadLogFileAsText(): String? {
val feedbackFile: FeedbackFile = val feedbackFile: File = userLogFile() ?: return null
mainActivity?.feedbackCoordinator?.findObserver() ?: return null Okio.buffer(Okio.source(feedbackFile)).use {
Okio.buffer(Okio.source(feedbackFile.file)).use {
return it.readUtf8() return it.readUtf8()
} }
} }
private fun writeLogcat(): File? {
try {
val outputFile = File("${ZcashWalletApp.instance.filesDir}/logs", "developer_log.txt")
val cmd = arrayOf("/bin/sh", "-c", "logcat -v time -d | grep \"@TWIG\" > ${outputFile.absolutePath}")
Runtime.getRuntime().exec(cmd)
return outputFile
} catch (e: IOException) {
e.printStackTrace()
twig("Failed to create log")
}
return null
}
} }

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

@ -2,6 +2,8 @@ package cash.z.ecc.android.ui.scan
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import cash.z.wallet.sdk.ext.retrySimple
import cash.z.wallet.sdk.ext.retryUpTo
import cash.z.wallet.sdk.ext.twig import cash.z.wallet.sdk.ext.twig
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import com.google.firebase.ml.vision.FirebaseVision import com.google.firebase.ml.vision.FirebaseVision
@ -27,22 +29,25 @@ class QrAnalyzer(val scanCallback: (qrContent: String, image: ImageProxy) -> Uni
if (rotation < 0) { if (rotation < 0) {
rotation += 360 rotation += 360
} }
val mediaImage = FirebaseVisionImage.fromMediaImage(
image.image!!, when (rotation) { retrySimple {
0 -> FirebaseVisionImageMetadata.ROTATION_0 val mediaImage = FirebaseVisionImage.fromMediaImage(
90 -> FirebaseVisionImageMetadata.ROTATION_90 image.image!!, when (rotation) {
180 -> FirebaseVisionImageMetadata.ROTATION_180 0 -> FirebaseVisionImageMetadata.ROTATION_0
270 -> FirebaseVisionImageMetadata.ROTATION_270 90 -> FirebaseVisionImageMetadata.ROTATION_90
else -> { 180 -> FirebaseVisionImageMetadata.ROTATION_180
FirebaseVisionImageMetadata.ROTATION_0 270 -> FirebaseVisionImageMetadata.ROTATION_270
else -> {
FirebaseVisionImageMetadata.ROTATION_0
}
} }
)
pendingTask = detector.detectInImage(mediaImage).also {
it.addOnSuccessListener { result ->
onImageScan(result, image)
}
it.addOnFailureListener(::onImageScanFailure)
} }
)
pendingTask = detector.detectInImage(mediaImage).also {
it.addOnSuccessListener { result ->
onImageScan(result, image)
}
it.addOnFailureListener(::onImageScanFailure)
} }
} }

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

@ -161,7 +161,7 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
style="@style/TextAppearance.AppCompat.Body1" style="@style/TextAppearance.AppCompat.Body1"
android:textSize="16sp" android:textSize="16sp"
android:text="See Application Log" android:text="See Application Logs"
android:textColor="@color/selector_button_text_light_dimmed" android:textColor="@color/selector_button_text_light_dimmed"
app:layout_constraintTop_toBottomOf="@id/button_backup" app:layout_constraintTop_toBottomOf="@id/button_backup"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Share files from a dedicated folder on the internal files storage. -->
<files-path name="logs" path="logs/"/>
</paths>

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