[#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 - 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 - 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 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 ### Changed
- The Not Enough Free Space screen UI has been slightly refactored to align with the latest design guidelines - 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(), snackbarHostState = SnackbarHostState(),
onBack = {}, onBack = {},
onScanned = {}, onScanned = {},
onScanError = {},
onOpenSettings = { onOpenSettings = {
onOpenSettingsCount.incrementAndGet() onOpenSettingsCount.incrementAndGet()
}, },

View File

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

View File

@ -318,7 +318,6 @@ private fun MainActivity.NavigationHome(
} }
) )
} else if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) { } else if (ConfigurationEntries.IS_APP_UPDATE_CHECK_ENABLED.getValue(RemoteConfig.current)) {
Twig.info { "App update available" }
WrapCheckForUpdate() WrapCheckForUpdate()
} }
} }

View File

@ -75,6 +75,9 @@ fun WrapScan(
} }
} }
}, },
onScanError = {
addressValidationResult = AddressType.Invalid()
},
onOpenSettings = { onOpenSettings = {
runCatching { runCatching {
context.startActivity(SettingsUtil.newSettingsIntent(context.packageName)) 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.Manifest
import android.content.Context import android.content.Context
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraControl import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -29,9 +32,13 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource 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.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.ImageUriToQrCodeConverter
import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale import com.google.accompanist.permissions.shouldShowRationale
@ -78,6 +88,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow 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 kotlin.math.roundToInt import kotlin.math.roundToInt
@Preview @Preview
@ -89,10 +100,11 @@ private fun ScanPreview() {
snackbarHostState = SnackbarHostState(), snackbarHostState = SnackbarHostState(),
onBack = {}, onBack = {},
onScanned = {}, onScanned = {},
onScanError = {},
onOpenSettings = {}, onOpenSettings = {},
onScanStateChanged = {}, onScanStateChanged = {},
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
addressValidationResult = AddressType.Transparent, addressValidationResult = AddressType.Invalid(),
) )
} }
} }
@ -107,6 +119,7 @@ private fun ScanDarkPreview() {
snackbarHostState = SnackbarHostState(), snackbarHostState = SnackbarHostState(),
onBack = {}, onBack = {},
onScanned = {}, onScanned = {},
onScanError = {},
onOpenSettings = {}, onOpenSettings = {},
onScanStateChanged = {}, onScanStateChanged = {},
topAppBarSubTitleState = TopAppBarSubTitleState.None, topAppBarSubTitleState = TopAppBarSubTitleState.None,
@ -118,30 +131,48 @@ private fun ScanDarkPreview() {
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
@Suppress("LongParameterList", "UnusedMaterial3ScaffoldPaddingParameter") @Suppress("LongParameterList", "UnusedMaterial3ScaffoldPaddingParameter", "LongMethod")
fun Scan( fun Scan(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
onBack: () -> Unit, onBack: () -> Unit,
onScanned: (String) -> Unit, onScanned: (String) -> Unit,
onScanError: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onScanStateChanged: (ScanState) -> Unit, onScanStateChanged: (ScanState) -> Unit,
topAppBarSubTitleState: TopAppBarSubTitleState, topAppBarSubTitleState: TopAppBarSubTitleState,
addressValidationResult: AddressType? addressValidationResult: AddressType?
) { ) {
val permissionState = val permissionState =
rememberPermissionState( if (LocalInspectionMode.current) {
Manifest.permission.CAMERA 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) = val (scanState, setScanState) =
rememberSaveable { if (LocalInspectionMode.current) {
mutableStateOf( remember {
if (permissionState.status.isGranted) { mutableStateOf(ScanState.Scanning)
ScanState.Scanning }
} else { } else {
ScanState.Permission rememberSaveable {
} mutableStateOf(
) if (permissionState.status.isGranted) {
ScanState.Scanning
} else {
ScanState.Permission
}
)
}
} }
Scaffold( Scaffold(
@ -151,6 +182,7 @@ fun Scan(
ScanMainContent( ScanMainContent(
addressValidationResult = addressValidationResult, addressValidationResult = addressValidationResult,
onScanned = onScanned, onScanned = onScanned,
onScanError = onScanError,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onBack = onBack, onBack = onBack,
onScanStateChanged = onScanStateChanged, onScanStateChanged = onScanStateChanged,
@ -282,11 +314,12 @@ data class FramePosition(
} }
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "LongParameterList") @Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod", "MagicNumber")
@Composable @Composable
private fun ScanMainContent( private fun ScanMainContent(
addressValidationResult: AddressType?, addressValidationResult: AddressType?,
onScanned: (String) -> Unit, onScanned: (String) -> Unit,
onScanError: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onScanStateChanged: (ScanState) -> Unit, onScanStateChanged: (ScanState) -> Unit,
@ -317,9 +350,13 @@ private fun ScanMainContent(
} }
// Calculate the best frame size for the current device screen // 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 val density = LocalDensity.current
@ -327,34 +364,60 @@ private fun ScanMainContent(
val framePosition = val framePosition =
FramePosition( FramePosition(
left = (framePossibleSize.value.width - frameActualSize) / 2f, left = (framePossibleSize.width - frameActualSize) / 2f,
top = (framePossibleSize.value.height - frameActualSize) / 2f, top = (framePossibleSize.height - frameActualSize) / 2f,
right = (framePossibleSize.value.width - frameActualSize) / 2f + frameActualSize, right = (framePossibleSize.width - frameActualSize) / 2f + frameActualSize,
bottom = (framePossibleSize.value.height - frameActualSize) / 2f + frameActualSize, bottom = (framePossibleSize.height - frameActualSize) / 2f + frameActualSize,
screenHeight = with(density) { configuration.screenHeightDp.dp.roundToPx() }, screenHeight = with(density) { configuration.screenHeightDp.dp.roundToPx() },
screenWidth = with(density) { configuration.screenWidthDp.dp.roundToPx() } screenWidth = with(density) { configuration.screenWidthDp.dp.roundToPx() }
) )
val (isTorchOn, setIsTorchOn) = rememberSaveable { mutableStateOf(false) } 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) { ConstraintLayout(modifier = modifier) {
val (frame, bottomItems) = createRefs() val (frame, bottomItems, bottomAnchor) = createRefs()
when (scanState) { when (scanState) {
ScanState.Permission -> { ScanState.Permission -> {
// Keep initial ui state // Keep initial ui state
onScanStateChanged(ScanState.Permission) onScanStateChanged(ScanState.Permission)
} }
ScanState.Scanning -> { ScanState.Scanning -> {
onScanStateChanged(ScanState.Scanning) onScanStateChanged(ScanState.Scanning)
ScanCameraView( if (!LocalInspectionMode.current) {
framePosition = framePosition, ScanCameraView(
isTorchOn = isTorchOn, framePosition = framePosition,
onScanned = onScanned, isTorchOn = isTorchOn,
permissionState = permissionState, onScanned = onScanned,
setScanState = setScanState, permissionState = permissionState,
) setScanState = setScanState,
)
}
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
clipRect( 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 = imageVector =
if (isTorchOn) { if (isTorchOn) {
ImageVector.vectorResource(R.drawable.ic_torch_off) ImageVector.vectorResource(R.drawable.ic_torch_off)
@ -388,16 +467,12 @@ private fun ScanMainContent(
) )
}, },
y = with(density) { framePosition.bottom.toDp() } y = with(density) { framePosition.bottom.toDp() }
) ),
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner)) ) {
.clickable { setIsTorchOn(!isTorchOn) } setIsTorchOn(!isTorchOn)
.padding(ZcashTheme.dimens.spacingDefault) }
.size(
width = ZcashTheme.dimens.cameraTorchButton,
height = ZcashTheme.dimens.cameraTorchButton
)
)
} }
ScanState.Failed -> { ScanState.Failed -> {
onScanStateChanged(ScanState.Failed) onScanStateChanged(ScanState.Failed)
} }
@ -408,14 +483,14 @@ private fun ScanMainContent(
Modifier Modifier
.constrainAs(frame) { .constrainAs(frame) {
top.linkTo(parent.top) top.linkTo(parent.top)
bottom.linkTo(bottomItems.top) bottom.linkTo(bottomAnchor.top)
start.linkTo(parent.start) start.linkTo(parent.start)
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 = coordinates
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -425,6 +500,15 @@ private fun ScanMainContent(
) )
} }
Spacer(
modifier =
Modifier
.fillMaxHeight(.28f)
.constrainAs(bottomAnchor) {
bottom.linkTo(parent.bottom)
},
)
Box( Box(
modifier = modifier =
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 @Composable
fun ScanFrame( fun ScanFrame(
frameSize: Int, frameSize: Int,
@ -457,18 +563,15 @@ fun ScanFrame(
Box( Box(
modifier = modifier =
modifier modifier
.then( .size(with(LocalDensity.current) { frameSize.toDp() })
Modifier .background(
.size(with(LocalDensity.current) { frameSize.toDp() }) if (isScanning) {
.background( Color.Transparent
if (isScanning) { } else {
Color.Transparent ZcashTheme.colors.cameraDisabledFrameColor
} else { }
ZcashTheme.colors.cameraDisabledFrameColor
}
)
.testTag(ScanTag.QR_FRAME)
) )
.testTag(ScanTag.QR_FRAME)
) { ) {
Icon( Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner), 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_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="scan_torch_content_description">Camera torch toggle</string>
<string name="gallery_content_description">Gallery</string>
</resources> </resources>