Compare commits

...
Author SHA1 Message Date
shihaam c7d3efa64e update docs: update payMV QR Design 2026-09-26 04:06:06 +05:00
shihaam 2688118f52 update payMV QR Design 2026-09-26 04:05:36 +05:00
11 changed files with 510 additions and 115 deletions
@@ -7,12 +7,15 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.text.TextPaint
import android.text.TextUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -65,7 +68,18 @@ class PayMvQrFragment : Fragment() {
private data class QrTarget(val accountNumber: String, val name: String, val bank: String) private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
private fun currentTarget(): QrTarget? = contactTarget private fun currentTarget(): QrTarget? = contactTarget
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) } ?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) }
/** Name printed on the card and put in the payload (tag 59). */
private fun qrHolderName(account: BankAccount): String = when {
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
?: CredentialStore(requireContext())
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
?.fullName?.takeIf { it.isNotBlank() }
?: account.accountBriefName
else -> account.accountBriefName
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
contactTarget = arguments?.let { args -> contactTarget = arguments?.let { args ->
@@ -146,11 +160,13 @@ class PayMvQrFragment : Fragment() {
"FAHIPAY" -> "FAHIMVMV" "FAHIPAY" -> "FAHIMVMV"
else -> "MADVMVMV" else -> "MADVMVMV"
} }
val amountFormatted = binding.etAmount.text?.toString()?.trim() val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
?.replace(",", "") val amountFormatted = amountRaw
?.toDoubleOrNull() ?.toDoubleOrNull()
?.takeIf { it > 0 } ?.takeIf { it > 0 }
?.let { "%.2f".format(it) } ?.let { "%.2f".format(it) }
// BML shows the amount on the card as typed (no forced decimals)
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
val ctx = requireContext() val ctx = requireContext()
val account = selectedAccount val account = selectedAccount
@@ -165,17 +181,28 @@ class PayMvQrFragment : Fragment() {
when { when {
m.startsWith("+") -> m m.startsWith("+") -> m
m.length == 7 -> "+960$m" m.length == 7 -> "+960$m"
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
else -> m else -> m
} }
} }
} else null } else null
val purpose = binding.etReference.text?.toString()?.trim() val purpose = binding.etReference.text?.toString()?.trim()
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default) ?.takeIf { it.isNotBlank() }
// The reference (62/05) is also printed vertically beside the QR, as each bank does
val reference = when (target.bank) {
// BML: base-32 account number followed by the amount as typed
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
else -> generateReference(9)
}
val bmp = withContext(Dispatchers.Default) { val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose) val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
renderQrCard(ctx, target, payload, amountFormatted) if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
else renderQrCard(ctx, target, payload, reference)
} }
if (_binding == null) return if (_binding == null) return
generatedBitmap = bmp generatedBitmap = bmp
@@ -194,14 +221,19 @@ class PayMvQrFragment : Fragment() {
acquirer: String, acquirer: String,
amountStr: String?, amountStr: String?,
mobile: String?, mobile: String?,
purpose: String purpose: String?,
ref: String,
bank: String
): String { ): String {
fun tlv(tag: String, value: String): String { fun tlv(tag: String, value: String): String {
val len = value.length val len = value.length
return tag + (if (len < 10) "0$len" else "$len") + value return tag + (if (len < 10) "0$len" else "$len") + value
} }
val format = tlv("00", "01") val format = tlv("00", "01")
val poi = tlv("01", "11") // Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
// as "***" when they don't; its scanner may reject QRs that differ
val fahipay = bank == "FAHIPAY"
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
val sub00 = tlv("00", "mv.favara.mpqr") val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer) val sub01 = tlv("01", acquirer)
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
@@ -211,21 +243,28 @@ class PayMvQrFragment : Fragment() {
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10) val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000") val mcc = tlv("52", "0000")
val currency = tlv("53", "462") val currency = tlv("53", "462")
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else "" val amountTLV = when {
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
fahipay -> tlv("54", "***")
else -> ""
}
val country = tlv("58", "MV") val country = tlv("58", "MV")
val name = tlv("59", accountName.take(25)) val name = tlv("59", accountName.uppercase().take(25))
val ref = generateReference() // Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose)) val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
val timestamp = java.time.LocalDateTime.now() val timestamp = java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000")) .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp)) val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304" val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
return prefix + crc16(prefix) return prefix + crc16(prefix)
} }
private fun generateReference(): String { private fun generateReference(length: Int): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..9).map { chars.random() }.joinToString("") return (1..length).map { chars.random() }.joinToString("")
} }
private fun crc16(data: String): String { private fun crc16(data: String): String {
@@ -242,88 +281,127 @@ class PayMvQrFragment : Fragment() {
// ── QR card rendering ──────────────────────────────────────────────────── // ── QR card rendering ────────────────────────────────────────────────────
/**
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
* [PX_PER_DP] pixels per dp.
*/
private fun renderQrCard( private fun renderQrCard(
ctx: Context, ctx: Context,
target: QrTarget, target: QrTarget,
qrPayload: String, qrPayload: String,
amountStr: String? qrId: String
): Bitmap { ): Bitmap {
val W = 900 val sw = SCREEN_WIDTH_DP
val H = 1080 fun px(dp: Float) = dp * PX_PER_DP
val outerCorner = 48f val mmaBlue = Color.parseColor("#0E5CA4")
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 cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
val qrSize = sw * 0.5f
val railW = sw / 1.85f
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = px(14f)
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = px(sw * 0.046f)
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
textAlign = Paint.Align.CENTER
}
// Android RN Text lines include font padding: line height = bottom - top
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
// --- Vertical layout (dp, card-local) ---
val topCardTop = 2f
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
val logoBoxH = logoBoxW * 0.1116751269035533f
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
val payMvBoxH = payMvBoxW * 0.17333333333333334f
val brandH = maxOf(logoBoxH, payMvBoxH)
val nameText = target.name.uppercase()
val hasName = nameText.isNotBlank()
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
val nameH = if (hasName) lineHeight(namePaint) else 0f
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
val footerLineH = lineHeight(footerPaint)
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm) val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG) val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Clip to outer rounded card shape // captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
val outerPath = Path() val outerPath = Path().apply {
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW) addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
canvas.clipPath(outerPath)
canvas.drawColor(Color.WHITE)
// --- Bank logo top-left ---
val logoRes = when (target.bank) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long
} }
canvas.clipPath(outerPath)
canvas.drawColor(mmaBlue)
// topCard: white, 2dp inset, top corners 18
paint.color = Color.WHITE
val r = px(18f)
canvas.drawPath(Path().apply {
addRoundRect(
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
)
}, paint)
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
val rowL = 2f + 40f
val rowR = cardW - 2f - 40f
val rowCenterY = brandTop + brandH / 2
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
AppCompatResources.getDrawable(ctx, logoRes)?.let { d -> AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
val nW = d.intrinsicWidth.coerceAtLeast(1) val nW = d.intrinsicWidth.coerceAtLeast(1)
val nH = d.intrinsicHeight.coerceAtLeast(1) val nH = d.intrinsicHeight.coerceAtLeast(1)
val maxW = 180f; val maxH = 76f // resizeMode "contain" inside the logo box, left-aligned in the row
val scale = minOf(maxW / nW, maxH / nH) val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
val lW = (nW * scale).toInt() val lW = (nW * scale).toInt()
val lH = (nH * scale).toInt() val lH = (nH * scale).toInt()
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10) val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
d.setBounds(24, lTop, 24 + lW, lTop + lH) val lTop = (px(rowCenterY) - lH / 2f).toInt()
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
d.draw(canvas) d.draw(canvas)
} }
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
// --- "PayMV QR" top-right --- paint.color = mmaBlue
paint.color = Color.parseColor("#1A1A2E") paint.typeface = footerPaint.typeface
paint.textSize = 36f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.RIGHT paint.textAlign = Paint.Align.RIGHT
canvas.drawText("PayMV QR", W - 28f, 66f, paint) paint.textSize = px(payMvBoxH)
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
canvas.drawText(
"PayMV QR", px(rowR),
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
)
// --- Blue rounded box --- // --- qrCard: mmaBlue, radius 16, 40dp side margins ---
paint.color = boxBlue val qrCardL = 2f + 40f
paint.textAlign = Paint.Align.LEFT val qrCardR = cardW - 2f - 40f
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint) paint.color = mmaBlue
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
// Account name (white, bold, uppercase, auto-scaled to fit) if (hasName) {
paint.color = Color.WHITE val maxNameW = px(qrCardR - qrCardL)
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) if (namePaint.measureText(nameText) > maxNameW) {
paint.textAlign = Paint.Align.CENTER namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
val nameText = target.name.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, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
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 // QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
val availH = boxB - qrTopY - 24f val qrPx = px(qrSize).toInt()
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200) val qrLeft = px(cardW / 2) - qrPx / 2f
val qrLeft = ((W - qrPx) / 2).toFloat()
try { try {
val hints = mapOf( val hints = mapOf(
EncodeHintType.MARGIN to 0, EncodeHintType.MARGIN to 0,
@@ -333,23 +411,150 @@ class PayMvQrFragment : Fragment() {
val pixels = IntArray(qrPx * qrPx) val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) { for (y in 0 until qrPx) {
for (x in 0 until qrPx) { for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
} }
} }
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888) val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null) canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
qrBm.recycle() qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ } } catch (_: Exception) { /* skip if encoding fails */ }
// --- Dark blue footer --- // qrIdRail: the reference, rotated -90°, beside the QR's right edge
paint.color = footerBlue if (qrId.isNotEmpty()) {
paint.textAlign = Paint.Align.LEFT val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint) color = Color.BLACK
alpha = (255 * 0.8f).toInt()
textSize = px(10f)
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val qrRight = cardW / 2 + qrSize / 2
val cx = px(qrRight + railW / 1.37f - railW / 2)
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
canvas.save()
canvas.rotate(-90f, cx, cy)
val fm = idPaint.fontMetrics
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
canvas.restore()
}
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
canvas.drawText(
"MALDIVES NATIONAL QR", px(cardW / 2),
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
)
return bm
}
/**
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
* except the vertical reference, which is Montserrat as on Fahipay's.
*/
private fun renderFahipayQrCard(
ctx: Context,
target: QrTarget,
qrPayload: String,
reference: String
): Bitmap {
val w = 1240f
val h = 1322f
val blue = Color.parseColor("#005DA3")
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Blue card with a 6px border around the white area; footer is the blue below it
canvas.clipPath(Path().apply {
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
})
canvas.drawColor(blue)
paint.color = Color.WHITE paint.color = Color.WHITE
paint.textSize = 32f canvas.drawPath(Path().apply {
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) addRoundRect(
paint.textAlign = Paint.Align.CENTER RectF(6f, 6f, w - 6f, 1174f),
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint) floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
)
}, paint)
// Square app icon, then the "FahiPay" wordmark, in one row
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
d.setBounds(94, 94, 163, 163)
d.draw(canvas)
}
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
val lH = 48
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
d.setBounds(178, 104, 178 + lW, 104 + lH)
d.draw(canvas)
}
// Sized so capitals match the reference's cap heights; positions below are cap tops
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = tf
letterSpacing = spacingEm
textAlign = align
textSize = 100f
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
textSize = 100f * capH / h
}
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
// "PayMV QR" top-right
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
// Blue QR panel
paint.color = blue
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
// Account name
val nameText = target.name.uppercase()
if (nameText.isNotBlank()) {
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
val maxNameW = 1074f - 166f - 80f
if (namePaint.measureText(nameText) > maxNameW) {
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
}
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
}
// QR — white modules on blue, no quiet zone
val qrPx = 562
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 blue
}
}
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, 338f, 417f, null)
qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ }
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
// starting level with y=1087 and with its baseline at x=1171
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
canvas.save()
canvas.rotate(-90f, 1171f, 1087f)
canvas.drawText(reference, 1171f, 1087f, refPaint)
canvas.restore()
// Footer
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
return bm return bm
} }
@@ -437,6 +642,10 @@ class PayMvQrFragment : Fragment() {
private const val ARG_ACCOUNT_NAME = "account_name" private const val ARG_ACCOUNT_NAME = "account_name"
private const val ARG_BANK = "bank" private const val ARG_BANK = "bank"
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
private const val SCREEN_WIDTH_DP = 560f
private const val PX_PER_DP = 2f
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */ /** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply { fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.
Binary file not shown.
-1
View File
@@ -127,7 +127,6 @@
<string name="paymvqr_save_failed">Failed to save image</string> <string name="paymvqr_save_failed">Failed to save image</string>
<string name="paymvqr_include_phone">Include phone number</string> <string name="paymvqr_include_phone">Include phone number</string>
<string name="paymvqr_reference_hint">Reference (optional)</string> <string name="paymvqr_reference_hint">Reference (optional)</string>
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
<!-- Toolbar --> <!-- Toolbar -->
<string name="action_lock">Lock app</string> <string name="action_lock">Lock app</string>
+1 -1
View File
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
--- ---
[← Profile Picture](06-profile-picture.md) [← Profile Picture](06-profile-picture.md) &nbsp;&nbsp;&nbsp; **Next →** [PayMV QR](08-paymv-qr.md)
+40
View File
@@ -0,0 +1,40 @@
# PayMV QR (Receive)
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
---
## Endpoint
```
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
```
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
## Response
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
## The card image
A 1240 × 1322 PNG:
- Blue `#005DA3` card with a 6 px border.
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
- A blue panel holding the holder's name and the QR.
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
- A "MALDIVES NATIONAL QR" footer.
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
---
&nbsp;
---
[← Saved Favourites](07-contacts.md)
+1
View File
@@ -127,6 +127,7 @@ Client Server
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history | | 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) | | 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service | | 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
--- ---
+80 -15
View File
@@ -2,37 +2,102 @@
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md). Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress).
--- ---
## Fragment — `PayMvQrFragment` ## Fragment — `PayMvQrFragment`
A single screen (no tabs). Re-renders the QR live as the user edits the form. A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden.
### Fields ### Fields
| Field | Source / behaviour | | Field | Source / behaviour |
|---|---| |---|---|
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set | | Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
| Amount (`etAmount`) | Optional. Blank → open-amount QR | | Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped |
| Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 | | Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) |
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) | | Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` |
### Generation ### What goes on the card
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md): | Item | Value |
|---|---|
| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased |
| QR | The payload below, white modules on the card blue, error correction M, no quiet zone |
| Vertical text | The QR's reference (tag 62→05) — see below |
| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text |
1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY` ### Vertical text (reference)
2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay)
3. Tag 62 container: random 9-char reference + the purpose text
4. Tag 80 container: GUI + ISO timestamp
5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
The rendered card image (bank-styled background plus QR) is shown in-place. Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both.
| Bank | Reference / vertical text | Example |
|---|---|---|
| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` |
| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` |
| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` |
BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference.
---
## Card Rendering
Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save.
### BML (and MIB) — `renderQrCard()`
A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide.
| Element | Spec |
|---|---|
| Colour | `mmaBlue` `#0E5CA4` everywhere |
| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border |
| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box |
| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin |
| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below |
| QR | `0.5·sw` square; 37 dp padding below |
| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` |
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding |
### Fahipay — `renderFahipayQrCard()`
Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights.
| Element | Spec |
|---|---|
| Colour | `#005DA3` |
| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer |
| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** |
| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) |
| QR panel | (166, 218)–(1074, 1126), radius 55 |
| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px |
| QR | 562 px at (338, 417) |
| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 |
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) |
Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat.
---
## Generation
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr):
1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic)
2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY`
3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay)
4. Tag 59: name, uppercased, max 25 chars
5. Tag 60: Fahipay only — `LD` + 4 random digits
6. Tag 62: the reference (above) + purpose
7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000`
8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
### Actions ### Actions
- **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND` - **Share** (`btnShare`) — writes `<name>_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND`
- **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/` - **Save** (`btnSave`) — writes `<name>_PayMV_QR.png` to `MediaStore.Images` / `Pictures/`
--- ---
+92 -11
View File
@@ -31,10 +31,10 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs | | `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
| `52` | Merchant category code | `"0000"` (generic) | | `52` | Merchant category code | `"0000"` (generic) |
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) | | `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs | | `54` | Transaction amount | Decimal string (e.g. `"1.50"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own QRs) |
| `58` | Country code | `"MV"` | | `58` | Country code | `"MV"` |
| `59` | Merchant / recipient name | Max 25 characters | | `59` | Merchant / recipient name | Max 25 characters, uppercase in every real QR seen |
| `60` | Merchant city / store code | BML POS QRs only | | `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown |
| `62` | Additional data field | Container — see sub-tags below | | `62` | Additional data field | Container — see sub-tags below |
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last | | `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
| `80` | Supplementary data | Container — timestamp and domain | | `80` | Supplementary data | Container — timestamp and domain |
@@ -66,8 +66,8 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| Sub-Tag | Field | Notes | | Sub-Tag | Field | Notes |
|---|---|---| |---|---|---|
| `05` | Reference / bill number | 9 random uppercase alphanumeric characters | | `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card |
| `08` | Payment purpose | Free-form text entered by the payee | | `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` |
--- ---
@@ -77,6 +77,7 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|---|---|---| |---|---|---|
| `00` | Domain | `"mv.favara.mpqr"` | | `00` | Domain | `"mv.favara.mpqr"` |
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` | | `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
| `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree |
--- ---
@@ -115,18 +116,98 @@ To create a QR that others can scan to pay you:
10 04 IPAY 10 04 IPAY
52 04 0000 ← MCC 52 04 0000 ← MCC
53 03 462 ← MVR 53 03 462 ← MVR
54 <len> <amount> ← Omit tag entirely if open-amount 54 <len> <amount> ← "%.2f". Open amount: omit (BML) / "***" (Fahipay)
58 02 MV 58 02 MV
59 <len> <name up to 25 chars> 59 <len> <NAME UP TO 25 CHARS> ← Uppercased
60 06 LD<4 random digits> ← Fahipay only
62 <len> 62 <len>
05 09 <9 random alphanum chars> ← Reference 05 <len> <reference> ← Bank-specific, see below
08 <len> <purpose text> 08 <len> <purpose text> ← Omit if blank (BML) / "PAYMENT" (Fahipay)
80 <len> 80 <len>
00 15 mv.favara.mpqr 00 15 mv.favara.mpqr
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp 01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
6304<CRC> 6304<CRC>
``` ```
For Fahipay, tag `01` is `12` (dynamic) when an amount is set.
---
## Reference (tag 62→05)
The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`).
### BML — base-32 account number + amount
```
reference = base32(accountNumber) + amountAsTyped
```
- `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first
- `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs
- Capped at 25 characters. A non-numeric account number falls back to 9 random characters
| Account | Amount | Reference / vertical text |
|---|---|---|
| `7730000188362` | — | `70V3UKKUA` |
| `7730000188362` | `100` | `70V3UKKUA100` |
Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card.
### Fahipay — `P` + 9 random characters
Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`.
### Others
9 random uppercase alphanumeric characters.
---
## Real Receive QRs (Reference Samples)
Decoded from QR images generated by the official apps (CRC verified).
**BML app** (open amount):
```
00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6
```
Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`).
**Fahipay app**, open amount:
```
00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707
```
**Fahipay app**, amount `55`:
```
00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304
```
---
## Fahipay (Work in Progress)
> ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app.
Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid:
| Change | Status |
|---|---|
| Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) |
| Name `59` uppercased | Done |
| `54` = `***` for open amount, `01` = `12` with an amount | Done |
| `60` = `LD` + 4 random digits | Done |
| `62→08` defaults to `PAYMENT` | Done |
| `62→05` shaped `P` + 9 chars | Done |
| `80→02` = `0005` (amount QRs only) | Not done |
The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it.
--- ---
## Parsing a PayMV QR (Incoming Scan) ## Parsing a PayMV QR (Incoming Scan)
@@ -219,11 +300,11 @@ exactly one request is made either way.
## Example Payload ## Example Payload
Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`: Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
``` ```
000201010211268...520400005303462 000201010211268...520400005303462
5802MV5909AHMED ALI6225050912345ABCDEF0804Rent 5802MV5909AHMED ALI6221050970V3UKKUA0804Rent
80...63044A2B 80...63044A2B
``` ```
+2 -2
View File
@@ -19,7 +19,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet | | [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt | | [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators | | [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) | | [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, BML/Fahipay card rendering, vertical reference text (Fahipay QRs WIP) |
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) | | [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits | | [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About | | [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
@@ -39,7 +39,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| Document | Description | | Document | Description |
|---|---| |---|---|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference | | [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model | | [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments | | [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy | | [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |