Merge pull request #65 from zcash/task/stress-test-fixes

Task/stress test fixes
This commit is contained in:
Kevin Gorham 2020-01-15 10:33:39 -05:00 committed by GitHub
commit 27a78a90b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 2150 additions and 497 deletions

View File

@ -5,11 +5,13 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'io.fabric'
apply plugin: 'com.google.firebase.firebase-perf'
//apply plugin: 'com.github.ben-manes.versions'
archivesBaseName = 'zcash-android-wallet'
group = 'cash.z.ecc.android'
version = '1.0.0-alpha05'
version = '1.0.0-alpha10'
android {
compileSdkVersion Deps.compileSdkVersion
@ -19,12 +21,13 @@ android {
applicationId 'cash.z.ecc.android'
minSdkVersion Deps.minSdkVersion
targetSdkVersion Deps.targetSdkVersion
versionCode = 1_00_00_005
versionCode = 1_00_00_010
// 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.
versionName = "$version"
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
testInstrumentationRunnerArguments clearPackageData: 'true'
multiDexEnabled true
// manifestPlaceholders = [rollbarToken: properties["rollbarToken"]]
}
flavorDimensions 'network'
productFlavors {
@ -87,6 +90,10 @@ android {
}
}
crashlytics {
enableNdk true
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':qrecycler')
@ -134,7 +141,12 @@ dependencies {
// 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
implementation 'com.google.guava:guava:27.0.1-android'
// Analytics
implementation 'com.mixpanel.android:mixpanel-android:5.6.3'
implementation 'com.google.firebase:firebase-analytics:17.2.1'
implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1'
implementation 'com.crashlytics.sdk.android:crashlytics-ndk:2.1.1'
implementation 'com.google.firebase:firebase-perf:19.0.4'
// QR Scanning
implementation 'com.google.firebase:firebase-ml-vision:24.0.1'
@ -144,10 +156,16 @@ dependencies {
implementation "androidx.camera:camera-extensions:1.0.0-alpha05"
implementation "androidx.camera:camera-lifecycle:1.0.0-alpha02"
// Misc.
implementation 'com.airbnb.android:lottie:3.1.0'
implementation 'com.facebook.stetho:stetho:1.5.1'
// Tests
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
testImplementation Deps.Test.JUNIT
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.3'
androidTestImplementation Deps.Test.Android.JUNIT
androidTestImplementation Deps.Test.Android.ESPRESSO
}

View File

@ -10,7 +10,7 @@
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/ZcashTheme">
<activity android:name=".ui.MainActivity" android:screenOrientation="portrait">

View File

@ -7,27 +7,37 @@ import androidx.camera.camera2.Camera2Config
import androidx.camera.core.CameraXConfig
import cash.z.ecc.android.di.component.AppComponent
import cash.z.ecc.android.di.component.DaggerAppComponent
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.FeedbackCoordinator
import cash.z.wallet.sdk.ext.SilentTwig
import cash.z.wallet.sdk.ext.TroubleshootingTwig
import cash.z.wallet.sdk.ext.Twig
import cash.z.wallet.sdk.ext.twig
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import javax.inject.Inject
import javax.inject.Provider
class ZcashWalletApp : Application(), CameraXConfig.Provider {
@Inject
lateinit var feedbackCoordinator: FeedbackCoordinator
lateinit var coordinator: FeedbackCoordinator
var creationTime: Long = 0
private set
var creationMeasured: Boolean = false
/**
* Intentionally private Scope for use with launching Feedback jobs. The feedback object has the
* longest scope in the app because it needs to be around early in order to measure launch times
* and stick around late in order to catch crashes. We intentionally don't expose this because
* application objects can have odd lifecycles, given that there is no clear onDestroy moment in
* many cases.
*/
private var feedbackScope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onCreate() {
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(Thread.getDefaultUncaughtExceptionHandler()))
Twig.plant(SilentTwig())
creationTime = System.currentTimeMillis()
instance = this
// Setup handler for uncaught exceptions.
@ -35,8 +45,9 @@ class ZcashWalletApp : Application(), CameraXConfig.Provider {
component = DaggerAppComponent.factory().create(this)
component.inject(this)
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(feedbackCoordinator, Thread.getDefaultUncaughtExceptionHandler()))
Twig.plant(TroubleshootingTwig())
feedbackScope.launch {
coordinator.feedback.start()
}
}
override fun attachBaseContext(base: Context) {
@ -58,17 +69,18 @@ class ZcashWalletApp : Application(), CameraXConfig.Provider {
* is complete, we can lazily initialize all the feedback objects at this moment so that we
* don't have to add any time to startup.
*/
class ExceptionReporter(private val coordinator: FeedbackCoordinator, private val ogHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler {
inner class ExceptionReporter(private val ogHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread?, e: Throwable?) {
twig("Uncaught Exception: $e")
twig("Uncaught Exception: $e caused by: ${e?.cause}")
coordinator.feedback.report(e)
coordinator.flush()
// can do this if necessary but first verify that we need it
runBlocking {
coordinator.await()
coordinator.feedback.stop()
coordinator.await()
coordinator.feedback.stop()
}
ogHandler.uncaughtException(t, e)
Thread.sleep(2000L)
}
}
}

View File

@ -8,6 +8,7 @@ import cash.z.ecc.android.di.annotation.ViewModelKey
import cash.z.ecc.android.di.viewmodel.ViewModelFactory
import cash.z.ecc.android.ui.detail.WalletDetailViewModel
import cash.z.ecc.android.ui.home.HomeViewModel
import cash.z.ecc.android.ui.profile.ProfileViewModel
import cash.z.ecc.android.ui.receive.ReceiveViewModel
import cash.z.ecc.android.ui.scan.ScanViewModel
import cash.z.ecc.android.ui.send.SendViewModel
@ -51,6 +52,12 @@ abstract class ViewModelsSynchronizerModule {
@ViewModelKey(ScanViewModel::class)
abstract fun bindScanViewModel(implementation: ScanViewModel): ViewModel
@SynchronizerScope
@Binds
@IntoMap
@ViewModelKey(ProfileViewModel::class)
abstract fun bindProfileViewModel(implementation: ProfileViewModel): ViewModel
/**
* Factory for view models that are not created until the Synchronizer exists. Only VMs that
* require the Synchronizer should wait until it is created. In other words, these are the VMs

View File

@ -19,10 +19,20 @@ object Report {
SEED_PHRASE_LOADED("metric.seedphrase.loaded", "seed phrase loaded"),
WALLET_CREATED("metric.wallet.created", "wallet created"),
WALLET_IMPORTED("metric.wallet.imported", "wallet imported"),
ACCOUNT_CREATED("metric.account.created", "account created")
ACCOUNT_CREATED("metric.account.created", "account created"),
// Transactions
TRANSACTION_INITIALIZED("metric.tx.initialized", "transaction initialized"),
TRANSACTION_CREATED("metric.tx.created", "transaction created successfully"),
TRANSACTION_SUBMITTED("metric.tx.submitted", "transaction submitted successfully"),
TRANSACTION_MINED("metric.tx.mined", "transaction mined")
}
}
/**
* Creates a metric with a start time of ZcashWalletApp.creationTime and an end time of when this
* instance was created. This can then be passed to [Feedback.report].
*/
class LaunchMetric private constructor(private val metric: Feedback.TimeMetric) :
Feedback.Metric by metric {
constructor() : this(

View File

@ -1,10 +1,13 @@
package cash.z.ecc.android.ui
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Color
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
import android.os.Vibrator
import android.util.Log
@ -15,6 +18,7 @@ import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@ -46,16 +50,18 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var clipboard: ClipboardManager
private val mediaPlayer: MediaPlayer = MediaPlayer()
private var snackbar: Snackbar? = null
lateinit var navController: NavController
lateinit var component: MainActivitySubcomponent
lateinit var synchronizerComponent: SynchronizerSubcomponent
private val hasCameraPermission
get() = ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
override fun onCreate(savedInstanceState: Bundle?) {
component = ZcashWalletApp.component.mainActivitySubcomponent().create(this).also {
@ -163,6 +169,13 @@ class MainActivity : AppCompatActivity() {
}
}
fun copyText(textToCopy: String, label: String = "zECC Wallet Text") {
clipboard.setPrimaryClip(
ClipData.newPlainText(label, textToCopy)
)
showMessage("$label copied!", "Sweet")
}
fun preventBackPress(fragment: Fragment) {
onFragmentBackPressed(fragment){}
}
@ -204,4 +217,43 @@ class MainActivity : AppCompatActivity() {
if (!it.isShownOrQueued) it.show()
}
}
/**
* @param popUpToInclusive the destination to remove from the stack before opening the camera.
* This only takes effect in the common case where the permission is granted.
*/
fun maybeOpenScan(popUpToInclusive: Int? = null) {
if (hasCameraPermission) {
openCamera(popUpToInclusive)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
requestPermissions(arrayOf(Manifest.permission.CAMERA), 101)
} else {
onNoCamera()
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 101) {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
openCamera()
} else {
onNoCamera()
}
}
}
private fun openCamera(popUpToInclusive: Int? = null) {
navController.navigate(popUpToInclusive ?: R.id.action_global_nav_scan)
}
private fun onNoCamera() {
showSnackbar("Well, this is awkward. You denied permission for the camera.")
}
}

View File

@ -13,12 +13,12 @@ class TransactionAdapter<T : ConfirmedTransaction> :
override fun areItemsTheSame(
oldItem: T,
newItem: T
) = oldItem.minedHeight == newItem.minedHeight
) = oldItem.minedHeight == newItem.minedHeight && oldItem.noteId == newItem.noteId
override fun areContentsTheSame(
oldItem: T,
newItem: T
) = oldItem.equals(newItem)
) = oldItem == newItem
}
) {
@ -33,5 +33,4 @@ class TransactionAdapter<T : ConfirmedTransaction> :
holder: TransactionViewHolder<T>,
position: Int
) = holder.bindTo(getItem(position))
}

View File

@ -4,10 +4,14 @@ import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import cash.z.ecc.android.R
import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.toAppColor
import cash.z.ecc.android.ui.MainActivity
import cash.z.wallet.sdk.entity.ConfirmedTransaction
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
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 java.text.SimpleDateFormat
import java.util.*
@ -16,16 +20,22 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
private val amountText = itemView.findViewById<TextView>(R.id.text_transaction_amount)
private val topText = itemView.findViewById<TextView>(R.id.text_transaction_top)
private val bottomText = itemView.findViewById<TextView>(R.id.text_transaction_bottom)
private val shieldIcon = itemView.findViewById<View>(R.id.image_shield)
private val formatter = SimpleDateFormat("M/d h:mma", Locale.getDefault())
fun bindTo(transaction: T?) {
// update view
var lineOne: String = ""
var lineTwo: String = ""
var amount: String = ""
var amountColor: Int = 0
var indicatorBackground: Int = 0
transaction?.apply {
itemView.setOnClickListener {
onTransactionClicked(this)
}
amount = value.convertZatoshiToZecString()
// TODO: these might be good extension functions
val timestamp = formatter.format(blockTimeInSeconds * 1000L)
@ -58,5 +68,33 @@ class TransactionViewHolder<T : ConfirmedTransaction>(itemView: View) : Recycler
amountText.setTextColor(amountColor.toAppColor())
val context = itemView.context
indicator.background = context.resources.getDrawable(indicatorBackground)
shieldIcon.goneIf((transaction?.raw != null || transaction?.expiryHeight != null) && !transaction?.toAddress.isShielded())
}
}
private fun onTransactionClicked(transaction: ConfirmedTransaction) {
val txId = transaction.rawTransactionId.toTxId()
val detailsMessage: String = "Zatoshi amount: ${transaction.value}\n\n" +
"Transaction: $txId"
MaterialAlertDialogBuilder(itemView.context)
.setMessage(detailsMessage)
.setTitle("Transaction Details")
.setCancelable(true)
.setPositiveButton("Ok") { dialog, _ ->
dialog.dismiss()
}
.setNegativeButton("Copy TX") { dialog, _ ->
(itemView.context as MainActivity).copyText(txId, "Transaction Id")
dialog.dismiss()
}
.show()
}
}
private fun ByteArray.toTxId(): String {
val sb = StringBuilder(size * 2)
for(i in (size - 1) downTo 0) {
sb.append(String.format("%02x", this[i]))
}
return sb.toString()
}

View File

@ -0,0 +1,52 @@
package cash.z.ecc.android.ui.detail
//
//import android.content.Context
//import android.graphics.Canvas
//import android.graphics.Rect
//import android.view.LayoutInflater
//import android.view.View
//import androidx.recyclerview.widget.RecyclerView
//import cash.z.ecc.android.R
//
//
//class TransactionsDrawableFooter(context: Context) : RecyclerView.ItemDecoration() {
//
// private var footer: View =
// LayoutInflater.from(context).inflate(R.layout.footer_transactions, null, false)
//
// override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
// super.onDraw(c, parent, state!!)
// footer.measure(
// View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.AT_MOST),
// View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
// )
// // layout basically just gets drawn on the reserved space on top of the first view
// footer.layout(parent.left, 0, parent.right, footer.measuredHeight)
// for (i in 0 until parent.childCount) {
// val view: View = parent.getChildAt(i)
// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
// c.save()
// val height: Int = footer.measuredHeight
// val top: Int = view.top - height
// c.translate(0.0f, top.toFloat())
// footer.draw(c)
// c.restore()
// break
// }
// }
// }
//
// override fun getItemOffsets(
// outRect: Rect,
// view: View,
// parent: RecyclerView,
// state: RecyclerView.State
// ) {
// super.getItemOffsets(outRect, view, parent, state)
// if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
// outRect.set(0, 0, 0, 150)
// } else {
// outRect.setEmpty()
// }
// }
//}

