[#28] Initial SDK integration

This sets up the infrastructure needed to continue implementing the onboarding UI for create and import of wallets.  By fleshing out the global state management in the app now, we can better manage asynchronous IO to avoid blocking the UI.

This adds:

 - Load and persistence a wallet in encrypted preferences
     - The stored data is written as a single JSON object, as opposed to multiple entries, to ensure atomic writes
     - The data is versioned, so that we can change the JSON format readily in the future
 - Detection of application state, e.g. onboarding versus loading the user's wallet
 - Touch points to initialize the SDK
This commit is contained in:
Carter Jernigan 2021-11-12 07:09:30 -05:00
parent 65792e92b0
commit ec983f1f8f
43 changed files with 736 additions and 40 deletions

View File

@ -0,0 +1,57 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="sdk-ext-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-app.sdk-ext-lib" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="PACKAGE_NAME" value="" />
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="RETENTION_ENABLED" value="No" />
<option name="RETENTION_MAX_SNAPSHOTS" value="2" />
<option name="RETENTION_COMPRESS_SNAPSHOTS" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View File

@ -8,10 +8,14 @@
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="RETENTION_ENABLED" value="No" />
<option name="RETENTION_MAX_SNAPSHOTS" value="2" />
<option name="RETENTION_COMPRESS_SNAPSHOTS" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
@ -42,7 +46,7 @@
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Callstack Sample" />
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
</Profilers>

View File

@ -85,12 +85,9 @@ dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.annotation)
implementation(libs.androidx.core)
implementation(libs.bundles.androidx.compose)
implementation(libs.google.material)
implementation(libs.kotlin)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash)
implementation(projects.uiLib)
androidTestImplementation(libs.bundles.androidx.test)

View File

@ -23,10 +23,25 @@ The main entrypoints of the application are:
## Modules
The logical components of the app are implemented as a number of Gradle modules.
* app — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `cash.z.ecc.app` while the Android package name is `cash.z.ecc`.
* build-info-lib — Collects information from the build environment (e.g. Git SHA, Git commit count) and compiles them into the application. Can also be used for injection of API keys or other secrets.
* ui-lib — User interface that the user interacts with. This contains 99% of the UI code, along with localizations, icons, and other assets.
* `app` — Compiles all of the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `cash.z.ecc.app` while the Android package name is `cash.z.ecc`.
* `build-info-lib` — Collects information from the build environment (e.g. Git SHA, Git commit count) and compiles them into the application. Can also be used for injection of API keys or other secrets.
* `ui-lib` — User interface that the user interacts with. This contains 99% of the UI code, along with localizations, icons, and other assets.
* preference
* preference-api-lib — Multiplatform interfaces for key-value storage of preferences.
* preference-impl-android-lib — Android-specific implementation for preference storage.
* test-lib — Provides common test utilities.
* `preference-api-lib` — Multiplatform interfaces for key-value storage of preferences.
* `preference-impl-android-lib` — Android-specific implementation for preference storage.
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
* `test-lib` — Provides common test utilities.
## Shared Resources
There are some app-wide resources that share a common namespace, and these should be documented here to make it easy to ensure there are no collisions.
* SharedPreferences
* "co.electriccoin.zcash.encrypted" is defined as a preference file in `EncryptedPreferenceSingleton.kt`
* Databases
* Some databases are defined by the SDK
* Notification IDs
* No notification IDs are currently defined
* Notification Channels
* No notification channels are currently defined
* WorkManager Tags
* No WorkManager tags are currently defined

View File

