[#1413] [#1460] [#1461] QR code scanning from gallery picker (#1479)

* [#1413] [#1460] [#1461] QR code scanning from gallery picker

Closes #1413
Closes #1460
Closes #1461

* [#1413] [#1460] [#1461] Code cleanup

* [#1413] [#1460] [#1461] Camera scan frame anchored to a static view

Closes #1413
Closes #1460
Closes #1461

* [#1413] [#1460] [#1461] Code cleanup

Closes #1413
Closes #1460
Closes #1461

* Changelog update

- Unrelated change: this commit also removes a log from Navigation that was introduced in some of the previous changes and does not describe the actual execution state

---------

Co-authored-by: Milan Cerovsky <milan.cerovsky@leeaf.life>
Co-authored-by: Honza <rychnovsky.honza@gmail.com>
This commit is contained in:
Milan 2024-06-21 10:36:45 +02:00 committed by GitHub
parent 987595eaa2
commit 31649ff718
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 229 additions and 52 deletions

View File

@ -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

View File

@ -53,6 +53,7 @@ class ScanViewTestSetup(
snackbarHostState = SnackbarHostState(),
onBack = {},
onScanned = {},
onScanError = {},
onOpenSettings = {
onOpenSettingsCount.incrementAndGet()
},

View File

@ -35,6 +35,7 @@ class ScanViewBasicTestSetup(
onBackCount.incrementAndGet()
},
onScanned = {},
onScanError = {},
onOpenSettings = {},
onScanStateChanged = {
scanState.set(it)

View File

@ -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()
}
}

View File

@ -75,6 +75,9 @@ fun WrapScan(
}
}
},
onScanError = {
addressValidationResult = AddressType.Invalid()
},
onOpenSettings = {
runCatching {
context.startActivity(SettingsUtil.newSettingsIntent(context.packageName))

View File

@ -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
}

View File

@ -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),

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="20dp"
android:viewportWidth="26"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0.839,0.474h25v18.611h-25z"/>
<path
android:pathData="M3.617,0.474C2.089,0.474 0.839,1.724 0.839,3.252V16.308C0.839,17.835 2.089,19.085 3.617,19.085H23.034C24.562,19.085 25.812,17.835 25.812,16.308V15.002C25.812,15.002 25.812,14.974 25.812,14.946V3.252C25.812,1.724 24.562,0.474 23.034,0.474H3.617ZM3.617,1.946H23.034C23.784,1.946 24.339,2.53 24.339,3.252V13.419L18.867,8.974C18.589,8.752 18.201,8.752 17.923,8.974L14.812,11.752L9.839,6.613C9.562,6.335 9.089,6.307 8.812,6.613L2.312,12.78V3.252C2.312,2.502 2.895,1.946 3.617,1.946ZM14.506,3.946C13.367,3.974 12.45,4.919 12.45,6.057C12.45,7.196 13.395,8.169 14.562,8.169C15.728,8.169 16.673,7.224 16.673,6.057C16.673,4.891 15.728,3.946 14.562,3.946C14.562,3.946 14.534,3.946 14.506,3.946ZM14.506,5.419C14.506,5.419 14.506,5.419 14.534,5.419C14.895,5.419 15.173,5.696 15.173,6.057C15.173,6.419 14.895,6.696 14.534,6.696C14.173,6.696 13.895,6.419 13.895,6.057C13.895,5.696 14.145,5.446 14.478,5.419H14.506ZM9.256,8.196L15.173,14.28C15.45,14.557 15.923,14.585 16.201,14.28C16.478,14.002 16.506,13.53 16.201,13.252L15.812,12.835L18.423,10.502L24.339,15.307V16.308C24.339,17.058 23.756,17.613 23.034,17.613H3.617C2.867,17.613 2.312,17.03 2.312,16.308V14.807L9.284,8.196H9.256Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</group>
</vector>

View File

@ -15,4 +15,5 @@
<string name="scan_address_validation_failed">This QR code is not a valid Zcash Address.</string>
<string name="scan_torch_content_description">Camera torch toggle</string>
<string name="gallery_content_description">Gallery</string>
</resources>