add support for PayMV QR scan
This commit is contained in:
169
app/src/main/java/sh/sar/basedbank/ui/home/QrScannerActivity.kt
Normal file
169
app/src/main/java/sh/sar/basedbank/ui/home/QrScannerActivity.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
48
app/src/main/java/sh/sar/basedbank/util/PaymvQrParser.kt
Normal file
48
app/src/main/java/sh/sar/basedbank/util/PaymvQrParser.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user