diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c45942..8a80313 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,6 +73,9 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + // ZXing core for QR code generation + implementation("com.google.zxing:core:3.5.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") diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 720d445..165b355 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -115,6 +115,7 @@ class HomeActivity : AppCompatActivity() { R.id.nav_accounts -> AccountsFragment() R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> TransferFragment() + R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_more -> MoreFragment() R.id.nav_activities -> ActivitiesFragment() R.id.nav_transfer_history -> TransferHistoryFragment() @@ -273,15 +274,16 @@ fun applyNavLabelVisibility() { fun navigateTo(itemId: Int, fragment: Fragment? = null) { val dest = fragment ?: when (itemId) { - R.id.nav_dashboard -> DashboardFragment() - R.id.nav_accounts -> AccountsFragment() - R.id.nav_contacts -> ContactsFragment() - R.id.nav_transfer -> TransferFragment() - R.id.nav_activities -> ActivitiesFragment() + R.id.nav_dashboard -> DashboardFragment() + R.id.nav_accounts -> AccountsFragment() + R.id.nav_contacts -> ContactsFragment() + R.id.nav_transfer -> TransferFragment() + R.id.nav_pay_mv_qr -> PayMvQrFragment() + R.id.nav_activities -> ActivitiesFragment() R.id.nav_transfer_history -> TransferHistoryFragment() - R.id.nav_finances -> FinancingFragment() - R.id.nav_otp -> OtpFragment() - R.id.nav_settings -> SettingsFragment() + R.id.nav_finances -> FinancingFragment() + R.id.nav_otp -> OtpFragment() + R.id.nav_settings -> SettingsFragment() else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return } } show(dest) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt new file mode 100644 index 0000000..cce6e2d --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt @@ -0,0 +1,411 @@ +package sh.sar.basedbank.ui.home + +import android.content.ContentValues +import android.content.Context +import android.graphics.* +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.app.Activity +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.FileProvider +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibAccount +import sh.sar.basedbank.databinding.FragmentPayMvQrBinding +import sh.sar.basedbank.databinding.ItemAccountDropdownBinding +import sh.sar.basedbank.util.PaymvQrParser +import java.io.File +import java.io.FileOutputStream + +class PayMvQrFragment : Fragment() { + + private var _binding: FragmentPayMvQrBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + + private var selectedAccount: MibAccount? = null + private var generatedBitmap: Bitmap? = null + private var generateJob: Job? = null + + 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 + } + val activity = requireActivity() as HomeActivity + activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr( + accountNumber = qr.accountNumber, + displayName = qr.merchantName ?: qr.accountNumber, + amount = qr.amount, + remarks = qr.purpose + )) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = FragmentPayMvQrBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setupDropdown() + binding.etAmount.addTextChangedListener { scheduleGenerate() } + binding.btnShare.isEnabled = false + binding.btnSave.isEnabled = false + binding.btnShare.setOnClickListener { shareQr() } + binding.btnSave.setOnClickListener { saveQr() } + binding.btnScanQr.setOnClickListener { + qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) + } + } + + private fun setupDropdown() { + viewModel.accounts.observe(viewLifecycleOwner) { accounts -> + val eligible = accounts.filter { + it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" + } + val adapter = QrAccountAdapter(requireContext(), eligible) + binding.actvAccount.setAdapter(adapter) + binding.actvAccount.setOnItemClickListener { _, _, position, _ -> + val picked = adapter.getAccount(position) ?: return@setOnItemClickListener + selectedAccount = picked + scheduleGenerate() + } + } + } + + private fun scheduleGenerate() { + generateJob?.cancel() + generateJob = viewLifecycleOwner.lifecycleScope.launch { + delay(300) + generateQr() + } + } + + private suspend fun generateQr() { + val account = selectedAccount ?: return + val acquirer = when (account.bank) { + "BML" -> "MALBMVMV" + "MIB" -> "MADVMVMV" + "FAHIPAY" -> "FAHIMVMV" + else -> "MADVMVMV" + } + val amountFormatted = binding.etAmount.text?.toString()?.trim() + ?.replace(",", "") + ?.toDoubleOrNull() + ?.takeIf { it > 0 } + ?.let { "%.2f".format(it) } + + val ctx = requireContext() + val bmp = withContext(Dispatchers.Default) { + val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted) + renderQrCard(ctx, account, payload, amountFormatted) + } + if (_binding == null) return + generatedBitmap = bmp + binding.tvQrPlaceholder.visibility = View.GONE + binding.ivQrCard.setImageBitmap(bmp) + binding.ivQrCard.visibility = View.VISIBLE + binding.btnShare.isEnabled = true + binding.btnSave.isEnabled = true + } + + // ── EMV MPQR payload ────────────────────────────────────────────────────── + + private fun buildQrPayload( + accountNumber: String, + accountName: String, + acquirer: String, + amountStr: String? + ): String { + fun tlv(tag: String, value: String): String { + val len = value.length + return tag + (if (len < 10) "0$len" else "$len") + value + } + val format = tlv("00", "01") + val poi = tlv("01", "11") + val sub00 = tlv("00", "mv.favara.mpqr") + val sub01 = tlv("01", acquirer) + val sub03 = tlv("03", accountNumber) + val sub10 = tlv("10", "IPAY") + val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10) + val currency = tlv("53", "462") + val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else "" + val country = tlv("58", "MV") + val name = tlv("59", accountName.take(25)) + val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304" + return prefix + crc16(prefix) + } + + private fun crc16(data: String): String { + var crc = 0xFFFF + for (c in data) { + crc = crc xor ((c.code and 0xFF) shl 8) + repeat(8) { + crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021 + else (crc shl 1) and 0xFFFF + } + } + return crc.toString(16).uppercase().padStart(4, '0') + } + + // ── QR card rendering ──────────────────────────────────────────────────── + + private fun renderQrCard( + ctx: Context, + account: MibAccount, + qrPayload: String, + amountStr: String? + ): Bitmap { + val W = 900 + val H = 1080 + val outerCorner = 48f + val boxBlue = Color.parseColor("#2272B7") + val footerBlue = Color.parseColor("#1A5799") + val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f + + val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bm) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Clip to outer rounded card shape + val outerPath = Path() + outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW) + canvas.clipPath(outerPath) + canvas.drawColor(Color.WHITE) + + // --- Bank logo top-left --- + val logoRes = when (account.bank) { + "BML" -> R.drawable.bml_logo_vector + "MIB" -> R.drawable.mib_faisanet_logo + else -> R.drawable.fahipay_logo_long + } + AppCompatResources.getDrawable(ctx, logoRes)?.let { d -> + val nW = d.intrinsicWidth.coerceAtLeast(1) + val nH = d.intrinsicHeight.coerceAtLeast(1) + val maxW = 180f; val maxH = 76f + val scale = minOf(maxW / nW, maxH / nH) + val lW = (nW * scale).toInt() + val lH = (nH * scale).toInt() + val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10) + d.setBounds(24, lTop, 24 + lW, lTop + lH) + d.draw(canvas) + } + + // --- "PayMV QR" top-right --- + paint.color = Color.parseColor("#1A1A2E") + paint.textSize = 36f + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + paint.textAlign = Paint.Align.RIGHT + canvas.drawText("PayMV QR", W - 28f, 66f, paint) + + // --- Blue rounded box --- + paint.color = boxBlue + paint.textAlign = Paint.Align.LEFT + canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint) + + // Account name (white, bold, uppercase, auto-scaled to fit) + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + paint.textAlign = Paint.Align.CENTER + val nameText = account.accountBriefName.uppercase() + paint.textSize = 36f + val maxNameW = boxR - boxL - 48f + if (paint.measureText(nameText) > maxNameW) { + paint.textSize = 36f * maxNameW / paint.measureText(nameText) + } + val nameBaseline = boxT + 68f + canvas.drawText(nameText, W / 2f, nameBaseline, paint) + + // Optional amount below name + val qrTopY: Float + if (!amountStr.isNullOrBlank()) { + paint.textSize = 28f + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) + val amtBaseline = nameBaseline + 42f + canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint) + qrTopY = amtBaseline + 20f + } else { + qrTopY = nameBaseline + 26f + } + + // QR code — white modules on the same blue as the box background + val availH = boxB - qrTopY - 24f + val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200) + val qrLeft = ((W - qrPx) / 2).toFloat() + try { + val hints = mapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M + ) + val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints) + val pixels = IntArray(qrPx * qrPx) + for (y in 0 until qrPx) { + for (x in 0 until qrPx) { + pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue + } + } + val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888) + canvas.drawBitmap(qrBm, qrLeft, qrTopY, null) + qrBm.recycle() + } catch (_: Exception) { /* skip if encoding fails */ } + + // --- Dark blue footer --- + paint.color = footerBlue + paint.textAlign = Paint.Align.LEFT + canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint) + paint.color = Color.WHITE + paint.textSize = 32f + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + paint.textAlign = Paint.Align.CENTER + canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint) + + return bm + } + + // ── Share / Save ───────────────────────────────────────────────────────── + + private fun shareQr() { + val bmp = generatedBitmap ?: return + val account = selectedAccount ?: return + lifecycleScope.launch { + val uri = withContext(Dispatchers.IO) { + try { + val dir = File(requireContext().cacheDir, "qr") + dir.mkdirs() + val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_") + val file = File(dir, "${safeName}_paymv_qr.png") + FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } + FileProvider.getUriForFile( + requireContext(), + "${requireContext().packageName}.fileprovider", + file + ) + } catch (_: Exception) { null } + } + if (uri == null || _binding == null) return@launch + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(android.content.Intent.EXTRA_STREAM, uri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share))) + } + } + + private fun saveQr() { + val bmp = generatedBitmap ?: return + val account = selectedAccount ?: return + lifecycleScope.launch { + val saved = withContext(Dispatchers.IO) { + try { + val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_") + val filename = "${safeName}_PayMV_QR.png" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + put(MediaStore.Images.Media.MIME_TYPE, "image/png") + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + } + val uri = requireContext().contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values + ) ?: return@withContext false + requireContext().contentResolver.openOutputStream(uri)?.use { + bmp.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } else { + @Suppress("DEPRECATION") + val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + dir.mkdirs() + FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + true + } catch (_: Exception) { false } + } + if (_binding == null) return@launch + Toast.makeText( + requireContext(), + if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed, + Toast.LENGTH_SHORT + ).show() + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.pay_mv_qr) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + // ── Account dropdown adapter ────────────────────────────────────────────── + + private inner class QrAccountAdapter( + private val context: Context, + private val accounts: List + ) : BaseAdapter(), Filterable { + + fun getAccount(position: Int): MibAccount? = accounts.getOrNull(position) + + override fun getCount() = accounts.size + override fun getItem(position: Int) = accounts.getOrNull(position) + override fun getItemId(position: Int) = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup) = + getDropDownView(position, convertView, parent) + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val acc = accounts[position] + val b = if (convertView?.tag is ItemAccountDropdownBinding) { + convertView.tag as ItemAccountDropdownBinding + } else { + ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false) + .also { it.root.tag = it } + } + val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else "" + b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}" + b.tvDropdownAccountNumber.text = acc.accountNumber + b.tvDropdownBalance.text = "" + b.root.alpha = 1f + return b.root + } + + override fun getFilter() = object : Filter() { + override fun performFiltering(c: CharSequence?) = + FilterResults().apply { values = accounts; count = accounts.size } + override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged() + override fun convertResultToString(r: Any?) = + (r as? MibAccount)?.let { + val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else "" + "$prefix${it.accountBriefName}" + } ?: "" + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 709ae56..02c4b90 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -102,6 +102,8 @@ class TransferFragment : Fragment() { private const val ARG_COLOR = "contact_color" private const val ARG_IMAGE_HASH = "contact_image_hash" private const val ARG_FROM_ACCOUNT = "from_account" + private const val ARG_AMOUNT_PREFILL = "amount_prefill" + private const val ARG_REMARKS_PREFILL = "remarks_prefill" fun newInstanceFrom(account: MibAccount) = TransferFragment().apply { arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } @@ -122,6 +124,22 @@ class TransferFragment : Fragment() { if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash) } } + + fun newInstanceFromQr( + accountNumber: String, + displayName: String, + amount: String?, + remarks: String? + ) = TransferFragment().apply { + arguments = Bundle().apply { + putString(ARG_ACCOUNT, accountNumber) + putString(ARG_NAME, displayName) + putString(ARG_SUBTITLE, accountNumber) + putString(ARG_COLOR, "#607D8B") + if (amount != null) putString(ARG_AMOUNT_PREFILL, amount) + if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks) + } + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -156,7 +174,7 @@ class TransferFragment : Fragment() { binding.etAmount.addTextChangedListener { updateTransferButton() } - // Pre-select contact if navigated from contacts page + // Pre-select contact if navigated from contacts page or QR scan arguments?.getString(ARG_ACCOUNT)?.let { account -> prefillToDirectly( accountNumber = account, @@ -166,6 +184,8 @@ class TransferFragment : Fragment() { imageHash = arguments?.getString(ARG_IMAGE_HASH) ) } + arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } + arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } } private fun startLookupLoading() { diff --git a/app/src/main/res/layout/fragment_pay_mv_qr.xml b/app/src/main/res/layout/fragment_pay_mv_qr.xml new file mode 100644 index 0000000..f8acb06 --- /dev/null +++ b/app/src/main/res/layout/fragment_pay_mv_qr.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d99971..910b080 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,15 @@ Transfer PayMV QR + + Select account + Amount (optional) + Leave empty to allow payer to enter any amount + Share + Save Image + QR saved to gallery + Failed to save image + Lock app Locking soon diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index bf2868b..34e0521 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ +