fix BML POS QRs
This commit is contained in:
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.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 =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("accept", "application/json")
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("user-agent", BML_USER_AGENT)
|
||||
.header("authorization", "Bearer ${session.accessToken}")
|
||||
.build()
|
||||
|
||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
||||
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(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.util.Base64
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Resolves a BML QR URL to merchant details.
|
||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||
* `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,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||
return client.newCall(request).execute().use { 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)
|
||||
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 addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun lookupMerchant(qrUrl: String) {
|
||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.anyBmlSession() ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
|
||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
|
||||
@@ -528,9 +528,9 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
private fun routeSharedQrText(text: String) {
|
||||
val store = CredentialStore(this)
|
||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
||||
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||
if (bmlTarget != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||
return
|
||||
}
|
||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||
|
||||
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
|
||||
@@ -172,13 +172,13 @@ class TransferFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
|
||||
|
||||
// BML card/gateway QR — hand off to dedicated payment screen
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
// BML card/gateway/POS QR — hand off to dedicated payment screen
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
val fromCard = selectedAccount?.takeIf {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
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.BmlSession
|
||||
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.TransferReceiptData
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.Totp
|
||||
@@ -173,9 +175,10 @@ class BmlTransferHandler(
|
||||
|
||||
fun lookupQrMerchant(qrUrl: String) {
|
||||
qrLookupAttempted = true
|
||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
||||
val base64Url = android.util.Base64.encodeToString(
|
||||
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||
val session = app.anyBmlSession() ?: return
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
@@ -185,14 +188,20 @@ class BmlTransferHandler(
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
catch (_: Exception) { null }
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
val info = result.getOrNull()
|
||||
if (info == null) {
|
||||
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
// An expired or rejected QR is BML telling us something specific — show its own
|
||||
// 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
|
||||
}
|
||||
qrInfo = info
|
||||
|
||||
@@ -9,22 +9,58 @@ data class PaymvQrData(
|
||||
|
||||
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.
|
||||
* 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)
|
||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
||||
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||
*
|
||||
* Three shapes exist:
|
||||
* - 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? {
|
||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
||||
return try {
|
||||
val root = parseTlv(raw)
|
||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
fun bmlQrPayTarget(raw: String): String? {
|
||||
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||
val ref = bmlQrReference(raw) ?: return null
|
||||
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||
}
|
||||
|
||||
/** 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? {
|
||||
|
||||
Reference in New Issue
Block a user