fix BML POS QRs

This commit is contained in:
2026-09-21 15:26:18 +05:00
parent 0357d7e0bc
commit 80fd238195
15 changed files with 226 additions and 58 deletions
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
/**
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
*/
internal fun bmlApiRequest(session: BmlSession, url: String): Request = internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url) Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}") .header("accept", "application/json")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION) .header("x-app-version", BML_APP_VERSION)
.header("user-agent", BML_USER_AGENT)
.header("authorization", "Bearer ${session.accessToken}")
.build() .build()
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
val currency: String val currency: String
) )
/**
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
* parse failures stay plain exceptions so the generic message is used for those instead.
*/
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
data class BmlQrPayResult( data class BmlQrPayResult(
val success: Boolean, val success: Boolean,
val merchant: String = "", val merchant: String = "",
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import android.util.Base64
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@@ -10,17 +11,27 @@ class BmlQrPayClient {
private val client = newBmlApiClient() private val client = newBmlApiClient()
/** /**
* Resolves a BML QR URL to merchant details. * Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
* [base64Url] is the full QR URL Base64-encoded (standard, with padding). * `35` → `20` → `01` reference.
*
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
* resolves too, so this is parity rather than a requirement.
*/ */
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo { fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
val key = Base64.encodeToString(
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
val request = bmlApiRequest(session, val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url") "$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response") val body = response.body?.string() ?: throw Exception("No response")
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
if (!body.trimStart().startsWith("{"))
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
val json = JSONObject(body) val json = JSONObject(body)
if (!json.optBoolean("success")) if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" }) throw BmlQrPayLookupException(
json.optInt("code"),
json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload") val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim() val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim() val addr3 = payload.optString("narrative3").trim()
@@ -5,7 +5,6 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
} }
private fun lookupMerchant(qrUrl: String) { private fun lookupMerchant(qrUrl: String) {
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: run { val session = app.anyBmlSession() ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) } try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
catch (_: Exception) { null } catch (_: Exception) { null }
} }
if (_binding == null) return@launch if (_binding == null) return@launch
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
) )
} else { } else {
val qr = PaymvQrParser.parse(raw) val qr = PaymvQrParser.parse(raw)
@@ -528,9 +528,9 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) { private fun routeSharedQrText(text: String) {
val store = CredentialStore(this) val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text) val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber())) navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
return return
} }
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text) val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
) )
} else { } else {
val qr = PaymvQrParser.parse(raw) val qr = PaymvQrParser.parse(raw)
@@ -172,13 +172,13 @@ class TransferFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return if (result.resultCode != Activity.RESULT_OK) return
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
// BML card/gateway QR — hand off to dedicated payment screen // BML card/gateway/POS QR — hand off to dedicated payment screen
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
val fromCard = selectedAccount?.takeIf { val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
} }
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber)) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
return return
} }
@@ -18,6 +18,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
import sh.sar.basedbank.api.bml.BmlQrPayResult import sh.sar.basedbank.api.bml.BmlQrPayResult
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlTransferClient import sh.sar.basedbank.api.bml.BmlTransferClient
@@ -31,6 +32,7 @@ import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.ui.home.TransferReceiptData import sh.sar.basedbank.ui.home.TransferReceiptData
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
@@ -173,9 +175,10 @@ class BmlTransferHandler(
fun lookupQrMerchant(qrUrl: String) { fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true qrLookupAttempted = true
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") // Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
val base64Url = android.util.Base64.encodeToString( // need the extra pre-initiate POST; ebanking qrpay URLs do not.
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP) gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
val session = app.anyBmlSession() ?: return val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading // Lock the "To" input row while loading
@@ -185,14 +188,20 @@ class BmlTransferHandler(
host?.setRefreshing(true) host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) } runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
catch (_: Exception) { null }
} }
host?.setRefreshing(false) host?.setRefreshing(false)
val info = result.getOrNull()
if (info == null) { if (info == null) {
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show() // An expired or rejected QR is BML telling us something specific — show its own
fragment.requireActivity().onBackPressedDispatcher.onBackPressed() // wording and stay put with the To row restored, rather than bouncing the user out
// of the screen they just scanned from.
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
?: ctx.getString(R.string.bml_qr_lookup_failed)
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
onStateChanged()
return@launch return@launch
} }
qrInfo = info qrInfo = info
@@ -9,22 +9,58 @@ data class PaymvQrData(
object PaymvQrParser { object PaymvQrParser {
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
/** /**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null. * Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR). *
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01) * Three shapes exist:
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes. * - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
* `35` → `20` → `01`;
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
*
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
* that follow it.
*/ */
fun extractBmlGatewayUrl(raw: String): String? { fun bmlQrPayTarget(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
return try { val ref = bmlQrReference(raw) ?: return null
val root = parseTlv(raw) if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null // A bare reference only means a BML POS QR when the supplementary domain says so; anything
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null // else keeps falling through to the PayMV parse, as it did before POS QRs existed.
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") } return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
} catch (_: Exception) { }
null
} /** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
private fun supplementaryDomain(raw: String): String? = try {
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
} catch (_: Exception) {
null
}
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
private fun bmlQrReference(raw: String): String? = try {
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
inner?.get("01")?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
/**
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
* "Unsupported payment link").
*/
fun bmlPayRequestKey(target: String): String {
if (target.startsWith("https://")) return target
return bmlQrReference(target) ?: target
} }
fun parse(raw: String): PaymvQrData? { fun parse(raw: String): PaymvQrData? {
+31 -1
View File
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL. The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
### 3. POS QR (`mv.com.bml.qtr`)
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
`mv.com.bml.qtr`. The same TLV path (`35``20``01`) holds a bare reference such as
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
understands it. See
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
--- ---
## PayMV QR Format (TLV) ## PayMV QR Format (TLV)
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url} GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
``` ```
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding). `{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35``20``01` reference.
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
rather than relying on either being required.
### Headers ### Headers
| Header | Value | | Header | Value |
|---|---| |---|---|
| `accept` | `application/json`**required in practice**, see below |
| `Authorization` | `Bearer <access_token>` | | `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` | | `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` | | `x-app-version` | `2.1.44.348` |
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
```bash ```bash
curl --request GET \ curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \ --url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
--header 'accept: application/json' \
--header 'Authorization: Bearer <access_token>' \ --header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \ --header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' --header 'x-app-version: 2.1.44.348'
@@ -116,6 +136,16 @@ curl --request GET \
| `amount` | Payment amount (`"0.00"` for static QRS) | | `amount` | Payment amount (`"0.00"` for static QRS) |
| `currency` | Currency code (typically `"MVR"`) | | `currency` | Currency code (typically `"MVR"`) |
### Failure Responses
`success: false` comes back with a code and a user-facing message; the client shows BML's own
wording and keeps the user on the Transfer screen.
| Code | Message |
|---|---|
| `103` | The payment request has expired |
| `112` | Unsupported payment link |
--- ---
## Step 2 — Pay (3-Step TOTP Flow) ## Step 2 — Pay (3-Step TOTP Flow)
+3 -1
View File
@@ -2,7 +2,9 @@
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md). > **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve``channel: token``otp`). The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve``channel: token``otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
--- ---
+63 -1
View File
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `00` | Format indicator | Always `"01"` | | `00` | Format indicator | Always `"01"` |
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR | | `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
| `26` | Merchant account information | Container — see sub-tags below | | `26` | Merchant account information | Container — see sub-tags below |
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only | | `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"`); absent for open-amount QRs |
| `58` | Country code | `"MV"` | | `58` | Country code | `"MV"` |
| `59` | Merchant / recipient name | Max 25 characters | | `59` | Merchant / recipient name | Max 25 characters |
| `60` | Merchant city / store code | BML POS QRs only |
| `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 |
@@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly. Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
---
## BML POS QR (`mv.com.bml.qtr`)
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
(tag `80``00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
number to transfer to. The same `35``20` container is used as in combined QRs, but sub-tag `01`
holds a bare reference instead of a URL.
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
```
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
```
| TLV path | Value | Notes |
|---|---|---|
| `00` | `01` | Format indicator |
| `01` | `12` | Dynamic QR |
| `35``20``00` | `02` | Version / format of the container (combined QRs use the URL form) |
| `35``20``01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
| `35``20``02` | `9809724081` | Merchant identifier (10 digits) |
| `35``20``03` | `74009538` | Terminal identifier (8 digits) |
| `52` | `0000` | MCC |
| `53` | `462` | MVR |
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
| `62``05` | `dtyams497d` | Reference / bill number |
| `62``08` | `POPE` | Purpose / terminal label |
| `80``00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
| `80``01` | `2026-09-21T13:33:22.00000` | Timestamp |
### Pay-Request Lookup Key
The lookup key is the **bare reference**, Base64-encoded without padding:
```
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
```
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
carry is `accept: application/json` — see
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
than with merchant details — but `103` means the reference itself resolved):
| Key tried | Response |
|---|---|
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
exactly one request is made either way.
--- ---
## Example Payload ## Example Payload
+15 -8
View File
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate | | `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown | | `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan | | `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount | | `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load | | `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
--- ---
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
## BML QR Merchant Payment Flow ## BML QR Merchant Payment Flow
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner. Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
Two sub-modes: Three sub-modes:
| Mode | Trigger | Extra step | | Mode | `bmlQrPayTarget()` returns | Extra step |
|---|---|---| |---|---|---|
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None | | Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate | | Gateway QR | the QR text, or the URL at TLV `35``20``01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
| POS QR | the whole EMV payload, for QRs whose tag `80``00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35``20``01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
Flow: Flow:
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row. 1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field. 2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
3. Remarks field is locked (not applicable for merchant payments). 3. Remarks field is locked (not applicable for merchant payments).
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP. 4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen. 5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
--- ---
## Transfer Button Enable Conditions ## Transfer Button Enable Conditions
@@ -238,7 +245,7 @@ Flow:
The transfer button is only enabled when all of the following are true: The transfer button is only enabled when all of the following are true:
- A source account is selected - A source account is selected
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set) - A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
- Amount is greater than `0` - Amount is greater than `0`
- No connectivity error for `NO_INTERNET` or for the source bank - No connectivity error for `NO_INTERNET` or for the source bank
+2 -2
View File
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
| Caller | Result handling | | Caller | Result handling |
|---|---| |---|---|
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` | | [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly | | [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs | | `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast | | [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card | | [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
### Share-to-Scan Fast Path ### Share-to-Scan Fast Path