* [#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:
parent
987595eaa2
commit
31649ff718
|
@ -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
|
||||
|
|
|
@ -53,6 +53,7 @@ class ScanViewTestSetup(
|
|||
snackbarHostState = SnackbarHostState(),
|
||||
onBack = {},
|
||||
onScanned = {},
|
||||
onScanError = {},
|
||||
onOpenSettings = {
|
||||
onOpenSettingsCount.incrementAndGet()
|
||||
},
|
||||
|
|
|
@ -35,6 +35,7 @@ class ScanViewBasicTestSetup(
|
|||
onBackCount.incrementAndGet()
|
||||
},
|
||||
onScanned = {},
|
||||
onScanError = {},
|
||||
onOpenSettings = {},
|
||||
onScanStateChanged = {
|
||||
scanState.set(it)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,6 +75,9 @@ fun WrapScan(
|
|||
}
|
||||
}
|
||||
},
|
||||
onScanError = {
|
||||
addressValidationResult = AddressType.Invalid()
|
||||
},
|
||||
onOpenSettings = {
|
||||
runCatching {
|
||||
context.startActivity(SettingsUtil.newSettingsIntent(context.packageName))
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,22 +131,39 @@ 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 =
|
||||
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) =
|
||||
if (LocalInspectionMode.current) {
|
||||
remember {
|
||||
mutableStateOf(ScanState.Scanning)
|
||||
}
|
||||
} else {
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
if (permissionState.status.isGranted) {
|
||||
|
@ -143,6 +173,7 @@ fun Scan(
|
|||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
|
@ -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,27 +364,52 @@ 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)
|
||||
|
||||
if (!LocalInspectionMode.current) {
|
||||
ScanCameraView(
|
||||
framePosition = framePosition,
|
||||
isTorchOn = isTorchOn,
|
||||
|
@ -355,6 +417,7 @@ private fun ScanMainContent(
|
|||
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,8 +563,6 @@ fun ScanFrame(
|
|||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.then(
|
||||
Modifier
|
||||
.size(with(LocalDensity.current) { frameSize.toDp() })
|
||||
.background(
|
||||
if (isScanning) {
|
||||
|
@ -468,7 +572,6 @@ fun ScanFrame(
|
|||
}
|
||||
)
|
||||
.testTag(ScanTag.QR_FRAME)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner),
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue