Merge pull request #34 from zcash/sprint/49

Tasks for Sprint 49
This commit is contained in:
Kevin Gorham 2019-12-17 16:37:00 -05:00 committed by GitHub
commit e3c72936da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 669 additions and 160 deletions

View File

@ -11,12 +11,6 @@ import javax.inject.Inject
class ZcashWalletApp : DaggerApplication() {
@Inject
lateinit var feedbackCoordinator: FeedbackCoordinator
@Inject
lateinit var feedbackObservers: Set<@JvmSuppressWildcards FeedbackCoordinator.FeedbackObserver>
var creationTime: Long = 0
private set
@ -30,7 +24,6 @@ class ZcashWalletApp : DaggerApplication() {
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(Thread.getDefaultUncaughtExceptionHandler()))
// Twig.plant(TroubleshootingTwig())
feedbackObservers.forEach { feedbackCoordinator.addObserver(it) }
}
/**

View File

@ -1,39 +1,15 @@
package cash.z.ecc.android.di
import cash.z.ecc.android.feedback.*
import android.content.Context
import cash.z.ecc.android.ZcashWalletApp
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import javax.inject.Singleton
@Module(includes = [AppBindingModule::class])
@Module(includes = [AppBindingModule::class, ViewModelModule::class])
class AppModule {
@Provides
@Singleton
fun provideFeedback() = Feedback()
@Provides
@Singleton
fun provideFeedbackCoordinator(feedback: Feedback) = FeedbackCoordinator(feedback)
//
// Feedback Observer Set
//
@Singleton
@Provides
@IntoSet
fun provideFeedbackFile(): FeedbackCoordinator.FeedbackObserver = FeedbackFile()
@Singleton
@Provides
@IntoSet
fun provideFeedbackConsole(): FeedbackCoordinator.FeedbackObserver = FeedbackConsole()
@Singleton
@Provides
@IntoSet
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
fun provideAppContext(): Context = ZcashWalletApp.instance
}

View File

@ -0,0 +1,40 @@
package cash.z.ecc.android.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import cash.z.ecc.android.di.annotation.ViewModelKey
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Module
abstract class ViewModelModule {
@Binds
abstract fun bindViewModelFactory(implementation: ViewModelFactory): ViewModelProvider.Factory
@Binds
@IntoMap
@ViewModelKey(WalletSetupViewModel::class)
abstract fun bindWalletSetupViewModel(implementation: WalletSetupViewModel): ViewModel
}
@Singleton
class ViewModelFactory @Inject constructor(
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
modelClass.isAssignableFrom(it.key)
}?.value ?: throw IllegalArgumentException(
"No map entry found for ${modelClass.canonicalName}." +
" Verify that this ViewModel has been added to the ViewModelModule."
)
@Suppress("UNCHECKED_CAST")
return creator.get() as T
}
}

View File

@ -0,0 +1,14 @@
package cash.z.ecc.android.di.annotation
import androidx.lifecycle.ViewModel
import dagger.MapKey
import kotlin.reflect.KClass
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.PROPERTY_SETTER
)
@Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class ViewModelKey(val value: KClass<out ViewModel>)

View File

@ -9,14 +9,23 @@ enum class NonUserAction(override val key: String, val description: String) : Fe
override fun toString(): String = description
}
enum class MetricType(override val key: String, val description: String) : Feedback.Action {
SEED_CREATION("metric.seed.creation", "seed created")
}
class LaunchMetric private constructor(private val metric: Feedback.TimeMetric) :
Feedback.Metric by metric {
constructor() : this(
Feedback
.TimeMetric("metric.app.launch", mutableListOf(ZcashWalletApp.instance.creationTime))
.TimeMetric(
"metric.app.launch",
"app launched",
mutableListOf(ZcashWalletApp.instance.creationTime)
)
.markTime()
)
override fun toString(): String {
return "app launched in ${metric.elapsedTime}ms"
}
override fun toString(): String = metric.toString()
}
fun <T> Feedback.measure(type: MetricType, block: () -> T) =
this.measure(type.key, type.description, block)

View File

@ -9,6 +9,7 @@ import android.os.Bundle
import android.os.Vibrator
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
@ -19,12 +20,13 @@ import androidx.navigation.findNavController
import cash.z.ecc.android.R
import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.di.annotation.ActivityScope
import cash.z.ecc.android.feedback.Feedback
import cash.z.ecc.android.feedback.LaunchMetric
import cash.z.ecc.android.feedback.NonUserAction.FEEDBACK_STOPPED
import cash.z.ecc.android.feedback.*
import com.google.android.material.snackbar.Snackbar
import dagger.Module
import dagger.Provides
import dagger.android.ContributesAndroidInjector
import dagger.android.support.DaggerAppCompatActivity
import dagger.multibindings.IntoSet
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -34,10 +36,15 @@ class MainActivity : DaggerAppCompatActivity() {
@Inject
lateinit var feedback: Feedback
@Inject
lateinit var feedbackCoordinator: FeedbackCoordinator
lateinit var navController: NavController
private val mediaPlayer: MediaPlayer = MediaPlayer()
private var snackbar: Snackbar? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
@ -53,7 +60,9 @@ class MainActivity : DaggerAppCompatActivity() {
false
)// | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, false)
lifecycleScope.launch { feedback.start() }
lifecycleScope.launch {
feedback.start()
}
}
override fun onResume() {
@ -70,7 +79,7 @@ class MainActivity : DaggerAppCompatActivity() {
override fun onDestroy() {
lifecycleScope.launch {
feedback.report(FEEDBACK_STOPPED)
feedback.report(NonUserAction.FEEDBACK_STOPPED)
feedback.stop()
}
super.onDestroy()
@ -140,11 +149,73 @@ class MainActivity : DaggerAppCompatActivity() {
private fun showMessage(message: String, action: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
fun showSnackbar(message: String, action: String = "OK"): Snackbar {
return if (snackbar == null) {
val view = findViewById<View>(R.id.main_activity_container)
val snacks = Snackbar
.make(view, "$message", Snackbar.LENGTH_INDEFINITE)
.setAction(action) { /*auto-close*/ }
val snackBarView = snacks.view as ViewGroup
val navigationBarHeight = resources.getDimensionPixelSize(resources.getIdentifier("navigation_bar_height", "dimen", "android"))
val params = snackBarView.getChildAt(0).layoutParams as ViewGroup.MarginLayoutParams
params.setMargins(
params.leftMargin,
params.topMargin,
params.rightMargin,
navigationBarHeight
)
snackBarView.getChildAt(0).setLayoutParams(params)
snacks
} else {
snackbar!!.setText(message).setAction(action) {/*auto-close*/}
}.also {
if (!it.isShownOrQueued) it.show()
}
}
}
@Module
abstract class MainActivityModule {
@ActivityScope
@ContributesAndroidInjector
@ContributesAndroidInjector(modules = [MainActivityProviderModule::class])
abstract fun contributeActivity(): MainActivity
}
@Module
class MainActivityProviderModule {
@Provides
@ActivityScope
fun provideFeedback(): Feedback = Feedback()
@Provides
@ActivityScope
fun provideFeedbackCoordinator(
feedback: Feedback,
defaultObservers: Set<@JvmSuppressWildcards FeedbackCoordinator.FeedbackObserver>
): FeedbackCoordinator = FeedbackCoordinator(feedback, defaultObservers)
//
// Default Feedback Observer Set
//
@Provides
@ActivityScope
@IntoSet
fun provideFeedbackFile(): FeedbackCoordinator.FeedbackObserver = FeedbackFile()
@Provides
@ActivityScope
@IntoSet
fun provideFeedbackConsole(): FeedbackCoordinator.FeedbackObserver = FeedbackConsole()
@Provides
@ActivityScope
@IntoSet
fun provideFeedbackMixpanel(): FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel()
}

