commit a5d8244a8eb6fd2469007d3bb889259c17dccedb parent d72e5d20bc014e63f92d87c1e4348c5d65283d1f Author: Iván Ávalos <avalos@disroot.org> Date: Sun, 26 Jul 2026 21:41:18 +0200 [wallet] improved QR scanner screen Diffstat:
13 files changed, 915 insertions(+), 154 deletions(-)
diff --git a/taler-kotlin-android/build.gradle b/taler-kotlin-android/build.gradle @@ -82,6 +82,12 @@ dependencies { // QR codes implementation 'com.google.zxing:core:3.5.3' // needs minSdkVersion 24+ or desugar + // CameraX for QR scanning (shared logic) + implementation "androidx.camera:camera-core:1.5.3" + implementation "androidx.camera:camera-camera2:1.5.3" + implementation "androidx.camera:camera-lifecycle:1.5.3" + implementation "androidx.camera:camera-view:1.5.3" + // JSON parsing and serialization api 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0' testImplementation "junit:junit:$junit_version" diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/qr/QrCameraAnalyzer.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/qr/QrCameraAnalyzer.kt @@ -0,0 +1,253 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.lib.android.qr + +import android.content.Context +import android.graphics.Bitmap +import android.media.Image +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.FocusMeteringAction +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceOrientedMeteringPointFactory +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.PlanarYUVLuminanceSource +import java.util.concurrent.Executor + +@androidx.annotation.OptIn(ExperimentalGetImage::class) +class QrCameraAnalyzer( + private val context: Context, + private val lifecycleOwner: LifecycleOwner, + private val previewView: PreviewView, + private val onQrDetected: (String) -> Unit, + private val onCameraReady: ((initialLinearZoom: Float) -> Unit)? = null, +) { + private val mainExecutor: Executor = ContextCompat.getMainExecutor(context) + private var camera: Camera? = null + + private val qrReader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.CHARACTER_SET to "UTF-8", + ), + ) + } + + fun startCamera() { + val providerFuture = ProcessCameraProvider.getInstance(context) + providerFuture.addListener({ + val provider = providerFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build().also { useCase -> + useCase.setAnalyzer(mainExecutor) { proxy -> + val mediaImage = proxy.image + if (mediaImage != null) { + val nv21 = yuv420888ToNv21(mediaImage) + val width = mediaImage.width + val height = mediaImage.height + val rotation = proxy.imageInfo.rotationDegrees + + val (rotatedNv21, rotatedWidth, rotatedHeight) = when (rotation) { + 90 -> rotateNV21(nv21, width, height, 90) + 180 -> rotateNV21(nv21, width, height, 180) + 270 -> rotateNV21(nv21, width, height, 270) + else -> Triple(nv21, width, height) + } + + val source = PlanarYUVLuminanceSource( + rotatedNv21, rotatedWidth, rotatedHeight, 0, 0, rotatedWidth, rotatedHeight, false, + ) + + val bitmap = BinaryBitmap(HybridBinarizer(source)) + try { + val result = qrReader.decodeWithState(bitmap) + onQrDetected(result.text) + } catch (_: NotFoundException) { + } finally { + proxy.close() + } + } else { + proxy.close() + } + } + } + + provider.unbindAll() + camera = provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + camera?.cameraInfo?.zoomState?.value?.let { zoomState -> + onCameraReady?.invoke(zoomState.linearZoom) + } + }, mainExecutor) + } + + fun stopCamera() { + try { + ProcessCameraProvider.getInstance(context).get().unbindAll() + camera = null + } catch (_: Exception) { + } + } + + fun enableTorch(enabled: Boolean) { + camera?.cameraControl?.enableTorch(enabled) + } + + fun setLinearZoom(zoom: Float) { + camera?.cameraControl?.setLinearZoom(zoom.coerceIn(0f, 1f)) + } + + fun focusAtPoint(x: Float, y: Float) { + val cam = camera ?: return + val factory = SurfaceOrientedMeteringPointFactory( + previewView.width.toFloat(), + previewView.height.toFloat(), + ) + val point = factory.createPoint(x, y) + val action = FocusMeteringAction.Builder(point) + .setAutoCancelDuration(3, java.util.concurrent.TimeUnit.SECONDS) + .build() + cam.cameraControl.startFocusAndMetering(action) + } + + private fun yuv420888ToNv21(image: Image): ByteArray { + val yPlane = image.planes[0].buffer + val uPlane = image.planes[1].buffer + val vPlane = image.planes[2].buffer + val ySize = yPlane.remaining() + val uSize = uPlane.remaining() + val vSize = vPlane.remaining() + val nv21 = ByteArray(ySize + uSize + vSize) + yPlane.get(nv21, 0, ySize) + vPlane.get(nv21, ySize, vSize) + uPlane.get(nv21, ySize + vSize, uSize) + return nv21 + } + + private fun rotateNV21(data: ByteArray, width: Int, height: Int, rotation: Int): Triple<ByteArray, Int, Int> { + if (rotation == 0) return Triple(data, width, height) + + val ySize = width * height + val uvSize = ySize / 2 + val rotated = ByteArray(ySize + uvSize) + + when (rotation) { + 90 -> { + var i = 0 + for (x in 0 until width) { + for (y in height - 1 downTo 0) { + rotated[i++] = data[y * width + x] + } + } + var uvIndex = ySize + for (x in 0 until width step 2) { + for (y in height - 1 downTo 0 step 2) { + val uvPos = ySize + (y * width + x) + if (uvPos + 1 < data.size) { + rotated[uvIndex++] = data[uvPos] + rotated[uvIndex++] = data[uvPos + 1] + } + } + } + return Triple(rotated, height, width) + } + 180 -> { + var i = ySize - 1 + for (j in 0 until ySize) { + rotated[j] = data[i--] + } + i = data.size - 1 + for (j in ySize until data.size step 2) { + rotated[j] = data[i - 1] + rotated[j + 1] = data[i] + i -= 2 + } + return Triple(rotated, width, height) + } + 270 -> { + var i = 0 + for (x in width - 1 downTo 0) { + for (y in 0 until height) { + rotated[i++] = data[y * width + x] + } + } + var uvIndex = ySize + for (x in width - 1 downTo 0 step 2) { + for (y in 0 until height step 2) { + val uvPos = ySize + (y * width + x - 1) + if (uvPos + 1 < data.size) { + rotated[uvIndex++] = data[uvPos] + rotated[uvIndex++] = data[uvPos + 1] + } + } + } + return Triple(rotated, height, width) + } + } + return Triple(data, width, height) + } + + companion object { + fun decodeQrFromBitmap(bitmap: Bitmap): String? { + val width = bitmap.width + val height = bitmap.height + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + val source = RGBLuminanceSource(width, height, pixels) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.CHARACTER_SET to "UTF-8", + ), + ) + } + + return try { + reader.decodeWithState(binaryBitmap).text + } catch (_: NotFoundException) { + null + } + } + } +} diff --git a/wallet/build.gradle b/wallet/build.gradle @@ -155,9 +155,12 @@ dependencies { // ViewModel and LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" - // QR codes - implementation 'com.journeyapps:zxing-android-embedded:4.3.0@aar' - // needed to support zxing library in taler-kotlin-android on API < 24 + // CameraX for QR scanning + implementation "androidx.camera:camera-camera2:1.5.3" + implementation "androidx.camera:camera-lifecycle:1.5.3" + implementation "androidx.camera:camera-view:1.5.3" + + // Core library desugaring for java.time APIs on older devices coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' // Nicer ProgressBar diff --git a/wallet/src/main/AndroidManifest.xml b/wallet/src/main/AndroidManifest.xml @@ -24,9 +24,14 @@ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.NFC" /> <uses-feature + android:name="android.hardware.camera" + android:required="false" /> + + <uses-feature android:name="android.hardware.telephony" android:required="false" /> <uses-feature @@ -86,11 +91,6 @@ </intent-filter> </activity> - <activity - android:name="com.journeyapps.barcodescanner.CaptureActivity" - android:screenOrientation="fullSensor" - tools:replace="screenOrientation" /> - <service android:name="net.taler.lib.android.TalerNfcService" android:exported="true" diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt @@ -51,6 +51,7 @@ import net.taler.wallet.peer.IncomingPullPaymentScreen import net.taler.wallet.peer.IncomingPushPaymentScreen import net.taler.wallet.peer.OutgoingPullScreen import net.taler.wallet.peer.OutgoingPushScreen +import net.taler.wallet.scan.ScanQrScreen import net.taler.wallet.settings.PerformanceStatsScreen import net.taler.wallet.tokens.TokenListScreen import net.taler.wallet.tokens.TokenViewMode @@ -68,7 +69,6 @@ fun WalletNavHost( navController: NavHostController, model: MainViewModel, modifier: Modifier = Modifier, - onScanQr: () -> Unit, onFulfillPayment: (url: String) -> Unit, onShowError: (TalerErrorInfo) -> Unit, ) { @@ -122,11 +122,19 @@ fun WalletNavHost( MainScreen( model = model, onNavigate = onNavigate, - onScanQr = onScanQr, onFulfillPayment = onFulfillPayment, onShowError = onShowError, ) } + composable<WalletDestination.ScanQr> { backStackEntry -> + val dest = backStackEntry.toRoute<WalletDestination.ScanQr>() + ScanQrScreen( + model = model, + initialTab = dest.initialTab, + onNavigate = onNavigate, + onNavigateBack = onNavigateBack, + ) + } composable<WalletDestination.HandleUri> { backStackEntry -> val dest = backStackEntry.toRoute<WalletDestination.HandleUri>() HandleUriScreen( diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt @@ -17,6 +17,7 @@ package net.taler.wallet import kotlinx.serialization.Serializable +import net.taler.wallet.scan.ScanTab @Serializable sealed interface WalletDestination { @@ -24,6 +25,9 @@ sealed interface WalletDestination { data object Main : WalletDestination @Serializable + data class ScanQr(val initialTab: ScanTab = ScanTab.SCAN_QR) : WalletDestination + + @Serializable data class HandleUri(val uri: String) : WalletDestination @Serializable diff --git a/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/main/MainActivity.kt @@ -68,13 +68,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController -import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN -import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanOptions -import com.journeyapps.barcodescanner.ScanOptions.QR_CODE import kotlinx.coroutines.launch -import net.taler.common.EventObserver import net.taler.lib.android.TalerNfcService import net.taler.wallet.R import net.taler.wallet.WalletDestination @@ -95,12 +89,6 @@ class MainActivity : FragmentActivity() { private lateinit var biometricPrompt: BiometricPrompt private lateinit var promptInfo: BiometricPrompt.PromptInfo - private val barcodeLauncher = registerForActivityResult(ScanContract()) { result -> - model.unlockWallet() // hack to prevent from locking after scanning QR - if (result == null || result.contents == null) return@registerForActivityResult - nav?.navigate(WalletDestination.HandleUri(result.contents)) - } - @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -131,7 +119,6 @@ class MainActivity : FragmentActivity() { navController = navController, model = model, modifier = Modifier.fillMaxSize(), - onScanQr = { model.scanCode() }, onFulfillPayment = { url: String -> launchInAppBrowser(this@MainActivity, url) }, onShowError = { errorInfo = it } ) @@ -191,17 +178,6 @@ class MainActivity : FragmentActivity() { } } - model.scanCodeEvent.observe(this, EventObserver { - val scanOptions = ScanOptions().apply { - setPrompt("") - setBeepEnabled(false) // FIXME: expose in settings - setOrientationLocked(false) - setDesiredBarcodeFormats(QR_CODE) - addExtra(SCAN_TYPE, MIXED_SCAN) - } - if (it) barcodeLauncher.launch(scanOptions) - }) - model.networkManager.networkStatus.observe(this) { online -> model.hintNetworkAvailability(online) } diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt @@ -16,27 +16,21 @@ package net.taler.wallet.main -import android.content.ClipboardManager import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BarChart -import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -53,12 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.core.content.getSystemService import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import net.taler.wallet.NavigateCallback @@ -69,10 +59,10 @@ import net.taler.wallet.balances.BalanceState import net.taler.wallet.balances.BalancesComposable import net.taler.wallet.compose.GlobalScaffold import net.taler.wallet.compose.collectAsStateLifecycleAware +import net.taler.wallet.scan.ScanTab import net.taler.wallet.settings.SettingsScreen import net.taler.wallet.transactions.Transaction import net.taler.wallet.transactions.TransactionMajorState -import net.taler.wallet.transactions.TransactionPayment import net.taler.wallet.transactions.TransactionPeerPullDebit import net.taler.wallet.transactions.TransactionPeerPushCredit import net.taler.wallet.transactions.TransactionState @@ -87,12 +77,10 @@ enum class MainTab { ASSETS, SETTINGS } fun MainScreen( model: MainViewModel, onNavigate: NavigateCallback, - onScanQr: () -> Unit, onFulfillPayment: (url: String) -> Unit, onShowError: (TalerErrorInfo) -> Unit, ) { var tab by rememberSaveable { mutableStateOf(MainTab.ASSETS) } - var showUriInput by remember { mutableStateOf(false) } var showSheet by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -117,13 +105,6 @@ fun MainScreen( model.settingsManager.getActionButtonUsed(context) }.collectAsStateLifecycleAware(true) - if (showUriInput) UriInputDialog( - onDismiss = { showUriInput = false }, - handleUri = { uri -> - onNavigate(WalletDestination.HandleUri(uri), true) - }, - ) - val tabTitle = when (tab) { MainTab.ASSETS -> when (viewMode) { is ViewMode.Transactions -> if (selectionMode) { @@ -197,7 +178,7 @@ fun MainScreen( }, onScanQr = { model.settingsManager.saveActionButtonUsed(context) - onScanQr() + onNavigate(WalletDestination.ScanQr(ScanTab.SCAN_QR), true) }, ) @@ -378,7 +359,7 @@ fun MainScreen( onScanQr = { showSheet = false model.settingsManager.saveActionButtonUsed(context) - onScanQr() + onNavigate(WalletDestination.ScanQr(ScanTab.SCAN_QR), true) }, onDeposit = { showSheet = false @@ -394,7 +375,7 @@ fun MainScreen( onEnterUri = { showSheet = false model.settingsManager.saveActionButtonUsed(context) - showUriInput = true + onNavigate(WalletDestination.ScanQr(ScanTab.ENTER_LINK), true) }, onShoppingDiscovery = { showSheet = false @@ -408,94 +389,6 @@ fun MainScreen( } } -@Composable -fun UriInputDialog( - onDismiss: () -> Unit, - handleUri: (String) -> Unit, -) { - val context = LocalContext.current - val invalidUriError = stringResource(R.string.uri_invalid) - val focusRequester = remember { FocusRequester() } - var uriText by remember { mutableStateOf("") } - var error by remember { mutableStateOf<String?>(null) } - val clipboard = context.getSystemService<ClipboardManager>() - - val isValidTalerUri = { uri: String -> - uri.trim().startsWith("taler://", ignoreCase = true) || - uri.trim().startsWith("taler+http://", ignoreCase = true) || - uri.trim().startsWith("payto://", ignoreCase = true) - } - - val getClipboardContents = { - val item = clipboard?.primaryClip?.getItemAt(0) - if (item?.text != null) { - item.text.toString().trim() - } else if (item?.uri != null) { - item.uri.toString().trim() - } else { - null - } - } - - AlertDialog( - title = { Text(stringResource(R.string.enter_uri)) }, - text = { - OutlinedTextField( - value = uriText, - onValueChange = { - uriText = it - error = null - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - label = { Text(stringResource(R.string.enter_uri_label)) }, - isError = error != null, - supportingText = error?.let { { Text(it) } }, - placeholder = { Text(stringResource(R.string.enter_uri_prefix)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - trailingIcon = { - IconButton(onClick = { - getClipboardContents()?.let { uriText = it } - }) { - Icon( - Icons.Default.ContentPaste, - contentDescription = stringResource(R.string.paste), - ) - } - }, - ) - }, - onDismissRequest = onDismiss, - confirmButton = { - Button(onClick = { - if (isValidTalerUri(uriText)) { - handleUri(uriText.trim()) - onDismiss() - } else { - error = invalidUriError - } - }) { - Text(stringResource(R.string.ok)) - } - }, - dismissButton = { - TextButton(onDismiss) { - Text(stringResource(R.string.cancel)) - } - }, - ) - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - getClipboardContents()?.let { uri -> - if (isValidTalerUri(uri)) { - uriText = uri - } - } - } -} - private fun onTransactionClicked( tx: Transaction, model: MainViewModel, diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.common.AmountParserException import net.taler.common.Event -import net.taler.common.toEvent import net.taler.wallet.accounts.AccountManager import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.NotificationPayload @@ -129,9 +128,6 @@ class MainViewModel( private val mShowObservabilityLog = MutableStateFlow(false) val showObservabilityLog: StateFlow<Boolean> = mShowObservabilityLog - private val mScanCodeEvent = MutableLiveData<Event<Boolean>>() - val scanCodeEvent: LiveData<Event<Boolean>> = mScanCodeEvent - private val mViewMode = MutableStateFlow<ViewMode>(ViewMode.Assets) val viewMode: StateFlow<ViewMode> = mViewMode @@ -248,11 +244,6 @@ class MainViewModel( balanceManager.resetBalances() } - @UiThread - fun scanCode() { - mScanCodeEvent.value = true.toEvent() - } - fun setDevMode(enabled: Boolean, onError: (error: TalerErrorInfo) -> Unit) { mDevMode.postValue(enabled) viewModelScope.launch { diff --git a/wallet/src/main/java/net/taler/wallet/scan/ScanQrScreen.kt b/wallet/src/main/java/net/taler/wallet/scan/ScanQrScreen.kt @@ -0,0 +1,601 @@ +/* + * This file is part of GNU Taler + * (C) 2026 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.scan + +import android.Manifest +import android.content.ClipboardManager +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import kotlinx.coroutines.launch +import net.taler.lib.android.qr.QrCameraAnalyzer +import net.taler.wallet.R +import net.taler.wallet.WalletDestination +import net.taler.wallet.NavigateCallback +import net.taler.wallet.compose.GlobalScaffold +import net.taler.wallet.main.MainViewModel + +enum class ScanTab { SCAN_QR, ENTER_LINK } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScanQrScreen( + model: MainViewModel, + initialTab: ScanTab, + onNavigate: NavigateCallback, + onNavigateBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + val pagerState = rememberPagerState( + initialPage = initialTab.ordinal, + pageCount = { ScanTab.entries.size }, + ) + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage == ScanTab.SCAN_QR.ordinal) { + keyboardController?.hide() + } + } + + GlobalScaffold( + model = model, + title = { Text(stringResource(R.string.scan_qr_title)) }, + onNavigateBack = onNavigateBack, + tabs = { + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == ScanTab.SCAN_QR.ordinal, + onClick = { scope.launch { pagerState.animateScrollToPage(ScanTab.SCAN_QR.ordinal) } }, + text = { Text(stringResource(R.string.scan_qr_tab)) }, + ) + Tab( + selected = pagerState.currentPage == ScanTab.ENTER_LINK.ordinal, + onClick = { scope.launch { pagerState.animateScrollToPage(ScanTab.ENTER_LINK.ordinal) } }, + text = { Text(stringResource(R.string.enter_link_tab)) }, + ) + } + }, + ) { innerPadding -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { page -> + when (ScanTab.entries[page]) { + ScanTab.SCAN_QR -> QrScannerTab( + onQrScanned = { uri -> + onNavigate(WalletDestination.HandleUri(uri), true) + }, + ) + ScanTab.ENTER_LINK -> EnterLinkTab( + onUriSubmitted = { uri -> + onNavigate(WalletDestination.HandleUri(uri), true) + }, + ) + } + } + } +} + +@Composable +private fun QrScannerTab( + onQrScanned: (String) -> Unit, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + var flashEnabled by remember { mutableStateOf(false) } + var zoomLevel by remember { mutableFloatStateOf(0f) } + var scannedOnce by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + hasCameraPermission = granted + } + + val galleryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) + if (bitmap != null) { + QrCameraAnalyzer.decodeQrFromBitmap(bitmap)?.let { result -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onQrScanned(result) + } + } + } + } + } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (hasCameraPermission) { + var previewView by remember { mutableStateOf<PreviewView?>(null) } + var analyzer by remember { mutableStateOf<QrCameraAnalyzer?>(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f)) { + AndroidView( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures { offset -> + analyzer?.focusAtPoint(offset.x, offset.y) + } + }, + factory = { ctx -> + PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + previewView = this + } + }, + ) + + ViewfinderOverlay( + modifier = Modifier.fillMaxSize(), + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f), + tonalElevation = 3.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 24.dp) + .padding(top = 20.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Top, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FilledTonalIconButton( + onClick = { + flashEnabled = !flashEnabled + analyzer?.enableTorch(flashEnabled) + }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (flashEnabled) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (flashEnabled) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon( + imageVector = if (flashEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff, + contentDescription = stringResource( + if (flashEnabled) R.string.flash_off else R.string.flash_on + ), + ) + } + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(if (flashEnabled) R.string.flash_off else R.string.flash_on), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FilledTonalIconButton( + onClick = { galleryLauncher.launch("image/*") }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon( + imageVector = Icons.Default.Image, + contentDescription = stringResource(R.string.scan_from_gallery), + ) + } + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(R.string.scan_from_gallery), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = "%.1f×".format(1f + zoomLevel * 9f), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.ZoomOut, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = zoomLevel, + onValueChange = { value -> + zoomLevel = value + analyzer?.setLinearZoom(value) + }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp), + ) + Icon( + imageVector = Icons.Default.ZoomIn, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + DisposableEffect(previewView) { + val pv = previewView + if (pv != null) { + val qa = QrCameraAnalyzer( + context = context, + lifecycleOwner = lifecycleOwner, + previewView = pv, + onQrDetected = { result -> + if (!scannedOnce) { + scannedOnce = true + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onQrScanned(result) + } + }, + onCameraReady = { initialLinearZoom -> + zoomLevel = initialLinearZoom + }, + ) + analyzer = qa + qa.startCamera() + } + onDispose { + analyzer?.stopCamera() + analyzer = null + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.camera_permission_required), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) { + Text(stringResource(R.string.grant_permission)) + } + } + } + } +} + +@Composable +private fun ViewfinderOverlay(modifier: Modifier = Modifier) { + val cornerColor = MaterialTheme.colorScheme.primary + Box( + modifier = modifier + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val scanAreaSize = size.minDimension * 0.7f + val left = (size.width - scanAreaSize) / 2 + val top = (size.height - scanAreaSize) / 2 + val scanRect = Rect(left, top, left + scanAreaSize, top + scanAreaSize) + + drawRect( + color = Color.Black.copy(alpha = 0.5f), + size = size, + ) + + val clearPath = Path().apply { + addRoundRect( + androidx.compose.ui.geometry.RoundRect( + scanRect, + CornerRadius(16.dp.toPx()), + ) + ) + } + drawPath( + path = clearPath, + color = Color.Transparent, + blendMode = BlendMode.Clear, + ) + + val cornerRadius = 16.dp.toPx() + val lineExtension = 24.dp.toPx() + val strokeWidth = 5.dp.toPx() + val strokeStyle = androidx.compose.ui.graphics.drawscope.Stroke( + width = strokeWidth, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + drawArc( + color = cornerColor, + startAngle = 180f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(scanRect.left, scanRect.top), + size = Size(cornerRadius * 2, cornerRadius * 2), + style = strokeStyle + ) + drawLine(cornerColor, + Offset(scanRect.left, scanRect.top + cornerRadius), + Offset(scanRect.left, scanRect.top + cornerRadius + lineExtension), strokeWidth) + drawLine(cornerColor, + Offset(scanRect.left + cornerRadius, scanRect.top), + Offset(scanRect.left + cornerRadius + lineExtension, scanRect.top), strokeWidth) + + drawArc( + color = cornerColor, + startAngle = 270f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(scanRect.right - cornerRadius * 2, scanRect.top), + size = Size(cornerRadius * 2, cornerRadius * 2), + style = strokeStyle + ) + drawLine(cornerColor, + Offset(scanRect.right, scanRect.top + cornerRadius), + Offset(scanRect.right, scanRect.top + cornerRadius + lineExtension), strokeWidth) + drawLine(cornerColor, + Offset(scanRect.right - cornerRadius, scanRect.top), + Offset(scanRect.right - cornerRadius - lineExtension, scanRect.top), strokeWidth) + + drawArc( + color = cornerColor, + startAngle = 90f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(scanRect.left, scanRect.bottom - cornerRadius * 2), + size = Size(cornerRadius * 2, cornerRadius * 2), + style = strokeStyle + ) + drawLine(cornerColor, + Offset(scanRect.left, scanRect.bottom - cornerRadius), + Offset(scanRect.left, scanRect.bottom - cornerRadius - lineExtension), strokeWidth) + drawLine(cornerColor, + Offset(scanRect.left + cornerRadius, scanRect.bottom), + Offset(scanRect.left + cornerRadius + lineExtension, scanRect.bottom), strokeWidth) + + drawArc( + color = cornerColor, + startAngle = 0f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(scanRect.right - cornerRadius * 2, scanRect.bottom - cornerRadius * 2), + size = Size(cornerRadius * 2, cornerRadius * 2), + style = strokeStyle + ) + drawLine(cornerColor, + Offset(scanRect.right, scanRect.bottom - cornerRadius), + Offset(scanRect.right, scanRect.bottom - cornerRadius - lineExtension), strokeWidth) + drawLine(cornerColor, + Offset(scanRect.right - cornerRadius, scanRect.bottom), + Offset(scanRect.right - cornerRadius - lineExtension, scanRect.bottom), strokeWidth) + } + } +} + +@Composable +private fun EnterLinkTab( + onUriSubmitted: (String) -> Unit, +) { + val context = LocalContext.current + val invalidUriError = stringResource(R.string.uri_invalid) + val focusRequester = remember { FocusRequester() } + var uriText by remember { mutableStateOf("") } + var error by remember { mutableStateOf<String?>(null) } + val clipboard = context.getSystemService<ClipboardManager>() + + val isValidTalerUri = { uri: String -> + uri.trim().startsWith("taler://", ignoreCase = true) || + uri.trim().startsWith("taler+http://", ignoreCase = true) || + uri.trim().startsWith("payto://", ignoreCase = true) + } + + val getClipboardContents = { + val item = clipboard?.primaryClip?.getItemAt(0) + if (item?.text != null) { + item.text.toString().trim() + } else if (item?.uri != null) { + item.uri.toString().trim() + } else { + null + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = uriText, + onValueChange = { + uriText = it + error = null + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + label = { Text(stringResource(R.string.enter_uri_label)) }, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + placeholder = { Text(stringResource(R.string.enter_uri_prefix)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + trailingIcon = { + IconButton(onClick = { + getClipboardContents()?.let { uriText = it } + }) { + Icon( + Icons.Default.ContentPaste, + contentDescription = stringResource(R.string.paste), + ) + } + }, + ) + + Button( + onClick = { + if (isValidTalerUri(uriText)) { + onUriSubmitted(uriText.trim()) + } else { + error = invalidUriError + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = uriText.isNotBlank(), + ) { + Text(stringResource(R.string.open)) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + getClipboardContents()?.let { uri -> + if (isValidTalerUri(uri)) { + uriText = uri + } + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/res/values-de/strings.xml b/wallet/src/main/res/values-de/strings.xml @@ -215,6 +215,14 @@ <string name="qr_scan_context_title">Möglicherweise unbeabsichtigte Aktion</string> <string name="qr_scan_context_receive_message">Es scheint, dass Sie Geld erhalten wollten, doch entspricht der eingescannte QR-Code einer anderen Aktion. Möchten Sie dennoch fortfahren?</string> <string name="qr_scan_context_send_message">Es scheint, dass Sie Geld senden wollten, doch entspricht der eingescannte QR-Code einer anderen Aktion. Möchten Sie dennoch fortfahren?</string> + <string name="scan_qr_title">Scannen oder Link eingeben</string> + <string name="scan_qr_tab">QR scannen</string> + <string name="enter_link_tab">Taler-Link eingeben</string> + <string name="camera_permission_required">Kameraberechtigung ist erforderlich, um QR-Codes zu scannen</string> + <string name="grant_permission">Berechtigung erteilen</string> + <string name="flash_on">Blitz einschalten</string> + <string name="flash_off">Blitz ausschalten</string> + <string name="scan_from_gallery">Aus Galerie scannen</string> <string name="amount_fee">Gebühr</string> <string name="amount_invalid">Ungültiger Betrag</string> <string name="amount_invoiced">Betrag angefordert</string> diff --git a/wallet/src/main/res/values-fr/strings.xml b/wallet/src/main/res/values-fr/strings.xml @@ -244,6 +244,14 @@ <string name="observability_show_json">Afficher le JSON</string> <string name="settings_alert_reset_canceled">Réinitialisation annulée</string> <string name="qr_scan_context_title">Action éventuellement involontaire</string> + <string name="scan_qr_title">Scanner ou saisir le lien</string> + <string name="scan_qr_tab">Scanner QR</string> + <string name="enter_link_tab">Saisir le lien Taler</string> + <string name="camera_permission_required">L\'autorisation de la caméra est requise pour scanner les codes QR</string> + <string name="grant_permission">Accorder l\'autorisation</string> + <string name="flash_on">Activer le flash</string> + <string name="flash_off">Désactiver le flash</string> + <string name="scan_from_gallery">Scanner depuis la galerie</string> <string name="exchange_delete">Effacer le prestataire</string> <string name="balance_scope_auditor">Auditeur : %1$s</string> <string name="balances_empty_withdraw_kudos_message">Obtenez de l\'argent fictif pour découvrir comment payer avec de l\'argent numérique.</string> diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -98,6 +98,15 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <!-- QR code handling --> + <string name="scan_qr_title">Scan or enter link</string> + <string name="scan_qr_tab">Scan QR</string> + <string name="enter_link_tab">Enter Taler link</string> + <string name="camera_permission_required">Camera permission is required to scan QR codes</string> + <string name="grant_permission">Grant permission</string> + <string name="flash_on">Turn flash on</string> + <string name="flash_off">Turn flash off</string> + <string name="scan_from_gallery">Scan from gallery</string> + <string name="qr_scan_context_title">Possibly unintended action</string> <string name="qr_scan_context_receive_message">Seems like you were intending to receive money, but the QR code that you scanned corresponds to a different action. Do you wish to continue?</string> <string name="qr_scan_context_send_message">Seems like you were intending to send money, but the QR code that you scanned corresponds to a different action. Do you wish to continue?</string>