[#764] Android Remote Config implementation
Note that there is no cloud integration yet. This creates the abstractions to support injecting different remote config sources
This commit is contained in:
parent
6b202e3c9f
commit
417fc4b8a5
|
@ -0,0 +1,53 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="configuration-impl-android-lib:connectedCheck" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
|
||||
<module name="zcash-android-app.configuration-impl-android-lib.androidTest" />
|
||||
<option name="TESTING_TYPE" value="0" />
|
||||
<option name="METHOD_NAME" value="" />
|
||||
<option name="CLASS_NAME" value="" />
|
||||
<option name="PACKAGE_NAME" value="" />
|
||||
<option name="TEST_NAME_REGEX" value="" />
|
||||
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
|
||||
<option name="EXTRA_OPTIONS" value="" />
|
||||
<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="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
|
||||
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
|
||||
<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>
|
|
@ -3,6 +3,7 @@ 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.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.datetime.Instant
|
||||
|
@ -14,7 +15,7 @@ class MergingConfigurationProvider(private val configurationProviders: Persisten
|
|||
|
||||
override fun getConfigurationFlow(): Flow<Configuration> {
|
||||
return combine(configurationProviders.map { it.getConfigurationFlow() }) { configurations ->
|
||||
MergingConfiguration(configurations.toList())
|
||||
MergingConfiguration(configurations.toList().toPersistentList())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +24,7 @@ class MergingConfigurationProvider(private val configurationProviders: Persisten
|
|||
}
|
||||
}
|
||||
|
||||
private data class MergingConfiguration(private val configurations: List<Configuration>) : Configuration {
|
||||
private data class MergingConfiguration(private val configurations: PersistentList<Configuration>) : Configuration {
|
||||
override val updatedAt: Instant?
|
||||
get() = configurations.mapNotNull { it.updatedAt }.maxOrNull()
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
id("secant.android-build-conventions")
|
||||
id("wtf.emulator.gradle")
|
||||
id("secant.emulator-wtf-conventions")
|
||||
id("secant.jacoco-conventions")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "co.electriccoin.zcash.configuration"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.immutable)
|
||||
api(projects.configurationApiLib)
|
||||
implementation(projects.spackleLib)
|
||||
|
||||
androidTestImplementation(libs.bundles.androidx.test)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
androidTestUtil(libs.androidx.test.services) {
|
||||
artifact {
|
||||
type = "apk"
|
||||
}
|
||||
}
|
||||
|
||||
if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) {
|
||||
androidTestUtil(libs.androidx.test.orchestrator) {
|
||||
artifact {
|
||||
type = "apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- For test coverage -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<!-- For test coverage on API 29 only -->
|
||||
<application
|
||||
android:label="zcash-configuration-test"
|
||||
android:requestLegacyExternalStorage="true" />
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,27 @@
|
|||
package co.electriccoin.zcash.configuration.internal.intent
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.filters.SmallTest
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class IntentProviderTest {
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testInsertValue() {
|
||||
val key = ConfigKey("test")
|
||||
assertFalse(IntentConfigurationProvider.peekConfiguration().hasKey(key))
|
||||
|
||||
IntentConfigurationReceiver().onReceive(
|
||||
null,
|
||||
Intent().apply {
|
||||
putExtra(ConfigurationIntent.EXTRA_STRING_KEY, key.key)
|
||||
putExtra(ConfigurationIntent.EXTRA_STRING_VALUE, "test")
|
||||
}
|
||||
)
|
||||
|
||||
assertTrue(IntentConfigurationProvider.peekConfiguration().hasKey(key))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".internal.intent.IntentConfigurationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application/>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,33 @@
|
|||
package co.electriccoin.zcash.configuration
|
||||
|
||||
import android.content.Context
|
||||
import co.electriccoin.zcash.configuration.api.ConfigurationProvider
|
||||
import co.electriccoin.zcash.configuration.api.MergingConfigurationProvider
|
||||
import co.electriccoin.zcash.configuration.internal.intent.IntentConfigurationProvider
|
||||
import co.electriccoin.zcash.spackle.LazyWithArgument
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
object AndroidConfigurationFactory {
|
||||
|
||||
private val instance = LazyWithArgument<Context, ConfigurationProvider> { context ->
|
||||
new(context)
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): ConfigurationProvider = instance.getInstance(context)
|
||||
|
||||
// Context will be needed for most cloud providers, e.g. to integrate with Firebase or other
|
||||
// remote configuration providers.
|
||||
private fun new(@Suppress("UNUSED_PARAMETER") context: Context): ConfigurationProvider {
|
||||
val configurationProviders = buildList<ConfigurationProvider> {
|
||||
// For ordering, ensure the IntentConfigurationProvider is first so that it can
|
||||
// override any other configuration providers.
|
||||
if (BuildConfig.DEBUG) {
|
||||
add(IntentConfigurationProvider)
|
||||
}
|
||||
|
||||
// In the future, add a third party cloud-based configuration provider
|
||||
}
|
||||
|
||||
return MergingConfigurationProvider(configurationProviders.toPersistentList())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package co.electriccoin.zcash.configuration.internal.intent
|
||||
|
||||
internal object ConfigurationIntent {
|
||||
const val EXTRA_STRING_KEY = "key" // $NON-NLS
|
||||
|
||||
const val EXTRA_STRING_VALUE = "value" // $NON-NLS
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package co.electriccoin.zcash.configuration.internal.intent
|
||||
|
||||
import co.electriccoin.zcash.configuration.api.ConfigurationProvider
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
internal object IntentConfigurationProvider : ConfigurationProvider {
|
||||
|
||||
private val configurationStateFlow = MutableStateFlow(StringConfiguration(persistentMapOf(), null))
|
||||
|
||||
override fun peekConfiguration() = configurationStateFlow.value
|
||||
|
||||
override fun getConfigurationFlow(): Flow<Configuration> = configurationStateFlow
|
||||
|
||||
override fun hintToRefresh() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the configuration to the provided value.
|
||||
*
|
||||
* @see IntentConfigurationProvider
|
||||
*/
|
||||
internal fun setConfiguration(configuration: StringConfiguration) {
|
||||
configurationStateFlow.value = configuration
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package co.electriccoin.zcash.configuration.internal.intent
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class IntentConfigurationReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
intent?.defuse()?.let {
|
||||
val key = it.getStringExtra(ConfigurationIntent.EXTRA_STRING_KEY)
|
||||
val value = it.getStringExtra(ConfigurationIntent.EXTRA_STRING_VALUE)
|
||||
|
||||
if (null != key) {
|
||||
val existingConfiguration = IntentConfigurationProvider.peekConfiguration().configurationMapping
|
||||
val newConfiguration = if (null == value) {
|
||||
existingConfiguration.remove(key)
|
||||
} else {
|
||||
existingConfiguration + (key to value)
|
||||
}
|
||||
|
||||
IntentConfigurationProvider.setConfiguration(StringConfiguration(newConfiguration.toPersistentMap(), Clock.System.now()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://issuetracker.google.com/issues/36927401
|
||||
private fun Intent.defuse(): Intent? {
|
||||
return try {
|
||||
extras?.containsKey(null)
|
||||
this
|
||||
} catch (@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
|
@ -3,10 +3,10 @@ _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-convention](../build-convention/) to prevent repetitive configuration as additional modules are added to the project
|
||||
* Much of the Gradle configuration lives in [build-conventions-secant](../build-conventions-secant/) 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 negatively impact local building, testing, or releasing the app
|
||||
* Repository restrictions are enabled in [build-convention](../build-convention/settings.gradle.kts), [settings.gradle.kts](../settings.gradle.kts), and [build.gradle.kts](../build.gradle.kts) to reduce likelihood of pulling in an incorrect dependency. If adding a new dependency, these restrictions may need to be changed otherwise an error that the dependency cannot be found will be displayed
|
||||
* Repository restrictions are enabled in [build-conventions-secant](../build-conventions-secant/settings.gradle.kts), [settings.gradle.kts](../settings.gradle.kts), and [build.gradle.kts](../build.gradle.kts) to reduce likelihood of pulling in an incorrect dependency. If adding a new dependency, these restrictions may need to be changed otherwise an error that the dependency cannot be found will be displayed
|
||||
|
||||
# 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:
|
||||
|
@ -26,7 +26,9 @@ 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.
|
||||
* configuration
|
||||
* `configuration-api-lib` — Multiplatform interfaces for remote configuration.
|
||||
* `configuration-impl-android-lib` — Android-specific implementation for remote configuration storage.
|
||||
* 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`
|
||||
|
@ -57,6 +59,11 @@ The following diagram shows a rough depiction of dependencies between the module
|
|||
sdkExtLib[[sdk-ext-lib]];
|
||||
end
|
||||
sdkLib[[sdk-lib]] --> sdkExtLib[[sdk-ext-lib]];
|
||||
subgraph configuration
|
||||
configurationApiLib[[configuration-api-lib]];
|
||||
configurationImplAndroidLib[[configuration-impl-android-lib]];
|
||||
end
|
||||
configurationApiLib[[configuration-api-lib]] --> configurationImplAndroidLib[[configuration-impl-android-lib]];
|
||||
subgraph preference
|
||||
preferenceApiLib[[preference-api-lib]];
|
||||
preferenceImplAndroidLib[[preference-impl-android-lib]];
|
||||
|
@ -82,6 +89,7 @@ The following diagram shows a rough depiction of dependencies between the module
|
|||
spackleAndroidLib[[spackle-android-lib]];
|
||||
end
|
||||
spackleLib[[spackle-lib]] --> spackleAndroidLib[[spackle-android-lib]];
|
||||
configuration --> ui[[ui]];
|
||||
preference --> ui[[ui]];
|
||||
sdk --> ui[[ui]];
|
||||
spackle[[spackle]] --> ui[[ui]];
|
||||
|
@ -93,6 +101,15 @@ The following diagram shows a rough depiction of dependencies between the module
|
|||
# Test Fixtures
|
||||
Until the Kotlin adopts support for fixtures, fixtures live within the main source modules. These fixtures make it easy to write automated tests, as well as create Compose previews. Although these fixtures are compiled into the main application, they should be removed by R8 in release builds.
|
||||
|
||||
# Debugging
|
||||
The application has support for remote configuration (aka feature toggles), which allows decoupling of releases from features being enabled.
|
||||
|
||||
Debug builds allow for manual override of feature toggle entries, which can be set by command line invocations. These overrides last for the lifetime of the process, so they will reset if the process dies. Pressing the home button on Android does not necessarily stop the process, so the best way to ensure process death is to choose Force Stop in the Android settings.
|
||||
|
||||
To set a configuration value manually, run the following shell command replacing `$SOME_KEY` and `$SOME_VALUE` with the key-value pair you'd like to set. The change will take effect immediately.
|
||||
|
||||
`adb shell am broadcast -n co.electriccoin.zcash/co.electriccoin.zcash.configuration.internal.intent.IntentConfigurationReceiver --es key "$SOME_KEY" --es value "$NEW_VALUE"`
|
||||
|
||||
# Shared Resources
|
||||
There are some app-wide resources that share a common namespace, and these should be documented here to make it easy to ensure there are no collisions.
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
The app has both debug and release builds, as well as testnet and mainnet flavors. We deploy release mainnet builds to users, but often use debug mainnet or testnet builds for testing.
|
||||
|
||||
# Ensure remote config debugging is disabled
|
||||
1. Download the release APK from CI server or from Google Play (you can also build it locally with `./gradlew assembleRelease` but fetching the version from CI or Google Play will be closer to the version users receive)
|
||||
1. In Android Studio, go to the Build menu and choose Analyze APK
|
||||
1. Navigate to the APK file
|
||||
1. Inspect the AndroidManifest and ensure that no `<receiver>` entry for `IntentConfigurationReceiver` exists
|
||||
|
||||
# Ensure logging is stripped
|
||||
|
||||
# Ensure application is minified - The application is minified through R8 without obfuscation. This is especially important to improve runtime performance of the app that we release.
|
|
@ -310,6 +310,7 @@ includeBuild("build-conventions-secant")
|
|||
include("app")
|
||||
include("build-info-lib")
|
||||
include("configuration-api-lib")
|
||||
include("configuration-impl-android-lib")
|
||||
include("crash-lib")
|
||||
include("crash-android-lib")
|
||||
include("preference-api-lib")
|
||||
|
|
|
@ -73,6 +73,8 @@ dependencies {
|
|||
implementation(libs.zxing)
|
||||
|
||||
implementation(projects.buildInfoLib)
|
||||
implementation(projects.configurationApiLib)
|
||||
implementation(projects.configurationImplAndroidLib)
|
||||
implementation(projects.crashAndroidLib)
|
||||
implementation(projects.preferenceApiLib)
|
||||
implementation(projects.preferenceImplAndroidLib)
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
package co.electriccoin.zcash.ui.configuration
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import co.electriccoin.zcash.configuration.model.entry.DefaultEntry
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class ConfigurationEntriesTest {
|
||||
// This test is primary to prevent copy-paste errors in configuration keys
|
||||
@SmallTest
|
||||
@Test
|
||||
fun keys_unique() {
|
||||
val fieldValueSet = mutableSetOf<String>()
|
||||
|
||||
ConfigurationEntries::class.memberProperties
|
||||
.map { it.getter.call(ConfigurationEntries) }
|
||||
.map { it as DefaultEntry<*> }
|
||||
.map { it.key }
|
||||
.forEach {
|
||||
assertFalse(fieldValueSet.contains(it.key), "Duplicate key $it")
|
||||
|
||||
fieldValueSet.add(it.key)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,24 +1,24 @@
|
|||
package co.electriccoin.zcash.ui.preference
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class EncryptedPreferenceKeysTest {
|
||||
// This test is primary to prevent copy-paste errors in preference keys
|
||||
@SmallTest
|
||||
@Test
|
||||
fun key_values_unique() {
|
||||
fun unique_keys() {
|
||||
val fieldValueSet = mutableSetOf<String>()
|
||||
|
||||
EncryptedPreferenceKeys::class.memberProperties
|
||||
.map { it.getter.call(EncryptedPreferenceKeys) }
|
||||
.map { it as PersistableWalletPreferenceDefault }
|
||||
.map { it as PreferenceDefault<*> }
|
||||
.map { it.key }
|
||||
.forEach {
|
||||
assertThat("Duplicate key $it", fieldValueSet.contains(it.key), equalTo(false))
|
||||
assertFalse(fieldValueSet.contains(it.key), "Duplicate key $it")
|
||||
|
||||
fieldValueSet.add(it.key)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
package co.electriccoin.zcash.ui.preference
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import co.electriccoin.zcash.preference.model.entry.PreferenceDefault
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
class StandardPreferenceKeysTest {
|
||||
// This test is primary to prevent copy-paste errors in preference keys
|
||||
@SmallTest
|
||||
@Test
|
||||
fun unique_keys() {
|
||||
val fieldValueSet = mutableSetOf<String>()
|
||||
|
||||
StandardPreferenceKeys::class.memberProperties
|
||||
.map { it.getter.call(StandardPreferenceKeys) }
|
||||
.map { it as PreferenceDefault<*> }
|
||||
.map { it.key }
|
||||
.forEach {
|
||||
assertFalse(fieldValueSet.contains(it.key), "Duplicate key $it")
|
||||
|
||||
fieldValueSet.add(it.key)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,9 +9,11 @@ import androidx.test.filters.MediumTest
|
|||
import co.electriccoin.zcash.build.gitSha
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.fixture.ConfigInfoFixture
|
||||
import co.electriccoin.zcash.ui.fixture.VersionInfoFixture
|
||||
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
|
||||
import co.electriccoin.zcash.ui.screen.about.view.About
|
||||
import co.electriccoin.zcash.ui.screen.support.model.ConfigInfo
|
||||
import co.electriccoin.zcash.ui.test.getStringResource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
|
@ -50,9 +52,17 @@ class AboutViewTest {
|
|||
assertEquals(1, testSetup.getOnBackCount())
|
||||
}
|
||||
|
||||
private fun newTestSetup() = TestSetup(composeTestRule, VersionInfoFixture.new())
|
||||
private fun newTestSetup() = TestSetup(
|
||||
composeTestRule,
|
||||
VersionInfoFixture.new(),
|
||||
ConfigInfoFixture.new()
|
||||
)
|
||||
|
||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, versionInfo: VersionInfo) {
|
||||
private class TestSetup(
|
||||
private val composeTestRule: ComposeContentTestRule,
|
||||
versionInfo: VersionInfo,
|
||||
configInfo: ConfigInfo
|
||||
) {
|
||||
|
||||
private val onBackCount = AtomicInteger(0)
|
||||
|
||||
|
@ -64,7 +74,7 @@ class AboutViewTest {
|
|||
init {
|
||||
composeTestRule.setContent {
|
||||
ZcashTheme {
|
||||
About(versionInfo = versionInfo) {
|
||||
About(versionInfo = versionInfo, configInfo = configInfo) {
|
||||
onBackCount.incrementAndGet()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.annotation.VisibleForTesting
|
|||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
|
@ -18,6 +19,7 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import co.electriccoin.zcash.ui.common.BindCompLocalProvider
|
||||
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
||||
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
import co.electriccoin.zcash.ui.design.component.Override
|
||||
|
@ -41,6 +43,8 @@ import kotlin.time.Duration.Companion.seconds
|
|||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
val homeViewModel by viewModels<HomeViewModel>()
|
||||
|
||||
val walletViewModel by viewModels<WalletViewModel>()
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
|
@ -74,7 +78,8 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
SecretState.Loading == walletViewModel.secretState.value
|
||||
// Note this condition needs to be kept in sync with the condition in MainContent()
|
||||
homeViewModel.configurationFlow.value == null || SecretState.Loading == walletViewModel.secretState.value
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -107,22 +112,35 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
@Composable
|
||||
private fun MainContent() {
|
||||
when (val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value) {
|
||||
SecretState.Loading -> {
|
||||
// For now, keep displaying splash screen using condition above.
|
||||
// In the future, we might consider displaying something different here.
|
||||
}
|
||||
SecretState.None -> {
|
||||
WrapOnboarding()
|
||||
}
|
||||
is SecretState.NeedsBackup -> {
|
||||
WrapBackup(
|
||||
secretState.persistableWallet,
|
||||
onBackupComplete = { walletViewModel.persistBackupComplete() }
|
||||
)
|
||||
}
|
||||
is SecretState.Ready -> {
|
||||
Navigation()
|
||||
val configuration = homeViewModel.configurationFlow.collectAsStateWithLifecycle().value
|
||||
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
|
||||
|
||||
// Note this condition needs to be kept in sync with the condition in setupSplashScreen()
|
||||
if (null == configuration || secretState == SecretState.Loading) {
|
||||
// For now, keep displaying splash screen using condition above.
|
||||
// In the future, we might consider displaying something different here.
|
||||
} else {
|
||||
// Note that the deeply nested child views will probably receive arguments derived from
|
||||
// the configuration. The CompositionLocalProvider is helpful for passing the configuration
|
||||
// to the "platform" layer, which is where the arguments will be derived from.
|
||||
CompositionLocalProvider(RemoteConfig provides configuration) {
|
||||
when (secretState) {
|
||||
SecretState.None -> {
|
||||
WrapOnboarding()
|
||||
}
|
||||
is SecretState.NeedsBackup -> {
|
||||
WrapBackup(
|
||||
secretState.persistableWallet,
|
||||
onBackupComplete = { walletViewModel.persistBackupComplete() }
|
||||
)
|
||||
}
|
||||
is SecretState.Ready -> {
|
||||
Navigation()
|
||||
}
|
||||
else -> {
|
||||
error("Unhandled secret state: $secretState")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,8 @@ import co.electriccoin.zcash.ui.NavigationTargets.SEND
|
|||
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.SUPPORT
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.WALLET_ADDRESS_DETAILS
|
||||
import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
|
||||
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
||||
import co.electriccoin.zcash.ui.screen.about.WrapAbout
|
||||
import co.electriccoin.zcash.ui.screen.address.WrapWalletAddresses
|
||||
import co.electriccoin.zcash.ui.screen.home.WrapHome
|
||||
|
@ -47,7 +49,9 @@ internal fun MainActivity.Navigation() {
|
|||
goRequest = { navController.navigateJustOnce(REQUEST) }
|
||||
)
|
||||
|
||||
WrapCheckForUpdate()
|
||||
if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
|
||||
WrapCheckForUpdate()
|
||||
}
|
||||
}
|
||||
composable(PROFILE) {
|
||||
WrapProfile(
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
package co.electriccoin.zcash.ui.configuration
|
||||
|
||||
import co.electriccoin.zcash.configuration.model.entry.BooleanConfigurationEntry
|
||||
import co.electriccoin.zcash.configuration.model.entry.ConfigKey
|
||||
|
||||
object ConfigurationEntries {
|
||||
val IS_APP_UPDATE_CHECK_ENABLED = BooleanConfigurationEntry(ConfigKey("is_update_check_enabled"), true)
|
||||
|
||||
/*
|
||||
* Disabled because we don't have the URI parser support in the SDK yet.
|
||||
*
|
||||
*/
|
||||
val IS_REQUEST_ZEC_ENABLED = BooleanConfigurationEntry(ConfigKey("is_update_check_enabled"), false)
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package co.electriccoin.zcash.ui.configuration
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import co.electriccoin.zcash.configuration.model.map.StringConfiguration
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
|
||||
val RemoteConfig = compositionLocalOf<Configuration> { StringConfiguration(persistentMapOf(), null) }
|
|
@ -0,0 +1,15 @@
|
|||
package co.electriccoin.zcash.ui.fixture
|
||||
|
||||
import co.electriccoin.zcash.ui.screen.support.model.ConfigInfo
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.toInstant
|
||||
|
||||
// Magic Number doesn't matter here for hard-coded fixture values
|
||||
@Suppress("MagicNumber")
|
||||
object ConfigInfoFixture {
|
||||
val UPDATED_AT = "2023-01-15T08:38:45.415Z".toInstant()
|
||||
|
||||
fun new(
|
||||
updatedAt: Instant? = UPDATED_AT,
|
||||
) = ConfigInfo(updatedAt)
|
||||
}
|
|
@ -4,10 +4,13 @@ package co.electriccoin.zcash.ui.screen.about
|
|||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import co.electriccoin.zcash.configuration.AndroidConfigurationFactory
|
||||
import co.electriccoin.zcash.spackle.getPackageInfoCompat
|
||||
import co.electriccoin.zcash.ui.MainActivity
|
||||
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
|
||||
import co.electriccoin.zcash.ui.screen.about.view.About
|
||||
import co.electriccoin.zcash.ui.screen.support.model.ConfigInfo
|
||||
|
||||
@Composable
|
||||
internal fun MainActivity.WrapAbout(
|
||||
|
@ -22,6 +25,12 @@ internal fun WrapAbout(
|
|||
goBack: () -> Unit
|
||||
) {
|
||||
val packageInfo = activity.packageManager.getPackageInfoCompat(activity.packageName, 0L)
|
||||
val configurationProvider = AndroidConfigurationFactory.getInstance(activity.applicationContext)
|
||||
|
||||
About(VersionInfo.new(packageInfo), goBack)
|
||||
About(VersionInfo.new(packageInfo), ConfigInfo.new(configurationProvider), goBack)
|
||||
|
||||
// Allows an implicit way to force configuration refresh by simply visiting the About screen
|
||||
LaunchedEffect(key1 = true) {
|
||||
AndroidConfigurationFactory.getInstance(activity.applicationContext).hintToRefresh()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,15 +27,21 @@ import co.electriccoin.zcash.ui.design.component.Body
|
|||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
import co.electriccoin.zcash.ui.design.component.Header
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.fixture.ConfigInfoFixture
|
||||
import co.electriccoin.zcash.ui.fixture.VersionInfoFixture
|
||||
import co.electriccoin.zcash.ui.screen.about.model.VersionInfo
|
||||
import co.electriccoin.zcash.ui.screen.support.model.ConfigInfo
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AboutPreview() {
|
||||
ZcashTheme(darkTheme = true) {
|
||||
GradientSurface {
|
||||
About(versionInfo = VersionInfoFixture.new(), goBack = {})
|
||||
About(
|
||||
versionInfo = VersionInfoFixture.new(),
|
||||
configInfo = ConfigInfoFixture.new(),
|
||||
goBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +50,7 @@ fun AboutPreview() {
|
|||
@Composable
|
||||
fun About(
|
||||
versionInfo: VersionInfo,
|
||||
configInfo: ConfigInfo,
|
||||
goBack: () -> Unit
|
||||
) {
|
||||
Scaffold(topBar = {
|
||||
|
@ -51,7 +58,8 @@ fun About(
|
|||
}) { paddingValues ->
|
||||
AboutMainContent(
|
||||
paddingValues,
|
||||
versionInfo
|
||||
versionInfo,
|
||||
configInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -75,7 +83,7 @@ private fun AboutTopAppBar(onBack: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun AboutMainContent(paddingValues: PaddingValues, versionInfo: VersionInfo) {
|
||||
fun AboutMainContent(paddingValues: PaddingValues, versionInfo: VersionInfo, configInfo: ConfigInfo) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
|
@ -96,6 +104,13 @@ fun AboutMainContent(paddingValues: PaddingValues, versionInfo: VersionInfo) {
|
|||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
configInfo.configurationUpdatedAt?.let { updatedAt ->
|
||||
Header(stringResource(id = R.string.about_build_configuration))
|
||||
Body(updatedAt.toString())
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Header(stringResource(id = R.string.about_legal_header))
|
||||
Body(stringResource(id = R.string.about_legal_info))
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.screen.home.viewmodel
|
|||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.electriccoin.zcash.configuration.AndroidConfigurationFactory
|
||||
import co.electriccoin.zcash.configuration.model.map.Configuration
|
||||
import co.electriccoin.zcash.ui.common.ANDROID_STATE_FLOW_TIMEOUT
|
||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceKeys
|
||||
import co.electriccoin.zcash.ui.preference.StandardPreferenceSingleton
|
||||
|
@ -21,4 +23,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
|||
val preferenceProvider = StandardPreferenceSingleton.getInstance(application)
|
||||
emitAll(StandardPreferenceKeys.IS_BACKGROUND_SYNC_ENABLED.observe(preferenceProvider))
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT.inWholeMilliseconds), null)
|
||||
|
||||
val configurationFlow: StateFlow<Configuration?> = AndroidConfigurationFactory.getInstance(application).getConfigurationFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT.inWholeMilliseconds), null)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import android.content.pm.PackageInfo
|
|||
import co.electriccoin.zcash.build.gitSha
|
||||
import co.electriccoin.zcash.spackle.versionCodeCompat
|
||||
|
||||
class AppInfo(val versionName: String, val versionCode: Long, val gitSha: String) {
|
||||
data class AppInfo(val versionName: String, val versionCode: Long, val gitSha: String) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("App version: $versionName ($versionCode) $gitSha")
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import co.electriccoin.zcash.configuration.api.ConfigurationProvider
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
data class ConfigInfo(val configurationUpdatedAt: Instant?) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Configuration: $configurationUpdatedAt")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(configurationProvider: ConfigurationProvider) = ConfigInfo(
|
||||
configurationProvider.peekConfiguration().updatedAt
|
||||
)
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ import co.electriccoin.zcash.spackle.io.listFilesSuspend
|
|||
import kotlinx.datetime.Instant
|
||||
import java.io.File
|
||||
|
||||
class CrashInfo(val exceptionClassName: String, val isUncaught: Boolean, val timestamp: Instant) {
|
||||
data class CrashInfo(val exceptionClassName: String, val isUncaught: Boolean, val timestamp: Instant) {
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Exception")
|
||||
appendLine(" Class name: $exceptionClassName")
|
||||
|
|
|
@ -2,7 +2,7 @@ package co.electriccoin.zcash.ui.screen.support.model
|
|||
|
||||
import android.os.Build
|
||||
|
||||
class DeviceInfo(val manufacturer: String, val device: String, val model: String) {
|
||||
data class DeviceInfo(val manufacturer: String, val device: String, val model: String) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Device: $manufacturer $device $model")
|
||||
|
|
|
@ -5,7 +5,7 @@ import cash.z.ecc.android.sdk.model.MonetarySeparators
|
|||
import co.electriccoin.zcash.global.StorageChecker
|
||||
import java.util.Locale
|
||||
|
||||
class EnvironmentInfo(val locale: Locale, val monetarySeparators: MonetarySeparators, val usableStorageMegabytes: Int) {
|
||||
data class EnvironmentInfo(val locale: Locale, val monetarySeparators: MonetarySeparators, val usableStorageMegabytes: Int) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("Locale: ${locale.androidResName()}")
|
||||
|
|
|
@ -3,7 +3,7 @@ package co.electriccoin.zcash.ui.screen.support.model
|
|||
import android.os.Build
|
||||
import co.electriccoin.zcash.spackle.AndroidApiVersion
|
||||
|
||||
class OperatingSystemInfo(val sdkInt: Int, val isPreview: Boolean) {
|
||||
data class OperatingSystemInfo(val sdkInt: Int, val isPreview: Boolean) {
|
||||
|
||||
fun toSupportString() = buildString {
|
||||
if (isPreview) {
|
||||
|
|
|
@ -6,7 +6,7 @@ import android.content.pm.PackageInfo
|
|||
import android.content.pm.PackageManager
|
||||
import co.electriccoin.zcash.spackle.getPackageInfoCompatSuspend
|
||||
|
||||
class PermissionInfo(val permissionName: String, val permissionStatus: PermissionStatus) {
|
||||
data class PermissionInfo(val permissionName: String, val permissionStatus: PermissionStatus) {
|
||||
fun toSupportString() = buildString {
|
||||
appendLine("$permissionName $permissionStatus")
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package co.electriccoin.zcash.ui.screen.support.model
|
||||
|
||||
import android.content.Context
|
||||
import co.electriccoin.zcash.configuration.AndroidConfigurationFactory
|
||||
import co.electriccoin.zcash.spackle.getPackageInfoCompatSuspend
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
enum class SupportInfoType {
|
||||
Time,
|
||||
|
@ -16,11 +19,12 @@ enum class SupportInfoType {
|
|||
data class SupportInfo(
|
||||
val timeInfo: TimeInfo,
|
||||
val appInfo: AppInfo,
|
||||
val configInfo: ConfigInfo,
|
||||
val operatingSystemInfo: OperatingSystemInfo,
|
||||
val deviceInfo: DeviceInfo,
|
||||
val environmentInfo: EnvironmentInfo,
|
||||
val permissionInfo: List<PermissionInfo>,
|
||||
val crashInfo: List<CrashInfo>
|
||||
val permissionInfo: PersistentList<PermissionInfo>,
|
||||
val crashInfo: PersistentList<CrashInfo>
|
||||
) {
|
||||
|
||||
// The set of enum values is to allow optional filtering of different types of information
|
||||
|
@ -62,15 +66,17 @@ data class SupportInfo(
|
|||
suspend fun new(context: Context): SupportInfo {
|
||||
val applicationContext = context.applicationContext
|
||||
val packageInfo = applicationContext.packageManager.getPackageInfoCompatSuspend(context.packageName, 0L)
|
||||
val configurationProvider = AndroidConfigurationFactory.getInstance(applicationContext)
|
||||
|
||||
return SupportInfo(
|
||||
TimeInfo.new(packageInfo),
|
||||
AppInfo.new(packageInfo),
|
||||
ConfigInfo.new(configurationProvider),
|
||||
OperatingSystemInfo.new(),
|
||||
DeviceInfo.new(),
|
||||
EnvironmentInfo.new(applicationContext),
|
||||
PermissionInfo.all(applicationContext),
|
||||
CrashInfo.all(context)
|
||||
PermissionInfo.all(applicationContext).toPersistentList(),
|
||||
CrashInfo.all(context).toPersistentList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import java.util.Date
|
|||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TimeInfo(
|
||||
data class TimeInfo(
|
||||
val currentTime: Instant,
|
||||
val rebootTime: Instant,
|
||||
val installTime: Instant,
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<string name="about_version_header">Version</string>
|
||||
<string name="about_version_format" formatted="true"><xliff:g id="version_name" example="1.0">%1$s</xliff:g> (<xliff:g id="version_code" example="1.0">%2$d</xliff:g>)</string>
|
||||
<string name="about_build_header">Build</string>
|
||||
<string name="about_build_configuration">Configuration</string>
|
||||
<string name="about_legal_header">Legal</string>
|
||||
<!-- TODO [#392] Update with real legal info. -->
|
||||
<!-- TODO [#392] https://github.com/zcash/secant-android-wallet/issues/392 -->
|
||||
|
|
Loading…
Reference in New Issue