[#312] Scan QR Screen Scaffold

* [#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.
- Suppress Lint warning
Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
Honza Rychnovsky 2022-05-19 14:41:58 +02:00 committed by GitHub
parent a8be47bf89
commit caf58f963a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 695 additions and 11 deletions

View File

@ -81,9 +81,11 @@ PLAY_PUBLISHER_PLUGIN_VERSION=3.7.0
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_COMPOSE_COMPILER_VERSION=1.2.0-alpha08
ANDROIDX_COMPOSE_MATERIAL3_VERSION=1.0.0-alpha09
ANDROIDX_COMPOSE_VERSION=1.1.1
ANDROIDX_CONSTRAINTLAYOUT_VERSION=1.0.0
ANDROIDX_CORE_VERSION=1.7.0
ANDROIDX_ESPRESSO_VERSION=3.4.0
ANDROIDX_LIFECYCLE_VERSION=2.4.1

View File

@ -114,9 +114,11 @@ dependencyResolutionManagement {
val androidxActivityVersion = extra["ANDROIDX_ACTIVITY_VERSION"].toString()
val androidxAnnotationVersion = extra["ANDROIDX_ANNOTATION_VERSION"].toString()
val androidxAppcompatVersion = extra["ANDROIDX_APPCOMPAT_VERSION"].toString()
val androidxCameraVersion = extra["ANDROIDX_CAMERA_VERSION"].toString()
val androidxComposeCompilerVersion = extra["ANDROIDX_COMPOSE_COMPILER_VERSION"].toString()
val androidxComposeMaterial3Version = extra["ANDROIDX_COMPOSE_MATERIAL3_VERSION"].toString()
val androidxComposeVersion = extra["ANDROIDX_COMPOSE_VERSION"].toString()
val androidxConstraintlayoutVersion = extra["ANDROIDX_CONSTRAINTLAYOUT_VERSION"].toString()
val androidxCoreVersion = extra["ANDROIDX_CORE_VERSION"].toString()
val androidxEspressoVersion = extra["ANDROIDX_ESPRESSO_VERSION"].toString()
val androidxLifecycleVersion = extra["ANDROIDX_LIFECYCLE_VERSION"].toString()
@ -152,6 +154,9 @@ dependencyResolutionManagement {
library("androidx-activity-compose", "androidx.activity:activity-compose:$androidxActivityVersion")
library("androidx-annotation", "androidx.annotation:annotation:$androidxAnnotationVersion")
library("androidx-appcompat", "androidx.appcompat:appcompat:$androidxAppcompatVersion")
library("androidx-camera", "androidx.camera:camera-camera2:$androidxCameraVersion")
library("androidx-camera-lifecycle", "androidx.camera:camera-lifecycle:$androidxCameraVersion")
library("androidx-camera-view", "androidx.camera:camera-view:$androidxCameraVersion")
library("androidx-compose-foundation", "androidx.compose.foundation:foundation:$androidxComposeVersion")
library("androidx-compose-material3", "androidx.compose.material3:material3:$androidxComposeMaterial3Version")
library("androidx-compose-material-icons-core", "androidx.compose.material:material-icons-core:$androidxComposeVersion")
@ -160,6 +165,7 @@ dependencyResolutionManagement {
library("androidx-compose-ui", "androidx.compose.ui:ui:$androidxComposeVersion")
library("androidx-compose-compiler", "androidx.compose.compiler:compiler:$androidxComposeCompilerVersion")
library("androidx-core", "androidx.core:core-ktx:$androidxCoreVersion")
library("androidx-constraintlayout", "androidx.constraintlayout:constraintlayout-compose:$androidxConstraintlayoutVersion")
library("androidx-lifecycle-livedata", "androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion")
library("androidx-navigation-compose", "androidx.navigation:navigation-compose:$androidxNavigationComposeVersion")
library("androidx-security-crypto", "androidx.security:security-crypto-ktx:$androidxSecurityCryptoVersion")
@ -172,6 +178,7 @@ dependencyResolutionManagement {
library("kotlin-test", "org.jetbrains.kotlin:kotlin-test:$kotlinVersion")
library("kotlinx-coroutines-android", "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion")
library("kotlinx-coroutines-core", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")
library("kotlinx-coroutines-guava", "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlinxCoroutinesVersion")
library("kotlinx-datetime", "org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
library("play-core", "com.google.android.play:core:$playCoreVersion")
library("play-core-ktx", "com.google.android.play:core-ktx:$playCoreKtxVersion")
@ -195,6 +202,14 @@ dependencyResolutionManagement {
library("kotlinx-coroutines-test", "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinxCoroutinesVersion")
// Bundles
bundle(
"androidx-camera",
listOf(
"androidx-camera",
"androidx-camera-lifecycle",
"androidx-camera-view"
)
)
bundle(
"androidx-compose-core",
listOf(

View File

@ -34,6 +34,7 @@ android {
"src/main/res/ui/home",
"src/main/res/ui/onboarding",
"src/main/res/ui/profile",
"src/main/res/ui/scan",
"src/main/res/ui/restore",
"src/main/res/ui/request",
"src/main/res/ui/seed",
@ -54,16 +55,19 @@ dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.annotation)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.splash)
implementation(libs.androidx.workmanager)
implementation(libs.bundles.androidx.camera)
implementation(libs.bundles.androidx.compose.core)
implementation(libs.bundles.androidx.compose.extended)
implementation(libs.bundles.play.core)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.kotlinx.datetime)
implementation(libs.zcash.sdk)
implementation(libs.zcash.bip39)

View File

@ -14,6 +14,9 @@
<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

@ -0,0 +1,35 @@
package co.electriccoin.zcash.ui.screen.scan
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxHeight
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
class TestScanActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupUiContent()
}
private fun setupUiContent() {
setContent {
ZcashTheme {
GradientSurface(
Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
WrapScan(
this,
goBack = {}
)
}
}
}
}
}

View File

@ -0,0 +1,30 @@
package co.electriccoin.zcash.ui.screen.scan.util
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import org.junit.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SettingsUtilTest {
companion object {
val SETTINGS_URI = SettingsUtil.SETTINGS_URI_PREFIX +
ApplicationProvider.getApplicationContext<Context>().packageName
}
@Test
@SmallTest
fun check_intent_to_settings() {
val intent = SettingsUtil.newSettingsIntent(ApplicationProvider.getApplicationContext<Context>().packageName)
assertNotNull(intent)
assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, intent.action)
assertContains(intent.categories, Intent.CATEGORY_DEFAULT)
assertEquals(SettingsUtil.FLAGS, intent.flags)
assertEquals(SETTINGS_URI, intent.data.toString())
}
}

View File

@ -0,0 +1,73 @@
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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
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.test.getStringResource
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
// TODO [#313]: https://github.com/zcash/secant-android-wallet/issues/313
class ScanPermissionGrantedViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestScanActivity>()
// 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.
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA)
@Test
@MediumTest
fun back() {
val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount())
composeTestRule.clickBack()
assertEquals(1, testSetup.getOnBackCount())
}
@Test
@MediumTest
fun check_all_ui_elements_displayed() {
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(getStringResource(R.string.scan_header)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
it.assertIsDisplayed()
}
}
private fun newTestSetup() = ScanViewTestSetup(composeTestRule).apply {
setDefaultContent()
}
private fun ComposeContentTestRule.clickBack() {
onNodeWithContentDescription(getStringResource(R.string.scan_back_content_description)).also {
it.performClick()
}
}
}

View File

@ -0,0 +1,63 @@
package co.electriccoin.zcash.ui.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.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(
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()
}
fun getScanState(): ScanState {
composeTestRule.waitForIdle()
return scanState.get()
}
@Composable
fun getDefaultContent() {
Scan(
snackbarHostState = SnackbarHostState(),
onBack = {
onBackCount.incrementAndGet()
},
onScan = {
onScanCount.incrementAndGet()
},
onOpenSettings = {
onOpenSettingsCount.incrementAndGet()
}
)
}
fun setDefaultContent() {
composeTestRule.setContent {
ZcashTheme {
getDefaultContent()
}
}
}
}

View File

@ -7,10 +7,11 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.filters.MediumTest
import co.electriccoin.zcash.test.UiTestPrerequisites
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.update.AppUpdateChecker
import co.electriccoin.zcash.ui.screen.update.RestoreTag
import co.electriccoin.zcash.ui.screen.update.TestUpdateActivity
import co.electriccoin.zcash.ui.screen.update.UpdateTag
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
@ -20,7 +21,7 @@ import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class UpdateViewTest {
class UpdateViewTest : UiTestPrerequisites() {
@get:Rule
val composeTestRule = createAndroidComposeRule<TestUpdateActivity>()
@ -122,7 +123,7 @@ class UpdateViewTest {
assertEquals(0, testSetup.getOnDownloadCount())
composeTestRule.onNodeWithText(RestoreTag.PROGRESSBAR_DOWNLOADING).also {
composeTestRule.onNodeWithText(UpdateTag.PROGRESSBAR_DOWNLOADING).also {
it.assertDoesNotExist()
}
@ -158,13 +159,13 @@ class UpdateViewTest {
}
private fun ComposeContentTestRule.clickLater() {
onNodeWithTag(RestoreTag.BTN_LATER).also {
onNodeWithTag(UpdateTag.BTN_LATER).also {
it.performClick()
}
}
private fun ComposeContentTestRule.clickDownload() {
onNodeWithTag(RestoreTag.BTN_DOWNLOAD).also {
onNodeWithTag(UpdateTag.BTN_DOWNLOAD).also {
it.performClick()
}
}

View File

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="co.electriccoin.zcash.ui">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:icon="@mipmap/ic_launcher_square"
android:roundIcon="@mipmap/ic_launcher_round"

View File

@ -47,6 +47,7 @@ import co.electriccoin.zcash.ui.screen.request.view.Request
import co.electriccoin.zcash.ui.screen.restore.view.RestoreWallet
import co.electriccoin.zcash.ui.screen.restore.viewmodel.CompleteWordSetState
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.scan.WrapScan
import co.electriccoin.zcash.ui.screen.seed.view.Seed
import co.electriccoin.zcash.ui.screen.send.view.Send
import co.electriccoin.zcash.ui.screen.settings.view.Settings
@ -253,7 +254,7 @@ class MainActivity : ComponentActivity() {
NavHost(navController = navController, startDestination = NAV_HOME) {
composable(NAV_HOME) {
WrapHome(
goScan = {},
goScan = { navController.navigate(NAV_SCAN) },
goProfile = { navController.navigate(NAV_PROFILE) },
goSend = { navController.navigate(NAV_SEND) },
goRequest = { navController.navigate(NAV_REQUEST) }
@ -307,6 +308,9 @@ class MainActivity : ComponentActivity() {
composable(NAV_ABOUT) {
WrapAbout(goBack = { navController.popBackStack() })
}
composable(NAV_SCAN) {
WrapScan(goBack = { navController.popBackStack() })
}
}
}
@ -491,6 +495,9 @@ class MainActivity : ComponentActivity() {
@VisibleForTesting
const val NAV_ABOUT = "about"
@VisibleForTesting
const val NAV_SCAN = "scan"
}
}

View File

@ -0,0 +1,47 @@
package co.electriccoin.zcash.ui.screen.scan
import androidx.activity.ComponentActivity
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.screen.scan.util.SettingsUtil
import co.electriccoin.zcash.ui.screen.scan.view.Scan
import kotlinx.coroutines.launch
@Composable
internal fun MainActivity.WrapScan(
goBack: () -> Unit
) {
WrapScan(this, goBack)
}
@Composable
fun WrapScan(
activity: ComponentActivity,
goBack: () -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scan(
snackbarHostState,
onBack = goBack,
onScan = {},
onOpenSettings = {
runCatching {
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName))
}.onFailure {
// This case should not really happen, as the Settings app should be available on every
// Android device, but we need to handle it somehow.
scope.launch {
snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_settings_open_failed)
)
}
}
}
)
}

View File

@ -0,0 +1,10 @@
package co.electriccoin.zcash.ui.screen.scan
/**
* These are only used for automated testing.
*/
object ScanTag {
const val TEXT_STATE = "text_state"
const val CAMERA_VIEW = "camera_view"
const val QR_FRAME = "frame"
}

View File

@ -0,0 +1,8 @@
package co.electriccoin.zcash.ui.screen.scan.model
enum class ScanState {
Failed,
Permission,
Scanning,
Success
}

View File

@ -0,0 +1,31 @@
package co.electriccoin.zcash.ui.screen.scan.util
import android.content.Intent
import android.net.Uri
import android.provider.Settings
object SettingsUtil {
internal const val SETTINGS_URI_PREFIX = "package:"
internal const val FLAGS = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_NO_HISTORY or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
/**
* Returns an intent to the system Settings page of the app given by packageName parameter.
*
* @param packageName of the app, which should be opened in the Settings
*
* @return Intent for launching the system Settings app
*/
internal fun newSettingsIntent(
packageName: String
): Intent {
return Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
addCategory(Intent.CATEGORY_DEFAULT)
data = Uri.parse("$SETTINGS_URI_PREFIX$packageName")
flags = FLAGS
}
}
}

View File

@ -0,0 +1,333 @@
package co.electriccoin.zcash.ui.screen.scan.view
import android.Manifest
import android.content.pm.PackageManager
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.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 kotlinx.coroutines.flow.Flow
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() {
ZcashTheme(darkTheme = true) {
GradientSurface {
Scan(
snackbarHostState = SnackbarHostState(),
onBack = {},
onScan = {},
onOpenSettings = {}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("UNUSED_VARIABLE")
@Composable
fun Scan(
snackbarHostState: SnackbarHostState,
onBack: () -> Unit,
onScan: (String) -> Unit,
onOpenSettings: () -> Unit,
) {
Scaffold(
topBar = { ScanTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
ScanMainContent(
onScan,
onOpenSettings,
onBack,
snackbarHostState
)
}
}
@Composable
fun ScanBottomItems(
scanState: ScanState,
onOpenSettings: () -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(id = R.string.scan_hint),
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.align(CenterHorizontally)
.padding(horizontal = 24.dp, vertical = 8.dp)
)
Text(
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,
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.align(CenterHorizontally)
.padding(horizontal = 24.dp, vertical = 8.dp)
.testTag(ScanTag.TEXT_STATE)
)
if (scanState == ScanState.Permission) {
SecondaryButton(
onClick = onOpenSettings,
text = stringResource(id = R.string.scan_settings_button),
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 12.dp)
)
}
}
}
@Composable
private fun ScanTopAppBar(onBack: () -> Unit) {
SmallTopAppBar(
title = { Text(text = stringResource(id = R.string.scan_header)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.scan_back_content_description)
)
}
}
)
}
@Suppress("UNUSED_VARIABLE", "UNUSED_PARAMETER", "MagicNumber", "LongMethod")
@Composable
private fun ScanMainContent(
onScan: (String) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val (scanState, setScanState) = rememberSaveable {
mutableStateOf(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
ScanState.Scanning
} else {
ScanState.Permission
}
)
}
val (scanResult, setScanResult) = rememberSaveable { mutableStateOf("") }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
setScanState(ScanState.Scanning)
} else {
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()) {
launcher.launch(Manifest.permission.CAMERA)
}
val cameraProviderFlow = remember {
flow<ProcessCameraProvider> { emit(ProcessCameraProvider.getInstance(context).await()) }
}
// we calculate the best frame size for the current device screen
val framePossibleSize = remember { mutableStateOf(IntSize.Zero) }
val configuration = LocalConfiguration.current
val frameActualSize = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
(framePossibleSize.value.height * 0.85).roundToInt()
} else {
(framePossibleSize.value.width * 0.7).roundToInt()
}
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (frame, bottomItems) = createRefs()
if (scanState == ScanState.Scanning) {
ScanCameraView(
onBack,
cameraProviderFlow,
lifecycleOwner,
snackbarHostState
)
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(bottomItems) { bottom.linkTo(parent.bottom) }) {
ScanBottomItems(scanState, onOpenSettings)
}
}
}
@Suppress("MagicNumber")
@Composable
fun ScanFrame(frameSize: Int) {
Box(
modifier = Modifier
.size(with(LocalDensity.current) { frameSize.toDp() })
.background(Color.Transparent)
.border(BorderStroke(10.dp, Color.White), RoundedCornerShape(10))
.testTag(ScanTag.QR_FRAME)
)
}
@Composable
fun ScanCameraView(
onBack: () -> Unit,
cameraProviderFlow: Flow<ProcessCameraProvider>,
lifecycleOwner: LifecycleOwner,
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val cameraProvider = cameraProviderFlow.collectAsState(initial = null).value
if (null == cameraProvider) {
// Show loading indicator
} else {
AndroidView(
factory = { factoryContext ->
val previewView = PreviewView(factoryContext).apply {
this.scaleType = PreviewView.ScaleType.FILL_CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
val preview = androidx.camera.core.Preview.Builder().build()
val selector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
preview.setSurfaceProvider(previewView.surfaceProvider)
runCatching {
// we must unbind the use-cases before rebinding them
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
selector,
preview
)
}.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()
}
}
}
previewView
},
Modifier
.fillMaxSize()
.testTag(ScanTag.CAMERA_VIEW),
)
}
}

