add support for PayMV QR scan

This commit is contained in:
2026-05-13 03:36:14 +05:00
parent b452940ed0
commit 6a3738fc2f
13 changed files with 395 additions and 3 deletions

1
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/BinaryEye" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,77 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /home/shihaam/git/sargit/basedbank/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt:23:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally$lambda$9$compile(IncrementalCompilerRunner.kt:249)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:267)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 34 more

View File

@@ -5,12 +5,12 @@ plugins {
android {
namespace = "sh.sar.basedbank"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 35
targetSdk = 36
versionCode = 1
versionName = "1.0"
@@ -61,6 +61,14 @@ dependencies {
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation("androidx.camera:camera-view:1.4.2")
implementation("com.github.markusfisch:zxing-cpp:v3.0.2.5")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -48,6 +49,11 @@
android:name=".ui.home.HomeActivity"
android:exported="false" />
<activity
android:name=".ui.home.QrScannerActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application>
</manifest>

View File

@@ -0,0 +1,169 @@
package sh.sar.basedbank.ui.home
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import de.markusfisch.android.zxingcpp.ZxingCpp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
import java.util.concurrent.Executors
class QrScannerActivity : AppCompatActivity() {
private lateinit var binding: ActivityQrScannerBinding
private val cameraExecutor = Executors.newSingleThreadExecutor()
private val parentJob = Job()
private val scope = CoroutineScope(Dispatchers.IO + parentJob)
private var resultDelivered = false
private val readerOptions = ZxingCpp.ReaderOptions(
tryHarder = true,
tryRotate = true,
tryInvert = true,
tryDownscale = true,
maxNumberOfSymbols = 1,
textMode = ZxingCpp.TextMode.PLAIN
)
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) startCamera() else finish() }
private val pickImageLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
uri ?: return@registerForActivityResult
scope.launch {
val bitmap = try {
contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) }
} catch (_: Exception) { null }
if (bitmap == null) {
withContext(Dispatchers.Main) {
Toast.makeText(this@QrScannerActivity, "Could not load image", Toast.LENGTH_SHORT).show()
}
return@launch
}
val text = bitmap.decodeQr()?.firstOrNull()?.text
withContext(Dispatchers.Main) {
if (text != null) {
deliverResult(text)
} else {
Toast.makeText(this@QrScannerActivity, "No QR code found in image", Toast.LENGTH_SHORT).show()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnCancel.setOnClickListener { finish() }
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) startCamera()
else permissionLauncher.launch(Manifest.permission.CAMERA)
}
private fun startCamera() {
val future = ProcessCameraProvider.getInstance(this)
future.addListener({
val provider = try {
future.get()
} catch (_: Exception) {
finish(); return@addListener
}
val resolutionSelector = ResolutionSelector.Builder()
.setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY)
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.build()
val preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
.also { it.setSurfaceProvider(binding.previewView.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { ia ->
ia.setAnalyzer(cameraExecutor) { image ->
if (!resultDelivered) {
val yPlane = image.planes[0]
ZxingCpp.readYBuffer(
yPlane.buffer,
yPlane.rowStride,
Rect(0, 0, image.width, image.height),
image.imageInfo.rotationDegrees,
readerOptions
)?.firstOrNull()?.let { result ->
runOnUiThread { deliverResult(result.text) }
}
}
image.close()
}
}
try {
provider.unbindAll()
provider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
)
} catch (_: Exception) {
finish()
}
}, ContextCompat.getMainExecutor(this))
}
// Mirror BinaryEye: try LOCAL_AVERAGE first, fall back to GLOBAL_HISTOGRAM
private fun Bitmap.decodeQr() = ZxingCpp.readBitmap(
this, 0, 0, width, height, 0,
readerOptions.apply { binarizer = ZxingCpp.Binarizer.LOCAL_AVERAGE }
) ?: ZxingCpp.readBitmap(
this, 0, 0, width, height, 0,
readerOptions.apply { binarizer = ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
)
private fun deliverResult(text: String) {
if (resultDelivered) return
resultDelivered = true
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR_CONTENT, text))
finish()
}
override fun onDestroy() {
super.onDestroy()
parentJob.cancel()
cameraExecutor.shutdown()
}
companion object {
const val EXTRA_QR_CONTENT = "qr_content"
}
}