View File

@ -0,0 +1,49 @@
package cash.z.ecc.android.ui.detail
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import cash.z.ecc.android.R
class TransactionsFooter(context: Context) : RecyclerView.ItemDecoration() {
private var footer: Drawable = context.resources.getDrawable(R.drawable.background_footer)
val bounds = Rect()
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
c.save()
val left: Int = 0
val right: Int = parent.width
val childCount = parent.childCount
val adapterItemCount = parent.adapter!!.itemCount
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
if (parent.getChildAdapterPosition(child) == adapterItemCount - 1) {
parent.getDecoratedBoundsWithMargins(child, bounds)
val bottom: Int = bounds.bottom + Math.round(child.translationY)
val top: Int = bottom - footer.intrinsicHeight
footer.setBounds(left, top, right, bottom)
footer.draw(c)
}
}
c.restore()
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
if (parent.getChildAdapterPosition(view) == parent.adapter!!.itemCount - 1) {
outRect.set(0, 0, 0, footer.intrinsicHeight)
} else {
outRect.setEmpty()
}
}
}

View File

@ -49,16 +49,18 @@ class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
private fun onBalanceUpdated(balance: WalletBalance) {
binding.textBalanceAvailable.text = balance.availableZatoshi.convertZatoshiToZecString()
val change = balance.totalZatoshi - balance.availableZatoshi
val change = (balance.totalZatoshi - balance.availableZatoshi)
binding.textBalanceDescription.apply {
goneIf(change <= 0)
text = "(expecting +$change ZEC in change)".toColoredSpan(R.color.text_light, "+$change")
goneIf(change <= 0L)
val changeString = change.convertZatoshiToZecString()
text = "(expecting +$changeString ZEC)".toColoredSpan(R.color.text_light, "+${changeString}")
}
}
private fun initTransactionUI() {
binding.recyclerTransactions.layoutManager =
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
binding.recyclerTransactions.addItemDecoration(TransactionsFooter(binding.recyclerTransactions.context))
adapter = TransactionAdapter()
viewModel.transactions.collectWith(resumedScope) { onTransactionsUpdated(it) }
binding.recyclerTransactions.adapter = adapter
@ -66,6 +68,12 @@ class WalletDetailFragment : BaseFragment<FragmentDetailBinding>() {
private fun onTransactionsUpdated(transactions: PagedList<ConfirmedTransaction>) {
twig("got a new paged list of transactions")
binding.groupEmptyViews.goneIf(transactions.size > 0)
adapter.submitList(transactions)
}
// TODO: maybe implement this for better fade behavior. Or do an actual scroll behavior instead, yeah do that. Or an item decoration.
fun onLastItemShown(item: ConfirmedTransaction, position: Int) {
binding.footerFade.alpha = position.toFloat() / (binding.recyclerTransactions.adapter?.itemCount ?: 1)
}
}

View File

@ -2,32 +2,30 @@ package cash.z.ecc.android.ui.home
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.PorterDuff
import android.os.Bundle
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.core.text.toSpannable
import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentHomeBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.*
import cash.z.ecc.android.ext.toAppColor
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
import cash.z.ecc.android.ui.send.SendViewModel
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.NO_SEED
import cash.z.wallet.sdk.Synchronizer.Status.SYNCING
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.Synchronizer.Status.*
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.convertZecToZatoshi
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
import cash.z.wallet.sdk.ext.twig
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -41,8 +39,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
private val sendViewModel: SendViewModel by activityViewModel()
private val viewModel: HomeViewModel by viewModel()
private val _typedChars = ConflatedBroadcastChannel<Char>()
private val typedChars = _typedChars.asFlow()
lateinit var snake: MagicSnakeLoader
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
FragmentHomeBinding.inflate(inflater)
@ -54,6 +51,10 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
override fun onAttach(context: Context) {
twig("HomeFragment.onAttach")
twig("ZZZ")
twig("ZZZ")
twig("ZZZ")
twig("ZZZ ===================== HOME FRAGMENT CREATED ==================================")
super.onAttach(context)
// this will call startSync either now or later (after initializing with newly created seed)
@ -92,15 +93,19 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
hitAreaReceive.onClickNavTo(R.id.action_nav_home_to_nav_profile)
iconDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
textDetail.onClickNavTo(R.id.action_nav_home_to_nav_detail)
hitAreaScan.onClickNavTo(R.id.action_nav_home_to_nav_scan)
hitAreaScan.setOnClickListener {
mainActivity?.maybeOpenScan()
}
textBannerAction.setOnClickListener {
onBannerAction(BannerAction.from((it as? TextView)?.text?.toString()))
}
buttonSend.setOnClickListener {
buttonSendAmount.setOnClickListener {
onSend()
}
setSendAmount("0")
setSendAmount("0", false)
snake = MagicSnakeLoader(binding.lottieButtonLoading)
}
binding.buttonNumberPadBack.setOnLongClickListener {
@ -108,46 +113,60 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
true
}
// if (::uiModel.isInitialized) {
// twig("uiModel exists!")
// onModelUpdated(HomeViewModel.UiModel(), uiModel)
// }
if (::uiModel.isInitialized) {
twig("uiModel exists!")
onModelUpdated(null, uiModel)
}
}
private fun onClearAmount() {
repeat(binding.textSendAmount.text.length) {
if (::uiModel.isInitialized) {
resumedScope.launch {
_typedChars.send('<')
binding.textSendAmount.text.apply {
while (uiModel.pendingSend != "0") {
viewModel.onChar('<')
delay(5)
}
}
}
}
}
override fun onResume() {
super.onResume()
viewModel.initialize(typedChars)
twig("HomeFragment.onResume resumeScope.isActive: ${resumedScope.isActive} $resumedScope")
viewModel.initializeMaybe()
twig("onResume (A)")
onClearAmount()
twig("onResume (B)")
viewModel.uiModels.scanReduce { old, new ->
onModelUpdated(old, new)
new
}.onCompletion {
twig("uiModel.scanReduce completed.")
}.catch { e ->
twig("exception while processing uiModels $e")
throw e
}.launchIn(resumedScope)
twig("onResume (C)")
// TODO: see if there is a better way to trigger a refresh of the uiModel on resume
// the latest one should just be in the viewmodel and we should just "resubscribe"
// but for some reason, this doesn't always happen, which kind of defeats the purpose
// of having a cold stream in the view model
resumedScope.launch {
twig("onResume (pre-fresh)")
viewModel.refreshBalance()
twig("onResume (post-fresh)")
}
twig("onResume (D)")
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
twig("HomeFragment.onSaveInstanceState")
if (::uiModel.isInitialized) {
outState.putParcelable("uiModel", uiModel)
// outState.putParcelable("uiModel", uiModel)
}
}
@ -155,7 +174,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
super.onViewStateRestored(savedInstanceState)
savedInstanceState?.let { inState ->
twig("HomeFragment.onViewStateRestored")
onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!)
// onModelUpdated(HomeViewModel.UiModel(), inState.getParcelable("uiModel")!!)
}
}
@ -165,29 +184,64 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
//
fun setSendEnabled(enabled: Boolean) {
binding.buttonSend.apply {
binding.buttonSendAmount.apply {
isEnabled = enabled
backgroundTintList = ColorStateList.valueOf( resources.getColor( if(enabled) R.color.colorPrimary else R.color.zcashWhite_24) )
if (enabled) {
// setTextColor(resources.getColorStateList(R.color.selector_button_text_dark))
binding.lottieButtonLoading.alpha = 1.0f
} else {
// setTextColor(R.color.zcashGray.toAppColor())
binding.lottieButtonLoading.alpha = 0.32f
}
}
}
fun setProgress(progress: Int) {
progress.let {
if (it < 100) {
setBanner("Downloading . . . $it%", NONE)
} else {
setBanner("Scanning . . .", NONE)
}
fun setProgress(uiModel: HomeViewModel.UiModel) {
if (!uiModel.processorInfo.hasData) {
twig("Warning: ignoring progress update because the processor has not started.")
return
}
snake.isSynced = uiModel.isSynced
if (!uiModel.isSynced) {
snake.downloadProgress = uiModel.downloadProgress
snake.scanProgress = uiModel.scanProgress
}
val sendText = when {
uiModel.isSynced -> if (uiModel.hasFunds) "SEND AMOUNT" else "NO FUNDS AVAILABLE"
uiModel.status == Synchronizer.Status.DISCONNECTED -> "DISCONNECTED"
uiModel.status == Synchronizer.Status.STOPPED -> "IDLE"
uiModel.isDownloading -> "Downloading . . . ${snake.downloadProgress}%"
uiModel.isValidating -> "Validating . . ."
uiModel.isScanning -> "Scanning . . . ${snake.scanProgress}%"
else -> "Updating"
}
// binding.lottieButtonLoading.progress = if (uiModel.isSynced) 1.0f else uiModel.totalProgress * 0.82f // line fully closes at 82% mark
binding.buttonSendAmount.text = sendText
// twig("Lottie progress set to ${binding.lottieButtonLoading.progress} (isSynced? ${uiModel.isSynced})")
twig("Send button set to: $sendText")
val resId = if (uiModel.isSynced) R.color.selector_button_text_dark else R.color.selector_button_text_light
binding.buttonSendAmount.setTextColor(resources.getColorStateList(resId))
// if (uiModel.status == DISCONNECTED || uiModel.status == STOPPED) {
// binding.buttonSendAmount.backgroundTintList = ColorStateList.valueOf(resources.getColor(R.color.zcashGray))
// } else {
// binding.buttonSendAmount.backgroundTintList = null
// }
}
/**
* @param amount the amount to send represented as ZEC, without the dollar sign.
*/
fun setSendAmount(amount: String) {
fun setSendAmount(amount: String, updateModel: Boolean = true) {
binding.textSendAmount.text = "\$$amount".toColoredSpan(R.color.text_light_dimmed, "$")
sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
binding.buttonSend.disabledIf(amount == "0")
if (updateModel) {
sendViewModel.zatoshiAmount = amount.safelyConvertToBigDecimal().convertZecToZatoshi()
}
binding.buttonSendAmount.disabledIf(amount == "0")
}
fun setAvailable(availableBalance: Long = -1L, totalBalance: Long = -1L) {
@ -197,17 +251,13 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
goneIf(availableBalance < 0)
text = if (availableBalance != -1L && (availableBalance < totalBalance)) {
val change = (totalBalance - availableBalance).convertZatoshiToZecString()
"(expecting +$change ZEC in change)".toColoredSpan(R.color.text_light, "+$change")
"(expecting +$change ZEC)".toColoredSpan(R.color.text_light, "+$change")
} else {
"(enter an amount to send)"
}
}
}
fun setSendText(buttonText: String = "Send Amount") {
binding.buttonSend.text = buttonText
}
fun setBanner(message: String = "", action: BannerAction = CLEAR) {
with(binding) {
val hasMessage = !message.isEmpty() || action != CLEAR
@ -225,30 +275,38 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// Private UI Events
//
private fun onModelUpdated(old: HomeViewModel.UiModel, new: HomeViewModel.UiModel) {
twig(new.toString())
private fun onModelUpdated(old: HomeViewModel.UiModel?, new: HomeViewModel.UiModel) {
twig("onModelUpdated: $new")
uiModel = new
if (old.pendingSend != new.pendingSend) {
twig("onModelUpdated (A)")
if (old?.pendingSend != new.pendingSend) {
twig("onModelUpdated (B)")
setSendAmount(new.pendingSend)
twig("onModelUpdated (C)")
}
twig("onModelUpdated (D)")
// TODO: handle stopped and disconnected flows
if (new.status == SYNCING) onSyncing(new) else onSynced(new)
setProgress(uiModel) // TODO: we may not need to separate anymore
twig("onModelUpdated (E)")
// if (new.status = SYNCING) onSyncing(new) else onSynced(new)
if (new.status == SYNCED) onSynced(new) else onSyncing(new)
twig("onModelUpdated (F)")
setSendEnabled(new.isSendEnabled)
twig("onModelUpdated (G) sendEnabled? ${new.isSendEnabled}")
twig("DONE onModelUpdated")
}
private fun onSyncing(uiModel: HomeViewModel.UiModel) {
setProgress(uiModel.progress) // calls setBanner
setAvailable()
setSendText("Syncing Blockchain…")
}
private fun onSynced(uiModel: HomeViewModel.UiModel) {
snake.isSynced = true
if (!uiModel.hasBalance) {
onNoFunds()
} else {
setBanner("")
setAvailable(uiModel.availableBalance, uiModel.totalBalance)
setSendText()
}
}
@ -260,18 +318,27 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
when (action) {
FUND_NOW -> {
MaterialAlertDialogBuilder(activity)
.setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
.setMessage("To make full use of this wallet, deposit funds to your address.")
.setTitle("No Balance")
.setCancelable(true)
.setPositiveButton("Tap Faucet") { dialog, _ ->
dialog.dismiss()
setBanner("Tapping faucet...", CANCEL)
}
.setNegativeButton("View Address") { dialog, _ ->
.setPositiveButton("View Address") { dialog, _ ->
dialog.dismiss()
mainActivity?.navController?.navigate(R.id.action_nav_home_to_nav_receive)
}
.show()
// MaterialAlertDialogBuilder(activity)
// .setMessage("To make full use of this wallet, deposit funds to your address or tap the faucet to trigger a tiny automatic deposit.\n\nFaucet funds are made available for the community by the community for testing. So please be kind enough to return what you borrow!")
// .setTitle("No Balance")
// .setCancelable(true)
// .setPositiveButton("Tap Faucet") { dialog, _ ->
// dialog.dismiss()
// setBanner("Tapping faucet...", CANCEL)
// }
// .setNegativeButton("View Address") { dialog, _ ->
// dialog.dismiss()
// mainActivity?.navController?.navigate(R.id.action_nav_home_to_nav_receive)
// }
// .show()
}
CANCEL -> {
// TODO: trigger banner / balance update
@ -310,7 +377,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
setOnClickListener {
lifecycleScope.launch {
twig("CHAR TYPED: $c")
_typedChars.send(c)
viewModel.onChar(c)
}
}
return this

View File

@ -1,20 +1,17 @@
package cash.z.ecc.android.ui.home
import android.os.Parcelable
import androidx.lifecycle.ViewModel
import cash.z.wallet.sdk.SdkSynchronizer
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.Synchronizer.Status.DISCONNECTED
import cash.z.wallet.sdk.Synchronizer.Status.SYNCED
import cash.z.wallet.sdk.Synchronizer.Status.*
import cash.z.wallet.sdk.block.CompactBlockProcessor
import cash.z.wallet.sdk.ext.ZcashSdk.MINERS_FEE_ZATOSHI
import cash.z.wallet.sdk.ext.ZcashSdk.ZATOSHI_PER_ZEC
import cash.z.wallet.sdk.ext.twig
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import kotlin.math.roundToInt
class HomeViewModel @Inject constructor() : ViewModel() {
@ -23,11 +20,12 @@ class HomeViewModel @Inject constructor() : ViewModel() {
lateinit var uiModels: Flow<UiModel>
private val _typedChars = ConflatedBroadcastChannel<Char>()
private val typedChars = _typedChars.asFlow()
var initialized = false
fun initialize(
typedChars: Flow<Char>
) {
fun initializeMaybe() {
twig("init called")
if (initialized) {
twig("Warning already initialized HomeViewModel. Ignoring call to initialize.")
@ -55,8 +53,9 @@ class HomeViewModel @Inject constructor() : ViewModel() {
}
}
}
twig("initializing view models stream")
uiModels = synchronizer.run {
combine(status, progress, balances, zec) { s, p, b, z->
combine(status, processorInfo, balances, zec) { s, p, b, z->
UiModel(s, p, b.availableZatoshi, b.totalZatoshi, z)
}
}.conflate()
@ -67,22 +66,64 @@ class HomeViewModel @Inject constructor() : ViewModel() {
twig("HomeViewModel cleared!")
}
suspend fun onChar(c: Char) {
_typedChars.send(c)
}
suspend fun refreshBalance() {
(synchronizer as SdkSynchronizer).refreshBalance()
}
@Parcelize
data class UiModel( // <- THIS ERROR IS AN IDE BUG WITH PARCELIZE
val status: Synchronizer.Status = DISCONNECTED,
val progress: Int = 0,
val processorInfo: CompactBlockProcessor.ProcessorInfo = CompactBlockProcessor.ProcessorInfo(),
val availableBalance: Long = -1L,
val totalBalance: Long = -1L,
val pendingSend: String = "0"
): Parcelable {
) {
// Note: the wallet is effectively empty if it cannot cover the miner's fee
val hasFunds: Boolean get() = availableBalance > (MINERS_FEE_ZATOSHI.toDouble() / ZATOSHI_PER_ZEC) // 0.0001
val hasBalance: Boolean get() = totalBalance > (MINERS_FEE_ZATOSHI.toDouble() / ZATOSHI_PER_ZEC) // 0.0001
val hasBalance: Boolean get() = totalBalance > 0
val isSynced: Boolean get() = status == SYNCED
val isSendEnabled: Boolean get() = isSynced && hasFunds
// Processor Info
val isDownloading = status == DOWNLOADING
val isScanning = status == SCANNING
val isValidating = status == VALIDATING
val downloadProgress: Int get() {
return processorInfo.run {
if (lastDownloadRange.isEmpty()) {
100
} else {
twig("NUMERATOR: $lastDownloadedHeight - ${lastDownloadRange.first} + 1 = ${lastDownloadedHeight - lastDownloadRange.first + 1} block(s) downloaded")
twig("DENOMINATOR: ${lastDownloadRange.last} - ${lastDownloadRange.first} + 1 = ${lastDownloadRange.last - lastDownloadRange.first + 1} block(s) to download")
val progress =
(((lastDownloadedHeight - lastDownloadRange.first + 1).coerceAtLeast(0).toFloat() / (lastDownloadRange.last - lastDownloadRange.first + 1)) * 100.0f).coerceAtMost(
100.0f
).roundToInt()
twig("RESULT: $progress")
progress
}
}
}
val scanProgress: Int get() {
return processorInfo.run {
if (lastScanRange.isEmpty()) {
100
} else {
twig("NUMERATOR: ${lastScannedHeight - lastScanRange.first + 1} block(s) scanned")
twig("DENOMINATOR: ${lastScanRange.last - lastScanRange.first + 1} block(s) to scan")
val progress = (((lastScannedHeight - lastScanRange.first + 1).coerceAtLeast(0).toFloat() / (lastScanRange.last - lastScanRange.first + 1)) * 100.0f).coerceAtMost(100.0f).roundToInt()
twig("RESULT: $progress")
progress
}
}
}
val totalProgress: Float get() {
val downloadWeighted = 0.40f * (downloadProgress.toFloat() / 100.0f).coerceAtMost(1.0f)
val scanWeighted = 0.60f * (scanProgress.toFloat() / 100.0f).coerceAtMost(1.0f)
return downloadWeighted.coerceAtLeast(0.0f) + scanWeighted.coerceAtLeast(0.0f)
}
}
}

View File

@ -0,0 +1,191 @@
package cash.z.ecc.android.ui.home
import android.animation.ValueAnimator
import cash.z.wallet.sdk.ext.twig
import com.airbnb.lottie.LottieAnimationView
class MagicSnakeLoader(
val lottie: LottieAnimationView,
private val scanningStartFrame: Int = 100,
private val scanningEndFrame: Int = 175,
val totalFrames: Int = 200
) : ValueAnimator.AnimatorUpdateListener {
private var isPaused: Boolean = true
private var isStarted: Boolean = false
var isSynced: Boolean = false
set(value) {
twig("ZZZ isSynced=$value isStarted=$isStarted")
if (value && !isStarted) {
twig("ZZZ isSynced=$value TURBO sync")
lottie.progress = 1.0f
field = value
return
}
// it is started but it hadn't reached the synced state yet
if (value && !field) {
twig("ZZZ synced was $field but now is $value so playing to completion since we are now synced")
field = value
playToCompletion()
} else {
field = value
twig("ZZZ isSynced=$value and lottie.progress=${lottie.progress}")
}
}
var scanProgress: Int = 0
set(value) {
field = value
twig("ZZZ scanProgress=$value")
if (value > 0) {
startMaybe()
onScanUpdated()
}
}
var downloadProgress: Int = 0
set(value) {
field = value
twig("ZZZ downloadProgress=$value")
if (value > 0) startMaybe()
}
private fun startMaybe() {
if (!isSynced && !isStarted) lottie.postDelayed({
// after some delay, if we're still not synced then we better start animating (unless we already are)!
if (!isSynced && isPaused) {
twig("ZZZ yes start!")
lottie.resumeAnimation()
isPaused = false
isStarted = true
} else {
twig("ZZZ I would have started but we're already synced!")
}
}, 200L).also { twig("ZZZ startMaybe???") }
}
// set(value) {
// field = value
// if (value in 1..99 && isStopped) {
// lottie.playAnimation()
// isStopped = false
// } else if (value >= 100) {
// isStopped = true
// }
// }
private val isDownloading get() = downloadProgress in 1..99
private val isScanning get() = scanProgress in 1..99
init {
lottie.addAnimatorUpdateListener(this)
}
// downloading = true
// lottieAnimationView.playAnimation()
// lottieAnimationView.addAnimatorUpdateListener { valueAnimator ->
// // Set animation progress
// val progress = (valueAnimator.animatedValue as Float * 100).toInt()
// progressTv.text = "Progress: $progress%"
//
// if (downloading && progress >= 40) {
// lottieAnimationView.progress = 0f
// }
// }
override fun onAnimationUpdate(animation: ValueAnimator) {
if (isSynced || isPaused) {
// playToCompletion()
return
}
twig("ZZZ")
twig("ZZZ\t\tonAnimationUpdate(${animation.animatedValue})")
// if we are scanning, then set the animation progress, based on the scan progress
// if we're not scanning, then we're looping
animation.currentFrame().let { frame ->
if (isDownloading) allowLoop(frame) else applyScanProgress(frame)
}
}
private val acceptablePauseFrames = arrayOf(33,34,67,68,99)
private fun applyScanProgress(frame: Int) {
twig("ZZZ applyScanProgress($frame) : isPaused=$isPaused isStarted=$isStarted min=${lottie.minFrame} max=${lottie.maxFrame}")
// don't hardcode the progress until the loop animation has completed, cleanly
if (isPaused) {
onScanUpdated()
} else {
// once we're ready to show scan progress, do it! Don't do extra loops.
if (frame >= scanningStartFrame || frame in acceptablePauseFrames) {
twig("ZZZ pausing so we can scan! ${if(frame<scanningStartFrame) "WE STOPPED EARLY!" else ""}")
pause()
}
}
}
private fun onScanUpdated() {
twig("ZZZ onScanUpdated : isPaused=$isPaused")
if (isSynced) {
// playToCompletion()
return
}
if (isPaused && isStarted) {
// move forward within the scan range, proportionate to how much scanning is complete
val scanRange = scanningEndFrame - scanningStartFrame
val scanRangeProgress = scanProgress.toFloat() / 100.0f * scanRange.toFloat()
lottie.progress = (scanningStartFrame.toFloat() + scanRangeProgress) / totalFrames
twig("ZZZ onScanUpdated : scanRange=$scanRange scanRangeProgress=$scanRangeProgress lottie.progress=${(scanningStartFrame.toFloat() + scanRangeProgress)}/$totalFrames=${lottie.progress}")
}
}
private fun playToCompletion() {
removeLoops()
twig("ZZZ playing to completion")
unpause()
}
private fun removeLoops() {
lottie.frame.let {frame ->
if (frame in 33..67) {
twig("ZZZ removing 1 loop!")
lottie.frame = frame + 34
} else if (frame in 0..33) {
twig("ZZZ removing 2 loops!")
lottie.frame = frame + 67
}
}
}
private fun allowLoop(frame: Int) {
twig("ZZZ allowLoop($frame) : isPaused=$isPaused")
unpause()
if (frame >= scanningStartFrame) {
twig("ZZZ resetting to 0f (LOOPING)")
lottie.progress = 0f
}
}
fun unpause() {
if (isPaused) {
twig("ZZZ unpausing")
lottie.resumeAnimation()
isPaused = false
}
}
fun pause() {
if (!isPaused) {
twig("ZZZ pausing")
lottie.pauseAnimation()
isPaused = true
}
}
private fun ValueAnimator.currentFrame(): Int {
return ((animatedValue as Float) * totalFrames).toInt()
}
}

View File

@ -7,14 +7,20 @@ import android.view.View
import cash.z.ecc.android.BuildConfig
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentProfileBinding
import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.onClick
import cash.z.ecc.android.ext.onClickNavBack
import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.feedback.FeedbackFile
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import kotlinx.coroutines.launch
import okio.Okio
class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
private val viewModel: ProfileViewModel by viewModel()
override fun inflate(inflater: LayoutInflater): FragmentProfileBinding =
FragmentProfileBinding.inflate(inflater)
@ -31,6 +37,13 @@ class ProfileFragment : BaseFragment<FragmentProfileBinding>() {
}
}
override fun onResume() {
super.onResume()
resumedScope.launch {
binding.textAddress.text = viewModel.getAddress().toAbbreviatedAddress(12, 12)
}
}
private fun onViewLogs() {
loadLogFileAsText().let { logText ->
if (logText == null) {

View File

@ -0,0 +1,19 @@
package cash.z.ecc.android.ui.profile
import androidx.lifecycle.ViewModel
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.ext.twig
import javax.inject.Inject
class ProfileViewModel @Inject constructor() : ViewModel() {
@Inject
lateinit var synchronizer: Synchronizer
suspend fun getAddress(): String = synchronizer.getAddress()
override fun onCleared() {
super.onCleared()
twig("ProfileViewModel cleared!")
}
}

View File

@ -39,7 +39,9 @@ class ReceiveFragment : BaseFragment<FragmentReceiveNewBinding>() {
// text_address_part_7,
// text_address_part_8
// )
binding.buttonScan.onClickNavTo(R.id.action_nav_receive_to_nav_scan)
binding.buttonScan.setOnClickListener {
mainActivity?.maybeOpenScan(R.id.action_nav_receive_to_nav_scan)
}
binding.backButtonHitArea.onClickNavBack()
}

View File

@ -14,6 +14,6 @@ class ReceiveViewModel @Inject constructor() : ViewModel() {
override fun onCleared() {
super.onCleared()
twig("WalletDetailViewModel cleared!")
twig("ReceiveViewModel cleared!")
}
}

View File

@ -2,15 +2,20 @@ package cash.z.ecc.android.ui.send
import android.content.ClipboardManager
import android.content.Context
import android.content.res.ColorStateList
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.EditText
import androidx.core.widget.doAfterTextChanged
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendAddressBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.*
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.block.CompactBlockProcessor.WalletBalance
import cash.z.wallet.sdk.ext.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -18,6 +23,8 @@ import kotlinx.coroutines.launch
class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
ClipboardManager.OnPrimaryClipChangedListener {
private var maxZatoshi: Long? = null
val sendViewModel: SendViewModel by activityViewModel()
override fun inflate(inflater: LayoutInflater): FragmentSendAddressBinding =
@ -25,55 +32,97 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home)
binding.buttonNext.setOnClickListener {
onSubmit()
}
binding.backButtonHitArea.onClickNavTo(R.id.action_nav_send_address_to_nav_home)
binding.textBannerAction.setOnClickListener {
onPaste()
}
binding.textBannerMessage.setOnClickListener {
onPaste()
}
binding.textMax.setOnClickListener {
onMax()
}
// Apply View Model
if (sendViewModel.zatoshiAmount > 0L) {
sendViewModel.zatoshiAmount.convertZatoshiToZecString(8).let { amount ->
binding.inputZcashAmount.setText(amount)
binding.textAmount.text = "Sending $amount ZEC"
}
} else {
binding.inputZcashAmount.setText(null)
}
if (!sendViewModel.toAddress.isNullOrEmpty()){
binding.textAmount.text = "Send to ${sendViewModel.toAddress.toAbbreviatedAddress()}"
if (!sendViewModel.toAddress.isNullOrEmpty()) {
binding.inputZcashAddress.setText(sendViewModel.toAddress)
} else {
binding.inputZcashAddress.setText(null)
}
binding.inputZcashAddress.onEditorActionDone(::onSubmit)
binding.inputZcashAmount.onEditorActionDone(::onSubmit)
binding.imageScanQr.onClickNavTo(R.id.action_nav_send_address_to_nav_scan)
binding.inputZcashAddress.apply {
doAfterTextChanged {
val trim = text.toString().trim()
if (text.toString() != trim) {
binding.inputZcashAddress
.findViewById<EditText>(R.id.input_zcash_address).setText(trim)
}
onAddressChanged(trim)
}
}
binding.textLayoutAddress.setEndIconOnClickListener {
mainActivity?.maybeOpenScan()
}
}
private fun onAddressChanged(address: String) {
resumedScope.launch {
var type = when (sendViewModel.validateAddress(address)) {
is Synchronizer.AddressType.Transparent -> "This is a valid transparent address" to R.color.zcashGreen
is Synchronizer.AddressType.Shielded -> "This is a valid shielded address" to R.color.zcashGreen
is Synchronizer.AddressType.Invalid -> "This address appears to be invalid" to R.color.zcashRed
}
if (address == sendViewModel.synchronizer.getAddress()) type =
"Warning, this appears to be your address!" to R.color.zcashRed
binding.textLayoutAddress.helperText = type.first
binding.textLayoutAddress.setHelperTextColor(ColorStateList.valueOf(type.second.toAppColor()))
}
}
private fun onSubmit(unused: EditText? = null) {
sendViewModel.toAddress = binding.inputZcashAddress.text.toString()
binding.inputZcashAmount.convertZecToZatoshi()?.let { sendViewModel.zatoshiAmount = it }
sendViewModel.validate().onFirstWith(resumedScope) {
sendViewModel.validate(maxZatoshi).onFirstWith(resumedScope) {
if (it == null) {
mainActivity?.navController?.navigate(R.id.action_nav_send_address_to_send_memo)
} else {
resumedScope.launch {
binding.textAddressError.text = it
delay(1500L)
binding.textAddressError.text = ""
binding.textAddressError.text = ""
}
}
}
}
private fun onMax() {
if (maxZatoshi != null) {
binding.inputZcashAmount.apply {
setText(maxZatoshi.convertZatoshiToZecString(8))
postDelayed({
requestFocus()
setSelection(text?.length ?: 0)
}, 10L)
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
mainActivity?.clipboard?.addPrimaryClipChangedListener(this)
@ -87,6 +136,18 @@ class SendAddressFragment : BaseFragment<FragmentSendAddressBinding>(),
override fun onResume() {
super.onResume()
updateClipboardBanner()
sendViewModel.synchronizer.balances.collectWith(resumedScope) {
onBalanceUpdated(it)
}
binding.inputZcashAddress.text.toString().let {
if (!it.isNullOrEmpty()) onAddressChanged(it)
}
}
private fun onBalanceUpdated(balance: WalletBalance) {
binding.textLayoutAmount.helperText =
"You have ${balance.availableZatoshi.convertZatoshiToZecString(8)} available"
maxZatoshi = balance.availableZatoshi - ZcashSdk.MINERS_FEE_ZATOSHI
}
override fun onPrimaryClipChanged() {

View File

@ -9,11 +9,15 @@ import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentSendFinalBinding
import cash.z.ecc.android.di.viewmodel.activityViewModel
import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.MetricType.*
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.toAbbreviatedAddress
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.twig
import com.crashlytics.android.Crashlytics
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
@ -32,6 +36,9 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
binding.buttonNext.setOnClickListener {
onExit()
}
binding.buttonRetry.setOnClickListener {
onRetry()
}
binding.backButtonHitArea.setOnClickListener {
onExit()
}
@ -69,28 +76,47 @@ class SendFinalFragment : BaseFragment<FragmentSendFinalBinding>() {
}
private fun onPendingTxUpdated(pendingTransaction: PendingTransaction?) {
val id = pendingTransaction?.id ?: -1
var isSending = true
val message = when {
pendingTransaction == null -> "Transaction not found"
pendingTransaction.isMined() -> "Transaction Mined (id: $id)!\n\nSEND COMPLETE".also { isSending = false }
pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ."
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false }
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false }
pendingTransaction.isCreated() -> "Transaction creation complete! (id: $id)"
pendingTransaction.isCreating() -> "Creating transaction . . ."
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
try {
if (pendingTransaction != null) sendViewModel.updateMetrics(pendingTransaction)
val id = pendingTransaction?.id ?: -1
var isSending = true
var isFailure = false
val message = when {
pendingTransaction == null -> "Transaction not found"
pendingTransaction.isMined() -> "Transaction Mined!\n\nSEND COMPLETE".also { isSending = false }
pendingTransaction.isSubmitSuccess() -> "Successfully submitted transaction!\nAwaiting confirmation . . ."
pendingTransaction.isFailedEncoding() -> "ERROR: failed to encode transaction! (id: $id)".also { isSending = false; isFailure = true }
pendingTransaction.isFailedSubmit() -> "ERROR: failed to submit transaction! (id: $id)".also { isSending = false; isFailure = true }
pendingTransaction.isCreated() -> "Transaction creation complete!"
pendingTransaction.isCreating() -> "Creating transaction . . ."
else -> "Transaction updated!".also { twig("Unhandled TX state: $pendingTransaction") }
}
twig("Pending TX (id: ${pendingTransaction?.id} Updated with message: $message")
binding.textStatus.apply {
text = "$message"
}
binding.backButton.goneIf(!binding.textStatus.text.toString().contains("Awaiting"))
binding.buttonNext.goneIf((pendingTransaction?.isSubmitSuccess() != true) && (pendingTransaction?.isCreated() != true) && !isFailure)
binding.buttonNext.text = if (isSending) "Done" else "Finished"
binding.buttonRetry.goneIf(!isFailure)
binding.progressHorizontal.goneIf(!isSending)
if (pendingTransaction?.isSubmitSuccess() == true) {
sendViewModel.reset()
}
} catch(t: Throwable) {
twig("ERROR: error while handling pending transaction update! $t")
Crashlytics.logException(t)
}
twig("Pending TX Updated: $message")
binding.textStatus.apply {
text = "$text\n$message"
}
binding.backButton.goneIf(!binding.textStatus.text.toString().contains("Awaiting"))
binding.buttonNext.goneIf(isSending)
binding.progressHorizontal.goneIf(!isSending)
}
private fun onExit() {
mainActivity?.navController?.popBackStack(R.id.nav_send_address, true)
mainActivity?.navController?.popBackStack(R.id.nav_home, false)
}
}
private fun onRetry() {
mainActivity?.navController?.popBackStack(R.id.nav_send_address, false)
}
}

View File

@ -10,6 +10,7 @@ import cash.z.ecc.android.di.viewmodel.activityViewModel
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.toColoredSpan
import cash.z.ecc.android.ui.base.BaseFragment
class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
@ -22,10 +23,10 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.buttonNext.setOnClickListener {
onAddMemo()
onTopButton()
}
binding.buttonSkip.setOnClickListener {
onSkip()
onBottomButton()
}
R.id.action_nav_send_memo_to_nav_send_address.let {
binding.backButtonHitArea.onClickNavTo(it)
@ -38,7 +39,7 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
binding.inputMemo.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
onAddMemo()
onTopButton()
true
} else {
false
@ -54,8 +55,20 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
}
private fun applyModel() {
binding.inputMemo.setText(sendViewModel.memo)
binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress
sendViewModel.isShielded.let { isShielded ->
binding.groupShielded.goneIf(!isShielded)
binding.groupTransparent.goneIf(isShielded)
if (isShielded) {
binding.inputMemo.setText(sendViewModel.memo)
binding.checkIncludeAddress.isChecked = sendViewModel.includeFromAddress
binding.buttonNext.text = "ADD MEMO"
binding.buttonSkip.text = "SEND WITHOUT MEMO"
} else {
binding.buttonNext.text = "WAIT, GO BACK"
binding.buttonSkip.text = "PROCEED"
binding.sadTitle.text = binding.sadTitle.text.toString().toColoredSpan(R.color.colorPrimary, "sad")
}
}
}
private fun onIncludeMemo(checked: Boolean) {
@ -64,18 +77,22 @@ class SendMemoFragment : BaseFragment<FragmentSendMemoBinding>() {
if (checked) binding.inputMemo.setHint("") else binding.inputMemo.setHint("Add a memo here")
}
private fun onSkip() {
private fun onTopButton() {
if (sendViewModel.isShielded) {
sendViewModel.memo = binding.inputMemo.text.toString()
onNext()
} else {
mainActivity?.navController?.navigate(R.id.action_nav_send_memo_to_nav_send_address)
}
}
private fun onBottomButton() {
binding.inputMemo.setText("")
sendViewModel.memo = ""
sendViewModel.includeFromAddress = false
onNext()
}
private fun onAddMemo() {
sendViewModel.memo = binding.inputMemo.text.toString()
onNext()
}
private fun onNext() {
mainActivity?.navController?.navigate(R.id.action_nav_send_memo_to_send_confirm)
}

View File

@ -1,22 +1,36 @@
package cash.z.ecc.android.ui.send
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.Feedback.Keyed
import cash.z.ecc.android.feedback.Feedback.TimeMetric
import cash.z.ecc.android.feedback.Report
import cash.z.ecc.android.feedback.Report.MetricType
import cash.z.ecc.android.feedback.Report.MetricType.*
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.wallet.sdk.Initializer
import cash.z.wallet.sdk.Synchronizer
import cash.z.wallet.sdk.entity.PendingTransaction
import cash.z.wallet.sdk.annotation.OpenForTesting
import cash.z.wallet.sdk.entity.*
import cash.z.wallet.sdk.ext.ZcashSdk
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.twig
import com.crashlytics.android.Crashlytics
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
class SendViewModel @Inject constructor() : ViewModel() {
private val metrics = mutableMapOf<String, TimeMetric>()
@Inject
lateinit var lockBox: LockBox
@ -26,6 +40,23 @@ class SendViewModel @Inject constructor() : ViewModel() {
@Inject
lateinit var initializer: Initializer
@Inject
lateinit var feedback: Feedback
var fromAddress: String = ""
var toAddress: String = ""
var memo: String = ""
var zatoshiAmount: Long = -1L
var includeFromAddress: Boolean = false
set(value) {
require(!value || (value && !fromAddress.isNullOrEmpty())) {
"Error: from address was empty while attempting to include it in the memo. Verify" +
" that initFromAddress() has previously been called on this viewmodel."
}
field = value
}
val isShielded get() = toAddress.startsWith("z")
fun send(): Flow<PendingTransaction> {
val memoToSend = if (includeFromAddress) "$memo\nsent from\n$fromAddress" else memo
val keys = initializer.deriveSpendingKeys(
@ -41,17 +72,20 @@ class SendViewModel @Inject constructor() : ViewModel() {
}
}
fun validate() = flow<String?> {
suspend fun validateAddress(address: String): Synchronizer.AddressType =
synchronizer.validateAddress(address)
fun validate(maxZatoshi: Long?) = flow<String?> {
when {
synchronizer.validateAddress(toAddress).isNotValid -> {
emit("Please enter a valid address")
}
zatoshiAmount < ZcashSdk.MINERS_FEE_ZATOSHI -> {
emit("Please enter a larger amount")
emit("Too little! Please enter at least 0.0001")
}
synchronizer.getAddress() == toAddress -> {
emit("That appears to be your address!")
maxZatoshi != null && zatoshiAmount > maxZatoshi -> {
emit( "Too much! Please enter no more than ${maxZatoshi.convertZatoshiToZecString(8)}")
}
else -> emit(null)
}
@ -64,16 +98,81 @@ class SendViewModel @Inject constructor() : ViewModel() {
}
}
var fromAddress: String = ""
var toAddress: String = ""
var memo: String = ""
var zatoshiAmount: Long = -1L
var includeFromAddress: Boolean = false
set(value) {
require(!value || (value && !fromAddress.isNullOrEmpty())) {
"Error: from address was empty while attempting to include it in the memo. Verify" +
" that initFromAddress() has previously been called on this viewmodel."
fun reset() {
fromAddress = ""
toAddress = ""
memo = ""
zatoshiAmount = -1L
includeFromAddress = false
}
fun updateMetrics(tx: PendingTransaction) {
try {
when {
tx.isMined() -> TRANSACTION_SUBMITTED to TRANSACTION_MINED by tx.id
tx.isSubmitSuccess() -> TRANSACTION_CREATED to TRANSACTION_SUBMITTED by tx.id
tx.isCreated() -> TRANSACTION_INITIALIZED to TRANSACTION_CREATED by tx.id
tx.isCreating() -> +TRANSACTION_INITIALIZED by tx.id
else -> null
}?.let { metricId ->
report(metricId)
}
field = value
} catch (t: Throwable) {
Crashlytics.logException(RuntimeException("Error while updating Metrics", t))
}
}
}
fun report(metricId: String?) {
metrics[metricId]?.let { metric ->
metric.takeUnless { (it.elapsedTime ?: 0) <= 0L }?.let {
viewModelScope.launch {
withContext(IO) {
feedback.report(metric)
// does this metric complete another metric?
metricId!!.toRelatedMetricId().let { relatedId ->
metrics[relatedId]?.let { relatedMetric ->
// then remove the related metric, itself. And the relation.
metrics.remove(relatedMetric.toMetricIdFor(metricId!!.toTxId()))
metrics.remove(relatedId)
}
}
// remove all top-level metrics
if (metric.key == Report.MetricType.TRANSACTION_MINED.key) metrics.remove(metricId)
}
}
}
}
}
private operator fun MetricType.unaryPlus(): TimeMetric = TimeMetric(key, description).markTime()
private infix fun TimeMetric.by(txId: Long) = this.toMetricIdFor(txId).also { metrics[it] = this }
private infix fun Pair<MetricType, MetricType>.by(txId: Long): String? {
val startMetric = first.toMetricIdFor(txId).let { metricId ->
metrics[metricId].also { if (it == null) println("Warning no start metric for id: $metricId") }
}
return startMetric?.endTime?.let { startMetricEndTime ->
TimeMetric(second.key, second.description, mutableListOf(startMetricEndTime))
.markTime().let { endMetric ->
endMetric.toMetricIdFor(txId).also { metricId ->
metrics[metricId] = endMetric
metrics[metricId.toRelatedMetricId()] = startMetric
}
}
}
}
private fun Keyed<String>.toMetricIdFor(id: Long): String = "$id.$key"
private fun String.toRelatedMetricId(): String = "$this.related"
private fun String.toTxId(): Long = split('.').first().toLong()
}

View File

@ -0,0 +1,47 @@
package cash.z.ecc.android.ui.util
//
//import android.Manifest
//import android.content.Context
//import android.content.pm.PackageManager
//import android.os.Bundle
//import android.widget.Toast
//import androidx.core.content.ContextCompat
//import androidx.fragment.app.Fragment
//import cash.z.ecc.android.ui.MainActivity
//
//class PermissionFragment : Fragment() {
//
// val activity get() = context as MainActivity
//
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// if (!hasPermissions(activity)) {
// requestPermissions(PERMISSIONS, REQUEST_CODE)
// } else {
// activity.openCamera()
// }
// }
//
// override fun onRequestPermissionsResult(
// requestCode: Int, permissions: Array<String>, grantResults: IntArray
// ) {
// super.onRequestPermissionsResult(requestCode, permissions, grantResults)
//
// if (requestCode == REQUEST_CODE) {
// if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
// activity.openCamera()
// } else {
// Toast.makeText(context, "Camera request denied", Toast.LENGTH_LONG).show()
// }
// }
// }
//
// companion object {
// private const val REQUEST_CODE = 101
// private val PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
//
// fun hasPermissions(context: Context) = PERMISSIONS.all {
// ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
// }
// }
//}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="false" android:color="@color/text_dark"/>
<item android:state_pressed="true" android:color="@color/text_light" />
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="false" android:color="@color/text_light_dimmed"/>
<item android:state_pressed="true" android:color="@color/text_dark" />
</selector>

View File

@ -1,31 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
android:width="108dp">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android">
<item android:height="16dp">
<shape>
<corners
android:bottomLeftRadius="10dp"
android:bottomRightRadius="10dp"
android:topLeftRadius="0dp"
android:topRightRadius="0dp" />
<solid android:color="@color/text_light_dimmed" />
</shape>
</item>
<item
android:end="1dp"
android:start="1dp"
android:left="1dp"
android:right="1dp"
android:bottom="1dp"
android:height="15dp">
<shape>
<corners
android:bottomLeftRadius="10dp"
android:bottomRightRadius="10dp"
android:topLeftRadius="0dp"
android:topRightRadius="0dp" />
<solid android:color="@color/background_banner" />
</shape>
</item></layer-list>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="@color/background_banner"
android:startColor="@android:color/transparent" />
</shape>

View File

@ -22,9 +22,6 @@
android:bottomRightRadius="0dp"
android:topLeftRadius="10dp"
android:topRightRadius="10dp" />
<stroke
android:color="#282828"
android:width="1dp" />
<solid android:color="@color/background_banner" />
</shape>
</item></layer-list>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>

View File

@ -1,171 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
android:width="108dp">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

View File

@ -0,0 +1,126 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="178dp"
android:height="178dp"
android:viewportWidth="178"
android:viewportHeight="178">
<path
android:pathData="M89,89m-89,0a89,89 0,1 1,178 0a89,89 0,1 1,-178 0"
android:strokeAlpha="0.8"
android:strokeWidth="1"
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:strokeColor="#00000000"
android:fillAlpha="0.8"/>
<path
android:pathData="M31,27C31,23.953 31,19.383 31,13.289C31,7.195 32.391,2.766 35.174,0L55,12.273L31,27Z"
android:strokeAlpha="0.8"
android:strokeWidth="1"
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:strokeColor="#00000000"
android:fillAlpha="0.8"/>
<group>
<clip-path
android:pathData="M27.234,93.372l71.508,0l0,67.628l-71.508,0z"/>
<path
android:pathData="M75.534,113.453C76.201,112.487 78.297,111.996 81.78,111.965L82.794,113.875L81.179,112.367L82.303,114.486L80.637,112.932L81.827,115.175L79.798,113.282L81.01,115.568L78.912,113.61L80.103,115.854L77.796,113.703L79.208,116.364L77.435,114.71L78.707,117.106L76.462,115.012L77.883,117.69L76.675,116.563L77.769,118.624L77.719,118.713L76.447,117.525L77.41,119.34C77.399,119.363 77.392,119.387 77.383,119.41L76.224,118.329L77.138,120.052C77.125,120.091 77.117,120.131 77.104,120.17L76.034,119.172L76.922,120.848L76.916,120.881L76.088,120.109L76.798,121.447L76.142,120.834L76.718,121.921C76.699,122.015 76.684,122.116 76.663,122.206L75.873,121.47L76.53,122.706C76.523,122.728 76.518,122.748 76.511,122.77L75.885,122.185L76.395,123.147C76.312,123.365 76.221,123.56 76.124,123.736C75.539,121.693 75.34,118.271 75.534,113.453M95.919,112.818L95.907,112.79L95.894,112.756L95.878,112.717L95.859,112.671L95.839,112.619L95.623,112.09L95.582,111.99L95.539,111.884L95.494,111.772L95.47,111.714L95.446,111.654C93.675,107.322 91.881,103.074 90.257,98.87L90.256,98.866C90.329,100.005 90.347,100.993 90.318,101.845L89.238,99.836L89.266,102.918L88.006,100.847L88.206,103.123L87.041,101.206L87.209,103.133L85.736,100.712L85.905,102.643L83.729,99.063L83.825,100.156L81.647,96.571L81.826,98.625C81.787,98.581 81.752,98.545 81.714,98.503L80.044,95.756L80.167,97.164C80.155,97.159 80.146,97.159 80.134,97.155L78.94,95.61L79.641,98.208C79.701,98.687 79.848,99.327 80.074,100.118L77.399,97.623L80.492,103.449L78.338,101.44L82.342,108.986L80.463,107.235L82.831,111.697L80.469,109.492L81.685,111.784C78.126,111.028 75.317,110.905 73.277,111.431C72.94,116.275 72.994,120.05 73.428,122.79L72.191,120.9L72.925,123.827L71.423,121.369L71.907,123.75L70.496,120.194L70.57,123.537C68.488,115.831 67.015,109.705 67.584,107.235C71.599,106.105 77.505,108.032 80.474,107.59C75.65,105.3 69.32,102.934 63.19,102.374C62.307,109.447 62.98,115.242 65.181,119.787L62.974,117.036L63.541,118.732L61.364,116.024L62.242,118.648C62.156,118.627 62.074,118.614 61.993,118.601L59.571,116.217L60.972,118.686C60.788,118.761 60.626,118.886 60.479,119.044C60.383,118.975 60.289,118.906 60.193,118.841C56.697,111.956 56.997,105.954 58.877,98.167C65.581,98.352 71.753,99.603 77.708,101.662C72.037,97.651 64.551,93.369 54.802,93.372C50.881,101.345 52.661,113.16 56.051,116.948C56.323,117.09 56.57,117.217 56.817,117.344C53.969,123.839 48.623,128.541 43.212,131.859C36.362,130.618 32.826,128.06 27.234,124.417C29.821,128.796 32.849,133.173 36.409,137.55C39.603,138.951 42.808,141.86 47.316,142.272C55.185,137.428 62.058,129.232 63.791,123.912C64.842,126.513 65.777,129.775 66.561,133.913C66.561,133.913 67.404,134.273 68.946,134.578C63.836,141.226 57.075,147.075 51.713,150.472C56.474,153.438 61.638,155.858 67.124,157.632C72.546,151.167 76.642,141.763 78.573,134.044C79.318,134.313 80.164,134.686 81.065,135.099C81.954,135.51 82.728,135.854 83.431,136.132C83.068,143.377 80.126,153.533 76.93,160.047C78.691,160.347 80.475,160.584 82.284,160.754C83.07,160.829 83.852,160.885 84.634,160.933C86.852,153.688 88.265,144.188 87.799,136.689C88.451,136.508 89.125,136.182 89.876,135.69C89.81,145.466 90.666,155.067 92.048,161C93.774,160.926 95.487,160.79 97.181,160.59C94.866,155.316 92.858,145.449 91.551,134.441C91.689,134.328 91.823,134.221 91.968,134.099C93.072,133.169 92.695,132.554 93.525,131.505C94.744,129.968 97.718,129.641 98.346,127.849C99.579,124.337 97.633,118.995 96.602,114.51C96.374,113.944 96.148,113.38 95.919,112.818"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</group>
<path
android:pathData="M73.653,44.407C73.653,44.406 73.654,44.406 73.654,44.405L73.554,44.215C73.595,44.254 73.637,44.292 73.679,44.329C73.681,44.363 73.683,44.399 73.684,44.432C73.674,44.424 73.663,44.416 73.653,44.407M87.7,69.284C87.701,69.164 87.703,69.045 87.704,68.926C87.707,69.041 87.703,69.163 87.7,69.284M61.99,32.998C61.703,35.915 58.112,38.538 55.697,42.426C54.823,43.834 54.989,44.767 54.637,46.58C54.181,48.92 53.45,52.304 54.671,55.6C54.907,56.234 56.02,51.16 57.925,46.601C59.004,44.014 62.283,40.502 65.272,38.646C62.085,42.781 59.59,50.671 59.704,56.763C61.543,58.06 64.29,59.572 64.918,61.598C64.395,54.53 66.266,45.281 68.75,39.339C67.865,47.251 67.532,57.47 67.693,63.089C72.924,70.569 79.247,81.626 82.767,93.29C79.337,77.947 75.918,66.805 72.509,62.357C72.488,54.003 72.84,48.925 73.561,44.907C73.616,44.98 73.671,45.055 73.723,45.127C74.229,53.988 74.781,59.365 75.382,61.243C79.702,73.58 82.901,86.212 87.693,102C81.994,78.934 79.386,66.139 77.153,60.754C76.667,56.548 76.426,54.446 76.426,54.446C78.265,58.449 79.187,60.452 79.187,60.452C81.681,69.88 85.208,82.978 89.769,99.743C85.37,79.315 82.569,66.189 81.366,60.363C80.07,56.455 78.706,52.95 77.28,49.819C79.66,52.909 81.648,55.47 83.149,57.928C83.833,59.049 84.232,60.963 84.747,61.899C84.87,62.125 84.973,62.329 85.086,62.544C83.291,69.249 85.173,79.303 90.127,92.673C88.282,84.204 87.785,77.884 87.704,72.544C89.481,70.52 90.532,68.816 90.851,67.433C91.171,66.252 91.065,65.342 90.541,64.7C90.95,64.424 91.319,64.273 91.639,64.272C90.749,63.477 89.918,63.184 89.144,63.352C88.473,62.365 88.085,60.967 87.194,59.914C86.273,58.822 85.75,56.663 84.84,55.366C82.955,52.685 80.599,50.308 78.106,48.108C78.169,48.162 78.232,48.213 78.295,48.267C80.518,49.479 83.274,51.634 85.846,54.462C87.207,55.961 89.137,59.148 90.102,59.939C89.994,59.828 87.828,53.839 87.009,52.76C85.5,50.776 82.024,47.346 78.509,44.128C79.498,44.47 80.549,44.802 80.969,45.178C82.481,46.532 83.389,47.894 83.824,48.504C84.142,48.949 83.734,46.926 82.586,44.439C82.231,43.671 80.125,42.302 79.727,41.694C79.719,41.684 79.708,41.675 79.701,41.666C81.677,41.906 83.696,41.759 85.761,41.172C98.386,39.005 112.126,22.523 106.836,10.884C102.453,12.51 98.424,12.541 94.7,15.262C83.983,23.098 84.821,31.106 80.151,35.415C79.663,35.424 78.616,36.088 78.051,36.145C78.754,34.6 79.421,32.575 80.05,30.06C79.449,31.513 78.658,32.755 77.694,33.8C77.342,34.159 77.035,34.418 76.95,34.27C78.285,32.618 79.513,29.578 80.633,25.107C80.191,26.169 79.662,27.132 79.043,27.995C78.963,28.024 78.881,28.052 78.787,28.082C79.341,26.634 79.87,24.897 80.378,22.869C79.669,24.582 78.724,26.028 77.552,27.218C77.579,27.137 77.608,27.051 77.637,26.964C78.735,24.644 79.771,21.543 80.748,17.643C80.365,18.567 79.93,19.431 79.448,20.244C79.397,20.29 79.338,20.354 79.265,20.455C78.384,21.652 77.687,22.575 77.131,23.288C78.002,21.166 78.832,18.524 79.624,15.362C78.901,17.106 77.993,18.64 76.917,19.985C77.476,18.345 78.018,16.486 78.543,14.389C77.704,16.417 76.618,18.168 75.299,19.657C76.051,17.687 76.773,15.324 77.465,12.563C76.67,14.479 75.662,16.155 74.441,17.588C74.413,17.338 74.389,17.095 74.372,16.864C74.707,14.847 74.913,12.472 74.987,9.733C74.715,11.042 74.295,12.252 73.758,13.385C73.494,11.928 73.048,10.418 72.327,8.889C72.646,10.075 72.644,11.501 72.439,13.014C72.211,12.332 71.95,11.62 71.657,10.877C71.58,10.506 71.47,10.189 71.301,9.983C70.906,9.031 70.479,8.047 69.985,7C70.282,8.1 70.451,9.18 70.517,10.247C70.236,9.602 69.95,8.951 69.625,8.264C69.918,9.349 70.088,10.417 70.156,11.472C69.872,10.816 69.579,10.154 69.25,9.457C69.582,10.691 69.752,11.901 69.795,13.093C69.111,11.809 68.768,11.167 68.768,11.167C68.999,12.776 69.175,14.001 69.302,14.882C69.287,14.937 69.272,14.993 69.256,15.048C68.71,14.025 68.428,13.493 68.428,13.493C68.601,14.701 68.742,15.687 68.856,16.485C68.856,16.486 68.855,16.488 68.855,16.49C68.328,15.502 68.056,14.99 68.056,14.99C68.211,16.077 68.341,16.977 68.448,17.731L67.791,16.496C67.932,17.478 68.051,18.311 68.153,19.021C67.869,20.047 67.678,20.74 67.677,20.753C67.013,27.006 62.565,27.155 61.99,32.998"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M58.315,64.931C58.57,64.567 59.288,64.603 59.918,65.008C60.548,65.414 60.852,66.037 60.597,66.4C60.34,66.764 59.621,66.729 58.992,66.324C58.362,65.917 58.058,65.294 58.315,64.931M56.186,68.69C57.396,71.109 59.935,72.683 65.697,72.002C65.697,72.002 66.806,72.337 69,73C68.429,72.713 67.707,71.701 66.833,69.944C65.232,64.332 60.954,61.017 54,60C54.062,60.394 54.12,60.769 54.178,61.121C54.202,61.282 55.071,61.516 55.094,61.668C55.474,64.084 54.708,65.74 56.186,68.69"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M34.187,48.09C34.699,47.505 35.252,46.937 35.827,46.379C39.045,43.91 47.449,38.323 46.099,44.41L46.1,44.41C44.772,50.782 45.573,58.935 49.047,65C48.162,61.604 47.088,51.546 48.047,48.109C48.103,51.048 48.902,53.723 50.444,56.132C49.121,47.94 51.158,40.913 54.072,37.761L54.069,37.761C55.832,36.521 57.143,35.075 58,33.424C55.922,35.53 53.808,33.017 53.75,30.109C53.85,24.838 54.635,19.548 53.909,16.361C52.479,10.088 45.465,7.971 36.4,2C29.204,16.179 30.835,28.659 41.292,39.441C38.393,41.563 36.024,44.446 34.187,48.09"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M53.798,85.526C59.921,89.794 68.125,86.664 78,92.83C68.278,82.595 55.752,83.954 51.506,79C42.743,89.786 43.216,100.904 50.626,111.498C53.373,112.5 54.746,113 54.746,113C48.652,103.762 48.11,94.172 53.798,85.526"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M31.578,50.359C33.086,56.237 32.536,63.28 29.93,71.49C25.876,85.045 26.03,94.775 30.388,100.679C32.983,103.559 34.28,105 34.28,105C29.221,95.598 28.403,84.998 31.825,73.2C34.744,64.577 35.59,57.011 34.364,50.505C36.893,47.549 39.771,45.047 43,43C38.756,44.414 34.949,46.866 31.578,50.359"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M38.879,51.831C39.596,55.815 39.206,61.663 37.71,69.375C34.935,77.256 33.717,84.464 34.055,91C34.774,83.862 36.236,78.48 38.442,74.855C40.141,71.09 41.066,66.309 41.216,60.511C40.563,56.326 40.824,52.156 42,48C40.689,49.515 39.65,50.793 38.879,51.831"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M27.141,73.7C29.222,68.502 30.508,61.935 31,54C24.202,64.678 22.354,77.013 25.461,91C24.453,85.402 25.014,79.636 27.141,73.7"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M45.733,110.523L43.589,109.584C44.373,109.887 45.206,110.214 46.094,110.681C31.329,94.039 44.903,85.505 47.749,72.734C54.47,80.172 68.209,77.553 78.659,92.81C76.154,84.636 74.452,78.41 71.457,74.616C70.996,79.433 72.058,79.056 67.134,77.488C60.86,75.489 56.635,72.961 53.293,70.632C45.734,65.365 45.24,52.526 45.171,44.061C43.847,51.726 43.464,57.497 43.815,61.917L43.812,61.918C44.73,79.047 26.921,92.175 39.528,107.185C40.033,107.787 40.611,108.206 41.239,108.554L41.134,108.509C27.843,101.921 19.614,91.74 21.686,74.951C20.136,83.355 19.655,90.108 20.242,95.211C26.209,105.144 35.668,110.692 45.733,110.523"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M53,113.727L49.638,112.538C44.547,113.9 42.906,115.752 40.751,115.873C33.032,112.836 25.126,108.138 21,104C22.007,108.368 22.96,111.661 23.863,113.879C26.193,116.985 30.638,119.635 40.599,123C44.757,120.785 49.07,118.216 53,113.727"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M107.716,134.26C107.657,114.194 101.418,100.109 89,92C100.713,108.476 104.469,123.052 100.268,135.729C99.051,146.888 99.456,154.978 101.481,160C107.334,158.816 111.507,157.722 114,156.716C109.043,150.659 106.949,143.172 107.716,134.26"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M98.922,97.522L97,97C112.927,117.911 110.819,134.785 115.876,156C119.206,154.43 121.58,153.193 123,152.29C115.335,139.485 114.839,111.646 98.922,97.522"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M124,100.118C124,100.118 121.555,100.071 119.633,100.017L119.226,100C116.601,100.242 114.132,101.081 113,101.831C113.734,102.608 121.644,100.477 124,100.118"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M150,99.383C150,99.383 147.997,99.618 143.992,100.089C136.388,102.524 131.08,112.385 129.335,125.752C127.678,122.428 125.754,119.184 123.629,116.018C128.03,108.975 133.641,102.701 141.679,100.312C136.915,100.736 132.474,100.951 128.351,100.952C125.471,103.02 122.861,106.578 120.523,111.625L120.532,111.646C117.258,107.287 113.592,103.096 109.628,99.103C109.628,99.103 109.519,99.078 105,98C117.168,110.365 125.273,125.387 129.314,143.062C129.885,145.554 132.639,146.402 133.048,149C135.687,147.088 141.795,141.023 145.163,136.929C140.259,132.813 139.62,127.74 138.231,123.622C138.781,113.038 141.659,105.258 150,99.383"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<group>
<clip-path
android:pathData="M146.012,98.002l12.862,0l0,33.828l-12.862,0z"/>
<path
android:pathData="M155.632,98.563C147.497,106.769 144.577,118.826 146.66,131.829C153.108,124.925 157.111,112.683 158.874,98.002C158.874,98.002 157.793,98.189 155.632,98.563"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</group>
<path
android:pathData="M60.912,25.072C60.709,28.607 60.579,31.698 59.85,33C62.873,28.076 63.277,21.836 62.865,16L62.115,18.947L61.813,16.538L61.576,19.573L60.873,17.265L60.85,17.286L60.571,20.852L59.824,18.4L59.668,20.386L59.225,18.932L59,21.815C59.562,23.849 59.865,26.083 59.911,28.52C60.579,26.22 60.912,25.072 60.912,25.072"
android:strokeWidth="1"
android:fillColor="#000000"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</vector>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<View
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="32dp"
android:background="@drawable/background_footer" />

View File

@ -101,6 +101,7 @@
android:layout_height="wrap_content"
android:text="(enter an amount to send)"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="@color/text_light_dimmed"
app:layout_constraintBottom_toBottomOf="@id/back_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -110,10 +111,10 @@
android:id="@+id/header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/background_header"
android:onClick="copyAddress"
android:paddingBottom="24dp"
android:paddingTop="24dp"
android:onClick="copyAddress"
android:background="@drawable/background_header"
app:layout_constraintEnd_toEndOf="@id/guideline_keyline_end"
app:layout_constraintStart_toStartOf="@id/guideline_keyline_start"
app:layout_constraintTop_toBottomOf="@id/back_button_hit_area">
@ -144,28 +145,51 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
tools:text="zs1g7cqw...9qmvyzgm"
android:textColor="@color/colorPrimaryMedium"
app:layout_constraintStart_toEndOf="@id/label_address"
app:layout_constraintTop_toBottomOf="@+id/text_header_title" />
app:layout_constraintTop_toBottomOf="@+id/text_header_title"
tools:text="zs1g7cqw...9qmvyzgm" />
<ImageView
android:id="@+id/image_copy"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@drawable/ic_content_copy"
app:layout_constraintTop_toTopOf="@id/label_address"
app:layout_constraintBottom_toBottomOf="@id/label_address"
app:layout_constraintStart_toEndOf="@id/text_address"
app:layout_constraintDimensionRatio="W,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.15"
app:layout_constraintDimensionRatio="W,1:1"/>
app:layout_constraintStart_toEndOf="@id/text_address"
app:layout_constraintTop_toTopOf="@id/label_address" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/empty_footer"
android:layout_width="0dp"
android:layout_height="16dp"
android:background="@drawable/background_footer"
app:layout_constraintEnd_toEndOf="@id/guideline_keyline_end"
app:layout_constraintStart_toStartOf="@id/guideline_keyline_start"
app:layout_constraintTop_toBottomOf="@id/header" />
<TextView
android:id="@+id/empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No history yet."
android:textColor="@color/text_light"
android:textSize="18dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_transactions"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/guideline_keyline_end"
app:layout_constraintStart_toStartOf="@id/guideline_keyline_start"
@ -173,6 +197,24 @@
tools:itemCount="15"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_transaction"
tools:orientation="vertical" />
tools:orientation="vertical"
tools:visibility="gone" />
<View
android:id="@+id/footer_fade"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_marginEnd="2dp"
android:layout_marginStart="2dp"
android:alpha="0.8"
android:background="@drawable/background_gradient_bottom"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/guideline_keyline_end"
app:layout_constraintStart_toStartOf="@id/guideline_keyline_start" />
<androidx.constraintlayout.widget.Group
android:id="@+id/group_empty_views"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="empty_footer,empty_message" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -241,23 +241,17 @@
app:layout_constraintTop_toBottomOf="@id/button_number_pad_9"
app:layout_constraintWidth_percent="@dimen/calculator_button_width_percent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_send"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
style="@style/Zcash.Button"
android:text="Send Amount"
android:enabled="false"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="#000000"
android:gravity="center"
android:padding="12dp"
app:layout_constraintEnd_toEndOf="@id/guide_keys"
app:layout_constraintStart_toStartOf="@id/guide_keys"
app:layout_constraintTop_toBottomOf="@id/guide_keys"/>
<!-- <com.google.android.material.button.MaterialButton-->
<!-- android:id="@+id/button_send"-->
<!-- android:layout_width="0dp"-->
<!-- android:layout_height="0dp"-->
<!-- style="@style/Zcash.Button"-->
<!-- android:text=""-->
<!-- android:enabled="false"-->
<!-- app:layout_constraintEnd_toEndOf="@id/guide_keys"-->
<!-- app:layout_constraintStart_toStartOf="@id/guide_keys"-->
<!-- app:layout_constraintTop_toTopOf="@id/lottie_button_loading"-->
<!-- app:layout_constraintBottom_toBottomOf="@id/lottie_button_loading"/>-->
<View
android:id="@+id/layer_lock"
@ -271,6 +265,41 @@
<!-- -->
<!-- Upper Layer -->
<!-- -->
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_button_loading"
android:layout_width="0dp"
android:layout_height="0dp"
android:elevation="6dp"
app:layout_constraintHeight_percent="0.075"
app:layout_constraintWidth_percent="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/guide_keys"
app:layout_constraintBottom_toTopOf="@id/text_detail"
app:layout_constraintVertical_bias="0.38"
app:lottie_autoPlay="false"
app:lottie_loop="false"
app:lottie_rawRes="@raw/lottie_button_loading_new" />
<TextView
android:id="@+id/button_send_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Downloading...5%"
android:elevation="6dp"
android:paddingTop="24dp"
android:paddingBottom="24dp"
android:paddingStart="56dp"
android:paddingEnd="56dp"
android:textColor="@color/selector_button_text_dark"
android:textSize="16dp"
app:layout_constraintTop_toTopOf="@id/lottie_button_loading"
app:layout_constraintBottom_toBottomOf="@id/lottie_button_loading"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<ImageView
android:id="@+id/icon_scan"
android:layout_width="0dp"
@ -280,7 +309,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.052"
app:layout_constraintHorizontal_bias="0.108"
app:layout_constraintHorizontal_bias="0.088"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0574"
@ -296,7 +325,7 @@
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintHorizontal_bias="0.8883"
app:layout_constraintHorizontal_bias="0.912"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.064"
app:layout_constraintWidth_percent="0.08"
@ -351,7 +380,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/icon_detail"
app:layout_constraintTop_toBottomOf="@id/button_send"
app:layout_constraintTop_toBottomOf="@id/lottie_button_loading"
app:layout_constraintVertical_bias="@dimen/ratio_golden_small" />
<TextView

View File

@ -44,7 +44,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.8883"
app:layout_constraintHorizontal_bias="0.912"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.064"
@ -56,7 +56,6 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:elevation="6dp"
android:tint="@color/text_light_dimmed"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
@ -64,7 +63,7 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.212"
app:layout_constraintWidth_percent="0.4"
app:srcCompat="@drawable/ic_account_circle" />
app:srcCompat="@drawable/ic_profile_zebra_01" />
<View
android:id="@+id/hit_area_close"
@ -80,6 +79,7 @@
android:id="@+id/text_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
style="@style/TextAppearance.AppCompat.Body1"
android:textSize="20dp"
android:text="Shielded User"
@ -162,6 +162,7 @@
style="@style/TextAppearance.AppCompat.Body1"
android:textSize="16sp"
android:text="See Application Log"
android:textColor="@color/selector_button_text_light_dimmed"
app:layout_constraintTop_toBottomOf="@id/button_backup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
@ -181,7 +182,7 @@
android:paddingTop="8dp"
android:text="zECC App"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/text_light"
android:textColor="@color/selector_button_text_light"
app:layout_constraintEnd_toEndOf="@id/guideline_content_end"
app:layout_constraintStart_toStartOf="@id/guideline_content_start"
app:layout_constraintTop_toBottomOf="@id/button_logs"

View File

@ -183,7 +183,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.065"
app:srcCompat="@drawable/ic_arrow_back_black_24dp" />
app:srcCompat="@drawable/ic_close_black_24dp" />
<View
android:id="@+id/back_button_hit_area"

View File

@ -178,7 +178,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.065"
app:srcCompat="@drawable/ic_arrow_back_black_24dp" />
app:srcCompat="@drawable/ic_close_black_24dp" />
<View
android:id="@+id/back_button_hit_area"

View File

@ -2,10 +2,10 @@
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/background_home"
xmlns:tools="http://schemas.android.com/tools">
android:background="@drawable/background_home">
<!-- Back Button -->
<ImageView
@ -13,13 +13,13 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tint="@color/text_light"
app:srcCompat="@drawable/ic_arrow_back_black_24dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.05"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.065"
app:layout_constraintHorizontal_bias="0.05"/>
app:srcCompat="@drawable/ic_arrow_back_black_24dp" />
<View
android:id="@+id/back_button_hit_area"
@ -38,71 +38,97 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
style="@style/TextAppearance.MaterialComponents.Headline6"
tools:text="Sending 12.34121212 ZEC"
android:textColor="@color/text_light"
android:autoSizeTextType="uniform"
android:maxLines="1"
app:layout_constraintStart_toEndOf="@id/back_button_hit_area"
android:text="Sending"
android:textColor="@color/text_light"
app:layout_constraintBottom_toBottomOf="@id/back_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/back_button"
app:layout_constraintBottom_toBottomOf="@id/back_button" />
app:layout_constraintStart_toEndOf="@id/back_button_hit_area"
app:layout_constraintTop_toTopOf="@id/back_button" />
<!-- Input: Address -->
<EditText
android:id="@+id/input_zcash_address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:hint="@string/send_hint_input_zcash_address"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions"
android:paddingRight="76dp"
android:singleLine="true"
android:paddingTop="0dp"
android:textColor="@color/text_light"
app:backgroundTint="@color/colorPrimary"
android:textColorHint="@color/text_light_dimmed"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.84"
app:layout_constraintVertical_bias="0.2"/>
<!-- Input: Amount -->
<EditText
android:id="@+id/input_zcash_amount"
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/text_layout_address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:hint="@string/send_hint_input_zcash_amount"
android:imeOptions="actionDone"
android:inputType="numberDecimal"
android:paddingRight="76dp"
android:singleLine="true"
android:paddingTop="0dp"
android:textColor="@color/text_light"
app:backgroundTint="@color/colorPrimary"
android:textColorHint="@color/text_light_dimmed"
app:layout_constraintTop_toBottomOf="@id/input_zcash_address"
app:layout_constraintStart_toStartOf="parent"
android:hint="To"
android:theme="@style/Zcash.Overlay.TextInputLayout"
app:endIconDrawable="@drawable/ic_qrcode_24dp"
app:endIconMode="custom"
app:helperText="Enter a valid Zcash address"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.84"/>
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_banner_message"
app:layout_constraintVertical_bias="0.08"
app:layout_constraintWidth_percent="0.84">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_zcash_address"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions"
android:singleLine="true"
android:maxLength="255"
android:textColor="@color/text_light"
android:textColorHint="@color/text_light_dimmed" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Input: Amount -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/text_layout_amount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:hint="Amount"
android:theme="@style/Zcash.Overlay.TextInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_layout_address"
app:layout_constraintWidth_percent="0.84"
tools:helperText="You have 23.23 ZEC available">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_zcash_amount"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionDone"
android:inputType="numberDecimal"
android:maxLength="20"
android:singleLine="true"
android:textColor="@color/text_light"
android:textColorHint="@color/text_light_dimmed" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/text_max"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:fontFamily="@font/inconsolata"
android:padding="16dp"
android:text="MAX"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/text_layout_amount"
app:layout_constraintEnd_toEndOf="@id/text_layout_amount"
app:layout_constraintTop_toTopOf="@id/text_layout_amount" />
<TextView
android:id="@+id/text_address_error"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/inconsolata"
android:textColor="@android:color/holo_red_light"
android:maxLines="1"
android:autoSizeTextType="uniform"
android:fontFamily="@font/inconsolata"
android:maxLines="1"
android:textColor="@color/zcashRed"
android:textSize="14dp"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="@+id/button_next"
app:layout_constraintStart_toStartOf="@+id/input_zcash_amount"
app:layout_constraintTop_toTopOf="@+id/button_next"
app:layout_constraintEnd_toStartOf="@id/button_next"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/text_layout_amount"
app:layout_constraintTop_toBottomOf="@+id/button_next"
app:layout_constraintVertical_bias="0.1"
tools:text="Please enter a larger amount of money also please enter a shorter sentence" />
<!-- Scan QR code -->
@ -110,14 +136,15 @@
android:id="@+id/image_scan_qr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="24dp"
android:paddingEnd="1dp"
android:paddingStart="6dp"
android:paddingTop="10dp"
android:paddingEnd="1dp"
android:paddingBottom="24dp"
android:tint="@color/zcashWhite"
app:layout_constraintBottom_toBottomOf="@id/input_zcash_address"
app:layout_constraintEnd_toEndOf="@id/input_zcash_address"
app:layout_constraintTop_toTopOf="@id/input_zcash_address"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/text_layout_address"
app:layout_constraintEnd_toEndOf="@id/text_layout_address"
app:layout_constraintTop_toTopOf="@id/text_layout_address"
app:srcCompat="@drawable/ic_qrcode_24dp" />
<com.google.android.material.button.MaterialButton
@ -127,8 +154,8 @@
android:layout_marginTop="16dp"
android:text="Next"
android:textColor="@color/text_dark"
app:layout_constraintEnd_toEndOf="@+id/input_zcash_address"
app:layout_constraintTop_toBottomOf="@+id/input_zcash_amount" />
app:layout_constraintEnd_toEndOf="@+id/text_layout_address"
app:layout_constraintTop_toBottomOf="@+id/text_layout_amount" />
<!-- -->
<!-- Banner -->
@ -146,19 +173,17 @@
android:text="Address on clipboard!"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/text_light"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/input_zcash_address"
app:layout_constraintEnd_toEndOf="@+id/text_layout_address"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/input_zcash_address"
app:layout_constraintTop_toBottomOf="@id/button_next"
app:layout_constraintVertical_bias="0.07" />
app:layout_constraintStart_toStartOf="@+id/text_layout_address"
app:layout_constraintTop_toBottomOf="@id/back_button_hit_area" />
<TextView
android:id="@+id/text_banner_action"
android:elevation="6dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:elevation="6dp"
android:text="Paste"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/colorPrimary"
@ -169,8 +194,8 @@
android:id="@+id/group_banner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="text_banner_message, text_banner_action"
android:visibility="visible"
app:constraint_referenced_ids="text_banner_message, text_banner_action"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -81,6 +81,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="Tap\nto send ZEC"
android:padding="12dp"
android:textColor="@color/text_dark"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

@ -34,6 +34,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.065"
android:visibility="gone"
app:srcCompat="@drawable/ic_close_black_24dp" />
<View
@ -72,7 +73,8 @@
android:layout_height="wrap_content"
android:textColor="@color/text_dark"
tools:text="Creating transaction..."
android:textSize="16dp"
android:gravity="center"
android:textSize="20dp"
app:layout_constraintTop_toBottomOf="@id/radio_include_address"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -104,6 +106,21 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text_confirmation" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_retry"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
style="@style/Zcash.Button.OutlinedButton"
app:strokeColor="@color/text_dark"
android:padding="12dp"
android:text="Retry"
android:visibility="gone"
android:textColor="@color/text_dark"
app:layout_constraintEnd_toEndOf="@id/guide_keys"
app:layout_constraintStart_toStartOf="@id/guide_keys"
app:layout_constraintBottom_toTopOf="@id/button_next"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/button_next"
android:layout_width="0dp"
@ -113,6 +130,7 @@
app:strokeColor="@color/text_dark"
android:padding="12dp"
android:text="Finished"
android:visibility="gone"
android:textColor="@color/text_dark"
app:layout_constraintEnd_toEndOf="@id/guide_keys"
app:layout_constraintStart_toStartOf="@id/guide_keys"

View File

@ -41,13 +41,14 @@
android:background="@drawable/background_banner"
android:elevation="6dp"
android:gravity="top"
android:imeActionLabel="add memo"
android:inputType="textImeMultiLine"
android:imeOptions="actionDone"
android:hint="Add a memo here"
android:maxLines="3"
android:imeActionLabel="add memo"
android:imeOptions="actionDone"
android:inputType="textImeMultiLine"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/text_light"
@ -65,38 +66,39 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:elevation="6dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
android:paddingEnd="16dp"
android:paddingStart="16dp"
android:textColor="@color/text_light_dimmed"
tools:text="sent from z23lk4jjl2k3jl43kkj542l3kl4hj2l3k1j41l2kjk423lkj423lklhk2jrhiuhrh2j4hh2hkj23hkj4"
app:layout_constraintStart_toStartOf="@id/input_memo"
app:layout_constraintBottom_toBottomOf="@id/input_memo"
app:layout_constraintEnd_toEndOf="@id/input_memo"
app:layout_constraintBottom_toBottomOf="@id/input_memo" />
app:layout_constraintStart_toStartOf="@id/input_memo"
tools:text="sent from z23lk4jjl2k3jl43kkj542l3kl4hj2l3k1j41l2kjk423lkj423lklhk2jrhiuhrh2j4hh2hkj23hkj4" />
<View
android:layout_width="0dp"
android:layout_height="1px"
android:elevation="6dp"
android:layout_marginBottom="4dp"
android:background="@color/text_light_dimmed"
app:layout_constraintStart_toStartOf="@id/text_included_address"
android:elevation="6dp"
app:layout_constraintEnd_toEndOf="@id/text_included_address"
app:layout_constraintTop_toTopOf="@id/text_included_address"/>
app:layout_constraintStart_toStartOf="@id/text_included_address"
app:layout_constraintTop_toTopOf="@id/text_included_address" />
<CheckBox
android:id="@+id/check_include_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="0dp"
android:layout_marginTop="16dp"
android:padding="0dp"
android:layout_marginRight="0dp"
android:text="Include your sending address in memo"
app:layout_constraintStart_toStartOf="@+id/input_memo"
app:layout_constraintTop_toBottomOf="@+id/input_memo" />
<TextView
android:id="@+id/text_info_shielded"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
@ -108,6 +110,67 @@
app:layout_constraintStart_toStartOf="@id/input_memo"
app:layout_constraintTop_toBottomOf="@id/check_include_address" />
<ImageView
android:id="@+id/sad_icon"
android:layout_width="0dp"
android:layout_height="0dp"
android:elevation="6dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.17"
app:layout_constraintWidth_percent="0.68"
app:srcCompat="@drawable/ic_sadzebra" />
<TextView
android:id="@+id/sad_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="16dp"
android:layout_marginTop="16dp"
android:drawableTint="@color/text_light_dimmed"
android:text="You are going to make the zebra sad."
android:textColor="@color/text_light"
android:textSize="18dp"
android:gravity="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sad_icon"
app:layout_constraintBottom_toTopOf="@id/sad_description"
app:layout_constraintVertical_chainStyle="packed"/>
<TextView
android:id="@+id/sad_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:drawablePadding="16dp"
android:drawableTint="@color/text_light_dimmed"
android:gravity="center"
android:text="Heads up! You are sending to a transparent address, which reduces your privacy and does not support memos."
android:textColor="@color/text_light"
android:textSize="18dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="@id/sad_title"
app:layout_constraintHeight_percent="0.2"
app:layout_constraintStart_toStartOf="@id/sad_title"
app:layout_constraintTop_toBottomOf="@id/sad_title"
app:layout_constraintBottom_toTopOf="@id/sad_checkbox"
app:layout_constraintVertical_bias="0.5263" />
<CheckBox
android:id="@+id/sad_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Don't show this again"
android:checked="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/sad_description"
app:layout_constraintBottom_toTopOf="@id/button_next"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/button_next"
android:layout_width="0dp"
@ -134,4 +197,17 @@
app:layout_constraintEnd_toEndOf="@id/button_next"
app:layout_constraintStart_toStartOf="@id/button_next"
app:layout_constraintTop_toBottomOf="@id/button_next" />
<androidx.constraintlayout.widget.Group
android:id="@+id/group_transparent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="sad_description, sad_icon, sad_title, sad_checkbox" />
<androidx.constraintlayout.widget.Group
android:id="@+id/group_shielded"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="input_memo, check_include_address, text_included_address, text_info_shielded"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -118,6 +118,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:paddingStart="12dp"
android:autoSizeTextType="uniform"
android:gravity="center_vertical"
android:maxLines="1"
@ -127,6 +128,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/indicator"
app:layout_constraintWidth_percent="0.25"
tools:text="+ 4345.2444" />
tools:text="+ 434.2444234" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -40,7 +40,7 @@
<action
android:id="@+id/action_nav_receive_to_nav_scan"
app:destination="@id/nav_scan"
app:popUpTo="@id/nav_scan"
app:popUpTo="@id/nav_receive"
app:popUpToInclusive="true"
app:exitAnim="@anim/anim_fade_out_address"
app:enterAnim="@anim/anim_fade_in_scanner"/>
@ -52,10 +52,11 @@
<action
android:id="@+id/action_nav_scan_to_nav_send_address"
app:destination="@id/nav_send_address"
app:popUpTo="@id/nav_send_address"/>
app:popUpTo="@id/nav_scan"
app:popUpToInclusive="true"/>
<action
android:id="@+id/action_nav_scan_to_nav_receive"
app:popUpTo="@id/nav_receive"
app:popUpTo="@id/nav_scan"
app:popUpToInclusive="true"
app:destination="@id/nav_receive"
app:exitAnim="@anim/anim_fade_out_medium"/>
@ -134,6 +135,11 @@
android:id="@+id/nav_send_final"
android:name="cash.z.ecc.android.ui.send.SendFinalFragment"
tools:layout="@layout/fragment_send_final" >
<action
android:id="@+id/action_nav_send_final_to_nav_home"
app:destination="@id/nav_home"
app:popUpTo="@id/nav_home"
app:popUpToInclusive="true"/>
</fragment>
@ -157,4 +163,16 @@
android:name="cash.z.ecc.android.ui.setup.BackupFragment"
tools:layout="@layout/fragment_backup" >
</fragment>
<!-- -->
<!-- Global actions -->
<!-- -->
<action
android:id="@+id/action_global_nav_scan"
app:destination="@id/nav_scan"
app:popUpTo="@id/nav_scan"
app:popUpToInclusive="true" />
</navigation>

View File

@ -0,0 +1 @@
{"v":"5.6.0","fr":30,"ip":0,"op":30,"w":324,"h":64,"nm":"BalanceHome","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79,"s":[0]},{"t":90,"s":[100]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":30,"op":127,"st":30,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[4]},{"t":90,"s":[2]}],"ix":5},"lc":2,"lj":1,"ml":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0]},{"t":85,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[15]},{"t":85,"s":[100]}],"ix":2},"o":{"a":0,"k":-90,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":30,"op":127,"st":30,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[162,-201.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-16.292],[16.292,0],[0,0],[0,16.292],[-16.292,0],[0,0]],"o":[[0,16.292],[0,0],[-16.292,0],[0,-16.292],[0,0],[16.292,0]],"v":[[160,0],[130.5,29.5],[-130.5,29.5],[-160,0],[-130.5,-29.5],[130.5,-29.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.72549021244,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":0,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,233.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":30,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[15]},{"t":30,"s":[15]}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[-90]},{"t":30,"s":[270]}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":30,"st":0,"bm":0}],"markers":[]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -24,6 +24,7 @@
and should map directly to the design style guide -->
<color name="zcashWhite">#F5F5F5</color>
<color name="zcashGray">#616161</color>
<color name="zcashWhite_12">#1FFFFFFF</color>
<color name="zcashWhite_24">#3DFFFFFF</color>
<color name="zcashWhite_40">#66FFFFFF</color>
@ -44,9 +45,9 @@
<color name="zcashBlue">#26DAB6</color>
<!-- yellows -->
<color name="zcashYellow_light">#FFD649</color>
<color name="zcashYellow">#FFB727</color>
<color name="zcashYellow_dark">#FFA918</color>
<color name="zcashYellow_light">#FFCF4A</color>
<color name="zcashYellow">#FFB900</color>
<color name="zcashYellow_dark">#FFAA17</color>
<!-- -->
@ -59,6 +60,7 @@
<color name="background_banner">@color/zcashBlack_dark</color>
<color name="scan_overlay_background">@color/zcashBlack_87</color>
<color name="spacer">#1FBB666A</color>
<color name="text_send_amount_disabled">@color/text_light</color>
<!-- text -->
<color name="text_light">#FFFFFF</color>

View File

@ -43,7 +43,7 @@
<style name="Zcash.TextAppearance.NumberPad" parent="TextAppearance.MaterialComponents.Body1">
<item name="android:textSize">30dp</item>
<item name="android:textColor">@color/selector_button_text</item>
<item name="android:textColor">@color/selector_button_text_light</item>
</style>
<style name="Zcash.TextAppearance.AddressPart" parent="TextAppearance.AppCompat">
@ -75,4 +75,13 @@
<item name="cornerFamily">rounded</item>
<item name="cornerSize">24dp</item>
</style>
<style name="Zcash.ShapeAppearance.TextInputLayout" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">8dp</item>
</style>
<!-- Theme Overlays -->
<style name="Zcash.Overlay.TextInputLayout" parent="ThemeOverlay.MaterialComponents">
<item name="shapeAppearanceSmallComponent">@style/Zcash.ShapeAppearance.TextInputLayout</item>
</style>
</resources>

View File

@ -0,0 +1,111 @@
package cash.z.ecc.android
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.ui.send.SendViewModel
import cash.z.wallet.sdk.entity.*
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyZeroInteractions
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.Spy
class SendViewModelTest {
@Mock lateinit var creatingTx: PendingTransaction
@Mock lateinit var createdTx: PendingTransaction
@Mock lateinit var submittedTx: PendingTransaction
@Mock lateinit var minedTx: PendingTransaction
@Mock
lateinit var feedback: Feedback
@Spy
lateinit var sendViewModel: SendViewModel
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
Dispatchers.setMain(newSingleThreadContext("Main thread"))
whenever(creatingTx.id).thenReturn(7)
whenever(creatingTx.submitAttempts).thenReturn(0)
whenever(createdTx.id).thenReturn(7)
whenever(createdTx.raw).thenReturn(byteArrayOf(0x1))
whenever(submittedTx.id).thenReturn(7)
whenever(submittedTx.raw).thenReturn(byteArrayOf(0x1))
whenever(submittedTx.submitAttempts).thenReturn(1)
whenever(minedTx.id).thenReturn(7)
whenever(minedTx.raw).thenReturn(byteArrayOf(0x1))
whenever(minedTx.submitAttempts).thenReturn(1)
whenever(minedTx.minedHeight).thenReturn(500_001)
sendViewModel.feedback = feedback
}
@Test
fun testUpdateMetrics_creating() {
// doNothing().whenever(sendViewModel).report(any())
assertEquals(true, creatingTx.isCreating())
sendViewModel.updateMetrics(creatingTx)
verify(sendViewModel).report("7.metric.tx.initialized")
assertEquals(1, sendViewModel.metrics.size)
verifyZeroInteractions(feedback)
}
@Test
fun testUpdateMetrics_created() {
assertEquals(false, createdTx.isCreating())
assertEquals(true, createdTx.isCreated())
sendViewModel.updateMetrics(creatingTx)
sendViewModel.updateMetrics(createdTx)
Thread.sleep(100)
println(sendViewModel.metrics)
verify(sendViewModel).report("7.metric.tx.created")
assertEquals(1, sendViewModel.metrics.size)
}
@Test
fun testUpdateMetrics_submitted() {
assertEquals(false, submittedTx.isCreating())
assertEquals(false, submittedTx.isCreated())
assertEquals(true, submittedTx.isSubmitSuccess())
sendViewModel.updateMetrics(creatingTx)
sendViewModel.updateMetrics(createdTx)
sendViewModel.updateMetrics(submittedTx)
assertEquals(5, sendViewModel.metrics.size)
Thread.sleep(100)
assertEquals(1, sendViewModel.metrics.size)
verify(feedback).report(sendViewModel.metrics.values.first())
}
@Test
fun testUpdateMetrics_mined() {
assertEquals(true, minedTx.isMined())
assertEquals(true, minedTx.isSubmitSuccess())
sendViewModel.updateMetrics(creatingTx)
sendViewModel.updateMetrics(createdTx)
sendViewModel.updateMetrics(submittedTx)
sendViewModel.updateMetrics(minedTx)
assertEquals(7, sendViewModel.metrics.size)
Thread.sleep(100)
assertEquals(0, sendViewModel.metrics.size)
}
}

View File

@ -0,0 +1 @@
mock-maker-inline

View File

@ -4,11 +4,16 @@ buildscript {
repositories {
google()
jcenter()
maven {
url 'https://maven.fabric.io/public'
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.0-rc01'
classpath 'com.google.gms:google-services:4.3.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Deps.kotlinVersion}"
classpath 'io.fabric.tools:gradle:1.31.2'
classpath 'com.google.firebase:perf-plugin:1.3.1'
}
}

View File

@ -1,12 +1,15 @@
package cash.z.ecc.android.feedback
import android.util.Log
import cash.z.ecc.android.feedback.util.CompositeJob
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.io.StringWriter
import java.lang.StringBuilder
import kotlin.coroutines.coroutineContext
class Feedback(capacity: Int = 256) {
@ -34,8 +37,17 @@ class Feedback(capacity: Int = 256) {
* [actions] channels will remain open unless [stop] is also called on this instance.
*/
suspend fun start(): Feedback {
check(!::scope.isInitialized) {
"Error: cannot initialize feedback because it has already been initialized."
val callStack = StringBuilder().let { s ->
Thread.currentThread().stackTrace.forEach {element ->
s.append("$element\n")
}
s.toString()
}
if(::scope.isInitialized) {
Log.e("@TWIG","Warning: did not initialize feedback because it has already been initialized. Call stack: $callStack")
return this
} else {
Log.e("@TWIG","Debug: Initializing feedback for the first time. Call stack: $callStack")
}
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
invokeOnCompletion {
@ -162,8 +174,8 @@ class Feedback(capacity: Int = 256) {
}
interface Metric : Mappable<String, Any> {
val key: String
interface Metric : Mappable<String, Any>, Keyed<String> {
override val key: String
val startTime: Long?
val endTime: Long?
val elapsedTime: Long?
@ -180,13 +192,17 @@ class Feedback(capacity: Int = 256) {
}
}
interface Action : Feedback.Mappable<String, Any> {
val key: String
interface Action : Feedback.Mappable<String, Any>, Keyed<String> {
override val key: String
override fun toMap(): Map<String, Any> {
return mapOf("key" to key)
}
}
interface Keyed<T> {
val key: T
}
interface Mappable<K, V> {
fun toMap(): Map<K, V>
}