@ -1,4 +1,14 @@
# Gathering Code Coverage
The app consists of different Gradle module types (e.g. Kotlin Multiplatform, Android). Generating coverage for these different module types requires different command line invocations.
## Kotlin Multiplatform
Kotlin Multiplatform does not support coverage for all platforms. Most of our code lives under commonMain, with a JVM target. This effectively allows generation of coverage reports with Jacoco.
Due to some quirks with the Jacoco integration, coverage must be generated in two Gradle invocations like this:
`./gradlew test -x connectedCheck -PIS_COVERAGE_ENABLED=true; ./gradlew jacocoTestReport -PIS_COVERAGE_ENABLED=true`
## Android
The Android Gradle plugin supports code coverage with Jacoco. This integration can sometimes be buggy. For that reason, coverage is disabled by default and can be enabled on a case-by-case basis, by passing `-PIS_COVERAGE_ENABLED=true` as a command line argument for Gradle builds. For example: `./gradlew :app:connectedCheck -PIS_COVERAGE_ENABLED=true`.
When coverage is enabled, running instrumentation tests will automatically generate coverage reports stored under `build/reports/coverage`.

View File

@ -71,6 +71,8 @@ JACOCO_VERSION=0.8.7
KOTLINX_COROUTINES_VERSION=1.5.2
KOTLIN_VERSION=1.5.31
ZCASH_SDK_VERSION=1.3.0-beta18
ZCASH_BIP39_VERSION=1.0.2
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
# Toolchain is the Java version used to build the application, which is separate from the
# Java version used to run the application. Android requires a minimum of 11. Apple Silicon

View File

@ -7,9 +7,13 @@ interface PreferenceProvider {
suspend fun hasKey(key: Key): Boolean
suspend fun putString(key: Key, value: String)
suspend fun putString(key: Key, value: String?)
suspend fun getString(key: Key): String?
suspend fun observe(key: Key): Flow<String?>
/**
* @return Flow to observe potential changes to the value associated with the key in the preferences.
* Consumers of the flow will need to then query the value and determine whether it has changed.
*/
fun observe(key: Key): Flow<Unit>
}

View File

@ -16,4 +16,8 @@ data class BooleanPreferenceDefault(
defaultValue
}
} ?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Boolean) {
preferenceProvider.putString(key, newValue.toString())
}
}

View File

@ -15,4 +15,8 @@ data class IntegerPreferenceDefault(
defaultValue
}
} ?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Int) {
preferenceProvider.putString(key, newValue.toString())
}
}

View File

@ -1,6 +1,9 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* An entry represents a key and a default value for a preference. By using a Default object,
@ -22,8 +25,23 @@ interface PreferenceDefault<T> {
val key: Key
/**
* @param preferenceProvider Provides actual preference values
* @param preferenceProvider Provides actual preference values.
* @return The value in the preference, or the default value if no preference exists.
*/
suspend fun getValue(preferenceProvider: PreferenceProvider): T
/**
* @param preferenceProvider Provides actual preference values.
* @param newValue New value to write.
*/
suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: T)
/**
* @param preferenceProvider Provides actual preference values.
* @return Flow that emits preference changes. Note that implementations should emit an initial value
* indicating what was stored in the preferences, in addition to subsequent updates.
*/
fun observe(preferenceProvider: PreferenceProvider): Flow<T> = preferenceProvider.observe(key)
.map { getValue(preferenceProvider) }
.distinctUntilChanged()
}

View File

@ -9,4 +9,8 @@ data class StringPreferenceDefault(
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)
?: defaultValue
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: String) {
preferenceProvider.putString(key, newValue)
}
}

View File