View File

@@ -17,6 +17,9 @@ import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import android.app.Activity
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -29,6 +32,7 @@ import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibTransferClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
@@ -41,6 +45,19 @@ class TransferFragment : Fragment() {
private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
if (qr.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "")
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferBinding.inflate(inflater, container, false)
return binding.root
@@ -61,6 +78,10 @@ class TransferFragment : Fragment() {
sheet.show(childFragmentManager, "contact_picker")
}
binding.btnScanQr.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
binding.btnTransfer.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
@@ -90,6 +111,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
}
@@ -99,6 +121,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
}
}
}
@@ -147,6 +170,7 @@ class TransferFragment : Fragment() {
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
saveToRecents(info)
@@ -166,6 +190,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
binding.etTo.setText(accountNumber)
lookupAccount()

View File

@@ -0,0 +1,48 @@
package sh.sar.basedbank.util
data class PaymvQrData(
val accountNumber: String?,
val amount: String?,
val purpose: String?,
val merchantName: String?
)
object PaymvQrParser {
fun parse(raw: String): PaymvQrData? {
return try {
val root = parseTlv(raw)
// ID 26: Favara/PayMV merchant account info
val merchantInfo = root["26"]?.let { parseTlv(it) }
// ID 54: transaction amount
// ID 59: merchant/recipient name
// ID 62: additional data (sub-ID 08 = purpose)
val additionalData = root["62"]?.let { parseTlv(it) }
PaymvQrData(
accountNumber = merchantInfo?.get("03"),
amount = root["54"],
purpose = additionalData?.get("08"),
merchantName = root["59"]
)
} catch (_: Exception) {
null
}
}
private fun parseTlv(data: String): Map<String, String> {
val result = mutableMapOf<String, String>()
var pos = 0
while (pos + 4 <= data.length) {
val id = data.substring(pos, pos + 2)
val len = data.substring(pos + 2, pos + 4).toIntOrNull() ?: break
pos += 4
if (pos + len > data.length) break
result[id] = data.substring(pos, pos + len)
pos += len
}
return result
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M9.5,6.5v3h-3v-3H9.5zM11,5L5,5v5.5h6L11,5zM9.5,14.5v3h-3v-3H9.5zM11,13L5,13v5.5h6L11,13zM17.5,6.5v3h-3v-3H17.5zM19,5h-6v5.5h6L19,5zM13,13h1.5v1.5L13,14.5zM14.5,14.5L16,14.5L16,16h-1.5zM16,13h1.5v1.5L16,14.5zM13,16h1.5v1.5L13,17.5zM14.5,17.5L16,17.5L16,19h-1.5zM16,16h1.5v1.5L16,17.5zM17.5,14.5L19,14.5L19,16h-1.5zM17.5,17.5L19,17.5L19,19h-1.5z"/>
</vector>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<androidx.camera.view.PreviewView
android:id="@+id/previewView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="48dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPickImage"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/qr_pick_image" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCancel"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/back" />
</LinearLayout>
</FrameLayout>

View File

@@ -81,6 +81,15 @@
app:icon="@drawable/ic_contacts"
android:contentDescription="@string/transfer_pick_contact" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanQr"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:icon="@drawable/ic_qr_scan"
android:contentDescription="@string/transfer_scan_qr" />
</LinearLayout>
<!-- Confirmed account info card (shown after successful lookup) -->

View File

@@ -104,6 +104,9 @@
<string name="transfer_lookup_account">Look up account</string>
<string name="transfer_clear_recipient">Clear recipient</string>
<string name="transfer_pick_contact">Pick contact</string>
<string name="transfer_scan_qr">Scan QR</string>
<string name="qr_pick_image">Pick image</string>
<string name="transfer_qr_invalid">Invalid or unsupported QR code</string>
<string name="transfer_enter_account_first">Enter an account number first</string>
<string name="transfer_account_not_found">Account not found</string>
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>

View File

@@ -1,6 +1,6 @@
[versions]
agp = "8.9.1"
kotlin = "2.0.21"
kotlin = "2.1.21"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"

View File

@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}