[#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:
Carter Jernigan 2023-02-20 11:07:26 -05:00 committed by Carter Jernigan
parent 6b202e3c9f
commit 417fc4b8a5
38 changed files with 511 additions and 46 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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