View File

@ -1,9 +1,14 @@
package cash.z.ecc.android.ui.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import cash.z.ecc.android.R
import cash.z.ecc.android.databinding.FragmentHomeBinding
import cash.z.ecc.android.di.annotation.FragmentScope
@ -11,14 +16,25 @@ import cash.z.ecc.android.ext.goneIf
import cash.z.ecc.android.ext.onClickNavTo
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.home.HomeFragment.BannerAction.*
import cash.z.ecc.android.ui.setup.WalletSetupViewModel
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.Module
import dagger.android.ContributesAndroidInjector
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
private lateinit var numberPad: List<TextView>
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
private val walletSetup: WalletSetupViewModel by activityViewModels { viewModelFactory }
override fun inflate(inflater: LayoutInflater): FragmentHomeBinding =
FragmentHomeBinding.inflate(inflater)
@ -53,6 +69,18 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
onNoFunds()
}
override fun onAttach(context: Context) {
super.onAttach(context)
walletSetup.checkSeed().onEach {
when(it) {
NO_SEED -> {
mainActivity?.navController?.navigate(R.id.action_nav_home_to_create_wallet)
}
}
}.launchIn(lifecycleScope)
}
private fun onBannerAction(action: BannerAction) {
when (action) {
LEARN_MORE -> {

View File

@ -8,10 +8,14 @@ import android.view.View
import android.widget.TextView
import android.widget.Toast
import cash.z.ecc.android.R
import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentBackupBinding
import cash.z.ecc.android.di.annotation.FragmentScope
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey
import cash.z.ecc.android.ui.util.AddressPartNumberSpan
import cash.z.ecc.kotlin.mnemonic.Mnemonics
import dagger.Module
import dagger.android.ContributesAndroidInjector
@ -24,14 +28,14 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
super.onViewCreated(view, savedInstanceState)
with(binding) {
applySpan(
textAddressPart1,
textAddressPart2,
textAddressPart3,
textAddressPart4,
textAddressPart5,
textAddressPart6,
textAddressPart7,
textAddressPart8
textAddressPart1, textAddressPart2, textAddressPart3,
textAddressPart4, textAddressPart5, textAddressPart6,
textAddressPart7, textAddressPart8, textAddressPart9,
textAddressPart10, textAddressPart11, textAddressPart12,
textAddressPart13, textAddressPart14, textAddressPart15,
textAddressPart16, textAddressPart17, textAddressPart18,
textAddressPart19, textAddressPart20, textAddressPart21,
textAddressPart22, textAddressPart23, textAddressPart24
)
}
binding.buttonPositive.setOnClickListener {
@ -41,17 +45,28 @@ class BackupFragment : BaseFragment<FragmentBackupBinding>() {
private fun onEnterWallet() {
Toast.makeText(activity, "Backup verification coming soon! For now, enjoy your new wallet!", Toast.LENGTH_LONG).show()
mainActivity?.navController?.navigate(R.id.action_nav_backup_to_nav_home)
mainActivity?.navController?.popBackStack(R.id.wallet_setup_navigation, true)
}
private fun applySpan(vararg textViews: TextView) {
val words = loadSeedWords()
val thinSpace = "\u2005" // 0.25 em space
textViews.forEachIndexed { index, textView ->
textView.text = SpannableString("${index + 1}$thinSpace${textView.text}").apply {
setSpan(AddressPartNumberSpan(), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
val numLength = "$index".length
val word = words[index]
// TODO: work with a charsequence here, rather than constructing a String
textView.text = SpannableString("${index + 1}$thinSpace${String(word)}").apply {
setSpan(AddressPartNumberSpan(), 0, 1 + numLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
private fun loadSeedWords(): List<CharArray> {
val lockBox = LockBox(ZcashWalletApp.instance)
val mnemonics = Mnemonics()
val seed = lockBox.getBytes(LockBoxKey.SEED)!!
return mnemonics.nextMnemonicList(seed)
}
}

View File

@ -4,16 +4,29 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
import cash.z.ecc.android.R
import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentLandingBinding
import cash.z.ecc.android.di.annotation.FragmentScope
import cash.z.ecc.android.feedback.MetricType.SEED_CREATION
import cash.z.ecc.android.feedback.measure
import cash.z.ecc.android.isEmulator
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.base.BaseFragment
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey
import cash.z.ecc.kotlin.mnemonic.Mnemonics
import dagger.Module
import dagger.android.ContributesAndroidInjector
import javax.inject.Inject
class LandingFragment : BaseFragment<FragmentLandingBinding>() {
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
private val walletSetup: WalletSetupViewModel by activityViewModels { viewModelFactory }
private var skipCount: Int = 0
override fun inflate(inflater: LayoutInflater): FragmentLandingBinding =
@ -62,6 +75,10 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
}
private fun onNewWallet() {
mainActivity?.feedback?.measure(SEED_CREATION) {
walletSetup.createSeed()
}
binding.textMessage.text = "Wallet created! Congratulations!"
binding.buttonNegative.text = "Skip"
binding.buttonPositive.text = "Backup"
@ -75,9 +92,8 @@ class LandingFragment : BaseFragment<FragmentLandingBinding>() {
}
private fun onEnterWallet() {
skipCount = 0
mainActivity?.navController?.navigate(R.id.action_nav_landing_to_nav_home)
mainActivity?.navController?.popBackStack()
}
}

View File

@ -0,0 +1,46 @@
package cash.z.ecc.android.ui.setup
import androidx.lifecycle.ViewModel
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey.HAS_BACKUP
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey.HAS_SEED
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.LockBoxKey.SEED
import cash.z.ecc.android.ui.setup.WalletSetupViewModel.WalletSetupState.*
import cash.z.ecc.kotlin.mnemonic.Mnemonics
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
class WalletSetupViewModel @Inject constructor(val mnemonics: Mnemonics, val lockBox: LockBox) :
ViewModel() {
enum class WalletSetupState {
UNKNOWN, SEED_WITH_BACKUP, SEED_WITHOUT_BACKUP, NO_SEED
}
fun checkSeed(): Flow<WalletSetupState> = flow {
when {
lockBox.getBoolean(HAS_BACKUP) -> emit(SEED_WITH_BACKUP)
lockBox.getBoolean(HAS_SEED) -> emit(SEED_WITHOUT_BACKUP)
else -> emit(NO_SEED)
}
}
fun createSeed() {
check(!lockBox.getBoolean(HAS_SEED)) {
"Error! Cannot create a seed when one already exists! This would overwrite the" +
" existing seed and could lead to a loss of funds if the user has no backup!"
}
mnemonics.apply {
lockBox.setBytes(SEED, nextSeed())
lockBox.setBoolean(HAS_SEED, true)
}
}
object LockBoxKey {
const val SEED = "cash.z.ecc.android.SEED1"
const val HAS_SEED = "cash.z.ecc.android.HAS_SEED1"
const val HAS_BACKUP = "cash.z.ecc.android.HAS_BACKUP1"
}
}

View File

@ -3,5 +3,5 @@
xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="#282828"/>
<solid android:color="#171717"/>
<solid android:color="@color/background_banner"/>
</shape>

View File

@ -17,6 +17,7 @@
<!-- Address parts -->
<!-- Someday, there will be an advanced VirtualLayout that helps us do this without nesting but for now, this seems to be the only clean way to center all the fields -->
<!-- its tempting to do this programmatically but for now, it's always 24 words so I'll do it statically. If this ever changes, we'll probably be using Jetpack Compose by then so it will be easier to do in code -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/receive_address_parts"
android:layout_width="wrap_content"
@ -28,50 +29,95 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/text_message"
>
<!-- -->
<!-- Column 1 -->
<!-- -->
<TextView
android:id="@+id/text_address_part_1"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="drum"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_3"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_4"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/text_address_part_3"
android:id="@+id/text_address_part_4"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="inject"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_5"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_1" />
<TextView
android:id="@+id/text_address_part_5"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="plate"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_7"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_3" />
app:layout_constraintTop_toBottomOf="@id/text_address_part_1"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_7" />
<TextView
android:id="@+id/text_address_part_7"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="talk"
app:layout_constraintBottom_toBottomOf="parent"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_5" />
app:layout_constraintTop_toBottomOf="@id/text_address_part_4"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_10" />
<TextView
android:id="@+id/text_address_part_10"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_7"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_13" />
<TextView
android:id="@+id/text_address_part_13"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_10"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_16" />
<TextView
android:id="@+id/text_address_part_16"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_13"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_19" />
<TextView
android:id="@+id/text_address_part_19"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_16"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_22" />
<TextView
android:id="@+id/text_address_part_22"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_1"
app:layout_constraintTop_toBottomOf="@id/text_address_part_19" />
<!-- -->
<!-- Column 2 -->
<!-- -->
<TextView
android:id="@+id/text_address_part_2"
@ -81,52 +127,193 @@
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:text="fitness"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_4"
app:layout_constraintStart_toEndOf="@id/barrier_left_address_column"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_5"
app:layout_constraintStart_toEndOf="@id/barrier_left_address_column_1"
app:layout_constraintTop_toTopOf="@id/text_address_part_1" />
<TextView
android:id="@+id/text_address_part_4"
style="@style/Zcash.TextAppearance.AddressPart"
android:id="@+id/text_address_part_5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="pool"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_6"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_8"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_2" />
<TextView
android:id="@+id/text_address_part_6"
style="@style/Zcash.TextAppearance.AddressPart"
android:id="@+id/text_address_part_8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="inform"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_8"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_11"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_4" />
app:layout_constraintTop_toBottomOf="@id/text_address_part_5" />
<TextView
android:id="@+id/text_address_part_8"
style="@style/Zcash.TextAppearance.AddressPart"
android:id="@+id/text_address_part_11"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="helmet"
app:layout_constraintBottom_toBottomOf="parent"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_14"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_6" />
app:layout_constraintTop_toBottomOf="@id/text_address_part_8" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_left_address_column"
<TextView
android:id="@+id/text_address_part_14"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_17"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_11" />
<TextView
android:id="@+id/text_address_part_17"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_20"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_14" />
<TextView
android:id="@+id/text_address_part_20"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_23"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_17" />
<TextView
android:id="@+id/text_address_part_23"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_2"
app:layout_constraintTop_toBottomOf="@id/text_address_part_20" />
<!-- -->
<!-- Column 3 -->
<!-- -->
<TextView
android:id="@+id/text_address_part_3"
style="@style/Zcash.TextAppearance.AddressPart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:text="goals"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_6"
app:layout_constraintStart_toEndOf="@id/barrier_left_address_column_2"
app:layout_constraintTop_toTopOf="@id/text_address_part_1" />
<TextView
android:id="@+id/text_address_part_6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_9"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_3" />
<TextView
android:id="@+id/text_address_part_9"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_12"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_6" />
<TextView
android:id="@+id/text_address_part_12"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_15"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_9" />
<TextView
android:id="@+id/text_address_part_15"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_18"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_12" />
<TextView
android:id="@+id/text_address_part_18"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_21"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_15" />
<TextView
android:id="@+id/text_address_part_21"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintBottom_toTopOf="@+id/text_address_part_24"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_18" />
<TextView
android:id="@+id/text_address_part_24"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/Zcash.TextAppearance.AddressPart"
android:text="word"
app:layout_constraintStart_toStartOf="@id/text_address_part_3"
app:layout_constraintTop_toBottomOf="@id/text_address_part_21" />
<!--
text_address_part_3, text_address_part_6, text_address_part_9, text_address_part_12, text_address_part_15, text_address_part_18, text_address_part_21, text_address_part_24
-->
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_left_address_column_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="150dp"
android:layout_marginRight="150dp"
android:padding="150dp"
app:barrierDirection="end"
app:constraint_referenced_ids="text_address_part_1,text_address_part_3,text_address_part_5,text_address_part_7" />
app:constraint_referenced_ids="text_address_part_1, text_address_part_4, text_address_part_7, text_address_part_10, text_address_part_13, text_address_part_16, text_address_part_19, text_address_part_22" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_left_address_column_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="text_address_part_2, text_address_part_5, text_address_part_8, text_address_part_11, text_address_part_14, text_address_part_17, text_address_part_20, text_address_part_23" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView

View File

@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:id="@+id/main_activity_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.MainActivity">

View File

@ -4,28 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/mobile_navigation"
app:startDestination="@+id/nav_landing">
<fragment
android:id="@+id/nav_landing"
android:name="cash.z.ecc.android.ui.setup.LandingFragment"
tools:layout="@layout/fragment_landing" >
<action
android:id="@+id/action_nav_landing_to_nav_home"
app:destination="@id/nav_home" />
<action
android:id="@+id/action_nav_landing_to_nav_backup"
app:destination="@id/nav_backup" />
</fragment>
<fragment
android:id="@+id/nav_backup"
android:name="cash.z.ecc.android.ui.setup.BackupFragment"
tools:layout="@layout/fragment_backup" >
<action
android:id="@+id/action_nav_backup_to_nav_home"
app:destination="@id/nav_home" />
</fragment>
app:startDestination="@+id/nav_home">
<fragment
android:id="@+id/nav_home"
@ -40,6 +19,9 @@
<action
android:id="@+id/action_nav_home_to_nav_detail"
app:destination="@id/nav_detail" />
<action
android:id="@+id/action_nav_home_to_create_wallet"
app:destination="@id/wallet_setup_navigation" />
</fragment>
<fragment
@ -57,4 +39,6 @@
android:name="cash.z.ecc.android.ui.detail.WalletDetailFragment"
tools:layout="@layout/fragment_detail" />
<include app:graph="@navigation/wallet_setup_navigation" />
</navigation>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation
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:id="@+id/wallet_setup_navigation"
app:startDestination="@+id/nav_landing">
<fragment
android:id="@+id/nav_landing"
android:name="cash.z.ecc.android.ui.setup.LandingFragment"
tools:layout="@layout/fragment_landing" >
<action
android:id="@+id/action_nav_landing_to_nav_backup"
app:destination="@id/nav_backup" />
</fragment>
<fragment
android:id="@+id/nav_backup"
android:name="cash.z.ecc.android.ui.setup.BackupFragment"
tools:layout="@layout/fragment_backup" >
</fragment>
</navigation>

View File

@ -10,6 +10,8 @@
<color name="colorPrimary">@color/zcashYellow</color>
<color name="colorPrimaryDark">#4D3805</color>
<color name="colorAccent">#A1A1A1</color>
<color name="colorSurface">@color/text_light</color>
<color name="colorOnSurface">@color/background_banner</color>
<!-- -->
@ -32,6 +34,7 @@
<color name="zcashBlack_40">#66000000</color>
<color name="zcashBlack_54">#8A000000</color>
<color name="zcashBlack_87">#DD000000</color>
<color name="zcashBlack_dark">#171717</color>
<color name="zcashBlack_0">#00000000</color>
<!-- yellows -->
@ -47,6 +50,8 @@
<!-- every color here should be a reference to a palette color
but have a more useful name for use in code -->
<color name="background_banner">@color/zcashBlack_dark</color>
<!-- text -->
<color name="text_light">#FFFFFF</color>
<color name="text_light_dimmed">@color/zcashWhite_50</color>

View File

@ -32,6 +32,9 @@ object Deps {
object Google {
const val MATERIAL = "com.google.android.material:material:1.1.0-beta01"
}
object JavaX {
const val INJECT = "javax.inject:javax.inject:1"
}
object Kotlin : Version(kotlinVersion) {
val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$version"
object Coroutines : Version("1.3.2") {
@ -50,9 +53,7 @@ object Deps {
}
}
open class Version(@JvmField val version: String) {
@JvmField val t = version
}
open class Version(@JvmField val version: String)
//zzz
//zzz "androidx.constraintlayout:constraintlayout:2.0.0-alpha3"

View File

@ -92,9 +92,9 @@ class Feedback(capacity: Int = 256) {
* will run concurrently--meaning a "happens before" relationship between the measurer and the
* measured cannot be established and thereby the concurrent action cannot be timed.
*/
inline fun <T> measure(description: Any = "measurement", block: () -> T) {
inline fun <T> measure(key: String = "measurement.generic", description: Any = "measurement", block: () -> T) {
ensureScope()
val metric = TimeMetric(description.toString()).markTime()
val metric = TimeMetric(key, description.toString()).markTime()
block()
metric.markTime()
report(metric)
@ -151,10 +151,12 @@ class Feedback(capacity: Int = 256) {
val startTime: Long?
val endTime: Long?
val elapsedTime: Long?
val description: String
override fun toMap(): Map<String, Any> {
return mapOf(
"key" to key,
"description" to description,
"startTime" to (startTime ?: 0),
"endTime" to (endTime ?: 0),
"elapsedTime" to (elapsedTime ?: 0)
@ -175,6 +177,7 @@ class Feedback(capacity: Int = 256) {
data class TimeMetric(
override val key: String,
override val description: String,
val times: MutableList<Long> = mutableListOf()
) : Metric {
override val startTime: Long? get() = times.firstOrNull()
@ -184,5 +187,9 @@ class Feedback(capacity: Int = 256) {
times.add(System.currentTimeMillis())
return this
}
override fun toString(): String {
return "$description in ${elapsedTime}ms"
}
}
}

View File

@ -1,13 +1,16 @@
package cash.z.ecc.android.feedback
import android.util.Log
import cash.z.ecc.android.feedback.util.CompositeJob
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.lang.IllegalStateException
import kotlin.coroutines.coroutineContext
/**
* Takes care of the boilerplate involved in processing feedback emissions. Simply provide callbacks
* and emissions will occur in a mutually exclusive way, across all processors, so that things like
@ -15,7 +18,7 @@ import kotlin.coroutines.coroutineContext
* waiting for any in-flight emissions to complete. Lastly, all monitoring will cleanly complete
* whenever the feedback is stopped or its parent scope is cancelled.
*/
class FeedbackCoordinator(val feedback: Feedback) {
class FeedbackCoordinator(val feedback: Feedback, defaultObservers: Set<FeedbackObserver> = setOf()) {
init {
feedback.apply {
@ -25,6 +28,11 @@ class FeedbackCoordinator(val feedback: Feedback) {
}
}
}
if (defaultObservers.size != 3) throw IllegalStateException("BOOM")
defaultObservers.forEach {
Log.e("BOOM", "adding observer: $it to $feedback")
addObserver(it)
}
}
private var contextMetrics = Dispatchers.IO

View File

@ -4,39 +4,39 @@ import kotlinx.coroutines.Job
class CompositeJob {
private val jobs = mutableListOf<Job>()
val size: Int get() = jobs.size
private val activeJobs = mutableListOf<Job>()
val size: Int get() = activeJobs.size
fun add(job: Job) {
jobs.add(job)
activeJobs.add(job)
job.invokeOnCompletion {
remove(job)
}
}
fun remove(job: Job): Boolean {
return jobs.remove(job)
return activeJobs.remove(job)
}
fun isActive(): Boolean {
return jobs.any { isActive() }
return activeJobs.any { isActive() }
}
suspend fun await() {
// allow for concurrent modification since the list isn't coroutine or thread safe
do {
val job = jobs.firstOrNull()
val job = activeJobs.firstOrNull()
if (job?.isActive == true) {
job.join()
} else {
// prevents an infinite loop in the extreme edge case where the list has a null item
try { jobs.remove(job) } catch (t: Throwable) {}
try { activeJobs.remove(job) } catch (t: Throwable) {}
}
} while (size > 0)
}
fun cancel() {
jobs.filter { isActive() }.forEach { it.cancel() }
activeJobs.filter { isActive() }.forEach { it.cancel() }
}
operator fun plusAssign(also: Job) {

View File

@ -37,6 +37,7 @@ android {
}
dependencies {
implementation Deps.JavaX.INJECT
implementation Deps.Kotlin.STDLIB
implementation Deps.AndroidX.APPCOMPAT
implementation Deps.AndroidX.CORE_KTX

View File

@ -6,24 +6,32 @@ import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.StandardCharsets
import java.util.*
import javax.inject.Inject
class LockBox @Inject constructor(private val appContext: Context) : LockBoxProvider {
class LockBox(private val appContext: Context) : LockBoxProvider {
override fun setBoolean(key: String, value: Boolean) {
SecurePreferences.setValue(appContext, key, value)
}
override fun getBoolean(key: String): Boolean {
return SecurePreferences.getBooleanValue(appContext, key, false)
}
override fun setBytes(key: String, value: ByteArray) {
SecurePreferences.setValue(appContext, key, value.toHex())
}
override fun getBytes(key: String): ByteArray {
return SecurePreferences.getStringValue(appContext, key, null)!!.fromHex()
override fun getBytes(key: String): ByteArray? {
return SecurePreferences.getStringValue(appContext, key, null)?.fromHex()
}
override fun setCharsUtf8(key: String, value: CharArray) {
setBytes(key, value.toBytes())
}
override fun getCharsUtf8(key: String): CharArray {
return getBytes(key).fromBytes()
override fun getCharsUtf8(key: String): CharArray? {
return getBytes(key)?.fromBytes()
}

View File

@ -6,8 +6,11 @@ package cash.z.ecc.android.lockbox
*/
interface LockBoxProvider {
fun setBytes(key: String, value: ByteArray)
fun getBytes(key: String): ByteArray
fun getBytes(key: String): ByteArray?
fun setCharsUtf8(key: String, value: CharArray)
fun getCharsUtf8(key: String): CharArray
fun getCharsUtf8(key: String): CharArray?
fun setBoolean(key: String, value: Boolean)
fun getBoolean(key: String): Boolean
}

View File

@ -3,6 +3,7 @@ import cash.z.ecc.android.Deps
apply plugin: 'kotlin'
dependencies {
implementation Deps.JavaX.INJECT
implementation Deps.Kotlin.STDLIB
implementation 'com.madgag.spongycastle:core:1.58.0.0'

View File

@ -5,18 +5,40 @@ package cash.z.ecc.kotlin.mnemonic
* interacts with it.
*/
interface MnemonicProvider {
/**
* Generate a random 24-word mnemonic phrase
* Generate a random seed.
*/
fun nextSeed(): ByteArray
/**
* Generate a random 24-word mnemonic phrase.
*/
fun nextMnemonic(): CharArray
/**
* Generate the 24-word mnemonic phrase corresponding to the given seed.
*/
fun nextMnemonic(seed: ByteArray): CharArray
/**
* Generate a random 24-word mnemonic phrase, represented as a list of words.
*/
fun nextMnemonicList(): List<CharArray>
/**
* Generate a 64-byte seed from the 24-word mnemonic phrase
* Generate the 24-word mnemonic phrase corresponding to the given seed, represented as a list.
*/
fun nextMnemonicList(seed: ByteArray): List<CharArray>
/**
* Generate a 64-byte seed from the 24-word mnemonic phrase.
*/
fun toSeed(mnemonic: CharArray): ByteArray
/**
* Split the given mnemonic around spaces.
*/
fun toWordList(mnemonic: CharArray): List<CharArray>
}

View File

@ -5,28 +5,40 @@ import io.github.novacrypto.bip39.SeedCalculator
import io.github.novacrypto.bip39.Words
import io.github.novacrypto.bip39.wordlists.English
import java.security.SecureRandom
import javax.inject.Inject
// TODO: either find another library that allows for doing this without strings or modify this code
// to leverage SecureCharBuffer (which doesn't work well with SeedCalculator.calculateSeed,
// which expects a string so for that reason, we just use Strings here)
class Mnemonics @Inject constructor(): MnemonicProvider {
override fun nextSeed(): ByteArray {
return ByteArray(Words.TWENTY_FOUR.byteLength()).apply {
SecureRandom().nextBytes(this)
}
}
class Mnemonics : MnemonicProvider {
override fun nextMnemonic(): CharArray {
// TODO: either find another library that allows for doing this without strings or modify this code to leverage SecureCharBuffer (which doesn't work well with SeedCalculator.calculateSeed, which expects a string so for that reason, we just use Strings here)
return nextMnemonic(nextSeed())
}
override fun nextMnemonic(seed: ByteArray): CharArray {
return StringBuilder().let { builder ->
ByteArray(Words.TWENTY_FOUR.byteLength()).also {
SecureRandom().nextBytes(it)
MnemonicGenerator(English.INSTANCE).createMnemonic(it) { c ->
builder.append(c)
}
MnemonicGenerator(English.INSTANCE).createMnemonic(seed) { c ->
builder.append(c)
}
builder.toString().toCharArray()
}
}
override fun nextMnemonicList(): List<CharArray> {
return nextMnemonicList(nextSeed())
}
override fun nextMnemonicList(seed: ByteArray): List<CharArray> {
return WordListBuilder().let { builder ->
ByteArray(Words.TWENTY_FOUR.byteLength()).also {
SecureRandom().nextBytes(it)
MnemonicGenerator(English.INSTANCE).createMnemonic(it) { c ->
builder.append(c)
}
MnemonicGenerator(English.INSTANCE).createMnemonic(seed) { c ->
builder.append(c)
}
builder.wordList
}
@ -37,11 +49,30 @@ class Mnemonics : MnemonicProvider {
return SeedCalculator().calculateSeed(mnemonic.toString(), "")
}
override fun toWordList(mnemonic: CharArray): List<CharArray> {
val wordList = mutableListOf<CharArray>()
var cursor = 0
repeat(mnemonic.size) { i ->
val isSpace = mnemonic[i] == ' '
if (isSpace || i == (mnemonic.size - 1)) {
val wordSize = i - cursor + if (isSpace) 0 else 1
wordList.add(CharArray(wordSize).apply {
repeat(wordSize) {
this[it] = mnemonic[cursor + it]
}
})
cursor = i + 1
}
}
return wordList
}
class WordListBuilder {
val wordList = mutableListOf<CharArray>()
fun append(c: CharSequence) {
if (c[0] != English.INSTANCE.space) addWord(c)
}
private fun addWord(c: CharSequence) {
c.length.let { size ->
val word = CharArray(size)

View File

@ -11,6 +11,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.lang.Math.max
import java.security.SecureRandom
@ -45,6 +46,24 @@ class MnemonicTest {
validate(words.map { String(it) })
}
@Test
fun testMnemonic_toList() {
val words = mnemonics.run {
toWordList(nextMnemonic())
}
assertEquals(24, words.size)
validate(words.map { String(it) })
}
@Test
fun testMnemonic_longestWord() {
var max = 0
repeat(2048) {
max = max(max, English.INSTANCE.getWord(it).length)
}
assertEquals(8, max)
}
private fun validate(words: List<String>) {
// return or crash!
words.forEach { word ->