diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea4fbd69..c98b59b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ directly impact users rather than highlighting other key architectural updates.*
- New bubble message style for the Send and Transaction history item text components
- Display all messages within the transaction history record when it is expanded
- The Dark mode is now officially supported by the entire app UI
+- The Scan screen now allows users to pick and scan a QR code of an address from a photo saved in the device library
### Changed
- The Not Enough Free Space screen UI has been slightly refactored to align with the latest design guidelines
diff --git a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt
index d5bc0ff4..16040a1e 100644
--- a/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt
+++ b/ui-integration-test/src/main/java/co/electriccoin/zcash/ui/integration/test/screen/scan/view/ScanViewTestSetup.kt
@@ -53,6 +53,7 @@ class ScanViewTestSetup(
snackbarHostState = SnackbarHostState(),
onBack = {},
onScanned = {},
+ onScanError = {},
onOpenSettings = {
onOpenSettingsCount.incrementAndGet()
},
diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt
index c66738df..1f179097 100644
--- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt
+++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/scan/view/ScanViewBasicTestSetup.kt
@@ -35,6 +35,7 @@ class ScanViewBasicTestSetup(
onBackCount.incrementAndGet()
},
onScanned = {},
+ onScanError = {},
onOpenSettings = {},
onScanStateChanged = {
scanState.set(it)
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt
index ed0d22e2..db75f6b6 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt
@@ -318,7 +318,6 @@ private fun MainActivity.NavigationHome(
}
)
} else if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
- Twig.info { "App update available" }
WrapCheckForUpdate()
}
}
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt
index e382aea1..9b27af6b 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/AndroidScan.kt
@@ -75,6 +75,9 @@ fun WrapScan(
}
}
},
+ onScanError = {
+ addressValidationResult = AddressType.Invalid()
+ },
onOpenSettings = {
runCatching {
context.startActivity(SettingsUtil.newSettingsIntent(context.packageName))
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/util/ImageUriToQrCodeConverter.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/util/ImageUriToQrCodeConverter.kt
new file mode 100644
index 00000000..52b6e6b0
--- /dev/null
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/util/ImageUriToQrCodeConverter.kt
@@ -0,0 +1,54 @@
+package co.electriccoin.zcash.ui.screen.scan.util
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import co.electriccoin.zcash.spackle.Twig
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.DecodeHintType
+import com.google.zxing.MultiFormatReader
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.HybridBinarizer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class ImageUriToQrCodeConverter {
+ suspend operator fun invoke(
+ context: Context,
+ uri: Uri
+ ): String? =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ uri.toBitmap(context)
+ .toBinaryBitmap()
+ .toQRCode()
+ }.onFailure {
+ Twig.error(it) { "Failed to convert Uri to QR code" }
+ }.getOrNull()
+ }
+
+ private fun Uri.toBitmap(context: Context): Bitmap =
+ context.contentResolver.openInputStream(this)
+ .use {
+ BitmapFactory.decodeStream(it)
+ }
+
+ private fun Bitmap.toBinaryBitmap(): BinaryBitmap {
+ val width = this.width
+ val height = this.height
+ val pixels = IntArray(width * height)
+ this.getPixels(pixels, 0, width, 0, 0, width, height)
+ this.recycle()
+ val source = RGBLuminanceSource(width, height, pixels)
+ return BinaryBitmap(HybridBinarizer(source))
+ }
+
+ private fun BinaryBitmap.toQRCode(): String =
+ MultiFormatReader()
+ .apply {
+ setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE)))
+ }
+ .decode(this@toQRCode).text
+}
diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt
index 286ca5ea..a024a540 100644
--- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt
+++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt
@@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.screen.scan.view
import android.Manifest
import android.content.Context
import android.view.ViewGroup
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
@@ -15,6 +17,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -29,9 +32,13 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -44,6 +51,7 @@ 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.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -67,9 +75,11 @@ import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
+import co.electriccoin.zcash.ui.screen.scan.util.ImageUriToQrCodeConverter
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.PermissionStatus
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
@@ -78,6 +88,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.guava.await
+import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Preview
@@ -89,10 +100,11 @@ private fun ScanPreview() {
snackbarHostState = SnackbarHostState(),
onBack = {},
onScanned = {},
+ onScanError = {},
onOpenSettings = {},
onScanStateChanged = {},
topAppBarSubTitleState = TopAppBarSubTitleState.None,
- addressValidationResult = AddressType.Transparent,
+ addressValidationResult = AddressType.Invalid(),
)
}
}
@@ -107,6 +119,7 @@ private fun ScanDarkPreview() {
snackbarHostState = SnackbarHostState(),
onBack = {},
onScanned = {},
+ onScanError = {},
onOpenSettings = {},
onScanStateChanged = {},
topAppBarSubTitleState = TopAppBarSubTitleState.None,
@@ -118,30 +131,48 @@ private fun ScanDarkPreview() {
@OptIn(ExperimentalPermissionsApi::class)
@Composable
-@Suppress("LongParameterList", "UnusedMaterial3ScaffoldPaddingParameter")
+@Suppress("LongParameterList", "UnusedMaterial3ScaffoldPaddingParameter", "LongMethod")
fun Scan(
snackbarHostState: SnackbarHostState,
onBack: () -> Unit,
onScanned: (String) -> Unit,
+ onScanError: () -> Unit,
onOpenSettings: () -> Unit,
onScanStateChanged: (ScanState) -> Unit,
topAppBarSubTitleState: TopAppBarSubTitleState,
addressValidationResult: AddressType?
) {
val permissionState =
- rememberPermissionState(
- Manifest.permission.CAMERA
- )
+ if (LocalInspectionMode.current) {
+ remember {
+ object : PermissionState {
+ override val permission = Manifest.permission.CAMERA
+ override val status = PermissionStatus.Granted
+
+ override fun launchPermissionRequest() = Unit
+ }
+ }
+ } else {
+ rememberPermissionState(
+ Manifest.permission.CAMERA
+ )
+ }
val (scanState, setScanState) =
- rememberSaveable {
- mutableStateOf(
- if (permissionState.status.isGranted) {
- ScanState.Scanning
- } else {
- ScanState.Permission
- }
- )
+ if (LocalInspectionMode.current) {
+ remember {
+ mutableStateOf(ScanState.Scanning)
+ }
+ } else {
+ rememberSaveable {
+ mutableStateOf(
+ if (permissionState.status.isGranted) {
+ ScanState.Scanning
+ } else {
+ ScanState.Permission
+ }
+ )
+ }
}
Scaffold(
@@ -151,6 +182,7 @@ fun Scan(
ScanMainContent(
addressValidationResult = addressValidationResult,
onScanned = onScanned,
+ onScanError = onScanError,
onOpenSettings = onOpenSettings,
onBack = onBack,
onScanStateChanged = onScanStateChanged,
@@ -282,11 +314,12 @@ data class FramePosition(
}
@OptIn(ExperimentalPermissionsApi::class)
-@Suppress("LongMethod", "LongParameterList")
+@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod", "MagicNumber")
@Composable
private fun ScanMainContent(
addressValidationResult: AddressType?,
onScanned: (String) -> Unit,
+ onScanError: () -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
onScanStateChanged: (ScanState) -> Unit,
@@ -317,9 +350,13 @@ private fun ScanMainContent(
}
// Calculate the best frame size for the current device screen
- val framePossibleSize = remember { mutableStateOf(IntSize.Zero) }
+ var framePossibleSize by remember { mutableStateOf(IntSize.Zero) }
- val frameActualSize = (framePossibleSize.value.width * FRAME_SIZE_RATIO).roundToInt()
+ val frameActualSize by remember {
+ derivedStateOf {
+ (framePossibleSize.width * FRAME_SIZE_RATIO).roundToInt()
+ }
+ }
val density = LocalDensity.current
@@ -327,34 +364,60 @@ private fun ScanMainContent(
val framePosition =
FramePosition(
- left = (framePossibleSize.value.width - frameActualSize) / 2f,
- top = (framePossibleSize.value.height - frameActualSize) / 2f,
- right = (framePossibleSize.value.width - frameActualSize) / 2f + frameActualSize,
- bottom = (framePossibleSize.value.height - frameActualSize) / 2f + frameActualSize,
+ left = (framePossibleSize.width - frameActualSize) / 2f,
+ top = (framePossibleSize.height - frameActualSize) / 2f,
+ right = (framePossibleSize.width - frameActualSize) / 2f + frameActualSize,
+ bottom = (framePossibleSize.height - frameActualSize) / 2f + frameActualSize,
screenHeight = with(density) { configuration.screenHeightDp.dp.roundToPx() },
screenWidth = with(density) { configuration.screenWidthDp.dp.roundToPx() }
)
val (isTorchOn, setIsTorchOn) = rememberSaveable { mutableStateOf(false) }
+ val convertImageUriToQrCode by remember { mutableStateOf(ImageUriToQrCodeConverter()) }
+
+ val scope = rememberCoroutineScope()
+
+ val context = LocalContext.current
+
+ val galleryLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ onResult = { uri ->
+ uri?.let {
+ scope.launch {
+ val qrCode = convertImageUriToQrCode(context = context, uri = uri)
+ if (qrCode == null) {
+ onScanError()
+ } else {
+ onScanned(qrCode)
+ }
+ }
+ }
+ }
+ )
+
ConstraintLayout(modifier = modifier) {
- val (frame, bottomItems) = createRefs()
+ val (frame, bottomItems, bottomAnchor) = createRefs()
when (scanState) {
ScanState.Permission -> {
// Keep initial ui state
onScanStateChanged(ScanState.Permission)
}
+
ScanState.Scanning -> {
onScanStateChanged(ScanState.Scanning)
- ScanCameraView(
- framePosition = framePosition,
- isTorchOn = isTorchOn,
- onScanned = onScanned,
- permissionState = permissionState,
- setScanState = setScanState,
- )
+ if (!LocalInspectionMode.current) {
+ ScanCameraView(
+ framePosition = framePosition,
+ isTorchOn = isTorchOn,
+ onScanned = onScanned,
+ permissionState = permissionState,
+ setScanState = setScanState,
+ )
+ }
Canvas(modifier = Modifier.fillMaxSize()) {
clipRect(
@@ -368,7 +431,23 @@ private fun ScanMainContent(
}
}
- Image(
+ ImageButton(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_gallery),
+ contentDescription = stringResource(id = R.string.gallery_content_description),
+ modifier =
+ Modifier
+ .offset(
+ x =
+ with(density) {
+ framePosition.left.toDp() - ZcashTheme.dimens.spacingMid
+ },
+ y = with(density) { framePosition.bottom.toDp() }
+ ),
+ ) {
+ galleryLauncher.launch("image/*")
+ }
+
+ ImageButton(
imageVector =
if (isTorchOn) {
ImageVector.vectorResource(R.drawable.ic_torch_off)
@@ -388,16 +467,12 @@ private fun ScanMainContent(
)
},
y = with(density) { framePosition.bottom.toDp() }
- )
- .clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
- .clickable { setIsTorchOn(!isTorchOn) }
- .padding(ZcashTheme.dimens.spacingDefault)
- .size(
- width = ZcashTheme.dimens.cameraTorchButton,
- height = ZcashTheme.dimens.cameraTorchButton
- )
- )
+ ),
+ ) {
+ setIsTorchOn(!isTorchOn)
+ }
}
+
ScanState.Failed -> {
onScanStateChanged(ScanState.Failed)
}
@@ -408,14 +483,14 @@ private fun ScanMainContent(
Modifier
.constrainAs(frame) {
top.linkTo(parent.top)
- bottom.linkTo(bottomItems.top)
+ bottom.linkTo(bottomAnchor.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
.onSizeChanged { coordinates ->
- framePossibleSize.value = coordinates
+ framePossibleSize = coordinates
},
contentAlignment = Alignment.Center
) {
@@ -425,6 +500,15 @@ private fun ScanMainContent(
)
}
+ Spacer(
+ modifier =
+ Modifier
+ .fillMaxHeight(.28f)
+ .constrainAs(bottomAnchor) {
+ bottom.linkTo(parent.bottom)
+ },
+ )
+
Box(
modifier =
Modifier
@@ -447,6 +531,28 @@ private fun ScanMainContent(
}
}
+@Composable
+private fun ImageButton(
+ imageVector: ImageVector,
+ contentDescription: String,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ Image(
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ modifier =
+ modifier
+ .clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
+ .clickable { onClick() }
+ .padding(ZcashTheme.dimens.spacingDefault)
+ .size(
+ width = ZcashTheme.dimens.cameraTorchButton,
+ height = ZcashTheme.dimens.cameraTorchButton
+ )
+ )
+}
+
@Composable
fun ScanFrame(
frameSize: Int,
@@ -457,18 +563,15 @@ fun ScanFrame(
Box(
modifier =
modifier
- .then(
- Modifier
- .size(with(LocalDensity.current) { frameSize.toDp() })
- .background(
- if (isScanning) {
- Color.Transparent
- } else {
- ZcashTheme.colors.cameraDisabledFrameColor
- }
- )
- .testTag(ScanTag.QR_FRAME)
+ .size(with(LocalDensity.current) { frameSize.toDp() })
+ .background(
+ if (isScanning) {
+ Color.Transparent
+ } else {
+ ZcashTheme.colors.cameraDisabledFrameColor
+ }
)
+ .testTag(ScanTag.QR_FRAME)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner),
diff --git a/ui-lib/src/main/res/ui/scan/drawable/ic_gallery.xml b/ui-lib/src/main/res/ui/scan/drawable/ic_gallery.xml
new file mode 100644
index 00000000..a1c81d16
--- /dev/null
+++ b/ui-lib/src/main/res/ui/scan/drawable/ic_gallery.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/ui-lib/src/main/res/ui/scan/values/strings.xml b/ui-lib/src/main/res/ui/scan/values/strings.xml
index b9c25c84..0ef108d8 100644
--- a/ui-lib/src/main/res/ui/scan/values/strings.xml
+++ b/ui-lib/src/main/res/ui/scan/values/strings.xml
@@ -15,4 +15,5 @@
This QR code is not a valid Zcash Address.
Camera torch toggle
+ Gallery