[#764] Multiplatform remote config API

This commit is contained in:
Carter Jernigan 2023-02-19 09:03:30 -05:00 committed by Carter Jernigan
parent b3f4601b14
commit 6b202e3c9f
38 changed files with 587 additions and 51 deletions

View File

@ -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"))
}
}
}
}

View File

@ -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()
}

View File

@ -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) }

View File

@ -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)
}

View File

@ -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." }
}
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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))
}
}

View File

@ -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()))))
}
}

View File

@ -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"))))
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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`. * `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. * `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 — 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-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` * `crash-android-lib` — Android-specific crash collection logic, built on top of the common and JVM implementation in `crash-lib`

View File

@ -144,6 +144,7 @@ JACOCO_VERSION=0.8.8
KOTLIN_VERSION=1.8.10 KOTLIN_VERSION=1.8.10
KOTLINX_COROUTINES_VERSION=1.6.4 KOTLINX_COROUTINES_VERSION=1.6.4
KOTLINX_DATETIME_VERSION=0.4.0 KOTLINX_DATETIME_VERSION=0.4.0
KOTLINX_IMMUTABLE_COLLECTIONS_VERSION=0.3.5
KOVER_VERSION=0.6.1 KOVER_VERSION=0.6.1
PLAY_APP_UPDATE_VERSION=2.0.1 PLAY_APP_UPDATE_VERSION=2.0.1
PLAY_APP_UPDATE_KTX_VERSION=2.0.1 PLAY_APP_UPDATE_KTX_VERSION=2.0.1

View File

@ -1,19 +1,15 @@
package co.electriccoin.zcash.preference.api 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 import kotlinx.coroutines.flow.Flow
interface PreferenceProvider { 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?
/** fun observe(key: PreferenceKey): Flow<String?>
* @return Flow to observe potential changes to the value associated with the key in the preferences.
* Consumers of the flow will need to then query the value and determine whether it has changed.
*/
fun observe(key: Key): Flow<Unit>
} }

View File