@ -8,18 +8,18 @@ import kotlinx.coroutines.flow.flowOf
/**
* @param mutableMapFactory Emits a new mutable map. Thread safety depends on the factory implementation.
*/
class MockPreferenceProvider(mutableMapFactory: () -> MutableMap<String, String> = { mutableMapOf() }) : PreferenceProvider {
class MockPreferenceProvider(mutableMapFactory: () -> MutableMap<String, String?> = { mutableMapOf() }) : PreferenceProvider {
private val map = mutableMapFactory()
override suspend fun getString(key: Key) = map[key.key]
// For the mock implementation, does not support observability of changes
override suspend fun observe(key: Key): Flow<String?> = flowOf(getString(key))
override fun observe(key: Key): Flow<Unit> = flowOf(Unit)
override suspend fun hasKey(key: Key) = map.containsKey(key.key)
override suspend fun putString(key: Key, value: String) {
override suspend fun putString(key: Key, value: String?) {
map[key.key] = value
}
}

View File

@ -16,7 +16,7 @@ android {
dependencies {
implementation(libs.androidx.security.crypto)
implementation(libs.kotlin)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(projects.preferenceApiLib)

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.preference">
<application android:label="zcash-preference-test" />
</manifest>

View File

@ -69,6 +69,10 @@ class EncryptedPreferenceProviderTest {
fun verify_no_plaintext() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
val text = File(File(ApplicationProvider.getApplicationContext<Context>().dataDir, "shared_prefs"), "$FILENAME.xml").readText()
assertFalse(text.contains(expectedValue))

View File

@ -14,7 +14,6 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
@ -41,7 +40,7 @@ class EncryptedPreferenceProvider(
}
@SuppressLint("ApplySharedPref")
override suspend fun putString(key: Key, value: String) = withContext(dispatcher) {
override suspend fun putString(key: Key, value: String?) = withContext(dispatcher) {
val editor = sharedPreferences.edit()
editor.putString(key.key, value)
@ -56,7 +55,7 @@ class EncryptedPreferenceProvider(
}
@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun observe(key: Key): Flow<String?> = callbackFlow<Unit> {
override fun observe(key: Key): Flow<Unit> = callbackFlow<Unit> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread
trySend(Unit)
@ -70,10 +69,13 @@ class EncryptedPreferenceProvider(
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}.flowOn(dispatcher)
.map { getString(key) }
companion object {
suspend fun new(context: Context, filename: String): PreferenceProvider {
/*
* Because of this line, we don't want multiple instances of this object created
* because we don't clean up the thread afterwards.
*/
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val mainKey = withContext(singleThreadedDispatcher) {

View File

@ -0,0 +1,32 @@
plugins {
id("com.android.library")
kotlin("android")
id("zcash.android-build-conventions")
}
android {
// TODO [#6]: Figure out how to move this into the build-conventions
kotlinOptions {
jvmTarget = libs.versions.java.get()
allWarningsAsErrors = project.property("IS_TREAT_WARNINGS_AS_ERRORS").toString().toBoolean()
freeCompilerArgs = freeCompilerArgs.plus("-Xopt-in=kotlin.RequiresOptIn")
}
}
dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash.sdk)
implementation(libs.zcash.bip39)
androidTestImplementation(libs.bundles.androidx.test)
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
androidTestUtil(libs.androidx.test.orchestrator) {
artifact {
type = "apk"
}
}
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="cash.z.ecc.sdk.ext"
xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="sdk-ext-test"/>
</manifest>

View File

@ -0,0 +1,16 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.android.sdk.type.WalletBirthday
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.sdk.model.PersistableWallet
object PersistableWalletFixture {
val NETWORK = ZcashNetwork.Testnet
val BIRTHDAY = WalletBirthdayFixture.new()
val SEED_PHRASE = "still champion voice habit trend flight survey between bitter process artefact blind carbon truly provide dizzy crush flush breeze blouse charge solid fish spread"
fun new(network: ZcashNetwork = NETWORK, birthday: WalletBirthday = BIRTHDAY, seedPhrase: String = SEED_PHRASE) = PersistableWallet(network, birthday, seedPhrase)
}

View File

@ -0,0 +1,18 @@
package cash.z.ecc.sdk.fixture
import cash.z.ecc.android.sdk.type.WalletBirthday
object WalletBirthdayFixture {
const val HEIGHT = 1500000
const val HASH = "00047a34c61409682f44640af9352023ad92f69b827d0f2b288f152ebea50f46"
const val EPOCH_SECONDS = 1627076501L
const val TREE = "01172b95f271c6af8f68388f08c8ef970db8ec8d8d61204ecb7b2bb2c38262b92d0010016284585a6c85dadfef27ff33f1403926b4bb391de92e8be797e4280cc4ca2971000001a1ff388639379c0120782b3929bd8871af797be4b651f694aa961bad65a9c12400000001d806c98bda9653d5ae22757eed750871e16e0fb657f52c3d771a4411668e84330001260f6e9fac0922f98d58afbcc3f391ac19d5d944081466929a33b99df19c0e6a0000013d2fd009bf8a22d68f720eac19c411c99014ed9c5f85d5942e15d1fc039e28680001f08f39275112dd8905b854170b7f247cf2df18454d4fa94e6e4f9320cca05f24011f8322ef806eb2430dc4a7a41c1b344bea5be946efc7b4349c1c9edb14ff9d39"
fun new(
height: Int = HEIGHT,
hash: String = HASH,
time: Long = EPOCH_SECONDS,
tree: String = TREE
) = WalletBirthday(height = height, hash = hash, time = time, tree = tree)
}

View File

@ -0,0 +1,50 @@
package cash.z.ecc.sdk.model
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.type.ZcashNetwork
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
import cash.z.ecc.sdk.test.count
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PersistableWalletTest {
@Test
@SmallTest
fun serialize() {
val persistableWallet = PersistableWalletFixture.new()
val jsonObject = persistableWallet.toJson()
assertEquals(4, jsonObject.keys().count())
assertTrue(jsonObject.has(PersistableWallet.KEY_VERSION))
assertTrue(jsonObject.has(PersistableWallet.KEY_NETWORK_ID))
assertTrue(jsonObject.has(PersistableWallet.KEY_SEED_PHRASE))
assertTrue(jsonObject.has(PersistableWallet.KEY_BIRTHDAY))
assertEquals(1, jsonObject.getInt(PersistableWallet.KEY_VERSION))
assertEquals(ZcashNetwork.Testnet.id, jsonObject.getInt(PersistableWallet.KEY_NETWORK_ID))
assertEquals(PersistableWalletFixture.SEED_PHRASE, jsonObject.getString(PersistableWallet.KEY_SEED_PHRASE))
// Birthday serialization is tested in a separate file
}
@Test
@SmallTest
fun round_trip() {
val persistableWallet = PersistableWalletFixture.new()
val deserialized = PersistableWallet.from(persistableWallet.toJson())
assertEquals(persistableWallet, deserialized)
assertFalse(persistableWallet === deserialized)
}
@Test
@SmallTest
fun toString_security() {
val actual = PersistableWalletFixture.new().toString()
assertFalse(actual.contains(PersistableWalletFixture.SEED_PHRASE))
}
}

View File

@ -0,0 +1,56 @@
package cash.z.ecc.sdk.model
import androidx.test.filters.SmallTest
import cash.z.ecc.sdk.fixture.WalletBirthdayFixture
import cash.z.ecc.sdk.test.count
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WalletBirthdayTest {
@Test
@SmallTest
fun serialize() {
val walletBirthday = WalletBirthdayFixture.new()
val jsonObject = walletBirthday.toJson()
assertEquals(5, jsonObject.keys().count())
assertTrue(jsonObject.has(WalletBirthdayCompanion.KEY_VERSION))
assertTrue(jsonObject.has(WalletBirthdayCompanion.KEY_HEIGHT))
assertTrue(jsonObject.has(WalletBirthdayCompanion.KEY_HASH))
assertTrue(jsonObject.has(WalletBirthdayCompanion.KEY_EPOCH_SECONDS))
assertTrue(jsonObject.has(WalletBirthdayCompanion.KEY_TREE))
assertEquals(1, jsonObject.getInt(WalletBirthdayCompanion.KEY_VERSION))
assertEquals(WalletBirthdayFixture.HEIGHT, jsonObject.getInt(WalletBirthdayCompanion.KEY_HEIGHT))
assertEquals(WalletBirthdayFixture.HASH, jsonObject.getString(WalletBirthdayCompanion.KEY_HASH))
assertEquals(WalletBirthdayFixture.EPOCH_SECONDS, jsonObject.getLong(WalletBirthdayCompanion.KEY_EPOCH_SECONDS))
assertEquals(WalletBirthdayFixture.TREE, jsonObject.getString(WalletBirthdayCompanion.KEY_TREE))
}
@Test
@SmallTest
fun epoch_seconds_as_long_that_would_overflow_int() {
val walletBirthday = WalletBirthdayFixture.new(time = Long.MAX_VALUE)
val jsonObject = walletBirthday.toJson()
assertEquals(Long.MAX_VALUE, jsonObject.getLong(WalletBirthdayCompanion.KEY_EPOCH_SECONDS))
WalletBirthdayCompanion.from(jsonObject).also {
assertEquals(Long.MAX_VALUE, it.time)
}
}
@Test
@SmallTest
fun round_trip() {
val walletBirthday = WalletBirthdayFixture.new()
val deserialized = WalletBirthdayCompanion.from(walletBirthday.toJson())
assertEquals(walletBirthday, deserialized)
assertFalse(walletBirthday === deserialized)
}
}

View File

@ -0,0 +1,8 @@
package cash.z.ecc.sdk.test
fun <T> Iterator<T>.count(): Int {
var count = 0
forEach { count++ }
return count
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="cash.z.ecc.sdk.ext">
<application />
</manifest>

View File

@ -0,0 +1,43 @@
package cash.z.ecc.sdk
import android.content.Context
import cash.z.ecc.android.bip39.Mnemonics
import cash.z.ecc.android.bip39.toSeed
import cash.z.ecc.android.sdk.Initializer
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.tool.DerivationTool
import cash.z.ecc.android.sdk.type.UnifiedViewingKey
import cash.z.ecc.sdk.model.PersistableWallet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Synchronizer needs a Companion object
// https://github.com/zcash/zcash-android-wallet-sdk/issues/310
object SynchronizerCompanion {
suspend fun load(context: Context, persistableWallet: PersistableWallet) {
val config = persistableWallet.toConfig()
val initializer = withContext(Dispatchers.IO) { Initializer(context, config) }
return withContext(Dispatchers.IO) { Synchronizer(initializer) }
}
}
private suspend fun PersistableWallet.deriveViewingKey(): UnifiedViewingKey {
// Dispatcher needed because SecureRandom is loaded, which is slow and performs IO
// https://github.com/zcash/kotlin-bip39/issues/13
val bip39Seed = withContext(Dispatchers.IO) { Mnemonics.MnemonicCode(seedPhrase).toSeed() }
// Dispatchers needed until an SDK is published with the implementation of
// https://github.com/zcash/zcash-android-wallet-sdk/issues/269
val viewingKey = withContext(Dispatchers.IO) { DerivationTool.deriveUnifiedViewingKeys(bip39Seed, network)[0] }
return viewingKey
}
private suspend fun PersistableWallet.toConfig(): Initializer.Config {
val network = network
val vk = deriveViewingKey()
return Initializer.Config {
it.importWallet(vk, birthday.height, network, network.defaultHost, network.defaultPort)
}
}

View File

@ -0,0 +1,56 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.type.WalletBirthday
import cash.z.ecc.android.sdk.type.ZcashNetwork
import org.json.JSONObject
/**
* Represents everything needed to save and restore a wallet.
*/
data class PersistableWallet(
val network: ZcashNetwork,
val birthday: WalletBirthday,
val seedPhrase: String
) {
/**
* @return Wallet serialized to JSON format, suitable for long-term encrypted storage.
*/
// Note: We're using a hand-crafted serializer so that we're less likely to have accidental
// breakage from reflection or annotation based methods, and so that we can carefully manage versioning.
fun toJson() = JSONObject().apply {
put(KEY_VERSION, VERSION_1)
put(KEY_NETWORK_ID, network.id)
put(KEY_BIRTHDAY, birthday.toJson())
put(KEY_SEED_PHRASE, seedPhrase)
}
override fun toString(): String {
// For security, intentionally override the toString method to reduce risk of accidentally logging secrets
return "PersistableWallet"
}
companion object {
private const val VERSION_1 = 1
internal const val KEY_VERSION = "v"
internal const val KEY_NETWORK_ID = "network_ID"
internal const val KEY_BIRTHDAY = "birthday"
internal const val KEY_SEED_PHRASE = "seed_phrase"
fun from(jsonObject: JSONObject): PersistableWallet {
when (val version = jsonObject.getInt(KEY_VERSION)) {
VERSION_1 -> {
val networkId = jsonObject.getInt(KEY_NETWORK_ID)
val birthday = WalletBirthdayCompanion.from(jsonObject.getJSONObject(KEY_BIRTHDAY))
val seedPhrase = jsonObject.getString(KEY_SEED_PHRASE)
return PersistableWallet(ZcashNetwork.from(networkId), birthday, seedPhrase)
}
else -> {
throw IllegalArgumentException("Unsupported version $version")
}
}
}
}
}

View File

@ -0,0 +1,41 @@
package cash.z.ecc.sdk.model
import cash.z.ecc.android.sdk.type.WalletBirthday
import org.json.JSONObject
// WalletBirthday needs a companion
// https://github.com/zcash/zcash-android-wallet-sdk/issues/310
object WalletBirthdayCompanion {
internal const val VERSION_1 = 1
internal const val KEY_VERSION = "version"
internal const val KEY_HEIGHT = "height"
internal const val KEY_HASH = "hash"
internal const val KEY_EPOCH_SECONDS = "epoch_seconds"
internal const val KEY_TREE = "tree"
fun from(jsonString: String) = from(JSONObject(jsonString))
fun from(jsonObject: JSONObject): WalletBirthday {
when (val version = jsonObject.getInt(KEY_VERSION)) {
VERSION_1 -> {
val height = jsonObject.getInt(KEY_HEIGHT)
val hash = jsonObject.getString(KEY_HASH)
val epochSeconds = jsonObject.getLong(KEY_EPOCH_SECONDS)
val tree = jsonObject.getString(KEY_TREE)
return WalletBirthday(height, hash, epochSeconds, tree)
}
else -> {
throw IllegalArgumentException("Unsupported version $version")
}
}
}
}
fun WalletBirthday.toJson() = JSONObject().apply {
put(WalletBirthdayCompanion.KEY_VERSION, WalletBirthdayCompanion.VERSION_1)
put(WalletBirthdayCompanion.KEY_HEIGHT, height)
put(WalletBirthdayCompanion.KEY_HASH, hash)
put(WalletBirthdayCompanion.KEY_EPOCH_SECONDS, time)
put(WalletBirthdayCompanion.KEY_TREE, tree)
}

View File

@ -66,6 +66,7 @@ dependencyResolutionManagement {
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
// Standalone versions
version("jacoco", jacocoVersion)
@ -89,10 +90,13 @@ dependencyResolutionManagement {
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
alias("desugaring").to("com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
alias("kotlin").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
alias("kotlin-stdlib").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
alias("kotlin-reflect").to("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
alias("kotlinx-coroutines-android").to("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
alias("kotlinx-coroutines-core").to("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
alias("zcash").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
alias("zcash-sdk").to("cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
alias("zcash-bip39").to("cash.z.ecc.android:kotlin-bip39:$zcashBip39Version")
alias("zcash-walletplgns").to("cash.z.ecc.android:zcash-android-wallet-plugins:$zcashBip39Version")
// Test libraries
alias("androidx-compose-test-junit").to("androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")
alias("androidx-compose-test-manifest").to("androidx.compose.ui:ui-test-manifest:$androidxComposeVersion")
@ -144,5 +148,6 @@ include("app")
include("build-info-lib")
include("preference-api-lib")
include("preference-impl-android-lib")
include("sdk-ext-lib")
include("test-lib")
include("ui-lib")

View File

@ -42,14 +42,20 @@ dependencies {
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.bundles.androidx.compose)
implementation(libs.google.material)
implementation(libs.kotlin)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.zcash)
implementation(libs.zcash.sdk)
implementation(libs.zcash.bip39)
implementation(projects.preferenceApiLib)
implementation(projects.preferenceImplAndroidLib)
implementation(projects.sdkExtLib)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.androidx.compose.test.junit)
androidTestImplementation(libs.androidx.compose.test.manifest)
androidTestImplementation(libs.kotlin.reflect)
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
androidTestUtil(libs.androidx.test.orchestrator) {

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="cash.z.ecc.ui">
<application
android:label="zcash-ui-test"/>
</manifest>

View File

@ -0,0 +1,26 @@
package cash.z.ecc.ui.preference
import androidx.test.filters.SmallTest
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import kotlin.reflect.full.memberProperties
class EncryptedPreferenceKeysTest {
// This test is primary to prevent copy-paste errors in preference keys
@SmallTest
@Test
fun key_values_unique() {
val fieldValueSet = mutableSetOf<String>()
EncryptedPreferenceKeys::class.memberProperties
.map { it.getter.call(EncryptedPreferenceKeys) }
.map { it as PersistableWalletPreferenceDefault }
.map { it.key }
.forEach {
assertThat("Duplicate key $it", fieldValueSet.contains(it.key), equalTo(false))
fieldValueSet.add(it.key)
}
}
}

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">zcash-ui-test</string>
</resources>

View File

@ -4,23 +4,37 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import cash.z.ecc.ui.screen.home.view.Home
import cash.z.ecc.ui.screen.home.viewmodel.WalletViewModel
import cash.z.ecc.ui.screen.onboarding.view.Onboarding
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
import cash.z.ecc.ui.theme.ZcashTheme
class MainActivity : ComponentActivity() {
private val walletViewModel by viewModels<WalletViewModel>()
private val onboardingViewModel by viewModels<OnboardingViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ZcashTheme {
Onboarding(
onboardingState = onboardingViewModel.onboardingState,
onImportWallet = { TODO("Implement wallet import") },
onCreateWallet = { TODO("Implement wallet create") }
)
if (null == walletViewModel.persistableWallet.collectAsState(null).value) {
// Optimized path to get to onboarding as quickly as possible
Onboarding(
onboardingState = onboardingViewModel.onboardingState,
onImportWallet = { TODO("Implement wallet import") },
onCreateWallet = { TODO("Implement wallet create") }
)
} else {
if (null == walletViewModel.synchronizer.collectAsState(null).value) {
// Continue displaying splash screen
} else {
Home()
}
}
}
}
}

View File

@ -0,0 +1,4 @@
package cash.z.ecc.ui.common
// Recommended timeout for Android configuration changes to keep Kotlin Flow from restarting
const val ANDROID_STATE_FLOW_TIMEOUT_MILLIS = 5000L

View File

@ -0,0 +1,8 @@
package cash.z.ecc.ui.preference
import co.electriccoin.zcash.preference.model.entry.Key
object EncryptedPreferenceKeys {
val PERSISTABLE_WALLET = PersistableWalletPreferenceDefault(Key("persistable_wallet"))
}

View File

@ -0,0 +1,15 @@
package cash.z.ecc.ui.preference
import android.content.Context
import cash.z.ecc.ui.util.Lazy
import co.electriccoin.zcash.preference.EncryptedPreferenceProvider
import co.electriccoin.zcash.preference.api.PreferenceProvider
object EncryptedPreferenceSingleton {
private const val PREF_FILENAME = "co.electriccoin.zcash.encrypted"
private val lazy = Lazy<Context, PreferenceProvider> { EncryptedPreferenceProvider.new(it, PREF_FILENAME) }
suspend fun getInstance(context: Context) = lazy.getInstance(context)
}

View File

@ -0,0 +1,20 @@
package cash.z.ecc.ui.preference
import cash.z.ecc.sdk.model.PersistableWallet
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.Key
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
import org.json.JSONObject
data class PersistableWalletPreferenceDefault(
override val key: Key
) : PreferenceDefault<PersistableWallet?> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
preferenceProvider.getString(key)?.let { PersistableWallet.from(JSONObject(it)) }
override suspend fun putValue(
preferenceProvider: PreferenceProvider,
newValue: PersistableWallet?
) = preferenceProvider.putString(key, newValue?.toJson()?.toString())
}

View File

@ -0,0 +1,26 @@
package cash.z.ecc.ui.screen.home.view
import androidx.compose.foundation.layout.Column
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import cash.z.ecc.ui.theme.ZcashTheme
@Preview
@Composable
fun ComposablePreview() {
ZcashTheme(darkTheme = true) {
Home()
}
}
@Composable
fun Home() {
Surface {
Column {
// Placeholder
Text("Welcome to your wallet")
}
}
}

View File

@ -0,0 +1,74 @@
package cash.z.ecc.ui.screen.home.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.SynchronizerCompanion
import cash.z.ecc.sdk.model.PersistableWallet
import cash.z.ecc.ui.common.ANDROID_STATE_FLOW_TIMEOUT_MILLIS
import cash.z.ecc.ui.preference.EncryptedPreferenceKeys
import cash.z.ecc.ui.preference.EncryptedPreferenceSingleton
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
// To make this more multiplatform compatible, we need to remove the dependency on Context
// for loading the preferences.
class WalletViewModel(application: Application) : AndroidViewModel(application) {
/**
* A flow of the user's stored wallet. Null indicates that no wallet has been stored.
*/
/*
* This is exposed, because loading the value here is faster than loading the entire Zcash SDK.
*
* This allows the UI to load the first launch onboarding experience a few hundred milliseconds
* faster.
*/
val persistableWallet = flow {
// EncryptedPreferenceSingleton.getInstance() is a suspending function, which is why we need
// the flow builder to provide a coroutine context.
val encryptedPreferenceProvider = EncryptedPreferenceSingleton.getInstance(application)
emitAll(EncryptedPreferenceKeys.PERSISTABLE_WALLET.observe(encryptedPreferenceProvider))
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
/**
* A flow of the Zcash SDK initialized with the user's stored wallet. Null indicates that no
* wallet has been stored.
*/
// Note: in the future we might want to convert this to emitting a sealed class with states like:
// - No wallet
// - Current wallet
// - Error loading wallet
val synchronizer = persistableWallet.map { persistableWallet ->
persistableWallet?.let { SynchronizerCompanion.load(application, persistableWallet) }
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(stopTimeoutMillis = ANDROID_STATE_FLOW_TIMEOUT_MILLIS),
null
)
/**
* Persists a wallet asynchronously. Clients observe either [persistableWallet] or [synchronizer]
* to see the side effects.
*
* This method does not prevent multiple calls, so clients should be careful not to call this
* method multiple times in rapid succession. While the persistableWallet write is atomic,
* the ordering of the writes is not specified. If the same persistableWallet is passed in,
* then there's no problem. But if different persistableWallets are passed in, then which one
* actually gets written is non-deterministic.
*/
fun persistWallet(persistableWallet: PersistableWallet) {
viewModelScope.launch {
val preferenceProvider = EncryptedPreferenceSingleton.getInstance(getApplication())
EncryptedPreferenceKeys.PERSISTABLE_WALLET.putValue(preferenceProvider, persistableWallet)
}
}
}

View File

@ -0,0 +1,28 @@
package cash.z.ecc.ui.util
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Implements a coroutines-friendly lazy singleton pattern with an input argument.
*
* This class is thread-safe.
*/
class Lazy<in Input, out Output>(private val deferredCreator: suspend ((Input) -> Output)) {
private var singletonInstance: Output? = null
private val mutex = Mutex()
suspend fun getInstance(input: Input): Output {
mutex.withLock {
singletonInstance?.let {
return it
}
val newInstance = deferredCreator(input)
singletonInstance = newInstance
return newInstance
}
}
}

View File

@ -1,5 +1,5 @@
<resources>
<string name="app_name">Demo App</string>
<string name="app_name">zcash-ui</string>
<string name="onboarding_back">Back</string>
<string name="onboarding_skip">Skip</string>

View File

@ -1,4 +0,0 @@
<resources>
<string name="app_name">Mainnet Demo</string>
<string name="network_name">Mainnet</string>
</resources>

View File

@ -1,4 +0,0 @@
<resources>
<string name="app_name">Testnet Demo</string>
<string name="network_name">Testnet</string>
</resources>