[#764] Multiplatform remote config API
This commit is contained in:
parent
b3f4601b14
commit
6b202e3c9f
|
@ -0,0 +1,33 @@
|
|||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("secant.kotlin-multiplatform-build-conventions")
|
||||
id("secant.dependency-conventions")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
getByName("commonMain") {
|
||||
dependencies {
|
||||
api(libs.kotlinx.datetime)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.immutable)
|
||||
}
|
||||
}
|
||||
getByName("commonTest") {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
api(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
getByName("jvmMain") {
|
||||
dependencies {
|
||||
}
|
||||
}
|
||||
getByName("jvmTest") {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package co.electriccoin.zcash.configuration.api
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Provides a remote config implementation.
|
||||
*/
|
||||
interface ConfigurationProvider {
|
||||
|
||||
/**
|
||||
* @return The configuration if it has been loaded already. If not loaded, returns an empty configuration.
|
||||
*/
|
||||
fun peekConfiguration(): Configuration
|
||||
|
||||
/**
|
||||
* @return A flow that provides snapshots of configuration updates.
|
||||
*/
|
||||
fun getConfigurationFlow(): Flow<Configuration>
|
||||
|
||||
/**
|
||||
* Signals to the configuration provider that now might be a good time to refresh.
|
||||
*/
|
||||
fun hintToRefresh()
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package co.electriccoin.zcash.configuration.api
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
class MergingConfigurationProvider(private val configurationProviders: PersistentList<ConfigurationProvider>) : ConfigurationProvider {
|
||||
override fun peekConfiguration(): Configuration {
|
||||
return MergingConfiguration(configurationProviders.map { it.peekConfiguration() }.toPersistentList())
|
||||
}
|
||||
|
||||
override fun getConfigurationFlow(): Flow<Configuration> {
|
||||
return combine(configurationProviders.map { it.getConfigurationFlow() }) { configurations ->
|
||||
MergingConfiguration(configurations.toList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun hintToRefresh() {
|
||||
configurationProviders.forEach { it.hintToRefresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class MergingConfiguration(private val configurations: List<Configuration>) : Configuration {
|
||||
override val updatedAt: Instant?
|
||||
get() = configurations.mapNotNull { it.updatedAt }.maxOrNull()
|
||||
|
||||
override fun hasKey(key: ConfigKey): Boolean {
|
||||
return null != configurations.firstWithKey(key)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: ConfigKey, defaultValue: Boolean): Boolean {
|
||||
return configurations.firstWithKey(key)?.let {
|
||||
return it.getBoolean(key, defaultValue)
|
||||
} ?: defaultValue
|
||||
}
|
||||
|
||||
override fun getInt(key: ConfigKey, defaultValue: Int): Int {
|
||||
return configurations.firstWithKey(key)?.let {
|
||||
return it.getInt(key, defaultValue)
|
||||
} ?: defaultValue
|
||||
}
|
||||
|
||||
override fun getString(key: ConfigKey, defaultValue: String): String {
|
||||
return configurations.firstWithKey(key)?.let {
|
||||
return it.getString(key, defaultValue)
|
||||
} ?: defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Configuration>.firstWithKey(key: ConfigKey): Configuration? = firstOrNull { it.hasKey(key) }
|
|
@ -0,0 +1,12 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
|
||||
data class BooleanConfigurationEntry(
|
||||
override val key: ConfigKey,
|
||||
private val defaultValue: Boolean
|
||||
) : DefaultEntry<Boolean> {
|
||||
|
||||
override fun getValue(configuration: Configuration) =
|
||||
configuration.getBoolean(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
/**
|
||||
* Defines a configuration key.
|
||||
*
|
||||
* Different configuration providers 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 ConfigKey(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_]*")
|
||||
|
||||
/**
|
||||
* Checks a configuration 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}) is not in the range [$MIN_KEY_LENGTH, $MAX_KEY_LENGTH]."
|
||||
}
|
||||
|
||||
// This is a Firebase requirement
|
||||
require(!key.first().isDigit()) { "Invalid key $key. Key must not start with a number." }
|
||||
|
||||
require(REGEX.matches(key)) { "Invalid key $key. Key must contain only letter and numbers." }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
|
||||
/**
|
||||
* An entry represents a key and a default value for the configuration. By using an entry object,
|
||||
* multiple parts of the code can fetch the same configuration 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 configuration 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 configuration entries is expected to be low compared to Booleans,
|
||||
* and perhaps many Integer values will also fit within the autoboxing cache.
|
||||
*/
|
||||
interface DefaultEntry<T> {
|
||||
|
||||
val key: ConfigKey
|
||||
|
||||
/**
|
||||
* @param configuration Configuration mapping to check for the key given to this entry.
|
||||
* @return The value in the configuration, or the default value if no mapping exists.
|
||||
*/
|
||||
fun getValue(configuration: Configuration): T
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
|
||||
data class IntegerConfigurationEntry(
|
||||
override val key: ConfigKey,
|
||||
private val defaultValue: Int
|
||||
) : DefaultEntry<Int> {
|
||||
|
||||
override fun getValue(configuration: Configuration) = configuration.getInt(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
|
||||
data class StringConfigurationEntry(
|
||||
override val key: ConfigKey,
|
||||
private val defaultValue: String
|
||||
) : DefaultEntry<String> {
|
||||
|
||||
override fun getValue(configuration: Configuration) = configuration.getString(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package co.electriccoin.zcash.configuration.model.map
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* An immutable snapshot of a key-value configuration.
|
||||
*/
|
||||
interface Configuration {
|
||||
/**
|
||||
* @return When the configuration was updated. Null indicates the configuration either doesn't refresh or has never been refreshed.
|
||||
*/
|
||||
val updatedAt: Instant?
|
||||
|
||||
/**
|
||||
* @param key Key to check.
|
||||
* @return True if a mapping for `key` exists.
|
||||
*/
|
||||
fun hasKey(key: ConfigKey): Boolean
|
||||
|
||||
/**
|
||||
* @param key Key to use to retrieve the value.
|
||||
* @param defaultValue Value to use if `key` doesn't exist in the
|
||||
* configuration. Some implementations may not use strong types, and the default can also
|
||||
* be returned if type coercion fails.
|
||||
* @return boolean mapping for `key` or `defaultValue`.
|
||||
*/
|
||||
fun getBoolean(key: ConfigKey, defaultValue: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* @param key Key to use to retrieve the value.
|
||||
* @param defaultValue Value to use if `key` doesn't exist in the
|
||||
* configuration. Some implementations may not use strong types, and the default can also
|
||||
* be returned if type coercion fails.
|
||||
* @return int mapping for `key` or `defaultValue`.
|
||||
*/
|
||||
fun getInt(key: ConfigKey, defaultValue: Int): Int
|
||||
|
||||
/**
|
||||
* @param key Key to use to retrieve the value.
|
||||
* @param defaultValue Value to use if `key` doesn't exist in the
|
||||
* configuration. Some implementations may not use strong types, and the default can also
|
||||
* be returned if type coercion fails.
|
||||
* @return String mapping for `key` or `defaultValue`.
|
||||
*/
|
||||
fun getString(key: ConfigKey, defaultValue: String): String
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package co.electriccoin.zcash.configuration.model.map
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
// The configurationMapping is intended to be a public API for configuration implementations rather
|
||||
// than a public API for configuration clients.
|
||||
data class StringConfiguration(val configurationMapping: PersistentMap<String, String>, override val updatedAt: Instant?) : Configuration {
|
||||
|
||||
override fun getBoolean(
|
||||
key: ConfigKey,
|
||||
defaultValue: Boolean
|
||||
) = configurationMapping[key.key]?.let {
|
||||
try {
|
||||
it.toBooleanStrict()
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
// In the future, log coercion failure as this could mean someone made an error in the remote config console
|
||||
defaultValue
|
||||
}
|
||||
} ?: defaultValue
|
||||
|
||||
override fun getInt(key: ConfigKey, defaultValue: Int) = configurationMapping[key.key]?.let {
|
||||
try {
|
||||
it.toInt()
|
||||
} catch (@Suppress("SwallowedException") e: NumberFormatException) {
|
||||
// In the future, log coercion failure as this could mean someone made an error in the remote config console
|
||||
defaultValue
|
||||
}
|
||||
} ?: defaultValue
|
||||
|
||||
override fun getString(key: ConfigKey, defaultValue: String) = configurationMapping.getOrElse(key.key) { defaultValue }
|
||||
|
||||
override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key)
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
package co.electriccoin.zcash.configuration.api
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
|
||||
import co.electriccoin.zcash.configuration.test.fixture.BooleanDefaultEntryFixture
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MergingConfigurationProviderTest {
|
||||
@Test
|
||||
fun peek_ordering() {
|
||||
val configurationProvider = MergingConfigurationProvider(
|
||||
persistentListOf(
|
||||
MockConfigurationProvider(StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()), null)),
|
||||
MockConfigurationProvider(StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()), null))
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(BooleanDefaultEntryFixture.newTrueEntry().getValue(configurationProvider.peekConfiguration()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getFlow_ordering() = runTest {
|
||||
val configurationProvider = MergingConfigurationProvider(
|
||||
persistentListOf(
|
||||
MockConfigurationProvider(StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()), null)),
|
||||
MockConfigurationProvider(StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()), null))
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(BooleanDefaultEntryFixture.newTrueEntry().getValue(configurationProvider.getConfigurationFlow().first()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getUpdatedAt_newest() = runTest {
|
||||
val older = "2023-01-15T08:38:45.415Z".toInstant()
|
||||
val newer = "2023-01-17T08:38:45.415Z".toInstant()
|
||||
|
||||
val configurationProvider = MergingConfigurationProvider(
|
||||
persistentListOf(
|
||||
MockConfigurationProvider(StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()), older)),
|
||||
MockConfigurationProvider(StringConfiguration(persistentMapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()), newer))
|
||||
)
|
||||
)
|
||||
|
||||
val updatedAt = configurationProvider.getConfigurationFlow().first().updatedAt
|
||||
assertEquals(newer, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private class MockConfigurationProvider(private val configuration: Configuration) : ConfigurationProvider {
|
||||
|
||||
override fun peekConfiguration(): Configuration {
|
||||
return configuration
|
||||
}
|
||||
|
||||
override fun getConfigurationFlow(): Flow<Configuration> {
|
||||
return flowOf(configuration)
|
||||
}
|
||||
|
||||
override fun hintToRefresh() {
|
||||
// no-op
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.test.MockConfiguration
|
||||
import co.electriccoin.zcash.configuration.test.fixture.BooleanDefaultEntryFixture
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BooleanDefaultEntryTest {
|
||||
@Test
|
||||
fun key() {
|
||||
assertEquals(BooleanDefaultEntryFixture.KEY, BooleanDefaultEntryFixture.newTrueEntry().key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_default_true() {
|
||||
val entry = BooleanDefaultEntryFixture.newTrueEntry()
|
||||
assertTrue(entry.getValue(MockConfiguration()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_default_false() {
|
||||
val entry = BooleanDefaultEntryFixture.newFalseEntry()
|
||||
assertFalse(entry.getValue(MockConfiguration()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_from_config_false() {
|
||||
val entry = BooleanDefaultEntryFixture.newTrueEntry()
|
||||
val config = MockConfiguration(mapOf(BooleanDefaultEntryFixture.KEY.key to false.toString()))
|
||||
assertFalse(entry.getValue(config))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_from_config_true() {
|
||||
val entry = BooleanDefaultEntryFixture.newTrueEntry()
|
||||
val config = MockConfiguration(mapOf(BooleanDefaultEntryFixture.KEY.key to true.toString()))
|
||||
assertTrue(entry.getValue(config))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.test.MockConfiguration
|
||||
import co.electriccoin.zcash.configuration.test.fixture.IntegerDefaultEntryFixture
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class IntegerDefaultEntryTest {
|
||||
@Test
|
||||
fun key() {
|
||||
assertEquals(IntegerDefaultEntryFixture.KEY, IntegerDefaultEntryFixture.newEntry().key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_default() {
|
||||
val entry = IntegerDefaultEntryFixture.newEntry()
|
||||
assertEquals(IntegerDefaultEntryFixture.DEFAULT_VALUE, entry.getValue(MockConfiguration()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_override() {
|
||||
val expected = IntegerDefaultEntryFixture.DEFAULT_VALUE + 5
|
||||
|
||||
val entry = IntegerDefaultEntryFixture.newEntry()
|
||||
assertEquals(expected, entry.getValue(MockConfiguration(mapOf(IntegerDefaultEntryFixture.KEY.key to expected.toString()))))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package co.electriccoin.zcash.configuration.model.entry
|
||||
|
||||
import co.electriccoin.zcash.configuration.test.MockConfiguration
|
||||
import co.electriccoin.zcash.configuration.test.fixture.StringDefaultEntryFixture
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class StringDefaultEntryTest {
|
||||
@Test
|
||||
fun key() {
|
||||
assertEquals(StringDefaultEntryFixture.KEY, StringDefaultEntryFixture.newEntryEntry().key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_default() {
|
||||
val entry = StringDefaultEntryFixture.newEntryEntry()
|
||||
assertEquals(StringDefaultEntryFixture.DEFAULT_VALUE, entry.getValue(MockConfiguration()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun value_override() {
|
||||
val entry = StringDefaultEntryFixture.newEntryEntry()
|
||||
assertEquals("override", entry.getValue(MockConfiguration(mapOf(StringDefaultEntryFixture.KEY.key to "override"))))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package co.electriccoin.zcash.configuration.test
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* @param configurationMapping A mapping of key-value pairs to be returned
|
||||
* by [.getString]. Note: this map is not defensively copied, allowing users of this class to
|
||||
* mutate the configuration by mutating the original map. The mapping is stored in a val field
|
||||
* though, making the initial mapping thread-safe.
|
||||
*/
|
||||
class MockConfiguration(private val configurationMapping: Map<String, String> = emptyMap()) : Configuration {
|
||||
|
||||
override val updatedAt: Instant? = null
|
||||
|
||||
override fun getBoolean(
|
||||
key: ConfigKey,
|
||||
defaultValue: Boolean
|
||||
) = configurationMapping[key.key]?.let {
|
||||
try {
|
||||
it.toBooleanStrict()
|
||||
} catch (@Suppress("SwallowedException") e: IllegalArgumentException) {
|
||||
// In the future, log coercion failure as this could mean someone made an error in the remote config console
|
||||
defaultValue
|
||||
}
|
||||
} ?: defaultValue
|
||||
|
||||
override fun getInt(key: ConfigKey, defaultValue: Int) = configurationMapping[key.key]?.let {
|
||||
try {
|
||||
it.toInt()
|
||||
} catch (@Suppress("SwallowedException") e: NumberFormatException) {
|
||||
// In the future, log coercion failure as this could mean someone made an error in the remote config console
|
||||
defaultValue
|
||||
}
|
||||
} ?: defaultValue
|
||||
|
||||
override fun getString(key: ConfigKey, defaultValue: String) = configurationMapping.getOrElse(key.key) { defaultValue }
|
||||
|
||||
override fun hasKey(key: ConfigKey) = configurationMapping.containsKey(key.key)
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package co.electriccoin.zcash.configuration.test.fixture
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.BooleanConfigurationEntry
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
|
||||
object BooleanDefaultEntryFixture {
|
||||
|
||||
val KEY = ConfigKey("some_boolean_key") // $NON-NLS
|
||||
|
||||
fun newTrueEntry() = BooleanConfigurationEntry(KEY, true)
|
||||
|
||||
fun newFalseEntry() = BooleanConfigurationEntry(KEY, false)
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package co.electriccoin.zcash.configuration.test.fixture
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import co.electriccoin.zcash.configuration.model.entry.IntegerConfigurationEntry
|
||||
|
||||
object IntegerDefaultEntryFixture {
|
||||
val KEY = ConfigKey("some_string_key") // $NON-NLS
|
||||
const val DEFAULT_VALUE = 123
|
||||
fun newEntry(key: ConfigKey = KEY, value: Int = DEFAULT_VALUE) = IntegerConfigurationEntry(key, value)
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package co.electriccoin.zcash.configuration.test.fixture
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import co.electriccoin.zcash.configuration.model.entry.StringConfigurationEntry
|
||||
|
||||
object StringDefaultEntryFixture {
|
||||
val KEY = ConfigKey("some_string_key") // $NON-NLS
|
||||
const val DEFAULT_VALUE = "some_default_value" // $NON-NLS
|
||||
fun newEntryEntry(key: ConfigKey = KEY, value: String = DEFAULT_VALUE) = StringConfigurationEntry(key, value)
|
||||
}
|
|
@ -26,6 +26,7 @@ The logical components of the app are implemented as a number of Gradle modules.
|
|||
|
||||
* `app` — Compiles all the modules together into the final application. This module contains minimal actual code. Note that the Java package structure for this module is under `co.electriccoin.zcash.app` while the Android package name is `co.electriccoin.zcash`.
|
||||
* `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.
|
||||
* `configuration-api-lib` — Multiplatform interfaces for remote configuration.
|
||||
* crash — For collecting and reporting exceptions and crashes
|
||||
* `crash-lib` — Common crash collection logic for Kotlin and JVM. This is not fully-featured by itself, but the long-term plan is multiplatform support.
|
||||
* `crash-android-lib` — Android-specific crash collection logic, built on top of the common and JVM implementation in `crash-lib`
|
||||
|
|
|
@ -144,6 +144,7 @@ JACOCO_VERSION=0.8.8
|
|||
KOTLIN_VERSION=1.8.10
|
||||
KOTLINX_COROUTINES_VERSION=1.6.4
|
||||
KOTLINX_DATETIME_VERSION=0.4.0
|
||||
KOTLINX_IMMUTABLE_COLLECTIONS_VERSION=0.3.5
|
||||
KOVER_VERSION=0.6.1
|
||||
PLAY_APP_UPDATE_VERSION=2.0.1
|
||||
PLAY_APP_UPDATE_KTX_VERSION=2.0.1
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
package co.electriccoin.zcash.preference.api
|
||||
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PreferenceProvider {
|
||||
|
||||
suspend fun hasKey(key: Key): Boolean
|
||||
suspend fun hasKey(key: PreferenceKey): Boolean
|
||||
|
||||
suspend fun putString(key: Key, value: String?)
|
||||
suspend fun putString(key: PreferenceKey, value: String?)
|
||||
|
||||
suspend fun getString(key: Key): String?
|
||||
suspend fun getString(key: PreferenceKey): 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>
|
||||
fun observe(key: PreferenceKey): Flow<String?>
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ package co.electriccoin.zcash.preference.model.entry
|
|||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
|
||||
data class BooleanPreferenceDefault(
|
||||
override val key: Key,
|
||||
override val key: PreferenceKey,
|
||||
private val defaultValue: Boolean
|
||||
) : PreferenceDefault<Boolean> {
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ package co.electriccoin.zcash.preference.model.entry
|
|||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
|
||||
data class IntegerPreferenceDefault(
|
||||
override val key: Key,
|
||||
override val key: PreferenceKey,
|
||||
private val defaultValue: Int
|
||||
) : PreferenceDefault<Int> {
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.map
|
|||
*/
|
||||
interface PreferenceDefault<T> {
|
||||
|
||||
val key: Key
|
||||
val key: PreferenceKey
|
||||
|
||||
/**
|
||||
* @param preferenceProvider Provides actual preference values.
|
||||
|
|
|
@ -9,7 +9,7 @@ import kotlin.jvm.JvmInline
|
|||
* find a least common denominator with some reasonable limits on what the keys can contain.
|
||||
*/
|
||||
@JvmInline
|
||||
value class Key(val key: String) {
|
||||
value class PreferenceKey(val key: String) {
|
||||
init {
|
||||
requireKeyConstraints(key)
|
||||
}
|
|
@ -3,7 +3,7 @@ package co.electriccoin.zcash.preference.model.entry
|
|||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
|
||||
data class StringPreferenceDefault(
|
||||
override val key: Key,
|
||||
override val key: PreferenceKey,
|
||||
private val defaultValue: String
|
||||
) : PreferenceDefault<String> {
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package co.electriccoin.zcash.preference.test
|
||||
|
||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* @param mutableMapFactory Emits a new mutable map. Thread safety depends on the factory implementation.
|
||||
|
@ -12,14 +12,14 @@ class MockPreferenceProvider(mutableMapFactory: () -> MutableMap<String, String?
|
|||
|
||||
private val map = mutableMapFactory()
|
||||
|
||||
override suspend fun getString(key: Key) = map[key.key]
|
||||
override suspend fun getString(key: PreferenceKey) = map[key.key]
|
||||
|
||||
// For the mock implementation, does not support observability of changes
|
||||
override fun observe(key: Key): Flow<Unit> = flowOf(Unit)
|
||||
override fun observe(key: PreferenceKey): Flow<String?> = flow { emit(getString(key)) }
|
||||
|
||||
override suspend fun hasKey(key: Key) = map.containsKey(key.key)
|
||||
override suspend fun hasKey(key: PreferenceKey) = map.containsKey(key.key)
|
||||
|
||||
override suspend fun putString(key: Key, value: String?) {
|
||||
override suspend fun putString(key: PreferenceKey, value: String?) {
|
||||
map[key.key] = value
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +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
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
|
||||
object BooleanPreferenceDefaultFixture {
|
||||
val KEY = Key("some_boolean_key") // $NON-NLS
|
||||
val KEY = PreferenceKey("some_boolean_key") // $NON-NLS
|
||||
fun newTrue() = BooleanPreferenceDefault(KEY, true)
|
||||
fun newFalse() = BooleanPreferenceDefault(KEY, false)
|
||||
}
|
||||
|
|
|
@ -1,10 +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
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
|
||||
object IntegerPreferenceDefaultFixture {
|
||||
val KEY = Key("some_string_key") // $NON-NLS
|
||||
val KEY = PreferenceKey("some_string_key") // $NON-NLS
|
||||
const val DEFAULT_VALUE = 123
|
||||
fun new(key: Key = KEY, value: Int = DEFAULT_VALUE) = IntegerPreferenceDefault(key, value)
|
||||
fun new(preferenceKey: PreferenceKey = KEY, value: Int = DEFAULT_VALUE) = IntegerPreferenceDefault(preferenceKey, value)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package co.electriccoin.zcash.preference.test.fixture
|
||||
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
|
||||
|
||||
object StringDefaultPreferenceFixture {
|
||||
val KEY = Key("some_string_key") // $NON-NLS
|
||||
val KEY = PreferenceKey("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)
|
||||
fun new(preferenceKey: PreferenceKey = KEY, value: String = DEFAULT_VALUE) = StringPreferenceDefault(preferenceKey, value)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package co.electriccoin.zcash.preference.test.fixture
|
||||
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
|
||||
|
||||
object StringDefaultPreferenceFixture {
|
||||
val KEY = Key("some_string_key") // $NON-NLS
|
||||
val KEY = PreferenceKey("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)
|
||||
fun new(preferenceKey: PreferenceKey = KEY, value: String = DEFAULT_VALUE) = StringPreferenceDefault(preferenceKey, value)
|
||||
}
|
||||
|
|
|
@ -6,14 +6,14 @@ 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 co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
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
|
||||
|
||||
|
@ -35,12 +35,12 @@ class AndroidPreferenceProvider(
|
|||
private val dispatcher: CoroutineDispatcher
|
||||
) : PreferenceProvider {
|
||||
|
||||
override suspend fun hasKey(key: Key) = withContext(dispatcher) {
|
||||
override suspend fun hasKey(key: PreferenceKey) = withContext(dispatcher) {
|
||||
sharedPreferences.contains(key.key)
|
||||
}
|
||||
|
||||
@SuppressLint("ApplySharedPref")
|
||||
override suspend fun putString(key: Key, value: String?) = withContext(dispatcher) {
|
||||
override suspend fun putString(key: PreferenceKey, value: String?) = withContext(dispatcher) {
|
||||
val editor = sharedPreferences.edit()
|
||||
|
||||
editor.putString(key.key, value)
|
||||
|
@ -50,12 +50,11 @@ class AndroidPreferenceProvider(
|
|||
Unit
|
||||
}
|
||||
|
||||
override suspend fun getString(key: Key) = withContext(dispatcher) {
|
||||
override suspend fun getString(key: PreferenceKey) = withContext(dispatcher) {
|
||||
sharedPreferences.getString(key.key, null)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun observe(key: Key): Flow<Unit> = callbackFlow<Unit> {
|
||||
override fun observe(key: PreferenceKey): Flow<String?> = callbackFlow<Unit> {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
|
||||
// Callback on main thread
|
||||
trySend(Unit)
|
||||
|
@ -69,6 +68,7 @@ class AndroidPreferenceProvider(
|
|||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}.flowOn(dispatcher)
|
||||
.map { getString(key) }
|
||||
|
||||
companion object {
|
||||
suspend fun newStandard(context: Context, filename: String): PreferenceProvider {
|
||||
|
|
|
@ -174,6 +174,7 @@ dependencyResolutionManagement {
|
|||
val kotlinVersion = extra["KOTLIN_VERSION"].toString()
|
||||
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
|
||||
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_VERSION"].toString()
|
||||
val kotlinxImmutableCollectionsVersion = extra["KOTLINX_IMMUTABLE_COLLECTIONS_VERSION"].toString()
|
||||
val playAppUpdateVersion = extra["PLAY_APP_UPDATE_VERSION"].toString()
|
||||
val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString()
|
||||
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
|
||||
|
@ -225,6 +226,7 @@ dependencyResolutionManagement {
|
|||
library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
|
||||
library("kotlinx-coroutines-guava", "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlinxCoroutinesVersion")
|
||||
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
|
||||
library("kotlinx-immutable", "org.jetbrains.kotlinx:kotlinx-collections-immutable:$kotlinxImmutableCollectionsVersion")
|
||||
library("play-update", "com.google.android.play:app-update:$playAppUpdateVersion")
|
||||
library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion")
|
||||
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
|
||||
|
@ -307,6 +309,7 @@ includeBuild("build-conventions-secant")
|
|||
|
||||
include("app")
|
||||
include("build-info-lib")
|
||||
include("configuration-api-lib")
|
||||
include("crash-lib")
|
||||
include("crash-android-lib")
|
||||
include("preference-api-lib")
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
|
||||
<application />
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package co.electriccoin.zcash.ui.preference
|
||||
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
|
||||
object EncryptedPreferenceKeys {
|
||||
|
||||
val PERSISTABLE_WALLET = PersistableWalletPreferenceDefault(Key("persistable_wallet"))
|
||||
val PERSISTABLE_WALLET = PersistableWalletPreferenceDefault(PreferenceKey("persistable_wallet"))
|
||||
}
|
||||
|
|
|
@ -2,11 +2,11 @@ package co.electriccoin.zcash.ui.preference
|
|||
|
||||
import cash.z.ecc.android.sdk.model.FiatCurrency
|
||||
import co.electriccoin.zcash.preference.api.PreferenceProvider
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
|
||||
data class FiatCurrencyPreferenceDefault(
|
||||
override val key: Key
|
||||
override val key: PreferenceKey
|
||||
) : PreferenceDefault<FiatCurrency> {
|
||||
|
||||
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
|
||||
|
|
|
@ -2,12 +2,12 @@ package co.electriccoin.zcash.ui.preference
|
|||
|
||||
import cash.z.ecc.android.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 co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
import org.json.JSONObject
|
||||
|
||||
data class PersistableWalletPreferenceDefault(
|
||||
override val key: Key
|
||||
override val key: PreferenceKey
|
||||
) : PreferenceDefault<PersistableWallet?> {
|
||||
|
||||
override suspend fun getValue(preferenceProvider: PreferenceProvider) =
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
package co.electriccoin.zcash.ui.preference
|
||||
|
||||
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault
|
||||
import co.electriccoin.zcash.preference.model.entry.Key
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
|
||||
|
||||
object StandardPreferenceKeys {
|
||||
|
||||
/**
|
||||
* Whether the user has completed the backup flow for a newly created wallet.
|
||||
*/
|
||||
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(Key("is_user_backup_complete"), false)
|
||||
val IS_USER_BACKUP_COMPLETE = BooleanPreferenceDefault(PreferenceKey("is_user_backup_complete"), false)
|
||||
|
||||
// Default to true until https://github.com/zcash/secant-android-wallet/issues/304
|
||||
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(Key("is_analytics_enabled"), true)
|
||||
val IS_ANALYTICS_ENABLED = BooleanPreferenceDefault(PreferenceKey("is_analytics_enabled"), true)
|
||||
|
||||
val IS_BACKGROUND_SYNC_ENABLED = BooleanPreferenceDefault(Key("is_background_sync_enabled"), true)
|
||||
val IS_BACKGROUND_SYNC_ENABLED = BooleanPreferenceDefault(PreferenceKey("is_background_sync_enabled"), true)
|
||||
|
||||
val IS_KEEP_SCREEN_ON_DURING_SYNC = BooleanPreferenceDefault(Key("is_keep_screen_on_during_sync"), true)
|
||||
val IS_KEEP_SCREEN_ON_DURING_SYNC = BooleanPreferenceDefault(PreferenceKey("is_keep_screen_on_during_sync"), true)
|
||||
|
||||
/**
|
||||
* The fiat currency that the user prefers.
|
||||
*/
|
||||
val PREFERRED_FIAT_CURRENCY = FiatCurrencyPreferenceDefault(Key("preferred_fiat_currency_code"))
|
||||
val PREFERRED_FIAT_CURRENCY = FiatCurrencyPreferenceDefault(PreferenceKey("preferred_fiat_currency_code"))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue