[#13] Add initial preference infrastructure

This adds infrastructure to read/write preferences, with both a multiplatform wrapper and an Android-specific implementation.

This implementation is primarily designed to cover the initial needs of implementing the wallet SDK integration for issue #28 for securely storing keys (with encryption) for the user's wallet.
This commit is contained in:
Carter Jernigan 2021-10-19 08:02:15 -04:00 committed by Carter Jernigan
parent 33588e4e66
commit c5f3a44340
31 changed files with 755 additions and 10 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ gen/
local.properties
/.idea/deploymentTargetDropDown.xml
*.hprof
/.idea/artifacts

View File

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="kotlin-test" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":preference-api-lib:check" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,57 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="preference-impl-android-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="zcash-android-app.preference-impl-android-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

@ -21,7 +21,7 @@ General Zcash questions and/or support requests and are best directed to either:
Contributions are very much welcomed! Please read our [Contributing Guidelines](docs/CONTRIBUTING.md) to learn about our process.
# Forking
If you plan to fork the project to create a new app of your own, please make the following changes. (If you're making a GitHub fork to contribute back to the project, these steps are not necessary.)
If you plan to fork the project to create a new app of your own, please make the following changes. (If you're making a GitHub fork to contribute back to the project, these steps are not necessary.)
1. Change the app name under app/
1. Remove any copyrighted ZCash or Electric Coin Company icons, logos, or assets

View File

@ -0,0 +1,25 @@
import org.gradle.jvm.toolchain.JavaToolchainSpec
pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
extensions.findByType<org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension>()?.apply {
jvmToolchain {
val javaVersion = JavaVersion.toVersion(project.property("ANDROID_JVM_TARGET").toString())
val javaLanguageVersion = JavaLanguageVersion.of(javaVersion.majorVersion)
(this as JavaToolchainSpec).languageVersion.set(javaLanguageVersion)
}
targets.all {
compilations.all {
kotlinOptions {
allWarningsAsErrors = project.property("IS_TREAT_WARNINGS_AS_ERRORS").toString().toBoolean()
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
}
}
}
sourceSets.all {
// Configure opt-in to various Kotlin APIs
// languageSettings.optIn("kotlin.time.ExperimentalTime")
}
}
}

View File

@ -0,0 +1,26 @@
if (project.property("IS_COVERAGE_ENABLED").toString().toBoolean()) {
apply(plugin = "java-library")
apply(plugin = "jacoco")
configure<JacocoPluginExtension> {
toolVersion = project.property("JACOCO_VERSION").toString()
}
afterEvaluate {
tasks.withType<JacocoReport>().configureEach {
classDirectories.setFrom(
fileTree("${buildDir}/classes/kotlin/jvm/") {
exclude("**/*Test*.*", "**/*Fixture*.*")
}
)
sourceDirectories.setFrom(
// This could be better if it dynamically got the source directories, e.g. more along the lines of
// kotlin.sourceSets["commonMain"].kotlin.sourceDirectories,
// kotlin.sourceSets["jvmMain"].kotlin.sourceDirectories
listOf("src/commonMain/kotlin", "src/jvmMain/kotlin")
)
executionData.setFrom("${buildDir}/jacoco/jvmTest.exec")
}
}
}

View File

@ -1,12 +1,20 @@
# Architecture
TODO This is a placeholder for describing the app architecture.
## Gradle
# Design and Architecture
_Note: This document will continue to be updated as the app is implemented._
# Gradle
* Versions are declared in [gradle.properties](../gradle.properties). There's still enough inconsistency in how versions are handled in Gradle, that this is as close as we can get to a universal system. A version catalog is used for dependencies and is configured in [settings.gradle.kts](../settings.gradle.kts), but other versions like Gradle Plug-ins, the NDK version, Java version, and Android SDK versions don't fit into the version catalog model and are read directly from the properties
* Much of the Gradle configuration lives in [build-conventions](../build-conventions/) to prevent repetitive configuration as additional modules are added to the project
* Build scripts are written in Kotlin, so that a single language is used across build and the app code bases
* Only Gradle, Google, and JetBrains plug-ins are included in the critical path. Third party plug-ins can be used, but they're outside the critical path. For example, the Gradle Versions Plugin could be removed and wouldn't negative impact building, testing, or deploying the app
## App
# Multiplatform
While this repository is for an Android application, efforts are made to give multiplatform flexibility in the future. Specific adaptions that are being made:
* Where possible, common code is extracted into multiplatform modules
* In UI state management code, Kotlin Flow is often preferred over Android LiveData and Compose State to grant future flexibility
Note: test coverage for multiplatform modules behaves differently than coverage for Android modules. Coverage is only generated for a JVM target, and requires running two tasks in sequence: `./gradlew check -PIS_COVERAGE_ENABLED=true; ./gradlew jacocoTestReport -PIS_COVERAGE_ENABLED=true`
# App
The main entrypoints of the application are:
* [AppImpl.kt](../app/src/main/java/cash/z/ecc/app/AppImpl.kt) - The root Application object defined in the app module
* [MainActivity.kt](../ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt) - The main Activity, defined in ui-lib. Note that the Activity is NOT exported. Instead, the app module defines an activity-alias in the AndroidManifest which is what presents the actual icon on the Android home screen.
@ -15,4 +23,8 @@ The main entrypoints of the application are:
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`.
* 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-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

View File

@ -5,6 +5,8 @@ org.gradle.caching=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx3g
kotlin.mpp.stability.nowarn=true
kapt.include.compile.classpath=false
kapt.incremental.apt=true
kapt.use.worker.api=true
@ -15,8 +17,8 @@ android.builder.sdkDownload=true
# Kotlin compiler warnings can be considered errors, failing the build.
IS_TREAT_WARNINGS_AS_ERRORS=true
# Optionally configure code coverage, as historically Jacoco has at times been buggy with respect to new Kotlin versions
IS_COVERAGE_ENABLED=true
# The app module will crash at launch when coverage is enabled, so coverage is only enabled explicitly for tests.
IS_COVERAGE_ENABLED=false
# Optionally configure test orchestrator.
# It is disabled by default, because it causes tests to take about 2x longer to run.
@ -57,6 +59,7 @@ ANDROIDX_CORE_VERSION=1.6.0
ANDROIDX_ESPRESSO_VERSION=3.4.0
ANDROIDX_LIFECYCLE_VERSION=2.4.0-rc01
ANDROIDX_NAVIGATION_VERSION=2.3.5
ANDROIDX_SECURITY_CRYPTO_VERSION=1.1.0-alpha03
ANDROIDX_TEST_VERSION=1.4.1-alpha03
ANDROIDX_TEST_JUNIT_VERSION=1.1.3
ANDROIDX_TEST_ORCHESTRATOR_VERSION=1.4.1-alpha03

View File

@ -0,0 +1,32 @@
plugins {
kotlin("multiplatform")
id("zcash.kotlin-multiplatform-build-conventions")
id("zcash.kotlin-multiplatform-jacoco-conventions")
}
kotlin {
jvm()
sourceSets {
getByName("commonMain") {
dependencies {
api(libs.kotlinx.coroutines.core)
}
}
getByName("commonTest") {
dependencies {
implementation(kotlin("test"))
implementation(projects.testLib)
}
}
getByName("jvmMain") {
dependencies {
}
}
getByName("jvmTest") {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
}

View File

@ -0,0 +1,15 @@
package co.electriccoin.zcash.preference.api
import co.electriccoin.zcash.preference.model.entry.Key
import kotlinx.coroutines.flow.Flow
interface PreferenceProvider {
suspend fun hasKey(key: Key): Boolean
suspend fun putString(key: Key, value: String)
suspend fun getString(key: Key): String?
suspend fun observe(key: Key): Flow<String?>
}

View File

@ -0,0 +1,19 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider
data class BooleanPreferenceDefault(
override val key: Key,
private val defaultValue: Boolean
) : PreferenceDefault<Boolean> {
@Suppress("SwallowedException")
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let {
try {
it.toBooleanStrict()
} catch (e: IllegalArgumentException) {
// [TODO #32]: Log coercion failure instead of just silently returning default
defaultValue
}
} ?: defaultValue
}

View File

@ -0,0 +1,18 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider
data class IntegerPreferenceDefault(
override val key: Key,
private val defaultValue: Int
) : PreferenceDefault<Int> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)?.let {
try {
it.toInt()
} catch (e: NumberFormatException) {
// [TODO #32]: Log coercion failure instead of just silently returning default
defaultValue
}
} ?: defaultValue
}

View File

@ -0,0 +1,36 @@
package co.electriccoin.zcash.preference.model.entry
import kotlin.jvm.JvmInline
/**
* Defines a preference key.
*
* Different preference providers may have unique restrictions on keys. This attempts to
* find a least common denominator with some reasonable limits on what the keys can contain.
*/
@JvmInline
value class Key(val key: String) {
init {
requireKeyConstraints(key)
}
companion object {
private const val MIN_KEY_LENGTH = 1
private const val MAX_KEY_LENGTH = 256
private val REGEX = Regex("[a-zA-Z0-9_]*") // $NON-NLS
/**
* Checks a preference key against known constraints.
*
* @param key Key to check.
*/
private fun requireKeyConstraints(key: String) {
require(key.length in 1..MAX_KEY_LENGTH) {
"Invalid key $key. Length (${key.length}) should be [$MIN_KEY_LENGTH, $MAX_KEY_LENGTH]." // $NON-NLS
}
require(REGEX.matches(key)) { "Invalid key $key. Key must contain only letter and numbers." } // $NON-NLS
}
}
}

View File

@ -0,0 +1,29 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider
/**
* An entry represents a key and a default value for a preference. By using a Default object,
* multiple parts of the code can fetch the same preference without duplication or accidental
* variation in default value. Clients define the key and default value together, rather than just
* the key.
*/
/*
* API note: the default value is not available through the public interface in order to prevent
* clients from accidentally using the default value instead of the preference value.
*
* Implementation note: although primitives would be nice, Objects don't increase memory usage much.
* The autoboxing cache solves Booleans, and Strings are already objects, so that just leaves Integers.
* Overall the number of Integer preference entries is expected to be low compared to Booleans,
* and perhaps many Integer values will also fit within the autoboxing cache.
*/
interface PreferenceDefault<T> {
val key: Key
/**
* @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
}

View File

@ -0,0 +1,12 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider
data class StringPreferenceDefault(
override val key: Key,
private val defaultValue: String
) : PreferenceDefault<String> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) = preferenceProvider.getString(key)
?: defaultValue
}

View File

@ -0,0 +1,42 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.test.MockPreferenceProvider
import co.electriccoin.zcash.preference.test.fixture.BooleanPreferenceDefaultFixture
import co.electriccoin.zcash.test.runBlockingTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BooleanPreferenceDefaultTest {
@Test
fun key() {
assertEquals(BooleanPreferenceDefaultFixture.KEY, BooleanPreferenceDefaultFixture.newTrue().key)
}
@Test
fun value_default_true() = runBlockingTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
assertTrue(entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_default_false() = runBlockingTest {
val entry = BooleanPreferenceDefaultFixture.newFalse()
assertFalse(entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_from_config_false() = runBlockingTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to false.toString()) }
assertFalse(entry.getValue(mockPreferenceProvider))
}
@Test
fun value_from_config_true() = runBlockingTest {
val entry = BooleanPreferenceDefaultFixture.newTrue()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(BooleanPreferenceDefaultFixture.KEY.key to true.toString()) }
assertTrue(entry.getValue(mockPreferenceProvider))
}
}

View File

@ -0,0 +1,31 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.test.MockPreferenceProvider
import co.electriccoin.zcash.preference.test.fixture.IntegerPreferenceDefaultFixture
import co.electriccoin.zcash.preference.test.fixture.StringDefaultPreferenceFixture
import co.electriccoin.zcash.test.runBlockingTest
import kotlin.test.Test
import kotlin.test.assertEquals
class IntegerPreferenceDefaultTest {
@Test
fun key() {
assertEquals(IntegerPreferenceDefaultFixture.KEY, IntegerPreferenceDefaultFixture.new().key)
}
@Test
fun value_default() = runBlockingTest {
val entry = IntegerPreferenceDefaultFixture.new()
assertEquals(IntegerPreferenceDefaultFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_override() = runBlockingTest {
val expected = IntegerPreferenceDefaultFixture.DEFAULT_VALUE + 5
val entry = IntegerPreferenceDefaultFixture.new()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(StringDefaultPreferenceFixture.KEY.key to expected.toString()) }
assertEquals(expected, entry.getValue(mockPreferenceProvider))
}
}

View File

@ -0,0 +1,29 @@
package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.test.MockPreferenceProvider
import co.electriccoin.zcash.preference.test.fixture.StringDefaultPreferenceFixture
import co.electriccoin.zcash.test.runBlockingTest
import kotlin.test.Test
import kotlin.test.assertEquals
class StringPreferenceDefaultTest {
@Test
fun key() {
assertEquals(StringDefaultPreferenceFixture.KEY, StringDefaultPreferenceFixture.new().key)
}
@Test
fun value_default() = runBlockingTest {
val entry = StringDefaultPreferenceFixture.new()
assertEquals(StringDefaultPreferenceFixture.DEFAULT_VALUE, entry.getValue(MockPreferenceProvider()))
}
@Test
fun value_override() = runBlockingTest {
val entry = StringDefaultPreferenceFixture.new()
val mockPreferenceProvider = MockPreferenceProvider { mutableMapOf(StringDefaultPreferenceFixture.KEY.key to "override") }
assertEquals("override", entry.getValue(mockPreferenceProvider))
}
}

View File

@ -0,0 +1,25 @@
package co.electriccoin.zcash.preference.test
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.Key
import kotlinx.coroutines.flow.Flow
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 {
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 suspend fun hasKey(key: Key) = map.containsKey(key.key)
override suspend fun putString(key: Key, value: String) {
map[key.key] = value
}
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
import co.electriccoin.zcash.preference.model.entry.Key
object BooleanPreferenceDefaultFixture {
val KEY = Key("some_boolean_key") // $NON-NLS
fun newTrue() = BooleanPreferenceDefault(KEY, true)
fun newFalse() = BooleanPreferenceDefault(KEY, false)
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture
import co.electriccoin.zcash.preference.model.entry.IntegerPreferenceDefault
import co.electriccoin.zcash.preference.model.entry.Key
object IntegerPreferenceDefaultFixture {
val KEY = Key("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = 123
fun new(key: Key = KEY, value: Int = DEFAULT_VALUE) = IntegerPreferenceDefault(key, value)
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture
import co.electriccoin.zcash.preference.model.entry.Key
import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture {
val KEY = Key("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun new(key: Key = KEY, value: String = DEFAULT_VALUE) = StringPreferenceDefault(key, value)
}

View File

@ -0,0 +1,34 @@
plugins {
id("com.android.library")
kotlin("android")
id("kotlin-parcelize")
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 + "-Xopt-in=kotlin.RequiresOptIn"
}
}
dependencies {
implementation(libs.androidx.security.crypto)
implementation(libs.kotlin)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(projects.preferenceApiLib)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.kotlinx.coroutines.test)
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
androidTestUtil(libs.androidx.test.orchestrator) {
artifact {
type = "apk"
}
}
}
}

View File

@ -0,0 +1,82 @@
package co.electriccoin.zcash.preference
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import co.electriccoin.zcash.preference.test.fixture.StringDefaultPreferenceFixture
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
// Areas that are not covered yet:
// 1. Test observer behavior
class EncryptedPreferenceProviderTest {
/*
* Note: This test relies on Test Orchestrator to avoid issues with multiple runs. Specifically,
* it purges the preference file and avoids corruption due to multiple instances of the
* EncryptedPreferenceProvider.
*/
private var isRun = false
@Before
fun checkUsingOrchestrator() {
check(!isRun) { "State appears to be retained between test method invocations; verify that Test Orchestrator is enabled and then re-run the tests" }
isRun = true
}
@Test
@SmallTest
fun put_and_get_string() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertEquals(expectedValue, StringDefaultPreferenceFixture.new().getValue(preferenceProvider))
}
@Test
@SmallTest
fun hasKey_false() = runBlocking {
val preferenceProvider = new()
assertFalse(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
}
@Test
@SmallTest
fun put_and_check_key() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val preferenceProvider = new().apply {
putString(StringDefaultPreferenceFixture.KEY, expectedValue)
}
assertTrue(preferenceProvider.hasKey(StringDefaultPreferenceFixture.new().key))
}
// Note: this test case relies on undocumented implementation details of SharedPreferences
// e.g. the directory path and the fact the preferences are stored as XML
@Test
@SmallTest
fun verify_no_plaintext() = runBlocking {
val expectedValue = StringDefaultPreferenceFixture.DEFAULT_VALUE + "extra"
val text = File(File(ApplicationProvider.getApplicationContext<Context>().dataDir, "shared_prefs"), "$FILENAME.xml").readText()
assertFalse(text.contains(expectedValue))
assertFalse(text.contains(StringDefaultPreferenceFixture.KEY.key))
}
companion object {
private val FILENAME = "encrypted_preference_test"
private suspend fun new() = EncryptedPreferenceProvider.new(ApplicationProvider.getApplicationContext(), FILENAME)
}
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture
import co.electriccoin.zcash.preference.model.entry.Key
import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture {
val KEY = Key("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
fun new(key: Key = KEY, value: String = DEFAULT_VALUE) = StringPreferenceDefault(key, value)
}

View File

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

View File

@ -0,0 +1,100 @@
package co.electriccoin.zcash.preference
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import co.electriccoin.zcash.preference.api.PreferenceProvider
import co.electriccoin.zcash.preference.model.entry.Key
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
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
/**
* Provides encrypted shared preferences.
*
* This class is thread-safe.
*
* For a given preference file, it is expected that only a single instance is constructed and that
* this instance lives for the lifetime of the application. Constructing multiple instances will
* potentially corrupt preference data and will leak resources.
*/
/*
* Implementation note: EncryptedSharedPreferences are not thread-safe, so this implementation
* confines them to a single background thread.
*/
class EncryptedPreferenceProvider(
private val sharedPreferences: SharedPreferences,
private val dispatcher: CoroutineDispatcher
) : PreferenceProvider {
override suspend fun hasKey(key: Key) = withContext(dispatcher) {
sharedPreferences.contains(key.key)
}
@SuppressLint("ApplySharedPref")
override suspend fun putString(key: Key, value: String) = withContext(dispatcher) {
val editor = sharedPreferences.edit()
editor.putString(key.key, value)
editor.commit()
Unit
}
override suspend fun getString(key: Key) = withContext(dispatcher) {
sharedPreferences.getString(key.key, null)
}
@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun observe(key: Key): Flow<String?> = callbackFlow<Unit> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread
trySend(Unit)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
// Kickstart the emissions
trySend(Unit)
awaitClose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}.flowOn(dispatcher)
.map { getString(key) }
companion object {
suspend fun new(context: Context, filename: String): PreferenceProvider {
val singleThreadedDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val mainKey = withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
MasterKey.Builder(context).apply {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
}.build()
}
val sharedPreferences = withContext(singleThreadedDispatcher) {
@Suppress("BlockingMethodInNonBlockingContext")
EncryptedSharedPreferences.create(
context,
filename,
mainKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
return EncryptedPreferenceProvider(sharedPreferences, singleThreadedDispatcher)
}
}
}

View File

@ -13,6 +13,7 @@ pluginManagement {
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
kotlin("jvm") version (kotlinVersion)
kotlin("multiplatform") version (kotlinVersion)
id("com.github.ben-manes.versions") version (gradleVersionsPluginVersion) apply (false)
id("io.gitlab.arturbosch.detekt") version (detektVersion) apply (false)
}
@ -41,6 +42,7 @@ dependencyResolutionManagement {
val androidxTestOrchestratorVersion = extra["ANDROIDX_TEST_ORCHESTRATOR_VERSION"].toString()
val androidxTestVersion = extra["ANDROIDX_TEST_VERSION"].toString()
val androidxUiAutomatorVersion = extra["ANDROIDX_UI_AUTOMATOR_VERSION"].toString()
val androidxSecurityCryptoVersion = extra["ANDROIDX_SECURITY_CRYPTO_VERSION"].toString()
val googleMaterialVersion = extra["GOOGLE_MATERIAL_VERSION"].toString()
val jacocoVersion = extra["JACOCO_VERSION"].toString()
val javaVersion = extra["ANDROID_JVM_TARGET"].toString()
@ -65,6 +67,7 @@ dependencyResolutionManagement {
alias("androidx-compose-compiler").to("androidx.compose.compiler:compiler:$androidxComposeCompilerVersion")
alias("androidx-core").to("androidx.core:core-ktx:$androidxCoreVersion")
alias("androidx-lifecycle-livedata").to("androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
alias("androidx-security-crypto").to("androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
alias("androidx-viewmodel-compose").to("androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion")
alias("google-material").to("com.google.android.material:material:$googleMaterialVersion")
alias("kotlin").to("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
@ -118,4 +121,7 @@ rootProject.name = "zcash-android-app"
includeBuild("build-conventions")
include("app")
include("ui-lib")
include("preference-api-lib")
include("preference-impl-android-lib")
include("test-lib")
include("ui-lib")

31
test-lib/build.gradle.kts Normal file
View File

@ -0,0 +1,31 @@
plugins {
kotlin("multiplatform")
id("zcash.kotlin-multiplatform-build-conventions")
id("zcash.kotlin-multiplatform-jacoco-conventions")
}
kotlin {
jvm()
sourceSets {
getByName("commonMain") {
dependencies {
api(libs.kotlinx.coroutines.core)
}
}
getByName("commonTest") {
dependencies {
implementation(kotlin("test"))
}
}
getByName("jvmMain") {
dependencies {
}
}
getByName("jvmTest") {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
}

View File

@ -0,0 +1,3 @@
package co.electriccoin.zcash.test
expect fun runBlockingTest(test: suspend kotlinx.coroutines.CoroutineScope.() -> Unit)

View File

@ -0,0 +1,6 @@
package co.electriccoin.zcash.test
import kotlinx.coroutines.runBlocking
actual fun runBlockingTest(test: suspend kotlinx.coroutines.CoroutineScope.() -> Unit) =
runBlocking(block = test)