[#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:
parent
33588e4e66
commit
c5f3a44340
|
@ -17,3 +17,4 @@ gen/
|
|||
local.properties
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
*.hprof
|
||||
/.idea/artifacts
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?>
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package co.electriccoin.zcash.test
|
||||
|
||||
expect fun runBlockingTest(test: suspend kotlinx.coroutines.CoroutineScope.() -> Unit)
|
|
@ -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)
|
Loading…
Reference in New Issue