[#313] scan qr screen functional

* [#312] [#309] Scaffold Scan QR Screen
- Screen scaffolding.
- Model classes.
- Screen states handling.
- Needed dependencies added.
- Camera permission handling. Redirect to Settings.
- Added SettingsUtilTest class.
- Added view classes tests.
- Renamed tag class in update package.
- Fix the scan frame size while changing the screen orientation.
- Use local variable for cameraProvider.
- Use UUID for source of randomness.
- Eliminate blocking call for camera.
- Fix preview name.
- Remove Google Guava dependency.
- Suppress Lint warning.
- Improved calculation of the camera frame size. Moved it into Constraint layout.
- Added custom image analyser class.
- Implemented logic for the QR scan screen while QR code is found.
- Manual tests added.
- New module with integration tests for QR Scan screen. Added 3 integration and 4 view tests.
- Simplify QR Scan screen view basic tests.
- Switched from pure compose permission handling to Accompanist way of handling CAMERA permission.
- Added validation of Zcash wallet address from QR scanning result.
- Fix the integration tests for the CI WTF emulator runs.
- Add comment on RTL test result.
- Improve waitForDeviceIdle() method. Use it on the other test too.
- Change the integration test module main manifest package name.
- Debounce scans.
- Improve thread safety of scan collection.
- Added instructions on how to set up an emulator in manual tests.
- Replace compose collectAsState() with coroutine launch().
- Add sample() to get rid of several callback events at the same time.
- Stop updating the scanState when it's already in Scanning state.
- Fix condition on navigation.
- Remove validateJob check.
- Speed up the integration test
- Wrap ImageAnalysis.qrCodeFlow to remember.
- Auto-close the camera image when we're done with it in all cases.
- Update minimal SDK version to 24 for WTF emulators.
- Update Architecture documentation.
- Removed extra blank space in ui-design module definition.
- Add ui-integration-test-lib.
- Update Mermaid diagram with newly added module.
- Move UI modules into one wrap in the diagram.
- Move sdk-ext-lib and sdk-ext-ui under the same modules section.
- Update camera dependencies.
Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
Honza Rychnovsky 2022-06-02 13:35:51 +02:00 committed by GitHub
parent 8b6d0bd1b4
commit adc774a20d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 823 additions and 145 deletions

View File

@ -2,7 +2,7 @@
// These are determined by `ew-cli --models`
@Suppress("MagicNumber", "PropertyName", "VariableNaming")
val EMULATOR_WTF_MIN_SDK = 23
val EMULATOR_WTF_MIN_SDK = 24
@Suppress("MagicNumber", "PropertyName", "VariableNaming")
val EMULATOR_WTF_MAX_SDK = 31

View File

@ -30,13 +30,15 @@ The logical components of the app are implemented as a number of Gradle modules.
* `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`
* ui
* `ui-design` — Contains UI theme elements only. Besides offering modularization, this allows for hiding of some Material Design components behind our own custom components.
* `ui-design` — Contains UI theme elements only. Besides offering modularization, this allows for hiding of some Material Design components behind our own custom components.
* `ui-lib` — User interface that the user interacts with. This contains 99% of the UI code, along with localizations, icons, and other assets.
* `ui-integration-test-lib` — Is dedicated for integration tests only. It has Android Test Orchestrator turned on — it allows us to run each of our tests within its own invocation of Instrumentation, and thus brings us benefits for the testing environment (minimal shared state, crashes are isolated, permissions are reset).
* preference
* `preference-api-lib` — Multiplatform interfaces for key-value storage of preferences.
* `preference-impl-android-lib` — Android-specific implementation for preference storage.
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
* `sdk-ext-ui` — Place for Zcash SDK components (same as `sdk-ext-lib`), which are related to the UI (e.g. depend on user locale and thus need to be translated via `strings.xml`).
* sdk
* `sdk-ext-lib` — Contains extensions on top of the to the Zcash SDK. Some of these extensions might be migrated into the SDK eventually, while others might represent Android-centric idioms. Depending on how this module evolves, it could adopt another name such as `wallet-lib` or be split into two.
* `sdk-ext-ui` — Place for Zcash SDK components (same as `sdk-ext-lib`), which are related to the UI (e.g. depend on user locale and thus need to be translated via `strings.xml`).
* `spackle` — Random utilities, to fill in the cracks in the frameworks.
* `spackle-lib` — Multiplatform implementation for Kotlin and JVM
* `spackle-android-lib` — Android-specific additions.
@ -63,18 +65,24 @@ The following diagram shows a rough depiction of dependencies between the module
crashAndroidLib[[crash-android-lib]];
end
crashLib[[crash-lib]] --> crashAndroidLib[[crash-android-lib]];
subgraph ui
uiDesignLib[[ui-design-lib]];
uiLib[[ui-lib]];
uiIntegrationTestLib[[ui-integration-test-lib]];
end
uiDesignLib[[ui-design-lib]] --> uiLib[[ui-lib]];
uiLib[[ui-lib]] --> uiIntegrationTestLib[[ui-integration-test-lib]];
uiDesignLib[[ui-design-lib]] -- > uiIntegrationTestLib[[ui-integration-test-lib]];
subgraph spackle
spackleLib[[spackle-lib]];
spackleAndroidLib[[spackle-android-lib]];
end
spackleLib[[spackle-lib]] --> spackleAndroidLib[[spackle-android-lib]];
preference --> uiLib[[ui-lib]];
sdk --> uiLib[[ui-lib]];
spackle[[spackle]] --> uiDesignLib[[ui-design-lib]];
spackle[[spackle]] --> uiLib[[ui-lib]];
uiDesignLib[[ui-design-lib]] --> uiLib[[ui-lib]];
preference --> ui[[ui]];
sdk --> ui[[ui]];
spackle[[spackle]] --> ui[[ui]];
ui[[ui]] --> app{app};
crash[[crash]] --> app{app};
uiLib[[ui-lib]] --> app{app};
```
# Test Fixtures

View File

@ -0,0 +1,48 @@
Note: To be able to fully test the QR Scan screen and its logic, we recommend you to test it on real Android devices, which have hardware camera accessories. Although there is a way on how to test the scanning functionalities on an Android emulator too. See the next section on how to set up a QR code for the emulated camera scene.
# Test Prerequisites
- Check you have a wallet configured in the Zcash wallet app
- Open Zcash wallet app in the system Settings app. Visit the permissions screen, then select Camera and make sure that you have the Camera permission denied.
- Prepare at least two Zcash wallet addresses - one valid and one invalid. A valid address for Testnet can be `tmEjY6KfCryQhJ1hKSGiA7p8EeVggpvN78r`. The invalid one can be its modification.
# Android emulator setup (optional)
This section is optional and is required only if you'd like to test on an Android emulator device.
1. Follow these [steps](https://developer.android.com/studio/install) to download and install Android studio
2. And these [steps](https://developer.android.com/studio/run/managing-avds#createavd) help you set up an Android emulator
3. Then you'll need to create a valid Zcash address QR code image. It can be done, for example, with the [QR Code Generator](https://www.qr-code-generator.com/) tool.
4. Download the image
5. Start the emulator
6. Click on More in the emulator panel to open the Extended controls window
7. Click on Camera
8. Click Add Image in the Wall section
9. Select your QR code image and close the window
10. Once you're in the QR Scan screen with the virtual camera opened, see these [instructions](https://developers.google.com/ar/develop/java/emulator#control_the_virtual_scene) on how to move in virtual scene.
11. The last step is to let the scanner read your QR code to be able to move to the smaller room (behind the dog), which will have the code displayed on the room wall.
# Check Camera permission allow functionality
1. Open QR Scan screen by QR code icon from the Home screen
2. Camera permission dialog should be prompt
3. Grant camera permission with Allow button (or its modifications)
4. Camera view and its square frame should appear
# Check Camera permission deny functionality
1. Open QR Scan screen by QR code icon from the Home screen
2. Camera permission dialog should be prompt
3. Deny camera permission with Deny button (or its modifications)
4. Camera view and its square frame shouldn't be visible. The screen should be black. Also, Open system Settings app button on the bottom side of the screen should be visible now.
5. Hit the button.
6. System Settings app should open on the Zcash wallet app screen.
# Scan valid QR code
1. Grant the Camera permission with one of the previous procedures
2. Create QR code from the valid Zcash wallet address. You can use, for example, the [QR Code Generator](https://www.qr-code-generator.com/) tool.
3. Scan the created QR code
4. The code should be scanned and validated
5. App should then close the QR Scan screen and navigate to another screen to proceed with the scanned result
# Scan invalid QR code
1. Grant the Camera permission with one of the previous procedures
2. Create QR code from the valid Zcash wallet address. You can use, for example, the [QR Code Generator](https://www.qr-code-generator.com/) tool.
3. Scan the created QR code
4. The code should be scanned but not validated
5. The app UI should not be changed and the Camera view should be still available for scanning another codes

View File

@ -81,10 +81,11 @@ JGIT_VERSION=6.1.0.202203080745-r
KTLINT_VERSION=0.45.2
PLAY_PUBLISHER_PLUGIN_VERSION=3.7.0
ACCOMPANIST_PERMISSIONS_VERSION=0.23.1
ANDROIDX_ACTIVITY_VERSION=1.4.0
ANDROIDX_ANNOTATION_VERSION=1.3.0
ANDROIDX_APPCOMPAT_VERSION=1.4.1
ANDROIDX_CAMERA_VERSION=1.1.0-beta03
ANDROIDX_CAMERA_VERSION=1.1.0-rc01
ANDROIDX_COMPOSE_COMPILER_VERSION=1.2.0-beta02
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.0.0-alpha09
ANDROIDX_COMPOSE_VERSION=1.1.1

View File

@ -114,6 +114,7 @@ dependencyResolutionManagement {
@Suppress("UnstableApiUsage", "MaxLineLength")
versionCatalogs {
create("libs") {
val accompanistPermissionsVersion = extra["ACCOMPANIST_PERMISSIONS_VERSION"].toString()
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
@ -153,6 +154,7 @@ dependencyResolutionManagement {
version("java", javaVersion)
// Aliases
library("accompanist-permissions", "com.google.accompanist:accompanist-permissions:$accompanistPermissionsVersion")
library("androidx-activity", "androidx.activity:activity-ktx:$androidxActivityVersion")
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
@ -270,6 +272,7 @@ include("spackle-lib")
include("spackle-android-lib")
include("test-lib")
include("ui-design-lib")
include("ui-integration-test-lib")
include("ui-lib")
if (extra["IS_SDK_INCLUDED_BUILD"].toString().toBoolean()) {

View File

@ -0,0 +1,55 @@
plugins {
id("com.android.library")
kotlin("android")
id("zcash.android-build-conventions")
id("wtf.emulator.gradle")
id("zcash.emulator-wtf-conventions")
}
// Force orchestrator to be used for this module, because we need cleared state to generate screenshots
val isOrchestratorEnabled = true
android {
defaultConfig {
if (isOrchestratorEnabled) {
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
testInstrumentationRunner = "co.electriccoin.zcash.test.ZcashUiTestRunner"
}
if (isOrchestratorEnabled) {
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.androidx.compose.compiler.get().versionConstraint.displayName
}
}
dependencies {
androidTestImplementation(projects.uiLib)
androidTestImplementation(projects.uiDesignLib)
androidTestImplementation(projects.testLib)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.bundles.androidx.compose.core)
androidTestImplementation(libs.androidx.compose.test.junit)
androidTestImplementation(libs.androidx.navigation.compose)
androidTestImplementation(libs.androidx.uiAutomator)
if (isOrchestratorEnabled) {
androidTestUtil(libs.androidx.test.orchestrator) {
artifact {
type = "apk"
}
}
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.ui.integration.test">
<application
android:label="zcash-ui-integration-test">
<activity
android:name="co.electriccoin.zcash.ui.integration.test.screen.scan.TestScanActivity"
android:exported="false" />
</application>
</manifest>

View File

@ -0,0 +1,59 @@
package co.electriccoin.zcash.ui.integration.test
import android.content.Context
import android.os.Build
import androidx.annotation.StringRes
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiSelector
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
fun getStringResource(@StringRes resId: Int) = ApplicationProvider.getApplicationContext<Context>().getString(resId)
fun getStringResourceWithArgs(@StringRes resId: Int, vararg formatArgs: String) = ApplicationProvider.getApplicationContext<Context>().getString(resId, *formatArgs)
// We're using indexes to find the right button, as it seems to be the best available way to test a click
// action on a permission button. These indexes remain the same for LTR as well as RTL layout direction.
fun getPermissionNegativeButtonUiObject(): UiObject? {
val instrumentation = InstrumentationRegistry.getInstrumentation()
return UiDevice.getInstance(instrumentation).findObject(
UiSelector()
.className("android.widget.Button") // $NON-NLS
.index(
// tested up to version 33
when {
Build.VERSION.SDK_INT <= 28 -> 0
Build.VERSION.SDK_INT == 29 -> 1
Build.VERSION.SDK_INT >= 30 -> 2
else -> 2
}
)
)
}
fun getPermissionPositiveButtonUiObject(): UiObject? {
val instrumentation = InstrumentationRegistry.getInstrumentation()
return UiDevice.getInstance(instrumentation).findObject(
UiSelector()
.className("android.widget.Button") // $NON-NLS
.index(
// tested up to version 33
when {
Build.VERSION.SDK_INT <= 28 -> 1
Build.VERSION.SDK_INT >= 29 -> 0
else -> 0
}
)
)
}
fun waitForDeviceIdle(timeoutMillis: Duration = 1000.milliseconds) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
UiDevice.getInstance(instrumentation).waitForWindowUpdate(
ApplicationProvider.getApplicationContext<Context>().packageName,
timeoutMillis.inWholeMilliseconds
)
}

View File

@ -1,4 +1,4 @@
package co.electriccoin.zcash.ui.screen.scan
package co.electriccoin.zcash.ui.integration.test.screen.scan
import android.os.Bundle
import androidx.activity.ComponentActivity
@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.scan.WrapScan
class TestScanActivity : ComponentActivity() {
@ -26,7 +27,8 @@ class TestScanActivity : ComponentActivity() {
) {
WrapScan(
this,
goBack = {}
goBack = {},
onScanned = {},
)
}
}

View File

@ -0,0 +1,101 @@
package co.electriccoin.zcash.ui.integration.test.screen.scan.view
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.filters.LargeTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.integration.test.getPermissionPositiveButtonUiObject
import co.electriccoin.zcash.ui.integration.test.screen.scan.TestScanActivity
import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class ScanViewIntegrationTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestScanActivity>()
private lateinit var testSetup: ScanViewTestSetup
@Before
fun prepare_test_setup() {
testSetup = ScanViewTestSetup(composeTestRule)
}
@Test
@LargeTest
fun scan_state_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
restorationTester.setContent {
testSetup.getDefaultContent()
}
assertEquals(testSetup.getScanState(), ScanState.Permission)
testSetup.grantPermission()
assertEquals(testSetup.getScanState(), ScanState.Scanning)
restorationTester.emulateSavedInstanceStateRestore()
assertEquals(testSetup.getScanState(), ScanState.Scanning)
}
@Test
@LargeTest
fun scan_permission_dialog_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
restorationTester.setContent {
testSetup.getDefaultContent()
}
val permissionPositiveButtonUiObject = getPermissionPositiveButtonUiObject()
// permission dialog displayed
Assert.assertNotNull(permissionPositiveButtonUiObject)
Assert.assertTrue(permissionPositiveButtonUiObject!!.exists())
restorationTester.emulateSavedInstanceStateRestore()
// permission dialog still exists
Assert.assertNotNull(permissionPositiveButtonUiObject)
Assert.assertTrue(permissionPositiveButtonUiObject.exists())
testSetup.denyPermission()
}
@Test
@LargeTest
fun scan_views_restoration() {
val restorationTester = StateRestorationTester(composeTestRule)
restorationTester.setContent {
testSetup.getDefaultContent()
}
testSetup.grantPermission()
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertIsDisplayed()
}
restorationTester.emulateSavedInstanceStateRestore()
// scan frame is still displayed
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertIsDisplayed()
}
// We don't run this test and its assertion on the camera view, as we'd need to wait for its
// layout as we already do in the ScanViewTest.grant_system_permission(). So we can speed up
// the test by omitting this assertion.
}
}

View File

@ -0,0 +1,148 @@
package co.electriccoin.zcash.ui.integration.test.screen.scan.view
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.LargeTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.integration.test.getPermissionPositiveButtonUiObject
import co.electriccoin.zcash.ui.integration.test.getStringResource
import co.electriccoin.zcash.ui.integration.test.screen.scan.TestScanActivity
import co.electriccoin.zcash.ui.integration.test.waitForDeviceIdle
import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class ScanViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestScanActivity>()
private lateinit var testSetup: ScanViewTestSetup
@Before
fun prepareTestSetup() {
testSetup = ScanViewTestSetup(composeTestRule).apply {
setDefaultContent()
}
}
@Test
@LargeTest
fun is_camera_permission_dialog_shown() {
val permissionPositiveButtonUiObject = getPermissionPositiveButtonUiObject()
// permission dialog displayed
assertNotNull(permissionPositiveButtonUiObject)
assertTrue(permissionPositiveButtonUiObject!!.exists())
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertDoesNotExist()
}
// hide permission dialog
permissionPositiveButtonUiObject.click()
}
@Test
@LargeTest
fun grant_camera_permission() {
assertEquals(ScanState.Permission, testSetup.getScanState())
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertDoesNotExist()
}
testSetup.grantPermission()
composeTestRule.onNodeWithContentDescription(
getStringResource(R.string.scan_back_content_description)
).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
it.assertIsDisplayed()
it.assertTextEquals(getStringResource(R.string.scan_state_scanning))
}
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertIsDisplayed()
}
assertEquals(ScanState.Scanning, testSetup.getScanState())
// we need to actively wait for the camera preview initialization
waitForDeviceIdle(timeoutMillis = 5000.milliseconds)
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertIsDisplayed()
}
}
@Test
@LargeTest
fun deny_camera_permission() {
assertEquals(ScanState.Permission, testSetup.getScanState())
testSetup.denyPermission()
assertEquals(ScanState.Permission, testSetup.getScanState())
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
it.assertIsDisplayed()
it.assertTextEquals(getStringResource(R.string.scan_state_permission))
}
composeTestRule.onNodeWithText(getStringResource(R.string.scan_settings_button)).also {
it.assertIsDisplayed()
it.assertHasClickAction()
}
}
@Test
@LargeTest
fun open_settings_test() {
testSetup.denyPermission()
assertEquals(0, testSetup.getOnOpenSettingsCount())
composeTestRule.onNodeWithText(getStringResource(R.string.scan_settings_button)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnOpenSettingsCount())
}
}

View File

@ -0,0 +1,70 @@
package co.electriccoin.zcash.ui.integration.test.screen.scan.view
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.integration.test.getPermissionNegativeButtonUiObject
import co.electriccoin.zcash.ui.integration.test.getPermissionPositiveButtonUiObject
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import co.electriccoin.zcash.ui.screen.scan.view.Scan
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
class ScanViewTestSetup(
private val composeTestRule: ComposeContentTestRule
) {
private val onOpenSettingsCount = AtomicInteger(0)
private val scanState = AtomicReference(ScanState.Permission)
fun getOnOpenSettingsCount(): Int {
composeTestRule.waitForIdle()
return onOpenSettingsCount.get()
}
fun getScanState(): ScanState {
composeTestRule.waitForIdle()
return scanState.get()
}
fun grantPermission() {
val permissionPositiveActionButton = getPermissionPositiveButtonUiObject()
assertNotNull(permissionPositiveActionButton)
assertTrue(permissionPositiveActionButton!!.exists())
permissionPositiveActionButton.click()
}
fun denyPermission() {
val permissionNegativeActionButton = getPermissionNegativeButtonUiObject()
assertNotNull(permissionNegativeActionButton)
assertTrue(permissionNegativeActionButton!!.exists())
permissionNegativeActionButton.click()
}
@Composable
fun getDefaultContent() {
Scan(
snackbarHostState = SnackbarHostState(),
onBack = {},
onScanned = {},
onOpenSettings = {
onOpenSettingsCount.incrementAndGet()
},
onScanStateChanged = {
scanState.set(it)
}
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
getDefaultContent()
}
}
}
}

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">zcash-ui-integration-test</string>
</resources>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="co.electriccoin.zcash.ui.integration" />

View File

@ -52,6 +52,7 @@ android {
dependencies {
coreLibraryDesugaring(libs.desugaring)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.annotation)

View File

@ -14,9 +14,6 @@
<activity
android:name="co.electriccoin.zcash.ui.screen.update.TestUpdateActivity"
android:exported="false" />
<activity
android:name="co.electriccoin.zcash.ui.screen.scan.TestScanActivity"
android:exported="false" />
</application>
</manifest>

View File

@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.screen.scan.view
import android.Manifest
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@ -13,18 +13,20 @@ import androidx.test.rule.GrantPermissionRule
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.TestScanActivity
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import co.electriccoin.zcash.ui.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
// The tests are built with the presumption that we have camera permission granted before each test.
// Its ensured by GrantPermissionRule component. More complex UI and integration tests can be found
// in the ui-integration-test-lib module.
class ScanViewBasicTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestScanActivity>()
val composeTestRule = createComposeRule()
// To automatically have CAMERA permission granted for all test in the class. Note, there is no
// way to revoke the granted permission after it's granted.
@ -38,7 +40,9 @@ class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.clickBack()
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.scan_back_content_description)).also {
it.performClick()
}
assertEquals(1, testSetup.getOnBackCount())
}
@ -48,29 +52,45 @@ class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
// https://github.com/zcash/secant-android-wallet/issues/447
@Ignore
fun check_all_ui_elements_displayed() {
composeTestRule.waitForIdle()
newTestSetup()
// Permission granted ui items (visible):
composeTestRule.onNodeWithText(getStringResource(R.string.scan_header)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertIsDisplayed()
}
// We don't test camera view, as it's not guaranteed to be laid out already.
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
it.assertIsDisplayed()
it.assertTextEquals(getStringResource(R.string.scan_state_scanning))
}
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also {
it.assertIsDisplayed()
}
// Permission denied ui items (not visible):
composeTestRule.onNodeWithText(getStringResource(R.string.scan_settings_button)).also {
it.assertDoesNotExist()
}
}
private fun newTestSetup() = ScanViewTestSetup(composeTestRule).apply {
@Test
@MediumTest
fun scan_state() {
val testSetup = newTestSetup()
assertEquals(ScanState.Scanning, testSetup.getScanState())
}
private fun newTestSetup() = ScanViewBasicTestSetup(composeTestRule).apply {
setDefaultContent()
}
private fun ComposeContentTestRule.clickBack() {
onNodeWithContentDescription(getStringResource(R.string.scan_back_content_description)).also {
it.performClick()
}
}
}

View File

@ -8,25 +8,12 @@ import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
class ScanViewTestSetup(
class ScanViewBasicTestSetup(
private val composeTestRule: ComposeContentTestRule
) {
private val onScanCount = AtomicInteger(0)
private val onOpenSettingsCount = AtomicInteger(0)
private val onBackCount = AtomicInteger(0)
private val scanState = AtomicReference(ScanState.Permission)
fun getOnScanCount(): Int {
composeTestRule.waitForIdle()
return onScanCount.get()
}
fun getOnOpenSettingsCount(): Int {
composeTestRule.waitForIdle()
return onOpenSettingsCount.get()
}
fun getOnBackCount(): Int {
composeTestRule.waitForIdle()
return onBackCount.get()
@ -44,11 +31,10 @@ class ScanViewTestSetup(
onBack = {
onBackCount.incrementAndGet()
},
onScan = {
onScanCount.incrementAndGet()
},
onOpenSettings = {
onOpenSettingsCount.incrementAndGet()
onScanned = {},
onOpenSettings = {},
onScanStateChanged = {
scanState.set(it)
}
)
}

View File

@ -311,11 +311,44 @@ class MainActivity : ComponentActivity() {
WrapAbout(goBack = { navController.popBackStack() })
}
composable(NAV_SCAN) {
WrapScan(goBack = { navController.popBackStack() })
WrapScanValidator(
onScanValid = {
// TODO [#449] https://github.com/zcash/secant-android-wallet/issues/449
if (navController.currentDestination?.route == NAV_SCAN) {
navController.navigate(NAV_SEND) {
popUpTo(NAV_HOME) { inclusive = false }
}
}
},
goBack = { navController.popBackStack() }
)
}
}
}
@Composable
private fun WrapScanValidator(
onScanValid: (address: String) -> Unit,
goBack: () -> Unit
) {
val synchronizer = walletViewModel.synchronizer.collectAsState().value
if (synchronizer == null) {
// Display loading indicator
} else {
WrapScan(
onScanDone = { result ->
lifecycleScope.launch {
val isAddressValid = !synchronizer.validateAddress(result).isNotValid
if (isAddressValid) {
onScanValid(result)
}
}
},
goBack = goBack
)
}
}
@Composable
private fun WrapHome(
goScan: () -> Unit,

View File

@ -13,14 +13,16 @@ import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapScan(
goBack: () -> Unit
goBack: () -> Unit,
onScanDone: (result: String) -> Unit
) {
WrapScan(this, goBack)
WrapScan(this, onScanDone, goBack)
}
@Composable
fun WrapScan(
activity: ComponentActivity,
onScanned: (result: String) -> Unit,
goBack: () -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() }
@ -29,7 +31,7 @@ fun WrapScan(
Scan(
snackbarHostState,
onBack = goBack,
onScan = {},
onScanned = onScanned,
onOpenSettings = {
runCatching {
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName))
@ -42,6 +44,7 @@ fun WrapScan(
)
}
}
}
},
onScanStateChanged = {}
)
}

View File

@ -3,6 +3,5 @@ package co.electriccoin.zcash.ui.screen.scan.model
enum class ScanState {
Failed,
Permission,
Scanning,
Success
Scanning
}

View File

@ -0,0 +1,67 @@
package co.electriccoin.zcash.ui.screen.scan.util
import android.graphics.ImageFormat
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import java.nio.ByteBuffer
// TODO [#437]: https://github.com/zcash/secant-android-wallet/issues/437
class QrCodeAnalyzer(
private val onQrCodeScanned: (String) -> Unit,
) : ImageAnalysis.Analyzer {
private val supportedImageFormats = listOf(
ImageFormat.YUV_420_888,
ImageFormat.YUV_422_888,
ImageFormat.YUV_444_888,
)
override fun analyze(image: ImageProxy) {
image.use {
if (image.format in supportedImageFormats) {
val bytes = image.planes.first().buffer.toByteArray()
val source = PlanarYUVLuminanceSource(
bytes,
image.width,
image.height,
0,
0,
image.width,
image.height,
false
)
val binaryBmp = BinaryBitmap(HybridBinarizer(source))
runCatching {
val result = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to arrayListOf(
BarcodeFormat.QR_CODE
)
)
)
}.decode(binaryBmp)
onQrCodeScanned(result.text)
}.onFailure {
// failed to found QR code in current frame
}
}
}
}
private fun ByteBuffer.toByteArray(): ByteArray {
rewind()
return ByteArray(remaining()).also {
get(it)
}
}
}

View File

@ -1,12 +1,11 @@
package co.electriccoin.zcash.ui.screen.scan.view
import android.Manifest
import android.content.pm.PackageManager
import android.content.Context
import android.content.res.Configuration
import android.view.ViewGroup
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.BorderStroke
@ -35,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
@ -57,22 +55,25 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.SecondaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.math.roundToInt
// TODO [#423]: https://github.com/zcash/secant-android-wallet/issues/423
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
@Preview("Scan")
@Composable
fun PreviewScan() {
@ -81,30 +82,32 @@ fun PreviewScan() {
Scan(
snackbarHostState = SnackbarHostState(),
onBack = {},
onScan = {},
onOpenSettings = {}
onScanned = {},
onOpenSettings = {},
onScanStateChanged = {}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("UNUSED_VARIABLE")
@Composable
fun Scan(
snackbarHostState: SnackbarHostState,
onBack: () -> Unit,
onScan: (String) -> Unit,
onScanned: (String) -> Unit,
onOpenSettings: () -> Unit,
onScanStateChanged: (ScanState) -> Unit,
) {
Scaffold(
topBar = { ScanTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
ScanMainContent(
onScan,
onScanned,
onOpenSettings,
onBack,
onScanStateChanged,
snackbarHostState
)
}
@ -131,7 +134,6 @@ fun ScanBottomItems(
text = when (scanState) {
ScanState.Permission -> stringResource(id = R.string.scan_state_permission)
ScanState.Scanning -> stringResource(id = R.string.scan_state_scanning)
ScanState.Success -> stringResource(id = R.string.scan_state_success)
ScanState.Failed -> stringResource(id = R.string.scan_state_failed)
},
color = Color.White,
@ -171,22 +173,25 @@ private fun ScanTopAppBar(onBack: () -> Unit) {
)
}
@Suppress("UNUSED_VARIABLE", "UNUSED_PARAMETER", "MagicNumber", "LongMethod")
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("MagicNumber", "LongMethod")
@Composable
private fun ScanMainContent(
onScan: (String) -> Unit,
onScanned: (String) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
onScanStateChanged: (ScanState) -> Unit,
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val permissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
val (scanState, setScanState) = rememberSaveable {
mutableStateOf(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
if (permissionState.hasPermission) {
ScanState.Scanning
} else {
ScanState.Permission
@ -194,27 +199,16 @@ private fun ScanMainContent(
)
}
val (scanResult, setScanResult) = rememberSaveable { mutableStateOf("") }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
setScanState(ScanState.Scanning)
} else {
setScanState(ScanState.Permission)
}
if (!permissionState.hasPermission) {
setScanState(ScanState.Permission)
LaunchedEffect(key1 = UUID.randomUUID()) {
permissionState.launchPermissionRequest()
}
)
// We use a random value to show the permission popup after a user grants the CAMERA permission
// outside of the app (in Settings).
LaunchedEffect(key1 = UUID.randomUUID()) {
launcher.launch(Manifest.permission.CAMERA)
}
val cameraProviderFlow = remember {
flow<ProcessCameraProvider> { emit(ProcessCameraProvider.getInstance(context).await()) }
} else if (scanState == ScanState.Failed) {
// keep current state
} else if (permissionState.hasPermission) {
if (scanState != ScanState.Scanning)
setScanState(ScanState.Scanning)
}
// we calculate the best frame size for the current device screen
@ -227,32 +221,58 @@ private fun ScanMainContent(
(framePossibleSize.value.width * 0.7).roundToInt()
}
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
val (frame, bottomItems) = createRefs()
if (scanState == ScanState.Scanning) {
ScanCameraView(
onBack,
cameraProviderFlow,
lifecycleOwner,
snackbarHostState
)
when (scanState) {
ScanState.Permission -> {
// keep initial ui state
onScanStateChanged(ScanState.Permission)
}
ScanState.Scanning -> {
// TODO [#437]: https://github.com/zcash/secant-android-wallet/issues/437
onScanStateChanged(ScanState.Scanning)
ScanCameraView(
onScanned = onScanned,
setScanState = setScanState,
permissionState = permissionState
)
Box(
modifier = Modifier
.constrainAs(frame) {
top.linkTo(parent.top)
bottom.linkTo(bottomItems.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}.onSizeChanged { coordinates ->
framePossibleSize.value = coordinates
},
contentAlignment = Alignment.Center
) {
ScanFrame(frameActualSize)
Box(
modifier = Modifier
.constrainAs(frame) {
top.linkTo(parent.top)
bottom.linkTo(bottomItems.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
.onSizeChanged { coordinates ->
framePossibleSize.value = coordinates
},
contentAlignment = Alignment.Center
) {
ScanFrame(frameActualSize)
}
}
ScanState.Failed -> {
onScanStateChanged(ScanState.Failed)
LaunchedEffect(key1 = true) {
setScanState(ScanState.Failed)
onScanStateChanged(ScanState.Failed)
val snackbarResult = snackbarHostState.showSnackbar(
message = context.getString(R.string.scan_setup_failed),
actionLabel = context.getString(R.string.scan_setup_back),
)
if (snackbarResult == SnackbarResult.ActionPerformed) {
onBack()
}
}
}
}
@ -274,20 +294,38 @@ fun ScanFrame(frameSize: Int) {
)
}
@OptIn(ExperimentalPermissionsApi::class)
@SuppressWarnings("LongMethod")
@Composable
fun ScanCameraView(
onBack: () -> Unit,
cameraProviderFlow: Flow<ProcessCameraProvider>,
lifecycleOwner: LifecycleOwner,
snackbarHostState: SnackbarHostState
onScanned: (result: String) -> Unit,
setScanState: (ScanState) -> Unit,
permissionState: PermissionState
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProvider = cameraProviderFlow.collectAsState(initial = null).value
if (null == cameraProvider) {
// we check the permission first, as the ProcessCameraProvider's emit won't be called again after
// recomposition with the permission granted
val cameraProviderFlow = if (permissionState.hasPermission) {
remember {
flow<ProcessCameraProvider> { emit(ProcessCameraProvider.getInstance(context).await()) }
}
} else {
null
}
val collectedCameraProvider = cameraProviderFlow?.collectAsState(initial = null)?.value
if (null == collectedCameraProvider) {
// Show loading indicator
} else {
val contentDescription = stringResource(id = R.string.scan_preview_content_description)
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
AndroidView(
factory = { factoryContext ->
val previewView = PreviewView(factoryContext).apply {
@ -297,37 +335,57 @@ fun ScanCameraView(
ViewGroup.LayoutParams.MATCH_PARENT
)
}
val preview = androidx.camera.core.Preview.Builder().build()
previewView.contentDescription = contentDescription
val selector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
preview.setSurfaceProvider(previewView.surfaceProvider)
val preview = androidx.camera.core.Preview.Builder().build().apply {
setSurfaceProvider(previewView.surfaceProvider)
}
runCatching {
// we must unbind the use-cases before rebinding them
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
collectedCameraProvider.unbindAll()
collectedCameraProvider.bindToLifecycle(
lifecycleOwner,
selector,
preview
preview,
imageAnalysis
)
}.onFailure {
scope.launch {
val snackbarResult = snackbarHostState.showSnackbar(
message = context.getString(R.string.scan_setup_failed),
actionLabel = context.getString(R.string.scan_setup_back),
)
if (snackbarResult == SnackbarResult.ActionPerformed) {
onBack()
}
}
setScanState(ScanState.Failed)
}
previewView
},
Modifier
.fillMaxSize()
.testTag(ScanTag.CAMERA_VIEW),
.testTag(ScanTag.CAMERA_VIEW)
)
imageAnalysis.qrCodeFlow(context).collectAsState(initial = null).value?.let {
onScanned(it)
}
}
}
// Using callbackFlow because QrCodeAnalyzer has a non-suspending callback which makes
// a basic flow builder not work here.
@Composable
fun ImageAnalysis.qrCodeFlow(context: Context): Flow<String> = remember {
callbackFlow {
setAnalyzer(
ContextCompat.getMainExecutor(context),
QrCodeAnalyzer { result ->
// Note that these callbacks aren't tied to the Compose lifecycle, so they could occur
// after the view goes away. Collection needs to occur within the Compose lifecycle
// to make this not be a problem.
trySend(result)
}
)
awaitClose {
// Nothing to close
}
}
}

View File

@ -2,6 +2,7 @@
<resources>
<string name="scan_header">Scan a Zcash QR</string>
<string name="scan_back_content_description">Back</string>
<string name="scan_preview_content_description">Camera</string>
<string name="scan_hint">We will validate any Zcash URI and take you to the appropriate action.</string>
@ -13,6 +14,5 @@
<string name="scan_state_permission">Permission for camera is necessary.</string>
<string name="scan_state_scanning">Scanning…</string>
<string name="scan_state_success">Successfully scanned.</string>
<string name="scan_state_failed">Scanning failed.</string>
</resources>