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

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