From 2aba8fe33be77458e6bc3d2cca25723c05970a3f Mon Sep 17 00:00:00 2001 From: Carter Jernigan Date: Mon, 31 Jan 2022 16:42:22 -0500 Subject: [PATCH] [#70] Generate screenshots automatically Test Orchestrator must be enabled, so that screenshot generation can walk through the app like a newly registered user. The screenshots are stored outside of the app's storage directory, so that Test Orchestrator does not delete them. Because hard-coded paths are used, it is possible this could be brittle with future versions of Android A future enhancement would be to also copy these screenshots off of tests run on Firebase Test Lab. This would likely involve configuring Fladle/Flank with additional directories to pull. --- README.md | 4 +- app/build.gradle.kts | 41 ++- app/src/androidTest/AndroidManifest.xml | 10 + .../java/cash/z/ecc/app/ScreenshotTest.kt | 324 ++++++++++++++++++ .../ecc/app/test/EccScreenCaptureProcessor.kt | 55 +++ .../cash/z/ecc/app/test/GetStringResource.kt | 7 + docs/Setup.md | 4 + settings.gradle.kts | 1 + .../main/java/cash/z/ecc/ui/MainActivity.kt | 74 ++-- .../z/ecc/ui/screen/home/view/HomeView.kt | 2 +- .../screen/home/viewmodel/WalletViewModel.kt | 10 +- .../screen/onboarding/view/OnboardingView.kt | 2 + 12 files changed, 502 insertions(+), 32 deletions(-) create mode 100644 app/src/androidTest/AndroidManifest.xml create mode 100644 app/src/androidTest/java/cash/z/ecc/app/ScreenshotTest.kt create mode 100644 app/src/androidTest/java/cash/z/ecc/app/test/EccScreenCaptureProcessor.kt create mode 100644 app/src/androidTest/java/cash/z/ecc/app/test/GetStringResource.kt diff --git a/README.md b/README.md index fda6085f..78646eb3 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,6 @@ If you plan to fork the project to create a new app of your own, please make the 1. Android Studio will warn about the Gradle checksum. This is a [known issue](https://github.com/gradle/gradle/issues/9361) and can be safely ignored. 1. [#96](https://github.com/zcash/secant-android-wallet/issues/96) - Release builds print some R8 warnings which can be safely ignored. 1. During app first launch, the following exception starting with `AndroidKeysetManager: keyset not found, will generate a new one` is printed twice. This exception is not an error, and the code is not being invoked twice. -1. The task `ktlintFormat` fails on Java 16 and greater. As a workaround, the task is run under Java 11. This requires that JDK 11 be installed, even if a newer JDK is also installed. While this is configured to use the Java toolchain API, [toolchain support for Java 11 does not work on Apple Silicon](https://github.com/gradle/gradle/issues/19140). [Azul Zulu](https://www.azul.com/downloads/?os=macos&architecture=arm-64-bit&package=jdk) does offer JDK 11, which must be installed manually to run this task from the command line on Apple Silicon. \ No newline at end of file +1. The task `ktlintFormat` fails on Java 16 and greater. As a workaround, the task is run under Java 11. This requires that JDK 11 be installed, even if a newer JDK is also installed. While this is configured to use the Java toolchain API, [toolchain support for Java 11 does not work on Apple Silicon](https://github.com/gradle/gradle/issues/19140). [Azul Zulu](https://www.azul.com/downloads/?os=macos&architecture=arm-64-bit&package=jdk) does offer JDK 11, which must be installed manually to run this task from the command line on Apple Silicon. +1. When running instrumentation tests for the app module, this warning will be printed `WARNING: Failed to retrieve additional test outputs from device. +com.android.ddmlib.SyncException: Remote object doesn't exist!` followed by a stacktrace. This can be safely ignored. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4afebdd8..75d6a0ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,9 @@ plugins { val packageName = "cash.z.ecc" +// Force orchestrator to be used for this module, because we need cleared state to generate screenshots +val isOrchestratorEnabled = true + android { defaultConfig { applicationId = packageName @@ -16,6 +19,16 @@ android { // when the deployment runs versionCode = project.property("ZCASH_VERSION_CODE").toString().toInt() versionName = project.property("ZCASH_VERSION_NAME").toString() + + if (isOrchestratorEnabled) { + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + } + + if (isOrchestratorEnabled) { + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } } compileOptions { @@ -118,9 +131,13 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(projects.uiLib) + androidTestImplementation(libs.androidx.compose.test.junit) + androidTestImplementation(libs.androidx.navigation.compose) + androidTestImplementation(libs.androidx.uiAutomator) androidTestImplementation(libs.bundles.androidx.test) + androidTestImplementation(projects.sdkExtLib) - if (project.property("IS_USE_TEST_ORCHESTRATOR").toString().toBoolean()) { + if (isOrchestratorEnabled) { androidTestUtil(libs.androidx.test.orchestrator) { artifact { type = "apk" @@ -173,3 +190,25 @@ if (googlePlayServiceKeyFilePath.isNotEmpty()) { } } } + +val reportsDirectory = "${buildDir}/reports/androidTests/connected" + +// This is coordinated with `EccScreenCaptureProcessor` +val onDeviceScreenshotsDirectory = "/sdcard/Pictures/zcash_screenshots" + +val clearScreenshotsTask = tasks.create("clearScreenshots") { + executable = project.android.adbExecutable.absolutePath + args = listOf("shell", "rm", "-r", onDeviceScreenshotsDirectory) +} + +val fetchScreenshotsTask = tasks.create("fetchScreenshots") { + executable = project.android.adbExecutable.absolutePath + args = listOf("pull", onDeviceScreenshotsDirectory, reportsDirectory) + finalizedBy(clearScreenshotsTask) +} + +tasks.whenTaskAdded { + if (name == "connectedZcashmainnetDebugAndroidTest") { + finalizedBy(fetchScreenshotsTask) + } +} diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..9ebd9e2d --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/androidTest/java/cash/z/ecc/app/ScreenshotTest.kt b/app/src/androidTest/java/cash/z/ecc/app/ScreenshotTest.kt new file mode 100644 index 00000000..e0ed2422 --- /dev/null +++ b/app/src/androidTest/java/cash/z/ecc/app/ScreenshotTest.kt @@ -0,0 +1,324 @@ +package cash.z.ecc.app + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.runner.screenshot.Screenshot +import cash.z.ecc.app.test.EccScreenCaptureProcessor +import cash.z.ecc.sdk.fixture.SeedPhraseFixture +import cash.z.ecc.ui.MainActivity +import cash.z.ecc.ui.R +import cash.z.ecc.ui.screen.backup.BackupTag +import cash.z.ecc.ui.screen.home.viewmodel.SecretState +import cash.z.ecc.ui.screen.restore.RestoreTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +class ScreenshotTest { + + companion object { + @BeforeClass + @JvmStatic + fun setupPPlus() { + if (Build.VERSION_CODES.P <= Build.VERSION.SDK_INT) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + if (PackageManager.PERMISSION_DENIED == instrumentation.context.checkCallingOrSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + instrumentation.uiAutomation.grantRuntimePermission(instrumentation.context.packageName, Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + } + + fun takeScreenshot(screenshotName: String) { + val screenshot = Screenshot.capture().apply { + name = screenshotName + } + screenshot.process(setOf(EccScreenCaptureProcessor.new())) + } + } + + private val composeTestRule = createAndroidComposeRule(MainActivity::class.java) + + @get:Rule + val ruleChain = if (Build.VERSION_CODES.P <= Build.VERSION.SDK_INT) { + composeTestRule + } else { + val runtimePermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE) + RuleChain.outerRule(runtimePermissionRule).around(composeTestRule) + } + + private fun navigateTo(route: String) = runBlocking { + withContext(Dispatchers.Main) { + composeTestRule.activity.navControllerForTesting.navigate(route) + } + } + + @Test + @SmallTest + fun take_screenshots_for_restore_wallet() { + composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_1_header)).also { + it.assertExists() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_skip)).also { + it.assertExists() + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_import_existing_wallet)).also { + it.assertExists() + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.restore_header)).also { + it.assertExists() + } + + takeScreenshot("Import 1") + + val seedPhraseSplitLength = SeedPhraseFixture.new().split.size + SeedPhraseFixture.new().split.forEachIndexed { index, string -> + composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { + it.performTextInput(string) + + // Take a screenshot half-way through filling in the seed phrase + if (index == seedPhraseSplitLength / 2) { + takeScreenshot("Import 2") + } + } + } + + composeTestRule.onNodeWithText(getStringResource(R.string.restore_complete_header)).also { + it.assertExists() + } + + takeScreenshot("Import 3") + } + + @Test + @SmallTest + fun take_screenshots_for_new_wallet_and_rest_of_app() { + onboardingScreenshots(composeTestRule) + backupScreenshots(composeTestRule) + homeScreenshots(composeTestRule) + + // Profile screen + // navigateTo(MainActivity.NAV_PROFILE) + composeTestRule.onNode(hasContentDescription(getStringResource(R.string.home_profile_content_description))).also { + it.assertExists() + it.performClick() + } + profileScreenshots(composeTestRule) + + // Settings is a subscreen of profile + composeTestRule.onNode(hasText(getStringResource(R.string.profile_settings))).also { + it.assertExists() + it.performClick() + } + settingsScreenshots(composeTestRule) + + // Back to profile + composeTestRule.onNode(hasContentDescription(getStringResource(R.string.settings_back_content_description))).also { + it.assertExists() + it.performClick() + } + + // Address Details is a subscreen of profile + composeTestRule.onNode(hasText(getStringResource(R.string.profile_see_address_details))).also { + it.assertExists() + it.performClick() + } + addressDetailsScreenshots(composeTestRule) + } +} + +private fun onboardingScreenshots(composeTestRule: AndroidComposeTestRule, MainActivity>) { + composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_1_header)).also { + it.assertExists() + } + ScreenshotTest.takeScreenshot("Onboarding 1") + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_2_header)).also { + it.assertExists() + ScreenshotTest.takeScreenshot("Onboarding 2") + } + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_3_header)).also { + it.assertExists() + ScreenshotTest.takeScreenshot("Onboarding 3") + } + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_next)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_header)).also { + it.assertExists() + ScreenshotTest.takeScreenshot("Onboarding 4") + } + + composeTestRule.onNodeWithText(getStringResource(R.string.onboarding_4_create_new_wallet)).also { + it.performClick() + } +} + +private fun backupScreenshots(composeTestRule: AndroidComposeTestRule, MainActivity>) { + composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.NeedsBackup } + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_1_header)).also { + it.assertExists() + } + ScreenshotTest.takeScreenshot("Backup 1") + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_1_button)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_2_header)).also { + it.assertExists() + } + ScreenshotTest.takeScreenshot("Backup 2") + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_2_button)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_header)).also { + it.assertExists() + } + ScreenshotTest.takeScreenshot("Backup 3") + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_finished)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_4_header_verify)).also { + it.assertExists() + } + ScreenshotTest.takeScreenshot("Backup 4") + + // Fail test first + composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also { + it[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick() + + it[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick() + + it[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick() + + it[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick() + } + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_4_header_ouch)).also { + it.assertExists() + ScreenshotTest.takeScreenshot("Backup Fail") + } + + composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_4_button_retry))).performClick() + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_header)).also { + it.assertExists() + } + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_3_button_finished)).also { + it.performClick() + } + + composeTestRule.onNodeWithText(getStringResource(R.string.new_wallet_4_header_verify)).also { + it.assertExists() + } + + composeTestRule.onAllNodesWithTag(BackupTag.DROPDOWN_CHIP).also { + it.assertCountEquals(4) + + it[0].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[1].performClick() + + it[1].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[0].performClick() + + it[2].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[3].performClick() + + it[3].performClick() + composeTestRule.onNode(hasTestTag(BackupTag.DROPDOWN_MENU)).onChildren()[2].performClick() + } + + composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_5_body))).also { + it.assertExists() + ScreenshotTest.takeScreenshot("Backup 5") + } + + composeTestRule.onNode(hasText(getStringResource(R.string.new_wallet_5_button_finished))).also { + it.assertExists() + it.performClick() + } +} + +private fun homeScreenshots(composeTestRule: AndroidComposeTestRule, MainActivity>) { + composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready } + composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.walletSnapshot.value != null } + + composeTestRule.onNode(hasText(getStringResource(R.string.home_button_send))).also { + it.assertExists() + ScreenshotTest.takeScreenshot("Home 1") + } +} + +private fun profileScreenshots(composeTestRule: AndroidComposeTestRule, MainActivity>) { + composeTestRule.waitUntil { composeTestRule.activity.walletViewModel.addresses.value != null } + + composeTestRule.onNode(hasText(getStringResource(R.string.profile_title))).also { + it.assertExists() + } + + ScreenshotTest.takeScreenshot("Profile 1") +} + +private fun settingsScreenshots(composeTestRule: ComposeTestRule) { + composeTestRule.onNode(hasText(getStringResource(R.string.settings_header))).also { + it.assertExists() + } + + ScreenshotTest.takeScreenshot("Settings 1") +} + +private fun addressDetailsScreenshots(composeTestRule: ComposeTestRule) { + composeTestRule.onNode(hasText(getStringResource(R.string.wallet_address_title))).also { + it.assertExists() + } + + ScreenshotTest.takeScreenshot("Addresses 1") +} diff --git a/app/src/androidTest/java/cash/z/ecc/app/test/EccScreenCaptureProcessor.kt b/app/src/androidTest/java/cash/z/ecc/app/test/EccScreenCaptureProcessor.kt new file mode 100644 index 00000000..6ce1827a --- /dev/null +++ b/app/src/androidTest/java/cash/z/ecc/app/test/EccScreenCaptureProcessor.kt @@ -0,0 +1,55 @@ +package cash.z.ecc.app.test + +import android.os.Build +import android.os.Environment +import androidx.test.runner.screenshot.ScreenCapture +import androidx.test.runner.screenshot.ScreenCaptureProcessor +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.UUID + +class EccScreenCaptureProcessor private constructor(private val screenshotDir: File) : ScreenCaptureProcessor { + + @Throws(IOException::class) + override fun process(capture: ScreenCapture): String { + screenshotDir.checkDirectoryIsWriteable() + + val filename = newFilename( + name = capture.name ?: "", + suffix = capture.format.toString().lowercase() + ) + + BufferedOutputStream(FileOutputStream(File(screenshotDir, filename))).use { + capture.bitmap.compress(capture.format, DEFAULT_QUALITY, it) + it.flush() + } + + return filename + } + + companion object { + const val DEFAULT_QUALITY = 100 + + fun new(): EccScreenCaptureProcessor { + + // Screenshots need to be stored in a public directory so they won't get cleared by Test Orchestrator + // This path must be coordinated with the build.gradle.kts script which copies these off the device + @Suppress("DEPRECATION") + val screenshotsDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "zcash_screenshots").also { + it.mkdirs() + } + + return EccScreenCaptureProcessor(screenshotsDirectory) + } + + private fun newFilename(name: String, suffix: String) = "screenshot-$name-${Build.VERSION.SDK_INT}-${Build.DEVICE}-${UUID.randomUUID()}.$suffix" + } +} + +private fun File.checkDirectoryIsWriteable() { + if (!isDirectory && !canWrite()) { + throw IOException("The directory $this does not exist or is not writable.") + } +} diff --git a/app/src/androidTest/java/cash/z/ecc/app/test/GetStringResource.kt b/app/src/androidTest/java/cash/z/ecc/app/test/GetStringResource.kt new file mode 100644 index 00000000..2c23e7a5 --- /dev/null +++ b/app/src/androidTest/java/cash/z/ecc/app/test/GetStringResource.kt @@ -0,0 +1,7 @@ +package cash.z.ecc.app + +import android.content.Context +import androidx.annotation.StringRes +import androidx.test.core.app.ApplicationProvider + +fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext().getString(resId) diff --git a/docs/Setup.md b/docs/Setup.md index 2ca2e253..70e21f47 100644 --- a/docs/Setup.md +++ b/docs/Setup.md @@ -54,6 +54,10 @@ A variety of Gradle tasks are set up within the project, and these tasks are als * `lint` - Performs static analysis with Android lint * `dependencyUpdates` - Checks for available dependency updates +A few notes on running instrumentation tests on the app module: + - Screenshots are generated automatically and copied to (/app/build/reports/androidTests/connected/zcash_screenshots)[../app/build/reports/androidTests/connected/zcash_screenshots] + - Running the Android tests on the app module will erase the data stored by the app. This is because Test Orchestrator is required to reset app state to successfully perform integration tests. + ## Gradle Properties A variety of Gradle properties can be used to configure the build. diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d4316eb..ff1cad71 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -62,6 +62,7 @@ dependencyResolutionManagement { includeGroup("android.arch.lifecycle") includeGroup("android.arch.core") includeGroup("com.google.android.material") + includeGroup("com.google.testing.platform") includeGroupByRegex("androidx.*") includeGroupByRegex("com\\.android.*") } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt b/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt index 6b94f01a..a279724b 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.core.content.res.ResourcesCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -44,6 +45,7 @@ import cash.z.ecc.ui.screen.wallet_address.view.WalletAddresses import cash.z.ecc.ui.theme.ZcashTheme import cash.z.ecc.ui.util.AndroidApiVersion import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration @@ -53,7 +55,11 @@ import kotlin.time.Duration.Companion.seconds @Suppress("TooManyFunctions") class MainActivity : ComponentActivity() { - private val walletViewModel by viewModels() + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val walletViewModel by viewModels() + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + lateinit var navControllerForTesting: NavHostController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -98,9 +104,7 @@ class MainActivity : ComponentActivity() { .fillMaxWidth() .fillMaxHeight() ) { - val secretState = walletViewModel.secretState.collectAsState().value - - when (secretState) { + when (val secretState = walletViewModel.secretState.collectAsState().value) { SecretState.Loading -> { // For now, keep displaying splash screen using condition above. // In the future, we might consider displaying something different here. @@ -111,13 +115,16 @@ class MainActivity : ComponentActivity() { is SecretState.NeedsBackup -> WrapBackup(secretState.persistableWallet) is SecretState.Ready -> Navigation() } - - if (secretState != SecretState.Loading) { - reportFullyDrawn() - } } } } + + // Force collection to improve performance; sync can start happening while + // the user is going through the backup flow. Don't use eager collection in the view model, + // so that the collection is still tied to UI lifecycle. + lifecycleScope.launch { + walletViewModel.synchronizer.collect() + } } @Composable @@ -132,6 +139,8 @@ class MainActivity : ComponentActivity() { walletViewModel.persistNewWallet() } ) + + reportFullyDrawn() } else { WrapRestore() } @@ -196,51 +205,47 @@ class MainActivity : ComponentActivity() { @Composable private fun Navigation() { - val navController = rememberNavController() + val navController = rememberNavController().also { + navControllerForTesting = it + } - val home = "home" - val profile = "profile" - val walletAddressDetails = "wallet_address_details" - val settings = "settings" - val seed = "seed" - - NavHost(navController = navController, startDestination = home) { - composable(home) { + NavHost(navController = navController, startDestination = NAV_HOME) { + composable(NAV_HOME) { WrapHome( goScan = {}, - goProfile = { navController.navigate(profile) }, + goProfile = { navController.navigate(NAV_PROFILE) }, goSend = {}, goRequest = {} ) } - composable(profile) { + composable(NAV_PROFILE) { WrapProfile( onBack = { navController.popBackStack() }, - onAddressDetails = { navController.navigate(walletAddressDetails) }, + onAddressDetails = { navController.navigate(NAV_WALLET_ADDRESS_DETAILS) }, onAddressBook = { }, - onSettings = { navController.navigate(settings) }, + onSettings = { navController.navigate(NAV_SETTINGS) }, onCoinholderVote = { }, onSupport = {} ) } - composable(walletAddressDetails) { + composable(NAV_WALLET_ADDRESS_DETAILS) { WrapWalletAddresses( goBack = { navController.popBackStack() } ) } - composable(settings) { + composable(NAV_SETTINGS) { WrapSettings( goBack = { navController.popBackStack() }, goWalletBackup = { - navController.navigate(seed) + navController.navigate(NAV_SEED) } ) } - composable(seed) { + composable(NAV_SEED) { WrapSeed( goBack = { navController.popBackStack() @@ -269,6 +274,8 @@ class MainActivity : ComponentActivity() { goSend = goSend, goProfile = goProfile ) + + reportFullyDrawn() } } @@ -332,7 +339,7 @@ class MainActivity : ComponentActivity() { // If wipe ever becomes an operation to also delete the seed, then we'll also need // to do the following to clear any retained state from onboarding (only happens if - // occuring during same session as onboarding) + // occurring during same session as onboarding) // onboardingViewModel.onboardingState.goToBeginning() // onboardingViewModel.isImporting.value = false } @@ -369,6 +376,21 @@ class MainActivity : ComponentActivity() { companion object { @VisibleForTesting internal val SPLASH_SCREEN_DELAY = 0.seconds + + @VisibleForTesting + const val NAV_HOME = "home" + + @VisibleForTesting + const val NAV_PROFILE = "profile" + + @VisibleForTesting + const val NAV_WALLET_ADDRESS_DETAILS = "wallet_address_details" + + @VisibleForTesting + const val NAV_SETTINGS = "settings" + + @VisibleForTesting + const val NAV_SEED = "seed" } } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt index 2ef5fb93..b22d6311 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/view/HomeView.kt @@ -124,7 +124,7 @@ private fun HomeMainContent( } @Composable -private fun Status(@Suppress("UNUSED_PARAMETER") walletSnapshot: WalletSnapshot) { +private fun Status(walletSnapshot: WalletSnapshot) { Column(Modifier.fillMaxWidth()) { Header(text = walletSnapshot.totalBalance().toString()) Body( diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt index 394cd6ba..8d302198 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/home/viewmodel/WalletViewModel.kt @@ -87,13 +87,17 @@ class WalletViewModel(application: Application) : AndroidViewModel(application) // This will likely move to an application global, so that it can be referenced by WorkManager // for background synchronization + /** + * Synchronizer for the Zcash SDK. Note that the synchronizer loads as soon as a secret is stored, + * even if the backup of the secret has not occurred yet. + */ @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - val synchronizer: StateFlow = secretState - .filterIsInstance() + val synchronizer: StateFlow = persistableWallet + .filterNotNull() .flatMapConcat { callbackFlow { val synchronizer = synchronizerMutex.withLock { - val synchronizer = SynchronizerCompanion.load(application, it.persistableWallet) + val synchronizer = SynchronizerCompanion.load(application, it) synchronizer.start(viewModelScope) } diff --git a/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingView.kt b/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingView.kt index 9411ef03..57be0278 100644 --- a/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingView.kt +++ b/ui-lib/src/main/java/cash/z/ecc/ui/screen/onboarding/view/OnboardingView.kt @@ -212,6 +212,8 @@ private fun More() { @Composable private fun Wallet(onCreateWallet: () -> Unit, onImportWallet: () -> Unit) { Column { + Header(stringResource(R.string.onboarding_4_header)) + Body(stringResource(R.string.onboarding_4_body)) PrimaryButton(onCreateWallet, stringResource(R.string.onboarding_4_create_new_wallet), Modifier.fillMaxWidth()) TertiaryButton( onImportWallet, stringResource(R.string.onboarding_4_import_existing_wallet),