[#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:
parent
65792e92b0
commit
ec983f1f8f
|
@ -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>
|
|
@ -8,10 +8,14 @@
|
||||||
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
|
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
|
||||||
<option name="EXTRA_OPTIONS" value="" />
|
<option name="EXTRA_OPTIONS" value="" />
|
||||||
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
|
<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="CLEAR_LOGCAT" value="false" />
|
||||||
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
|
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
|
||||||
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
|
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
|
||||||
<option name="FORCE_STOP_RUNNING_APP" 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="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
|
||||||
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
|
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
|
||||||
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
|
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
|
||||||
|
@ -42,7 +46,7 @@
|
||||||
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
|
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
|
||||||
<option name="STARTUP_PROFILING_ENABLED" value="false" />
|
<option name="STARTUP_PROFILING_ENABLED" value="false" />
|
||||||
<option name="STARTUP_CPU_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="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
|
||||||
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
|
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
|
||||||
</Profilers>
|
</Profilers>
|
||||||
|
|
|
@ -85,12 +85,9 @@ dependencies {
|
||||||
implementation(libs.androidx.activity)
|
implementation(libs.androidx.activity)
|
||||||
implementation(libs.androidx.annotation)
|
implementation(libs.androidx.annotation)
|
||||||
implementation(libs.androidx.core)
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.bundles.androidx.compose)
|
implementation(libs.kotlin.stdlib)
|
||||||
implementation(libs.google.material)
|
|
||||||
implementation(libs.kotlin)
|
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.zcash)
|
|
||||||
implementation(projects.uiLib)
|
implementation(projects.uiLib)
|
||||||
|
|
||||||
androidTestImplementation(libs.bundles.androidx.test)
|
androidTestImplementation(libs.bundles.androidx.test)
|
||||||
|
|
|
@ -23,10 +23,25 @@ The main entrypoints of the application are:
|
||||||
## Modules
|
## Modules
|
||||||
The logical components of the app are implemented as a number of Gradle 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`.
|
* `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.
|
* `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.
|
* `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
|
||||||
* preference-api-lib — Multiplatform interfaces for key-value storage of preferences.
|
* `preference-api-lib` — Multiplatform interfaces for key-value storage of preferences.
|
||||||
* preference-impl-android-lib — Android-specific implementation for preference storage.
|
* `preference-impl-android-lib` — Android-specific implementation for preference storage.
|
||||||
* test-lib — Provides common test utilities.
|
* `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
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
# Gathering Code Coverage
|
# 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`.
|
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`.
|
When coverage is enabled, running instrumentation tests will automatically generate coverage reports stored under `build/reports/coverage`.
|
|
@ -71,6 +71,8 @@ JACOCO_VERSION=0.8.7
|
||||||
KOTLINX_COROUTINES_VERSION=1.5.2
|
KOTLINX_COROUTINES_VERSION=1.5.2
|
||||||
KOTLIN_VERSION=1.5.31
|
KOTLIN_VERSION=1.5.31
|
||||||
ZCASH_SDK_VERSION=1.3.0-beta18
|
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
|
# 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
|
# Java version used to run the application. Android requires a minimum of 11. Apple Silicon
|
||||||
|
|
|
@ -7,9 +7,13 @@ interface PreferenceProvider {
|
||||||
|
|
||||||
suspend fun hasKey(key: Key): Boolean
|
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 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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,4 +16,8 @@ data class BooleanPreferenceDefault(
|
||||||
defaultValue
|
defaultValue
|
||||||
}
|
}
|
||||||
} ?: defaultValue
|
} ?: defaultValue
|
||||||
|
|
||||||
|
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Boolean) {
|
||||||
|
preferenceProvider.putString(key, newValue.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,4 +15,8 @@ data class IntegerPreferenceDefault(
|
||||||
defaultValue
|
defaultValue
|
||||||
}
|
}
|
||||||
} ?: defaultValue
|
} ?: defaultValue
|
||||||
|
|
||||||
|
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: Int) {
|
||||||
|
preferenceProvider.putString(key, newValue.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
package co.electriccoin.zcash.preference.model.entry
|
package co.electriccoin.zcash.preference.model.entry
|
||||||
|
|
||||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
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,
|
* 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
|
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.
|
* @return The value in the preference, or the default value if no preference exists.
|
||||||
*/
|
*/
|
||||||
suspend fun getValue(preferenceProvider: PreferenceProvider): T
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,4 +9,8 @@ data class StringPreferenceDefault(
|
||||||
|
|
||||||
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)
|
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)
|
||||||
?: defaultValue
|
?: defaultValue
|
||||||
|
|
||||||
|
override suspend fun putValue(preferenceProvider: PreferenceProvider, newValue: String) {
|
||||||
|
preferenceProvider.putString(key, newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,18 +8,18 @@ import kotlinx.coroutines.flow.flowOf
|
||||||
/**
|
/**
|
||||||
* @param mutableMapFactory Emits a new mutable map. Thread safety depends on the factory implementation.
|
* @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()
|
private val map = mutableMapFactory()
|
||||||
|
|
||||||
override suspend fun getString(key: Key) = map[key.key]
|
override suspend fun getString(key: Key) = map[key.key]
|
||||||
|
|
||||||
// For the mock implementation, does not support observability of changes
|
// 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 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
|
map[key.key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.kotlin)
|
implementation(libs.kotlin.stdlib)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(projects.preferenceApiLib)
|
implementation(projects.preferenceApiLib)
|
||||||
|
|
|
@ -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>
|
|
@ -69,6 +69,10 @@ class EncryptedPreferenceProviderTest {
|
||||||
fun verify_no_plaintext() = runBlocking {
|
fun verify_no_plaintext() = runBlocking {
|
||||||
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
|
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()
|
val text = File(File(ApplicationProvider.getApplicationContext<Context>().dataDir, "shared_prefs"), "$FILENAME.xml").readText()
|
||||||
|
|
||||||
assertFalse(text.contains(expectedValue))
|
assertFalse(text.contains(expectedValue))
|
||||||
|
|
|
@ -14,7 +14,6 @@ import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@ -41,7 +40,7 @@ class EncryptedPreferenceProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ApplySharedPref")
|
@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()
|
val editor = sharedPreferences.edit()
|
||||||
|
|
||||||
editor.putString(key.key, value)
|
editor.putString(key.key, value)
|
||||||
|
@ -56,7 +55,7 @@ class EncryptedPreferenceProvider(
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@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 { _, _ ->
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
|
||||||
// Callback on main thread
|
// Callback on main thread
|
||||||
trySend(Unit)
|
trySend(Unit)
|
||||||
|
@ -70,10 +69,13 @@ class EncryptedPreferenceProvider(
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
}
|
}
|
||||||
}.flowOn(dispatcher)
|
}.flowOn(dispatcher)
|
||||||
.map { getString(key) }
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun new(context: Context, filename: String): PreferenceProvider {
|
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 singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
|
||||||
val mainKey = withContext(singleThreadedDispatcher) {
|
val mainKey = withContext(singleThreadedDispatcher) {
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package cash.z.ecc.sdk.test
|
||||||
|
|
||||||
|
fun <T> Iterator<T>.count(): Int {
|
||||||
|
var count = 0
|
||||||
|
forEach { count++ }
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="cash.z.ecc.sdk.ext">
|
||||||
|
|
||||||
|
<application />
|
||||||
|
|
||||||
|
</manifest>
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -66,6 +66,7 @@ dependencyResolutionManagement {
|
||||||
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
|
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
|
||||||
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
|
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
|
||||||
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
|
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
|
||||||
|
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
|
||||||
|
|
||||||
// Standalone versions
|
// Standalone versions
|
||||||
version("jacoco", jacocoVersion)
|
version("jacoco", jacocoVersion)
|
||||||
|
@ -89,10 +90,13 @@ dependencyResolutionManagement {
|
||||||
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
|
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
|
||||||
alias("desugaring").to("com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
|
alias("desugaring").to("com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion")
|
||||||
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
|
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-android").to("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
|
||||||
alias("kotlinx-coroutines-core").to("org.jetbrains.kotlinx:kotlinx-coroutines-core:$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
|
// Test libraries
|
||||||
alias("androidx-compose-test-junit").to("androidx.compose.ui:ui-test-junit4:$androidxComposeVersion")
|
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")
|
alias("androidx-compose-test-manifest").to("androidx.compose.ui:ui-test-manifest:$androidxComposeVersion")
|
||||||
|
@ -144,5 +148,6 @@ include("app")
|
||||||
include("build-info-lib")
|
include("build-info-lib")
|
||||||
include("preference-api-lib")
|
include("preference-api-lib")
|
||||||
include("preference-impl-android-lib")
|
include("preference-impl-android-lib")
|
||||||
|
include("sdk-ext-lib")
|
||||||
include("test-lib")
|
include("test-lib")
|
||||||
include("ui-lib")
|
include("ui-lib")
|
||||||
|
|
|
@ -42,14 +42,20 @@ dependencies {
|
||||||
implementation(libs.androidx.lifecycle.livedata)
|
implementation(libs.androidx.lifecycle.livedata)
|
||||||
implementation(libs.bundles.androidx.compose)
|
implementation(libs.bundles.androidx.compose)
|
||||||
implementation(libs.google.material)
|
implementation(libs.google.material)
|
||||||
implementation(libs.kotlin)
|
implementation(libs.kotlin.stdlib)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
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.bundles.androidx.test)
|
||||||
androidTestImplementation(libs.androidx.compose.test.junit)
|
androidTestImplementation(libs.androidx.compose.test.junit)
|
||||||
androidTestImplementation(libs.androidx.compose.test.manifest)
|
androidTestImplementation(libs.androidx.compose.test.manifest)
|
||||||
|
androidTestImplementation(libs.kotlin.reflect)
|
||||||
|
|
||||||
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
|
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
|
||||||
androidTestUtil(libs.androidx.test.orchestrator) {
|
androidTestUtil(libs.androidx.test.orchestrator) {
|
||||||
|
|
|
@ -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>
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">zcash-ui-test</string>
|
||||||
|
</resources>
|
|
@ -4,23 +4,37 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
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.view.Onboarding
|
||||||
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
import cash.z.ecc.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||||
import cash.z.ecc.ui.theme.ZcashTheme
|
import cash.z.ecc.ui.theme.ZcashTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val walletViewModel by viewModels<WalletViewModel>()
|
||||||
|
|
||||||
private val onboardingViewModel by viewModels<OnboardingViewModel>()
|
private val onboardingViewModel by viewModels<OnboardingViewModel>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
ZcashTheme {
|
ZcashTheme {
|
||||||
|
if (null == walletViewModel.persistableWallet.collectAsState(null).value) {
|
||||||
|
// Optimized path to get to onboarding as quickly as possible
|
||||||
Onboarding(
|
Onboarding(
|
||||||
onboardingState = onboardingViewModel.onboardingState,
|
onboardingState = onboardingViewModel.onboardingState,
|
||||||
onImportWallet = { TODO("Implement wallet import") },
|
onImportWallet = { TODO("Implement wallet import") },
|
||||||
onCreateWallet = { TODO("Implement wallet create") }
|
onCreateWallet = { TODO("Implement wallet create") }
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
if (null == walletViewModel.synchronizer.collectAsState(null).value) {
|
||||||
|
// Continue displaying splash screen
|
||||||
|
} else {
|
||||||
|
Home()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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"))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Demo App</string>
|
<string name="app_name">zcash-ui</string>
|
||||||
|
|
||||||
<string name="onboarding_back">Back</string>
|
<string name="onboarding_back">Back</string>
|
||||||
<string name="onboarding_skip">Skip</string>
|
<string name="onboarding_skip">Skip</string>
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
<resources>
|
|
||||||
<string name="app_name">Mainnet Demo</string>
|
|
||||||
<string name="network_name">Mainnet</string>
|
|
||||||
</resources>
|
|
|
@ -1,4 +0,0 @@
|
||||||
<resources>
|
|
||||||
<string name="app_name">Testnet Demo</string>
|
|
||||||
<string name="network_name">Testnet</string>
|
|
||||||
</resources>
|
|
Loading…
Reference in New Issue