@ -3,7 +3,7 @@ package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider import co.electriccoin.zcash.preference.api.PreferenceProvider
data class BooleanPreferenceDefault( data class BooleanPreferenceDefault(
override val key: Key, override val key: PreferenceKey,
private val defaultValue: Boolean private val defaultValue: Boolean
) : PreferenceDefault<Boolean> { ) : PreferenceDefault<Boolean> {

View File

@ -3,7 +3,7 @@ package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider import co.electriccoin.zcash.preference.api.PreferenceProvider
data class IntegerPreferenceDefault( data class IntegerPreferenceDefault(
override val key: Key, override val key: PreferenceKey,
private val defaultValue: Int private val defaultValue: Int
) : PreferenceDefault<Int> { ) : PreferenceDefault<Int> {

View File

@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.map
*/ */
interface PreferenceDefault<T> { interface PreferenceDefault<T> {
val key: Key val key: PreferenceKey
/** /**
* @param preferenceProvider Provides actual preference values. * @param preferenceProvider Provides actual preference values.

View File

@ -9,7 +9,7 @@ import kotlin.jvm.JvmInline
* find a least common denominator with some reasonable limits on what the keys can contain. * find a least common denominator with some reasonable limits on what the keys can contain.
*/ */
@JvmInline @JvmInline
value class Key(val key: String) { value class PreferenceKey(val key: String) {
init { init {
requireKeyConstraints(key) requireKeyConstraints(key)
} }

View File

@ -3,7 +3,7 @@ package co.electriccoin.zcash.preference.model.entry
import co.electriccoin.zcash.preference.api.PreferenceProvider import co.electriccoin.zcash.preference.api.PreferenceProvider
data class StringPreferenceDefault( data class StringPreferenceDefault(
override val key: Key, override val key: PreferenceKey,
private val defaultValue: String private val defaultValue: String
) : PreferenceDefault<String> { ) : PreferenceDefault<String> {

View File

@ -1,9 +1,9 @@
package co.electriccoin.zcash.preference.test package co.electriccoin.zcash.preference.test
import co.electriccoin.zcash.preference.api.PreferenceProvider 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.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. * @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() 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 // 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 map[key.key] = value
} }
} }

View File

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

View File

@ -1,10 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture package co.electriccoin.zcash.preference.test.fixture
import co.electriccoin.zcash.preference.model.entry.IntegerPreferenceDefault 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 { object IntegerPreferenceDefaultFixture {
val KEY = Key("some_string_key") // $NON-NLS val KEY = PreferenceKey("some_string_key") // $NON-NLS
const val DEFAULT_VALUE = 123 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)
} }

View File

@ -1,10 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture 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 import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture { 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 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)
} }

View File

@ -1,10 +1,10 @@
package co.electriccoin.zcash.preference.test.fixture 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 import co.electriccoin.zcash.preference.model.entry.StringPreferenceDefault
object StringDefaultPreferenceFixture { 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 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)
} }

View File

@ -6,14 +6,14 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import co.electriccoin.zcash.preference.api.PreferenceProvider 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.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -35,12 +35,12 @@ class AndroidPreferenceProvider(
private val dispatcher: CoroutineDispatcher private val dispatcher: CoroutineDispatcher
) : PreferenceProvider { ) : PreferenceProvider {
override suspend fun hasKey(key: Key) = withContext(dispatcher) { override suspend fun hasKey(key: PreferenceKey) = withContext(dispatcher) {
sharedPreferences.contains(key.key) sharedPreferences.contains(key.key)
} }
@SuppressLint("ApplySharedPref") @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() val editor = sharedPreferences.edit()
editor.putString(key.key, value) editor.putString(key.key, value)
@ -50,12 +50,11 @@ class AndroidPreferenceProvider(
Unit Unit
} }
override suspend fun getString(key: Key) = withContext(dispatcher) { override suspend fun getString(key: PreferenceKey) = withContext(dispatcher) {
sharedPreferences.getString(key.key, null) sharedPreferences.getString(key.key, null)
} }
@OptIn(ExperimentalCoroutinesApi::class) override fun observe(key: PreferenceKey): Flow<String?> = callbackFlow<Unit> {
override fun observe(key: Key): Flow<Unit> = callbackFlow<Unit> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ ->
// Callback on main thread // Callback on main thread
trySend(Unit) trySend(Unit)
@ -69,6 +68,7 @@ class AndroidPreferenceProvider(
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
} }
}.flowOn(dispatcher) }.flowOn(dispatcher)
.map { getString(key) }
companion object { companion object {
suspend fun newStandard(context: Context, filename: String): PreferenceProvider { suspend fun newStandard(context: Context, filename: String): PreferenceProvider {

View File

@ -174,6 +174,7 @@ dependencyResolutionManagement {
val kotlinVersion = extra["KOTLIN_VERSION"].toString() val kotlinVersion = extra["KOTLIN_VERSION"].toString()
val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString() val kotlinxDateTimeVersion = extra["KOTLINX_DATETIME_VERSION"].toString()
val kotlinxCoroutinesVersion = extra["KOTLINX_COROUTINES_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 playAppUpdateVersion = extra["PLAY_APP_UPDATE_VERSION"].toString()
val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString() val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_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-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
library("kotlinx-coroutines-guava", "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlinxCoroutinesVersion") library("kotlinx-coroutines-guava", "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlinxCoroutinesVersion")
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion") 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", "com.google.android.play:app-update:$playAppUpdateVersion")
library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion") library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion")
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion") library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
@ -307,6 +309,7 @@ includeBuild("build-conventions-secant")
include("app") include("app")
include("build-info-lib") include("build-info-lib")
include("configuration-api-lib")
include("crash-lib") include("crash-lib")
include("crash-android-lib") include("crash-android-lib")
include("preference-api-lib") include("preference-api-lib")

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 /> <application />

View File

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

View File

@ -2,11 +2,11 @@ package co.electriccoin.zcash.ui.preference
import cash.z.ecc.android.sdk.model.FiatCurrency import cash.z.ecc.android.sdk.model.FiatCurrency
import co.electriccoin.zcash.preference.api.PreferenceProvider 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.PreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
data class FiatCurrencyPreferenceDefault( data class FiatCurrencyPreferenceDefault(
override val key: Key override val key: PreferenceKey
) : PreferenceDefault<FiatCurrency> { ) : PreferenceDefault<FiatCurrency> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) = override suspend fun getValue(preferenceProvider: PreferenceProvider) =

View File

@ -2,12 +2,12 @@ package co.electriccoin.zcash.ui.preference
import cash.z.ecc.android.sdk.model.PersistableWallet import cash.z.ecc.android.sdk.model.PersistableWallet
import co.electriccoin.zcash.preference.api.PreferenceProvider 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.PreferenceDefault
import co.electriccoin.zcash.preference.model.entry.PreferenceKey
import org.json.JSONObject import org.json.JSONObject
data class PersistableWalletPreferenceDefault( data class PersistableWalletPreferenceDefault(
override val key: Key override val key: PreferenceKey
) : PreferenceDefault<PersistableWallet?> { ) : PreferenceDefault<PersistableWallet?> {
override suspend fun getValue(preferenceProvider: PreferenceProvider) = override suspend fun getValue(preferenceProvider: PreferenceProvider) =

View File

@ -1,24 +1,24 @@
package co.electriccoin.zcash.ui.preference package co.electriccoin.zcash.ui.preference
import co.electriccoin.zcash.preference.model.entry.BooleanPreferenceDefault 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 { object StandardPreferenceKeys {
/** /**
* Whether the user has completed the backup flow for a newly created wallet. * 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 // 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. * 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"))
} }