[#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:
parent
8b6d0bd1b4
commit
adc774a20d
|
@ -2,7 +2,7 @@
|
||||||
// These are determined by `ew-cli --models`
|
// These are determined by `ew-cli --models`
|
||||||
|
|
||||||
@Suppress("MagicNumber", "PropertyName", "VariableNaming")
|
@Suppress("MagicNumber", "PropertyName", "VariableNaming")
|
||||||
val EMULATOR_WTF_MIN_SDK = 23
|
val EMULATOR_WTF_MIN_SDK = 24
|
||||||
|
|
||||||
@Suppress("MagicNumber", "PropertyName", "VariableNaming")
|
@Suppress("MagicNumber", "PropertyName", "VariableNaming")
|
||||||
val EMULATOR_WTF_MAX_SDK = 31
|
val EMULATOR_WTF_MAX_SDK = 31
|
||||||
|
|
|
@ -32,9 +32,11 @@ The logical components of the app are implemented as a number of Gradle modules.
|
||||||
* ui
|
* 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-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
|
||||||
* `preference-api-lib` — Multiplatform interfaces for key-value storage of preferences.
|
* `preference-api-lib` — Multiplatform interfaces for key-value storage of preferences.
|
||||||
* `preference-impl-android-lib` — Android-specific implementation for preference storage.
|
* `preference-impl-android-lib` — Android-specific implementation for preference storage.
|
||||||
|
* 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-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-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` — Random utilities, to fill in the cracks in the frameworks.
|
||||||
|
@ -63,18 +65,24 @@ The following diagram shows a rough depiction of dependencies between the module
|
||||||
crashAndroidLib[[crash-android-lib]];
|
crashAndroidLib[[crash-android-lib]];
|
||||||
end
|
end
|
||||||
crashLib[[crash-lib]] --> crashAndroidLib[[crash-android-lib]];
|
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
|
subgraph spackle
|
||||||
spackleLib[[spackle-lib]];
|
spackleLib[[spackle-lib]];
|
||||||
spackleAndroidLib[[spackle-android-lib]];
|
spackleAndroidLib[[spackle-android-lib]];
|
||||||
end
|
end
|
||||||
spackleLib[[spackle-lib]] --> spackleAndroidLib[[spackle-android-lib]];
|
spackleLib[[spackle-lib]] --> spackleAndroidLib[[spackle-android-lib]];
|
||||||
preference --> uiLib[[ui-lib]];
|
preference --> ui[[ui]];
|
||||||
sdk --> uiLib[[ui-lib]];
|
sdk --> ui[[ui]];
|
||||||
spackle[[spackle]] --> uiDesignLib[[ui-design-lib]];
|
spackle[[spackle]] --> ui[[ui]];
|
||||||
spackle[[spackle]] --> uiLib[[ui-lib]];
|
ui[[ui]] --> app{app};
|
||||||
uiDesignLib[[ui-design-lib]] --> uiLib[[ui-lib]];
|
|
||||||
crash[[crash]] --> app{app};
|
crash[[crash]] --> app{app};
|
||||||
uiLib[[ui-lib]] --> app{app};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# Test Fixtures
|
# Test Fixtures
|
||||||
|
|
|
@ -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
|
|
@ -81,10 +81,11 @@ JGIT_VERSION=6.1.0.202203080745-r
|
||||||
KTLINT_VERSION=0.45.2
|
KTLINT_VERSION=0.45.2
|
||||||
PLAY_PUBLISHER_PLUGIN_VERSION=3.7.0
|
PLAY_PUBLISHER_PLUGIN_VERSION=3.7.0
|
||||||
|
|
||||||
|
ACCOMPANIST_PERMISSIONS_VERSION=0.23.1
|
||||||
ANDROIDX_ACTIVITY_VERSION=1.4.0
|
ANDROIDX_ACTIVITY_VERSION=1.4.0
|
||||||
ANDROIDX_ANNOTATION_VERSION=1.3.0
|
ANDROIDX_ANNOTATION_VERSION=1.3.0
|
||||||
ANDROIDX_APPCOMPAT_VERSION=1.4.1
|
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_COMPILER_VERSION=1.2.0-beta02
|
||||||
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.0.0-alpha09
|
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.0.0-alpha09
|
||||||
ANDROIDX_COMPOSE_VERSION=1.1.1
|
ANDROIDX_COMPOSE_VERSION=1.1.1
|
||||||
|
|
|
@ -114,6 +114,7 @@ dependencyResolutionManagement {
|
||||||
@Suppress("UnstableApiUsage", "MaxLineLength")
|
@Suppress("UnstableApiUsage", "MaxLineLength")
|
||||||
versionCatalogs {
|
versionCatalogs {
|
||||||
create("libs") {
|
create("libs") {
|
||||||
|
val accompanistPermissionsVersion = extra["ACCOMPANIST_PERMISSIONS_VERSION"].toString()
|
||||||
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
|
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
|
||||||
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
|
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
|
||||||
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
|
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
|
||||||
|
@ -153,6 +154,7 @@ dependencyResolutionManagement {
|
||||||
version("java", javaVersion)
|
version("java", javaVersion)
|
||||||
|
|
||||||
// Aliases
|
// Aliases
|
||||||
|
library("accompanist-permissions", "com.google.accompanist:accompanist-permissions:$accompanistPermissionsVersion")
|
||||||
library("androidx-activity", "androidx.activity:activity-ktx:$androidxActivityVersion")
|
library("androidx-activity", "androidx.activity:activity-ktx:$androidxActivityVersion")
|
||||||
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
|
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
|
||||||
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
|
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
|
||||||
|
@ -270,6 +272,7 @@ include("spackle-lib")
|
||||||
include("spackle-android-lib")
|
include("spackle-android-lib")
|
||||||
include("test-lib")
|
include("test-lib")
|
||||||
include("ui-design-lib")
|
include("ui-design-lib")
|
||||||
|
include("ui-integration-test-lib")
|
||||||
include("ui-lib")
|
include("ui-lib")
|
||||||
|
|
||||||
if (extra["IS_SDK_INCLUDED_BUILD"].toString().toBoolean()) {
|
if (extra["IS_SDK_INCLUDED_BUILD"].toString().toBoolean()) {
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
|
import co.electriccoin.zcash.ui.screen.scan.WrapScan
|
||||||
|
|
||||||
class TestScanActivity : ComponentActivity() {
|
class TestScanActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@ -26,7 +27,8 @@ class TestScanActivity : ComponentActivity() {
|
||||||
) {
|
) {
|
||||||
WrapScan(
|
WrapScan(
|
||||||
this,
|
this,
|
||||||
goBack = {}
|
goBack = {},
|
||||||
|
onScanned = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">zcash-ui-integration-test</string>
|
||||||
|
</resources>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="co.electriccoin.zcash.ui.integration" />
|
||||||
|
|
|
@ -52,6 +52,7 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring(libs.desugaring)
|
coreLibraryDesugaring(libs.desugaring)
|
||||||
|
|
||||||
|
implementation(libs.accompanist.permissions)
|
||||||
implementation(libs.androidx.activity)
|
implementation(libs.androidx.activity)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.annotation)
|
implementation(libs.androidx.annotation)
|
||||||
|
|
|
@ -14,9 +14,6 @@
|
||||||
<activity
|
<activity
|
||||||
android:name="co.electriccoin.zcash.ui.screen.update.TestUpdateActivity"
|
android:name="co.electriccoin.zcash.ui.screen.update.TestUpdateActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<activity
|
|
||||||
android:name="co.electriccoin.zcash.ui.screen.scan.TestScanActivity"
|
|
||||||
android:exported="false" />
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -2,8 +2,8 @@ package co.electriccoin.zcash.ui.screen.scan.view
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.test.UiTestPrerequisites
|
||||||
import co.electriccoin.zcash.ui.R
|
import co.electriccoin.zcash.ui.R
|
||||||
import co.electriccoin.zcash.ui.screen.scan.ScanTag
|
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 co.electriccoin.zcash.ui.test.getStringResource
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Ignore
|
import org.junit.Ignore
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
|
// The tests are built with the presumption that we have camera permission granted before each test.
|
||||||
class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
|
// 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
|
@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
|
// 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.
|
// way to revoke the granted permission after it's granted.
|
||||||
|
@ -38,7 +40,9 @@ class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
|
||||||
|
|
||||||
assertEquals(0, testSetup.getOnBackCount())
|
assertEquals(0, testSetup.getOnBackCount())
|
||||||
|
|
||||||
composeTestRule.clickBack()
|
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.scan_back_content_description)).also {
|
||||||
|
it.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
assertEquals(1, testSetup.getOnBackCount())
|
assertEquals(1, testSetup.getOnBackCount())
|
||||||
}
|
}
|
||||||
|
@ -48,29 +52,45 @@ class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
|
||||||
// https://github.com/zcash/secant-android-wallet/issues/447
|
// https://github.com/zcash/secant-android-wallet/issues/447
|
||||||
@Ignore
|
@Ignore
|
||||||
fun check_all_ui_elements_displayed() {
|
fun check_all_ui_elements_displayed() {
|
||||||
composeTestRule.waitForIdle()
|
newTestSetup()
|
||||||
|
|
||||||
|
// Permission granted ui items (visible):
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(getStringResource(R.string.scan_header)).also {
|
composeTestRule.onNodeWithText(getStringResource(R.string.scan_header)).also {
|
||||||
it.assertIsDisplayed()
|
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 {
|
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
|
||||||
it.assertIsDisplayed()
|
it.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
|
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
|
||||||
it.assertIsDisplayed()
|
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()
|
setDefaultContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ComposeContentTestRule.clickBack() {
|
|
||||||
onNodeWithContentDescription(getStringResource(R.string.scan_back_content_description)).also {
|
|
||||||
it.performClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -8,25 +8,12 @@ import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
|
class ScanViewBasicTestSetup(
|
||||||
class ScanViewTestSetup(
|
|
||||||
private val composeTestRule: ComposeContentTestRule
|
private val composeTestRule: ComposeContentTestRule
|
||||||
) {
|
) {
|
||||||
private val onScanCount = AtomicInteger(0)
|
|
||||||
private val onOpenSettingsCount = AtomicInteger(0)
|
|
||||||
private val onBackCount = AtomicInteger(0)
|
private val onBackCount = AtomicInteger(0)
|
||||||
private val scanState = AtomicReference(ScanState.Permission)
|
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 {
|
fun getOnBackCount(): Int {
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
return onBackCount.get()
|
return onBackCount.get()
|
||||||
|
@ -44,11 +31,10 @@ class ScanViewTestSetup(
|
||||||
onBack = {
|
onBack = {
|
||||||
onBackCount.incrementAndGet()
|
onBackCount.incrementAndGet()
|
||||||
},
|
},
|
||||||
onScan = {
|
onScanned = {},
|
||||||
onScanCount.incrementAndGet()
|
onOpenSettings = {},
|
||||||
},
|
onScanStateChanged = {
|
||||||
onOpenSettings = {
|
scanState.set(it)
|
||||||
onOpenSettingsCount.incrementAndGet()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -311,9 +311,42 @@ class MainActivity : ComponentActivity() {
|
||||||
WrapAbout(goBack = { navController.popBackStack() })
|
WrapAbout(goBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
composable(NAV_SCAN) {
|
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
|
@Composable
|
||||||
|
|
|
@ -13,14 +13,16 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MainActivity.WrapScan(
|
internal fun MainActivity.WrapScan(
|
||||||
goBack: () -> Unit
|
goBack: () -> Unit,
|
||||||
|
onScanDone: (result: String) -> Unit
|
||||||
) {
|
) {
|
||||||
WrapScan(this, goBack)
|
WrapScan(this, onScanDone, goBack)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WrapScan(
|
fun WrapScan(
|
||||||
activity: ComponentActivity,
|
activity: ComponentActivity,
|
||||||
|
onScanned: (result: String) -> Unit,
|
||||||
goBack: () -> Unit
|
goBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
@ -29,7 +31,7 @@ fun WrapScan(
|
||||||
Scan(
|
Scan(
|
||||||
snackbarHostState,
|
snackbarHostState,
|
||||||
onBack = goBack,
|
onBack = goBack,
|
||||||
onScan = {},
|
onScanned = onScanned,
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
runCatching {
|
runCatching {
|
||||||
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName))
|
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName))
|
||||||
|
@ -42,6 +44,7 @@ fun WrapScan(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onScanStateChanged = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,5 @@ package co.electriccoin.zcash.ui.screen.scan.model
|
||||||
enum class ScanState {
|
enum class ScanState {
|
||||||
Failed,
|
Failed,
|
||||||
Permission,
|
Permission,
|
||||||
Scanning,
|
Scanning
|
||||||
Success
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,11 @@
|
||||||
package co.electriccoin.zcash.ui.screen.scan.view
|
package co.electriccoin.zcash.ui.screen.scan.view
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
@ -35,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
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.ConstraintLayout
|
||||||
import androidx.constraintlayout.compose.Dimension
|
import androidx.constraintlayout.compose.Dimension
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import co.electriccoin.zcash.ui.R
|
import co.electriccoin.zcash.ui.R
|
||||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||||
import co.electriccoin.zcash.ui.design.component.SecondaryButton
|
import co.electriccoin.zcash.ui.design.component.SecondaryButton
|
||||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||||
import co.electriccoin.zcash.ui.screen.scan.ScanTag
|
import co.electriccoin.zcash.ui.screen.scan.ScanTag
|
||||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
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.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.guava.await
|
import kotlinx.coroutines.guava.await
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
// TODO [#423]: https://github.com/zcash/secant-android-wallet/issues/423
|
// TODO [#423]: https://github.com/zcash/secant-android-wallet/issues/423
|
||||||
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
|
|
||||||
@Preview("Scan")
|
@Preview("Scan")
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewScan() {
|
fun PreviewScan() {
|
||||||
|
@ -81,30 +82,32 @@ fun PreviewScan() {
|
||||||
Scan(
|
Scan(
|
||||||
snackbarHostState = SnackbarHostState(),
|
snackbarHostState = SnackbarHostState(),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onScan = {},
|
onScanned = {},
|
||||||
onOpenSettings = {}
|
onOpenSettings = {},
|
||||||
|
onScanStateChanged = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Suppress("UNUSED_VARIABLE")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Scan(
|
fun Scan(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onScan: (String) -> Unit,
|
onScanned: (String) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onScanStateChanged: (ScanState) -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { ScanTopAppBar(onBack = onBack) },
|
topBar = { ScanTopAppBar(onBack = onBack) },
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) {
|
) {
|
||||||
ScanMainContent(
|
ScanMainContent(
|
||||||
onScan,
|
onScanned,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onBack,
|
onBack,
|
||||||
|
onScanStateChanged,
|
||||||
snackbarHostState
|
snackbarHostState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -131,7 +134,6 @@ fun ScanBottomItems(
|
||||||
text = when (scanState) {
|
text = when (scanState) {
|
||||||
ScanState.Permission -> stringResource(id = R.string.scan_state_permission)
|
ScanState.Permission -> stringResource(id = R.string.scan_state_permission)
|
||||||
ScanState.Scanning -> stringResource(id = R.string.scan_state_scanning)
|
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)
|
ScanState.Failed -> stringResource(id = R.string.scan_state_failed)
|
||||||
},
|
},
|
||||||
color = Color.White,
|
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
|
@Composable
|
||||||
private fun ScanMainContent(
|
private fun ScanMainContent(
|
||||||
onScan: (String) -> Unit,
|
onScanned: (String) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onScanStateChanged: (ScanState) -> Unit,
|
||||||
snackbarHostState: SnackbarHostState
|
snackbarHostState: SnackbarHostState
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
|
val permissionState = rememberPermissionState(
|
||||||
|
Manifest.permission.CAMERA
|
||||||
|
)
|
||||||
|
|
||||||
val (scanState, setScanState) = rememberSaveable {
|
val (scanState, setScanState) = rememberSaveable {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
if (permissionState.hasPermission) {
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
ScanState.Scanning
|
ScanState.Scanning
|
||||||
} else {
|
} else {
|
||||||
ScanState.Permission
|
ScanState.Permission
|
||||||
|
@ -194,27 +199,16 @@ private fun ScanMainContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val (scanResult, setScanResult) = rememberSaveable { mutableStateOf("") }
|
if (!permissionState.hasPermission) {
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
onResult = { granted ->
|
|
||||||
if (granted) {
|
|
||||||
setScanState(ScanState.Scanning)
|
|
||||||
} else {
|
|
||||||
setScanState(ScanState.Permission)
|
setScanState(ScanState.Permission)
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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()) {
|
LaunchedEffect(key1 = UUID.randomUUID()) {
|
||||||
launcher.launch(Manifest.permission.CAMERA)
|
permissionState.launchPermissionRequest()
|
||||||
}
|
}
|
||||||
|
} else if (scanState == ScanState.Failed) {
|
||||||
val cameraProviderFlow = remember {
|
// keep current state
|
||||||
flow<ProcessCameraProvider> { emit(ProcessCameraProvider.getInstance(context).await()) }
|
} else if (permissionState.hasPermission) {
|
||||||
|
if (scanState != ScanState.Scanning)
|
||||||
|
setScanState(ScanState.Scanning)
|
||||||
}
|
}
|
||||||
|
|
||||||
// we calculate the best frame size for the current device screen
|
// we calculate the best frame size for the current device screen
|
||||||
|
@ -227,15 +221,25 @@ private fun ScanMainContent(
|
||||||
(framePossibleSize.value.width * 0.7).roundToInt()
|
(framePossibleSize.value.width * 0.7).roundToInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
|
ConstraintLayout(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
val (frame, bottomItems) = createRefs()
|
val (frame, bottomItems) = createRefs()
|
||||||
|
|
||||||
if (scanState == ScanState.Scanning) {
|
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(
|
ScanCameraView(
|
||||||
onBack,
|
onScanned = onScanned,
|
||||||
cameraProviderFlow,
|
setScanState = setScanState,
|
||||||
lifecycleOwner,
|
permissionState = permissionState
|
||||||
snackbarHostState
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
@ -247,7 +251,8 @@ private fun ScanMainContent(
|
||||||
end.linkTo(parent.end)
|
end.linkTo(parent.end)
|
||||||
width = Dimension.fillToConstraints
|
width = Dimension.fillToConstraints
|
||||||
height = Dimension.fillToConstraints
|
height = Dimension.fillToConstraints
|
||||||
}.onSizeChanged { coordinates ->
|
}
|
||||||
|
.onSizeChanged { coordinates ->
|
||||||
framePossibleSize.value = coordinates
|
framePossibleSize.value = coordinates
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
|
@ -255,6 +260,21 @@ private fun ScanMainContent(
|
||||||
ScanFrame(frameActualSize)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.constrainAs(bottomItems) { bottom.linkTo(parent.bottom) }) {
|
Box(modifier = Modifier.constrainAs(bottomItems) { bottom.linkTo(parent.bottom) }) {
|
||||||
ScanBottomItems(scanState, onOpenSettings)
|
ScanBottomItems(scanState, onOpenSettings)
|
||||||
|
@ -274,20 +294,38 @@ fun ScanFrame(frameSize: Int) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@SuppressWarnings("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanCameraView(
|
fun ScanCameraView(
|
||||||
onBack: () -> Unit,
|
onScanned: (result: String) -> Unit,
|
||||||
cameraProviderFlow: Flow<ProcessCameraProvider>,
|
setScanState: (ScanState) -> Unit,
|
||||||
lifecycleOwner: LifecycleOwner,
|
permissionState: PermissionState
|
||||||
snackbarHostState: SnackbarHostState
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
val cameraProvider = cameraProviderFlow.collectAsState(initial = null).value
|
// we check the permission first, as the ProcessCameraProvider's emit won't be called again after
|
||||||
if (null == cameraProvider) {
|
// 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
|
// Show loading indicator
|
||||||
} else {
|
} else {
|
||||||
|
val contentDescription = stringResource(id = R.string.scan_preview_content_description)
|
||||||
|
|
||||||
|
val imageAnalysis = ImageAnalysis.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { factoryContext ->
|
factory = { factoryContext ->
|
||||||
val previewView = PreviewView(factoryContext).apply {
|
val previewView = PreviewView(factoryContext).apply {
|
||||||
|
@ -297,37 +335,57 @@ fun ScanCameraView(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val preview = androidx.camera.core.Preview.Builder().build()
|
previewView.contentDescription = contentDescription
|
||||||
val selector = CameraSelector.Builder()
|
val selector = CameraSelector.Builder()
|
||||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||||
.build()
|
.build()
|
||||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
val preview = androidx.camera.core.Preview.Builder().build().apply {
|
||||||
|
setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
// we must unbind the use-cases before rebinding them
|
// we must unbind the use-cases before rebinding them
|
||||||
cameraProvider.unbindAll()
|
collectedCameraProvider.unbindAll()
|
||||||
cameraProvider.bindToLifecycle(
|
collectedCameraProvider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
selector,
|
selector,
|
||||||
preview
|
preview,
|
||||||
|
imageAnalysis
|
||||||
)
|
)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
scope.launch {
|
setScanState(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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
previewView
|
previewView
|
||||||
},
|
},
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="scan_header">Scan a Zcash QR</string>
|
<string name="scan_header">Scan a Zcash QR</string>
|
||||||
<string name="scan_back_content_description">Back</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>
|
<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_permission">Permission for camera is necessary.</string>
|
||||||
<string name="scan_state_scanning">Scanning…</string>
|
<string name="scan_state_scanning">Scanning…</string>
|
||||||
<string name="scan_state_success">Successfully scanned.</string>
|
|
||||||
<string name="scan_state_failed">Scanning failed.</string>
|
<string name="scan_state_failed">Scanning failed.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
Loading…
Reference in New Issue