View File

@ -1,5 +1,6 @@
package co.electriccoin.zcash.ui.screen.support.model
import android.annotation.SuppressLint
import android.content.Context
import cash.z.ecc.sdk.ext.ui.model.MonetarySeparators
import co.electriccoin.zcash.spackle.AndroidApiVersion
@ -22,6 +23,7 @@ class EnvironmentInfo(val locale: Locale, val monetarySeparators: MonetarySepara
currentLocaleLegacy(context)
}
@SuppressLint("NewApi")
private fun currentLocaleNPlus(context: Context) = context.resources.configuration.locales[0]
@Suppress("Deprecation")

View File

@ -3,7 +3,7 @@ package co.electriccoin.zcash.ui.screen.update
/**
* These are only used for automated testing.
*/
object RestoreTag {
object UpdateTag {
const val BTN_LATER = "btn_later"
const val BTN_DOWNLOAD = "btn_download"
const val PROGRESSBAR_DOWNLOADING = "progressbar_downloading"

View File

@ -31,7 +31,7 @@ import co.electriccoin.zcash.ui.design.component.PrimaryButton
import co.electriccoin.zcash.ui.design.component.Reference
import co.electriccoin.zcash.ui.design.component.TertiaryButton
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.update.RestoreTag
import co.electriccoin.zcash.ui.screen.update.UpdateTag
import co.electriccoin.zcash.ui.screen.update.fixture.UpdateInfoFixture
import co.electriccoin.zcash.ui.screen.update.model.UpdateInfo
import co.electriccoin.zcash.ui.screen.update.model.UpdateState
@ -96,7 +96,7 @@ fun UpdateOverlayRunning(updateInfo: UpdateInfo) {
.background(ZcashTheme.colors.overlay.copy(0.5f))
.fillMaxWidth()
.fillMaxHeight()
.testTag(RestoreTag.PROGRESSBAR_DOWNLOADING),
.testTag(UpdateTag.PROGRESSBAR_DOWNLOADING),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@ -136,7 +136,7 @@ private fun UpdateBottomAppBar(
text = stringResource(R.string.update_download_button),
modifier = Modifier
.fillMaxWidth()
.testTag(RestoreTag.BTN_DOWNLOAD),
.testTag(UpdateTag.BTN_DOWNLOAD),
enabled = updateInfo.state != UpdateState.Running
)
@ -153,7 +153,7 @@ private fun UpdateBottomAppBar(
),
modifier = Modifier
.fillMaxWidth()
.testTag(RestoreTag.BTN_LATER),
.testTag(UpdateTag.BTN_LATER),
enabled = !updateInfo.isForce && updateInfo.state != UpdateState.Running
)
}

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="scan_header">Scan a Zcash QR</string>
<string name="scan_back_content_description">Back</string>
<string name="scan_hint">We will validate any Zcash URI and take you to the appropriate action.</string>
<string name="scan_settings_button">Enable camera permission</string>
<string name="scan_settings_open_failed">Unable to launch Settings app.</string>
<string name="scan_setup_failed">Unable to run QR scanner.</string>
<string name="scan_setup_back">Back</string>
<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>