Compare commits

...
8 Commits
Author SHA1 Message Date
shihaam 877147959a Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 6m0s
2026-10-01 06:21:59 +05:00
shihaam 68583465de Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-10-01 06:17:26 +05:00
shihaam 1bf63ed55b Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 7s
Build and Release APK / build (push) Successful in 4m38s
2026-10-01 06:11:03 +05:00
shihaam 3350c84a33 press and hold to update verified card 2026-10-01 06:10:22 +05:00
shihaam 449c27ced2 update docs: card payments 2026-10-01 06:06:57 +05:00
shihaam 3c5d3ff883 add warning when paying via card 2026-10-01 06:06:41 +05:00
shihaam 25b5c80c49 inital tests for pay via card 2026-10-01 06:00:23 +05:00
shihaam 2b2fd59543 Gateway payments via card, Step 1: verify cards
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-01 05:08:17 +05:00
24 changed files with 2007 additions and 13 deletions
+12 -3
View File
@@ -24,11 +24,20 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
if git tag -l | grep -q "^v${VERSION}$"; then BEFORE="${{ github.event.before }}"
echo "Tag v${VERSION} already exists, skipping" if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BEFORE="HEAD~1"
fi
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
echo "versionCode unchanged (${VERSION_CODE}), skipping"
echo "should_release=false" >> $GITHUB_OUTPUT echo "should_release=false" >> $GITHUB_OUTPUT
elif git tag -l | grep -q "^v${VERSION}$"; then
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
exit 1
else else
echo "New version detected: v${VERSION}" echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "should_release=true" >> $GITHUB_OUTPUT echo "should_release=true" >> $GITHUB_OUTPUT
fi fi
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 33 versionCode = 34
versionName = "1.0.32" versionName = "1.0.33"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})" internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
/** Browser User-Agent used for BML's web/Cloudflare-fronted endpoints (login, merchant pay page, ACS). */
internal val BML_WEB_USER_AGENT = "Mozilla/5.0 (Android ${Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
internal const val BML_APP_VERSION = "2.1.44.348" internal const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder() internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
@@ -27,7 +27,7 @@ class BmlLoginFlow {
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})" private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
private val APP_VERSION = "2.1.44.348" private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0" private val WEB_USER_AGENT = BML_WEB_USER_AGENT
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -0,0 +1,301 @@
package sh.sar.basedbank.api.bml
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
import java.util.concurrent.TimeUnit
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
import android.util.Base64
/**
* Pays a BML Merchant Services payment link by card, for merchants that don't have BML Pay
* enabled. It performs the same request sequence the link's own card form (Pomelo JS) and the
* issuer's 3-D Secure page perform in a browser:
*
* 1. `GET public-client/credentials/<id>` (auth header: the page's `pomeloJsKey`) → RSA public
* key + Pomelo API key.
* 2. `POST api.pay.pomelopay.com/bin-lookup` with the PAN, CVV and `YYMM` expiry, each
* RSA-OAEP(SHA-1) encrypted with that key → a card `tokenId`.
* 3. `POST public-client/transactions/next-action` RATE_OPTIONS, polling while the server says
* WAIT, until it returns a `3dsUrl`.
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
* 5. Poll next-action until TRANSACTION_CONFIRMED.
*
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies.
*/
class BmlMerchantCardPayClient {
data class Card(
val pan: String,
val expiryMonth: String, // "07"
val expiryYear: String, // "28"
val cvv: String,
val holderName: String
)
sealed class Result {
object Success : Result()
data class Failure(val message: String) : Result()
}
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(45, TimeUnit.SECONDS)
// Credentials/next-action tolerate okhttp, but the Cloudflare-fronted ACS does not — send a
// browser UA on everything (only when the caller didn't set one).
.addInterceptor { chain ->
val req = chain.request()
chain.proceed(
if (req.header("User-Agent") == null)
req.newBuilder().header("User-Agent", BML_WEB_USER_AGENT).build()
else req
)
}
.cookieJar(object : CookieJar {
override fun saveFromResponse(url: HttpUrl, newCookies: List<Cookie>) {
val list = cookies.getOrPut(url.host) { mutableListOf() }
for (c in newCookies) { list.removeAll { it.name == c.name }; list.add(c) }
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
cookies.values.flatten().filter { it.matches(url) }
})
.build()
/**
* Runs the whole payment. [otp] returns the current BML token code; it is called again with
* `retry = true` if the ACS rejects a code (it can expire between generating and submitting).
*/
fun pay(page: BmlMerchantTxnClient.PayPage, card: Card, otp: (retry: Boolean) -> String): Result {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
// 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
val keyInfo = creds.getJSONObject("publicKey")
val publicKey = parsePublicKey(keyInfo.getString("publicKeyPem"))
val binBody = JSONObject()
.put("encryptedCardNumber", encrypt(publicKey, card.pan))
.put("encryptedCardSecurityCode", encrypt(publicKey, card.cvv))
.put("encryptedCardExpiry", encrypt(publicKey, card.expiryYear + card.expiryMonth))
.put("externalId", txnId)
.put("cardHolderName", card.holderName)
.put("encryptedCardExpiryMonth", card.expiryMonth)
.put("encryptedCardExpiryYear", card.expiryYear)
.put("encSerialId", keyInfo.getString("publicKeyId"))
val binReq = Request.Builder()
.url(creds.optString("binLookupUrl").ifBlank { "https://api.pay.pomelopay.com/bin-lookup" })
.post(binBody.toString().toRequestBody(JSON))
.header("tenant", "bankofmaldives")
.header("x-api-key", creds.getString("apiKey"))
.header("x-tenant-id", creds.optString("tid"))
.build()
val bin = execJson(binReq)
val tokenId = bin.optString("tokenId").ifBlank { return Result.Failure("Card was not accepted") }
// 3. Rate options → poll while WAIT → 3-D Secure URL
val cardFields = JSONObject()
.put("transactionId", txnId)
.put("tokenId", tokenId)
.put("bin8", bin.optString("bin8"))
.put("cardBrand", bin.optString("brand"))
for ((from, to) in listOf("issuer" to "cardIssuer", "country" to "cardCountry",
"cardCategory" to "cardCategory", "isCommercial" to "isCommercial",
"isPrepaid" to "isPrepaid", "isReloadable" to "isReloadable", "paddedPan" to "paddedPan")) {
if (bin.has(from) && !bin.isNull(from)) cardFields.put(to, bin.get(from))
}
var action = nextAction(pk, copy(cardFields).put("action", "RATE_OPTIONS").withBrowserInfo())
val resolved = setOf("WAIT", "POLL", "TRANSACTION_CONFIRMED", "TRANSACTION_FAILED")
if (action.optString("action") !in resolved && action.optString("3dsUrl").isBlank()) {
action = nextAction(pk, copy(cardFields).put("action", "THREEDS").withBrowserInfo())
}
var threeDsUrl: String? = null
for (attempt in 0..MAX_POLLS) {
when (action.optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
if (threeDsUrl != null) break
if (attempt == MAX_POLLS) return Result.Failure("Timed out waiting for the bank")
Thread.sleep(POLL_MS)
action = poll(pk, txnId)
}
// 4. 3-D Secure challenge (handles the authenticator channel + TOTP)
runThreeDs(threeDsUrl!!, otp)?.let { return it }
// 5. Wait for the gateway's verdict to reach BML
repeat(MAX_POLLS * 2) {
when (poll(pk, txnId).optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
Thread.sleep(POLL_MS / 2)
}
return Result.Failure("Payment status unknown — check with the merchant before retrying")
}
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
// issuer's ACS. The ACS's own channel/OTP forms carry no action attribute — their JS posts
// back to this same creq URL — so it is the fallback action for everything that follows.
var form = AcsForm.parse(execText(get(threeDsUrl)), null)
?: return Result.Failure("Couldn't start card authentication")
val acsUrl = form.action
var html = execText(form.toRequest())
// Channel picker (Mobile / Email / Authenticator). The BML token is the "token" channel.
if (html.contains("name=\"destValue\"")) {
form = AcsForm.parse(html, acsUrl) ?: return Result.Failure("Unexpected authentication page")
form.fields["destValue"] = "token"
form.fields["selectChannel"] = "token"
form.fields["authMethod"] = "OOB"
form.fields["otpDest"] = ""
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
}
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
var retry = false
for (attempt in 0..1) {
form = AcsForm.parse(html, acsUrl) ?: break
if (!form.fields.containsKey("otpValue")) break
form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
retry = true
}
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
repeat(3) {
val next = AcsForm.parse(html, acsUrl) ?: return null
if (next.fields.keys.none { it == "cres" || it == "order.id" }) return null
html = execText(next.toRequest())
}
return null
}
// ── next-action helpers ──────────────────────────────────────────────────
private fun nextAction(pk: String, body: JSONObject): JSONObject =
execJson(Request.Builder()
.url("$API_BASE/public-client/transactions/next-action")
.post(body.toString().toRequestBody(JSON))
.header("Authorization", pk)
.build())
private fun poll(pk: String, txnId: String): JSONObject =
nextAction(pk, JSONObject().put("action", "POLL").put("transactionId", txnId))
private fun JSONObject.withBrowserInfo(): JSONObject = this
.put("javaEnabled", false).put("javascriptEnabled", true)
.put("language", "en-US").put("colorDepth", 24)
.put("screenHeight", 1850).put("screenWidth", 1080)
.put("tz", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / -60000)
.put("userAgent", "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile)")
private fun copy(o: JSONObject) = JSONObject(o.toString())
// ── HTTP ─────────────────────────────────────────────────────────────────
private fun get(url: String) = Request.Builder().url(url).build()
private fun getJson(url: String, auth: String): JSONObject =
execJson(Request.Builder().url(url).header("Authorization", auth).header("Accept", "application/json").build())
private fun execJson(request: Request): JSONObject = client.newCall(request).execute().use { r ->
val text = r.body?.string().orEmpty()
if (!r.isSuccessful) throw Exception("Request failed (HTTP ${r.code})")
if (text.isBlank()) JSONObject() else JSONObject(text)
}
private fun execText(request: Request): String = client.newCall(request).execute().use { r ->
r.body?.string().orEmpty()
}
// ── RSA-OAEP(SHA-1), matching the Pomelo JS crypto.subtle config ──────────
private fun parsePublicKey(pem: String): java.security.PublicKey {
val der = Base64.decode(pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(Regex("\\s"), ""), Base64.DEFAULT)
return KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
}
private fun encrypt(key: java.security.PublicKey, value: String): String {
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, OAEPParameterSpec(
"SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT))
return Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
}
/**
* One `application/x-www-form-urlencoded` form scraped from an ACS HTML page: its POST target
* plus every `<input>` name/value. [fields] is mutable so the caller can fill in the chosen
* channel and the OTP before re-submitting.
*/
private class AcsForm(val action: String, val fields: MutableMap<String, String>) {
fun toRequest(): Request {
val body = FormBody.Builder()
for ((k, v) in fields) body.add(k, v)
return Request.Builder().url(action).post(body.build()).build()
}
companion object {
private val FORM = Regex("<form\\b[^>]*>", RegexOption.IGNORE_CASE)
private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
private val INPUT = Regex("<input\\b[^>]*>", RegexOption.IGNORE_CASE)
private val NAME = Regex("name\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
private val VALUE = Regex("value\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE)
/**
* The first `<form>` and its inputs. The form's `action` is used when present;
* otherwise [defaultAction] (the ACS pages set it via JS to the current creq URL).
* Null only when there is no form, or no action at all.
*/
fun parse(html: String, defaultAction: String?): AcsForm? {
val form = FORM.find(html) ?: return null
val action = ACTION.find(form.value)?.groupValues?.get(1)?.let { unescape(it) }
?: defaultAction ?: return null
val fields = linkedMapOf<String, String>()
for (m in INPUT.findAll(html)) {
val name = NAME.find(m.value)?.groupValues?.get(1) ?: continue
fields[unescape(name)] = unescape(VALUE.find(m.value)?.groupValues?.get(1) ?: "")
}
return AcsForm(action, fields)
}
private fun unescape(s: String) = s
.replace("&amp;", "&").replace("&quot;", "\"")
.replace("&#34;", "\"").replace("&#39;", "'").replace("&lt;", "<").replace("&gt;", ">")
}
}
companion object {
private val JSON = "application/json".toMediaType()
private const val POLL_MS = 5_000L
private const val MAX_POLLS = 10
}
}
@@ -3,17 +3,89 @@ package sh.sar.basedbank.api.bml
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
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`), * BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it * e.g. the bill links Fenaka sends. Merchants with BML Pay enabled get their QR's text fetched so it
* can go through the regular BML QR payment flow. * can go through the regular BML QR payment flow; card-only merchants are paid by
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
*/ */
class BmlMerchantTxnClient { class BmlMerchantTxnClient {
private val client = newBmlApiClient() private val client = newBmlApiClient()
/** What the payment page knows about a transaction, from its embedded `window.appData`. */
data class PayPage(
val transactionId: String,
val merchantName: String,
val merchantAddress: String,
/** Major units (the page's amounts are in cents). */
val amount: Double,
val currency: String,
val state: String,
/** BML Pay (`bml_mpos`) is offered: pay through [fetchQrPayload] and the QR flow. */
val supportsBmlPay: Boolean,
/** Card entry (MPGS via Pomelo) is offered: pay with [BmlMerchantCardPayClient]. */
val supportsCard: Boolean,
/** `pk_production_…` key the page's card form authenticates with. */
val pomeloKey: String?
) {
val isPaid get() = state == "CONFIRMED"
}
/**
* Loads `/<id>/paynow`. The page is server-rendered with everything inline: the transaction,
* the merchant, `availableProviders` (lists `bml_mpos` when BML Pay is enabled — empty for
* card-only merchants) and the card form's `pomeloJsKey` / `pomeloJsProviders`.
*/
fun fetchPayPage(transactionId: String): PayPage {
val request = Request.Builder()
.url("$PAGE_ORIGIN/$transactionId/paynow")
// The page host is behind Cloudflare, which 403s non-browser User-Agents.
.header("User-Agent", BML_WEB_USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.build()
val html = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("Payment page failed (HTTP ${response.code})")
response.body?.string().orEmpty()
}
val start = html.indexOf(APP_DATA_PREFIX).takeIf { it >= 0 }
?.let { it + APP_DATA_PREFIX.length } ?: throw Exception("Payment page has no app data")
val end = html.indexOf("</script>", start).takeIf { it >= 0 } ?: throw Exception("Payment page has no app data")
val data = JSONObject(html.substring(start, end))
val txn = data.optJSONObject("transaction") ?: throw Exception("Payment page has no transaction")
val merchant = data.optJSONObject("merchant")
val providers = data.optJSONArray("availableProviders") ?: JSONArray()
val bmlPay = (0 until providers.length()).any {
val p = providers.optJSONObject(it)
p?.optString("value") == PROVIDER_BML && p.optBoolean("enabled", true)
}
val pomeloProviders = data.optJSONArray("pomeloJsProviders") ?: JSONArray()
val pomeloKey = data.optString("pomeloJsKey").ifBlank { null }
val card = pomeloKey != null && (0 until pomeloProviders.length()).any { pomeloProviders.optString(it) == "mpgs" }
val cents = if (txn.isNull("payAmount")) txn.optLong("amount") else txn.optLong("payAmount")
return PayPage(
transactionId = transactionId,
merchantName = merchant?.optString("tradingName")?.ifBlank { null }
?: merchant?.optString("registeredName").orEmpty(),
merchantAddress = listOfNotNull(
merchant?.optString("address1")?.ifBlank { null },
merchant?.optString("city")?.ifBlank { null }
).joinToString(", "),
amount = cents / 100.0,
currency = txn.optString("payCurrency").ifBlank { txn.optString("currency", "MVR") },
state = txn.optString("state"),
supportsBmlPay = bmlPay,
supportsCard = card,
pomeloKey = pomeloKey
)
}
/** /**
* Returns the transaction's EMV QR payload (`vendorQrCode`). * Returns the transaction's EMV QR payload (`vendorQrCode`).
* *
@@ -41,6 +113,12 @@ class BmlMerchantTxnClient {
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR") return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
} }
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */
fun announceBrowser(transactionId: String) {
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
patch(transactionId, JSONObject().put("fx", "reset"))
}
private fun patch(transactionId: String, body: JSONObject): JSONObject { private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder() val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId") .url("$API_BASE/transactions/$transactionId")
@@ -66,8 +144,9 @@ class BmlMerchantTxnClient {
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null } if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
companion object { companion object {
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv" internal const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv" internal const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
private const val APP_DATA_PREFIX = "window.appData = "
private const val PROVIDER_BML = "bml_mpos" private const val PROVIDER_BML = "bml_mpos"
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$") private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$") private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
@@ -0,0 +1,186 @@
package sh.sar.basedbank.nfc
import android.nfc.Tag
import android.nfc.tech.IsoDep
import java.io.ByteArrayOutputStream
/**
* Minimal contactless EMV reader: selects the payment app, runs GPO and reads the
* AFL records until it finds the PAN (tag 5A / Track 2 tag 57) and expiry (5F24 / Track 2).
*/
object EmvCardReader {
/** [expiry] is "MM/YY". */
data class CardData(val pan: String, val expiry: String?)
private class Collected {
var pan: String? = null
var expiry: String? = null
val complete get() = pan != null && expiry != null
fun result() = pan?.let { CardData(it, expiry) }
}
/** Returns the card data, or null if the PAN couldn't be read. Blocking — call off the main thread. */
fun read(tag: Tag): CardData? {
val iso = IsoDep.get(tag) ?: return null
val c = Collected()
iso.use {
it.connect()
it.timeout = 5000
val aids = selectPpse(it).ifEmpty { KNOWN_AIDS }
for (aid in aids) {
val fci = transceive(it, selectApdu(aid)) ?: continue
val pdol = findTag(fci, 0x9F38)
val gpo = transceive(it, gpoApdu(pdol)) ?: continue
collect(gpo, c)
if (c.complete) return c.result()
// Format 1 (tag 80): AIP (2 bytes) + AFL. Format 2 (tag 77): AFL in tag 94.
val afl = findTag(gpo, 0x94)
?: findTag(gpo, 0x80)?.let { b -> if (b.size > 2) b.copyOfRange(2, b.size) else null }
?: continue
for (i in 0 until afl.size / 4) {
val sfi = (afl[i * 4].toInt() and 0xFF) shr 3
val first = afl[i * 4 + 1].toInt() and 0xFF
val last = afl[i * 4 + 2].toInt() and 0xFF
for (rec in first..last) {
val data = transceive(it, readRecordApdu(sfi, rec)) ?: continue
collect(data, c)
if (c.complete) return c.result()
}
}
if (c.pan != null) return c.result()
}
}
return c.result()
}
private val KNOWN_AIDS = listOf(
"A0000000031010", // Visa
"A0000000041010", // Mastercard
"A0000000043060", // Maestro
"A000000025010801", // Amex
"A0000003330101", // UnionPay
).map { hex(it) }
private fun selectPpse(iso: IsoDep): List<ByteArray> {
val resp = transceive(iso, selectApdu("2PAY.SYS.DDF01".toByteArray())) ?: return emptyList()
return findAllTags(resp, 0x4F)
}
private fun collect(data: ByteArray, c: Collected) {
findTag(data, 0x5A)?.let { c.pan = c.pan ?: toHex(it).trimEnd('F') }
findTag(data, 0x57)?.let { raw ->
val t2 = toHex(raw)
c.pan = c.pan ?: t2.substringBefore('D')
// Track 2: PAN 'D' YYMM service-code ...
val yymm = t2.substringAfter('D', "").take(4)
if (c.expiry == null && yymm.length == 4) c.expiry = "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}"
}
findTag(data, 0x5F24)?.let { raw ->
val yymmdd = toHex(raw)
if (yymmdd.length >= 4) c.expiry = "${yymmdd.substring(2, 4)}/${yymmdd.substring(0, 2)}"
}
}
private fun selectApdu(aid: ByteArray): ByteArray =
byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00)
private fun readRecordApdu(sfi: Int, rec: Int): ByteArray =
byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00)
/** Builds GPO with the PDOL filled in: sensible TTQ/country/currency/date, zeros otherwise. */
private fun gpoApdu(pdol: ByteArray?): ByteArray {
val out = ByteArrayOutputStream()
if (pdol != null) {
var i = 0
while (i < pdol.size) {
var tag = pdol[i].toInt() and 0xFF
i++
if (tag and 0x1F == 0x1F) {
do {
tag = (tag shl 8) or (pdol[i].toInt() and 0xFF)
} while (pdol[i++].toInt() and 0x80 != 0 && i < pdol.size)
}
if (i >= pdol.size) break
val len = pdol[i++].toInt() and 0xFF
val value = when (tag) {
0x9F66 -> hex("B620C000") // TTQ: contactless qVSDC, online capable
0x9F1A, 0x5F2A -> hex("0462") // Maldives / MVR
0x9A -> hex("260101")
0x9C -> hex("00")
0x9F37 -> hex("12345678")
else -> ByteArray(len)
}
out.write(value.copyOf(len))
}
}
val pdolData = out.toByteArray()
val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData
return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00)
}
/** Sends an APDU, returning the response data on 9000 (following 61xx / 6Cxx), else null. */
private fun transceive(iso: IsoDep, apdu: ByteArray): ByteArray? {
var resp = iso.transceive(apdu)
if (resp.size < 2) return null
var sw1 = resp[resp.size - 2].toInt() and 0xFF
if (sw1 == 0x6C) {
val retry = apdu.copyOf()
retry[retry.size - 1] = resp[resp.size - 1]
resp = iso.transceive(retry)
sw1 = resp[resp.size - 2].toInt() and 0xFF
}
if (sw1 == 0x61) {
resp = iso.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1]))
sw1 = resp[resp.size - 2].toInt() and 0xFF
}
val sw2 = resp[resp.size - 1].toInt() and 0xFF
return if (sw1 == 0x90 && sw2 == 0x00) resp.copyOf(resp.size - 2) else null
}
// ── BER-TLV ──────────────────────────────────────────────────────────────
private fun findTag(data: ByteArray, target: Int): ByteArray? = findAllTags(data, target).firstOrNull()
private fun findAllTags(data: ByteArray, target: Int): List<ByteArray> {
val found = mutableListOf<ByteArray>()
walk(data, 0, data.size, target, found)
return found
}
private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList<ByteArray>) {
var i = start
while (i < end) {
val b0 = data[i].toInt() and 0xFF
if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // padding
val constructed = b0 and 0x20 != 0
var tag = b0
i++
if (b0 and 0x1F == 0x1F) {
while (i < end) {
val b = data[i++].toInt() and 0xFF
tag = (tag shl 8) or b
if (b and 0x80 == 0) break
}
}
if (i >= end) return
var len = data[i++].toInt() and 0xFF
if (len and 0x80 != 0) {
val n = len and 0x7F
len = 0
repeat(n) { if (i < end) len = (len shl 8) or (data[i++].toInt() and 0xFF) }
}
if (len < 0 || i + len > end) return
if (tag == target) found.add(data.copyOfRange(i, i + len))
if (constructed) walk(data, i, i + len, target, found)
i += len
}
}
private fun hex(s: String): ByteArray =
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
private fun toHex(b: ByteArray): String = b.joinToString("") { "%02X".format(it) }
}
@@ -0,0 +1,236 @@
package sh.sar.basedbank.ui.home
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.os.SystemClock
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.OvershootInterpolator
import com.google.android.material.color.MaterialColors
import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin
/**
* "Tap card to verify" animation: a bank card swings onto the back of a phone, NFC waves
* ripple out from the contact point, then it lifts away and repeats. Has reading / success /
* error states so the fragment can reflect what the reader is doing.
*/
class CardVerifyAnimationView(context: Context) : View(context) {
enum class State { WAITING, READING, SUCCESS, ERROR }
private var state = State.WAITING
private var stateStart = SystemClock.uptimeMillis()
private var label: String = ""
/** Text shown under the animation while waiting (and restored after an error). */
var waitingLabel: String = ""
set(value) { field = value; if (state == State.WAITING) label = value; invalidate() }
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
private val rect = RectF()
private val path = Path()
private val easeInOut = AccelerateDecelerateInterpolator()
private val overshoot = OvershootInterpolator(2.2f)
// Drives redraws only; all motion is derived from elapsed time in the current state.
private val ticker = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1000
repeatCount = ValueAnimator.INFINITE
addUpdateListener { invalidate() }
}
private val revertToWaiting = Runnable { setState(State.WAITING) }
fun setState(newState: State, text: String? = null) {
removeCallbacks(revertToWaiting)
state = newState
stateStart = SystemClock.uptimeMillis()
label = text ?: if (newState == State.WAITING) waitingLabel else label
if (newState == State.ERROR) postDelayed(revertToWaiting, ERROR_HOLD_MS)
invalidate()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ticker.start()
}
override fun onDetachedFromWindow() {
ticker.cancel()
removeCallbacks(revertToWaiting)
super.onDetachedFromWindow()
}
override fun onDraw(canvas: Canvas) {
val w = width.toFloat(); val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val dp = resources.displayMetrics.density
val colorOnSurface = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, 0xFF000000.toInt())
val colorPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, 0xFF3F51B5.toInt())
val colorOnPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, 0xFFFFFFFF.toInt())
val colorSurfaceVariant = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, 0xFFDDDDDD.toInt())
val colorError = MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, 0xFFB3261E.toInt())
// Artwork is laid out in a DESIGN_W x DESIGN_H dp box, scaled to fit the available area.
val textArea = 36 * dp
val scale = min(min(w / (DESIGN_W * dp), (h - textArea) / (DESIGN_H * dp)), 1.3f).coerceAtLeast(0.3f)
val u = dp * scale
val cx = w / 2f
val top = ((h - textArea) - DESIGN_H * u) / 2f
val elapsed = SystemClock.uptimeMillis() - stateStart
// ── Card motion: 0 = resting away from phone, 1 = held on phone ─────────
val contact = when (state) {
State.WAITING -> {
val p = (elapsed % CYCLE_MS) / CYCLE_MS.toFloat()
when {
p < 0.35f -> easeInOut.getInterpolation(p / 0.35f)
p < 0.70f -> 1f
p < 1.00f -> 1f - easeInOut.getInterpolation((p - 0.70f) / 0.30f)
else -> 0f
}
}
else -> 1f
}
val shake = if (state == State.ERROR && elapsed < 500)
sin(elapsed / 500f * 6 * PI).toFloat() * (1f - elapsed / 500f) * 8 * u else 0f
// Phone
val phoneW = 64 * u; val phoneH = 112 * u
val phoneL = cx - phoneW / 2f; val phoneT = top + 44 * u
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
rect.set(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH)
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = colorOnSurface
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
// Camera bump (we're looking at the back of the phone)
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 60
rect.set(phoneL + 8 * u, phoneT + 8 * u, phoneL + 26 * u, phoneT + 30 * u)
canvas.drawRoundRect(rect, 5 * u, 5 * u, paint)
paint.alpha = 255
// Contact point where the NFC antenna sits
val touchX = cx; val touchY = phoneT + phoneH * 0.42f
// ── NFC waves (behind the card) ────────────────────────────────────────
val waveStrength = when (state) {
State.WAITING -> ((contact - 0.85f) / 0.15f).coerceIn(0f, 1f)
State.READING -> 1f
else -> 0f
}
if (waveStrength > 0f) {
val period = if (state == State.READING) 700f else 1100f
val base = (elapsed % period.toLong()) / period
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * u
for (i in 0..2) {
val p = (base + i / 3f) % 1f
val r = 58 * u + p * 46 * u
paint.color = colorPrimary
paint.alpha = ((1f - p) * 220 * waveStrength).toInt().coerceIn(0, 255)
rect.set(touchX - r, touchY - r * 0.72f, touchX + r, touchY + r * 0.72f)
canvas.drawOval(rect, paint)
}
paint.alpha = 255
}
// ── Card ───────────────────────────────────────────────────────────────
val cardW = 104 * u; val cardH = 66 * u
val restX = cx + 58 * u; val restY = top + 48 * u
val cardCx = restX + (touchX - restX) * contact + shake
val cardCy = restY + (touchY - restY) * contact
val rotation = 18f * (1f - contact)
val lift = 1f + 0.08f * (1f - contact)
canvas.save()
canvas.translate(cardCx, cardCy)
canvas.rotate(rotation)
canvas.scale(lift, lift)
// Same flat look as the phone: surface-variant body, on-surface outline, primary tint for the chip
val outline = if (state == State.ERROR) colorError else colorOnSurface
rect.set(-cardW / 2, -cardH / 2, cardW / 2, cardH / 2)
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = outline
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
// Chip
rect.set(-cardW / 2 + 12 * u, -9 * u, -cardW / 2 + 30 * u, 5 * u)
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
paint.alpha = 255
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * u; paint.color = outline
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
canvas.drawLine(rect.left, rect.centerY(), rect.right, rect.centerY(), paint)
// Contactless symbol on the card
paint.strokeWidth = 1.8f * u; paint.strokeCap = Paint.Cap.ROUND
for (i in 0..2) {
val r = (5 + i * 4.5f) * u
rect.set(cardW / 2 - 28 * u - r, -14 * u - r, cardW / 2 - 28 * u + r, -14 * u + r)
canvas.drawArc(rect, -45f, 90f, false, paint)
}
// Number + name placeholders
paint.strokeWidth = 3f * u; paint.alpha = 150
for (g in 0..3) {
val x = -cardW / 2 + 12 * u + g * 21 * u
canvas.drawLine(x, 16 * u, x + 15 * u, 16 * u, paint)
}
paint.alpha = 100; paint.strokeWidth = 2.5f * u
canvas.drawLine(-cardW / 2 + 12 * u, 26 * u, -cardW / 2 + 48 * u, 26 * u, paint)
paint.alpha = 255; paint.strokeCap = Paint.Cap.BUTT
canvas.restore()
// ── Success badge ──────────────────────────────────────────────────────
if (state == State.SUCCESS) {
val t = (elapsed / 450f).coerceIn(0f, 1f)
val badgeR = 22 * u * overshoot.getInterpolation(t)
val bx = touchX + cardW / 2 - 6 * u; val by = touchY - cardH / 2 + 4 * u
paint.style = Paint.Style.FILL; paint.color = colorPrimary
canvas.drawCircle(bx, by, badgeR, paint)
val checkT = ((elapsed - 200) / 350f).coerceIn(0f, 1f)
if (checkT > 0f) {
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3.5f * u
paint.strokeCap = Paint.Cap.ROUND; paint.color = colorOnPrimary
val x0 = bx - 9 * u; val y0 = by
val x1 = bx - 3 * u; val y1 = by + 7 * u
val x2 = bx + 10 * u; val y2 = by - 7 * u
path.reset(); path.moveTo(x0, y0)
if (checkT < 0.4f) {
val k = checkT / 0.4f
path.lineTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k)
} else {
val k = (checkT - 0.4f) / 0.6f
path.lineTo(x1, y1); path.lineTo(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k)
}
canvas.drawPath(path, paint)
paint.strokeCap = Paint.Cap.BUTT
}
}
// ── Label ──────────────────────────────────────────────────────────────
textPaint.textSize = 16 * dp
textPaint.color = if (state == State.ERROR) colorError else colorOnSurface
textPaint.alpha = when (state) {
State.WAITING -> (170 + 60 * sin(elapsed / 600.0).toFloat()).toInt().coerceIn(0, 255)
else -> 230
}
canvas.drawText(label, cx, h - textArea / 2f + textPaint.textSize / 3f, textPaint)
}
companion object {
private const val DESIGN_W = 240f
private const val DESIGN_H = 170f
private const val CYCLE_MS = 2600L
private const val ERROR_HOLD_MS = 1800L
}
}
@@ -45,15 +45,18 @@ import sh.sar.basedbank.api.bml.BmlCardClient
import sh.sar.basedbank.api.bml.BmlTapToPayClient import sh.sar.basedbank.api.bml.BmlTapToPayClient
import sh.sar.basedbank.api.mib.MibCardsClient import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.nfc.EmvCardReader
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import android.text.InputType import android.text.InputType
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding
import sh.sar.basedbank.databinding.FragmentCardsBinding import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.VerifiedCardStore
import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
@@ -265,6 +268,253 @@ class CardsFragment : Fragment() {
} }
} }
binding.btnBlock.setOnClickListener(wip) binding.btnBlock.setOnClickListener(wip)
binding.btnVerify.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
// Already-verified cards: a tap only informs; long-press re-verifies to update.
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) {
Toast.makeText(requireContext(), R.string.card_verify_already, Toast.LENGTH_SHORT).show()
} else {
onVerifyClicked(item)
}
}
binding.btnVerify.setOnLongClickListener {
cards.getOrNull(currentCardPosition)?.let { onVerifyClicked(it) }
true
}
binding.btnCancelVerify.setOnClickListener { setVerifyMode(false) }
binding.btnManualVerify.setOnClickListener {
verifyItem?.let { showCardDetailsDialog(it) }
}
}
// ── Card verification (NFC tap or manual entry) ───────────────────────────
private var isVerifyMode = false
private var verifyItem: CardItem? = null
private var verifyAnimView: CardVerifyAnimationView? = null
/** True while the CVV / manual dialog is up; the NFC reader stays off meanwhile. */
private var verifyDialogOpen = false
private fun cardLast4(item: CardItem): String {
val number = when (item) {
is CardItem.Bml -> item.account.accountNumber
is CardItem.Mib -> item.card.maskedCardNumber
}
return number.filter { it.isDigit() }.takeLast(4)
}
private fun onVerifyClicked(item: CardItem) {
val ctx = requireContext()
val adapter = android.nfc.NfcAdapter.getDefaultAdapter(ctx)
when {
adapter == null -> showCardDetailsDialog(item)
!adapter.isEnabled -> MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.nfc_disabled_title)
.setMessage(R.string.card_verify_nfc_disabled_message)
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
startActivity(Intent(android.provider.Settings.ACTION_NFC_SETTINGS))
}
.setNeutralButton(R.string.card_verify_manual) { _, _ -> showCardDetailsDialog(item) }
.setNegativeButton(R.string.cancel, null)
.show()
else -> setVerifyMode(true, item)
}
}
private fun setVerifyMode(enabled: Boolean, item: CardItem? = null) {
if (enabled == isVerifyMode) return
isVerifyMode = enabled
verifyItem = if (enabled) item else null
verifyDialogOpen = false
requireActivity().title = getString(if (enabled) R.string.card_verify_title else R.string.card_manage)
val manageVisibility = if (enabled) View.GONE else View.VISIBLE
binding.llManageButtons.visibility = manageVisibility
binding.llDefaultCardRow.visibility = manageVisibility
binding.llHideDashboardRow.visibility = manageVisibility
binding.bottomSpacer.visibility = manageVisibility
binding.flVerifyArea.visibility = if (enabled) View.VISIBLE else View.GONE
binding.llVerifyButtons.visibility = if (enabled) View.VISIBLE else View.GONE
binding.flVerifyArea.removeAllViews()
if (enabled) {
val anim = CardVerifyAnimationView(requireContext()).apply {
waitingLabel = getString(R.string.card_verify_tap)
alpha = 0f
}
verifyAnimView = anim
binding.flVerifyArea.addView(anim, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
anim.animate().alpha(1f).setDuration(300).start()
startVerifyReader()
} else {
verifyAnimView = null
stopVerifyReader()
}
}
private fun startVerifyReader() {
if (!isVerifyMode || verifyDialogOpen || !isResumed) return
val activity = requireActivity()
val adapter = android.nfc.NfcAdapter.getDefaultAdapter(activity) ?: return
adapter.enableReaderMode(activity, { tag ->
// Binder thread: fine to block on the card here.
view?.post {
if (isVerifyMode) verifyAnimView?.setState(
CardVerifyAnimationView.State.READING, getString(R.string.card_verify_reading))
}
val data = runCatching { EmvCardReader.read(tag) }.getOrNull()
view?.post { onVerifyCardRead(data) }
}, android.nfc.NfcAdapter.FLAG_READER_NFC_A or android.nfc.NfcAdapter.FLAG_READER_NFC_B or
android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null)
}
private fun stopVerifyReader() {
val activity = activity ?: return
android.nfc.NfcAdapter.getDefaultAdapter(activity)?.disableReaderMode(activity)
}
private fun onVerifyCardRead(data: EmvCardReader.CardData?) {
val item = verifyItem
if (!isVerifyMode || item == null || _binding == null || verifyDialogOpen) return
val anim = verifyAnimView
val expected = cardLast4(item)
when {
data == null -> anim?.setState(CardVerifyAnimationView.State.ERROR,
getString(R.string.card_verify_read_failed))
data.pan.takeLast(4) != expected -> anim?.setState(CardVerifyAnimationView.State.ERROR,
getString(R.string.card_verify_mismatch, data.pan.takeLast(4)))
else -> {
anim?.setState(CardVerifyAnimationView.State.SUCCESS, getString(R.string.card_verify_matched))
verifyDialogOpen = true
stopVerifyReader()
// Let the check mark land before the dialog covers it
binding.root.postDelayed({
if (isVerifyMode && verifyItem === item && _binding != null) showCardDetailsDialog(item, data)
}, 750)
}
}
}
private fun resumeWaitingForTap() {
verifyDialogOpen = false
if (!isVerifyMode) return
verifyAnimView?.setState(CardVerifyAnimationView.State.WAITING)
startVerifyReader()
}
private fun cardHolderName(item: CardItem): String = when (item) {
is CardItem.Bml -> item.account.accountBriefName
is CardItem.Mib -> item.card.cardHolderName
}
/**
* Card details form. With [nfcData] (after a matching tap) the number and expiry read from the
* chip are prefilled and locked, so only the CVV is asked for; without it everything but the
* name is entered manually. The name always comes from the bank API and is read-only.
*/
private fun showCardDetailsDialog(item: CardItem, nfcData: EmvCardReader.CardData? = null) {
val ctx = requireContext()
val expected = cardLast4(item)
val b = DialogCardManualVerifyBinding.inflate(layoutInflater)
b.etName.setText(cardHolderName(item))
b.tilName.isEnabled = false
// Auto-insert the "/" in MM/YY while typing forwards
b.etExpiry.addTextChangedListener(object : android.text.TextWatcher {
private var deleting = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { deleting = after < count }
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable) {
if (!deleting && s.length == 2 && !s.contains('/')) s.append('/')
}
})
if (nfcData != null) {
b.etCardNumber.setText(nfcData.pan.chunked(4).joinToString(" "))
b.tilCardNumber.isEnabled = false
nfcData.expiry?.let {
b.etExpiry.setText(it)
b.tilExpiry.isEnabled = false
}
}
if (isVerifyMode) {
verifyDialogOpen = true
stopVerifyReader()
}
var saved = false
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(if (nfcData != null) getString(R.string.card_verify_cvv_title, nfcData.pan.takeLast(4))
else getString(R.string.card_verify_manual_title))
.setView(b.root)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.card_verify_confirm, null)
.setOnDismissListener { if (!saved && isVerifyMode) resumeWaitingForTap() }
.create()
dialog.setOnShowListener {
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
b.tilCardNumber.error = null; b.tilExpiry.error = null; b.tilCvv.error = null
val pan = b.etCardNumber.text?.toString().orEmpty().filter { it.isDigit() }
val expiry = normalizeExpiry(b.etExpiry.text?.toString().orEmpty())
val cvv = b.etCvv.text?.toString().orEmpty()
var ok = true
if (pan.length !in 12..19 || !luhnValid(pan)) {
b.tilCardNumber.error = getString(R.string.card_verify_number_invalid); ok = false
} else if (pan.takeLast(4) != expected) {
b.tilCardNumber.error = getString(R.string.card_verify_number_mismatch, expected); ok = false
}
if (expiry == null) { b.tilExpiry.error = getString(R.string.card_verify_expiry_invalid); ok = false }
if (!cvv.matches(Regex("\\d{3,4}"))) { b.tilCvv.error = getString(R.string.card_verify_cvv_invalid); ok = false }
if (!ok) return@setOnClickListener
saved = true
saveVerifiedCard(item, VerifiedCardStore.VerifiedCard(
pan = pan,
expiry = expiry!!,
cvv = cvv,
method = if (nfcData != null) VerifiedCardStore.METHOD_NFC else VerifiedCardStore.METHOD_MANUAL,
verifiedAt = System.currentTimeMillis()
))
dialog.dismiss()
}
// Focus the first field the user actually has to fill in
val firstEditable = listOf(b.tilCardNumber to b.etCardNumber, b.tilExpiry to b.etExpiry, b.tilCvv to b.etCvv)
.first { it.first.isEnabled }.second
firstEditable.requestFocus()
}
dialog.window?.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
dialog.show()
}
private fun saveVerifiedCard(item: CardItem, card: VerifiedCardStore.VerifiedCard) {
VerifiedCardStore.save(requireContext(), cardItemKey(item), card)
Toast.makeText(requireContext(), R.string.card_verify_success, Toast.LENGTH_SHORT).show()
setVerifyMode(false)
if (isManageMode) cards.getOrNull(currentCardPosition)?.let { bindManageCardData(it) }
}
/** Accepts "MMYY" or "MM/YY"; returns "MM/YY" if it's a valid, unexpired month. */
private fun normalizeExpiry(raw: String): String? {
val m = Regex("^(0[1-9]|1[0-2])/?(\\d{2})$").find(raw.trim()) ?: return null
val month = m.groupValues[1].toInt()
val year = 2000 + m.groupValues[2].toInt()
val now = java.util.Calendar.getInstance()
val nowYear = now.get(java.util.Calendar.YEAR)
val nowMonth = now.get(java.util.Calendar.MONTH) + 1
if (year < nowYear || (year == nowYear && month < nowMonth)) return null
return "%02d/%02d".format(month, year % 100)
}
private fun luhnValid(pan: String): Boolean {
var sum = 0
pan.reversed().forEachIndexed { i, c ->
var d = c - '0'
if (i % 2 == 1) { d *= 2; if (d > 9) d -= 9 }
sum += d
}
return sum % 10 == 0
} }
private fun confirmBmlFreezeToggle(item: CardItem.Bml) { private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
@@ -400,6 +650,7 @@ class CardsFragment : Fragment() {
} }
private fun setManageMode(enabled: Boolean) { private fun setManageMode(enabled: Boolean) {
if (!enabled) setVerifyMode(false)
isManageMode = enabled isManageMode = enabled
if (!enabled) managedCardKey = null if (!enabled) managedCardKey = null
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card) requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
@@ -441,6 +692,10 @@ class CardsFragment : Fragment() {
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus) val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
binding.btnChangePin.isEnabled = !mibFrozen binding.btnChangePin.isEnabled = !mibFrozen
binding.btnBlock.isEnabled = !mibFrozen binding.btnBlock.isEnabled = !mibFrozen
binding.btnVerify.setText(
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) R.string.card_action_verified
else R.string.card_action_verify
)
} }
private fun rebindManagedCardIfNeeded() { private fun rebindManagedCardIfNeeded() {
@@ -1038,6 +1293,10 @@ class CardsFragment : Fragment() {
} }
fun onBackPressed(): Boolean { fun onBackPressed(): Boolean {
if (isVerifyMode) {
setVerifyMode(false)
return true
}
if (isTapMode) { if (isTapMode) {
setTapMode(false) setTapMode(false)
return true return true
@@ -1051,6 +1310,7 @@ class CardsFragment : Fragment() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (isVerifyMode) stopVerifyReader()
if (isTapMode) { if (isTapMode) {
BmlHostCardEmulatorService.clearToken() BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null BmlHostCardEmulatorService.onTransactionComplete = null
@@ -1059,7 +1319,9 @@ class CardsFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (isVerifyMode) startVerifyReader()
requireActivity().title = getString(when { requireActivity().title = getString(when {
isVerifyMode -> R.string.card_verify_title
isTapMode -> R.string.card_pay_nfc isTapMode -> R.string.card_pay_nfc
isManageMode -> R.string.card_manage isManageMode -> R.string.card_manage
else -> R.string.nav_pay_with_card else -> R.string.nav_pay_with_card
@@ -1067,6 +1329,7 @@ class CardsFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
if (isVerifyMode) stopVerifyReader()
tapAnimView?.stopAnimation() tapAnimView?.stopAnimation()
tapAnimView = null tapAnimView = null
BmlHostCardEmulatorService.clearToken() BmlHostCardEmulatorService.clearToken()
@@ -463,9 +463,11 @@ class TransferFragment : Fragment() {
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount) if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks) if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
val bmlQr = draft.bmlQrInfo val bmlQr = draft.bmlQrInfo
val bmlCardMerchant = draft.bmlCardMerchant
val mfaisaQr = draft.mfaisaQrInfo val mfaisaQr = draft.mfaisaQrInfo
val mfaisaRecipient = draft.mfaisaRecipient val mfaisaRecipient = draft.mfaisaRecipient
when { when {
bmlCardMerchant != null -> bmlHandler().showCardMerchant(bmlCardMerchant)
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr) bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr) mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false) mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
@@ -553,7 +555,7 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ -> binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
if (bmlHandler().hasQrMerchant) { if (bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) {
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT" val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
if (!isCard) { if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
@@ -826,6 +828,7 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener { binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant() bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler().clearQrMerchant() mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
@@ -879,6 +882,20 @@ class TransferFragment : Fragment() {
private fun lookupBmlMerchantTransaction(transactionId: String) { private fun lookupBmlMerchantTransaction(transactionId: String) {
startLookupLoading() startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
// Load the payment page first: it says whether the merchant takes BML Pay (QR flow)
// or only cards (Pomelo + 3-D Secure flow).
val page = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchPayPage(transactionId) }.getOrNull()
}
if (_binding == null) return@launch
if (page != null && !page.supportsBmlPay && page.supportsCard) {
stopLookupLoading()
bmlHandler().payCardMerchant(page)
return@launch
}
// BML Pay (or unknown): resolve the QR and pay it like a scanned merchant QR.
val target = withContext(Dispatchers.IO) { val target = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) } runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) } .getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
@@ -1244,6 +1261,12 @@ class TransferFragment : Fragment() {
return return
} }
// BML card-only merchant payment (no BML Pay) — verified card + 3-D Secure
if (bmlHandler().hasCardMerchant) {
bmlHandler().submitCardPayment()
return
}
val src = selectedAccount ?: run { val src = selectedAccount ?: 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()
return return
@@ -1672,7 +1695,7 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() { private fun updateTransferButton() {
if (bmlHandler().isOtpFlowActive) return if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank() val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0 val hasAll = selectedAccount != null && recipientReady && amount > 0
if (!hasAll) { binding.btnTransfer.isEnabled = false; return } if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet() val errors = viewModel.connectivityErrors.value ?: emptySet()
@@ -1684,6 +1707,7 @@ class TransferFragment : Fragment() {
internal fun clearForm() { internal fun clearForm() {
bmlHandler().resetOtpState() bmlHandler().resetOtpState()
bmlHandler().clearQrMerchant() bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler?.clearState() mfaisaHandler?.clearState()
mfaisaHandler?.clearQrMerchant() mfaisaHandler?.clearQrMerchant()
selectedAccount = null selectedAccount = null
@@ -1879,7 +1903,7 @@ class TransferFragment : Fragment() {
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when { b.root.alpha = when {
inactive -> 0.4f inactive -> 0.4f
bmlHandler().hasQrMerchant && !isCard -> 0.35f (bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) && !isCard -> 0.35f
else -> 1f else -> 1f
} }
val networkIcon = BmlCardParser.cardNetworkIcon(acc) val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
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
@@ -417,6 +418,176 @@ class BmlTransferHandler(
} }
} }
// ─── Card-only merchant payment (no BML Pay) ─────────────────────────────
/** A card-only BML merchant is loaded — the fragment treats it like the QR merchant mode. */
val hasCardMerchant: Boolean get() = cardMerchant != null
private val cardMerchant get() = draft.bmlCardMerchant
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
private fun verifiedCardCandidates(): List<BankAccount> {
val store = CredentialStore(ctx)
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
return (viewModel.accounts.value ?: emptyList())
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
}
/**
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
* a card payment: paints the merchant as the recipient, locks the amount, and limits the source
* to the user's verified BML cards. Send then runs the Pomelo + 3-D Secure flow.
*/
fun payCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
if (page.isPaid) {
Toast.makeText(ctx, R.string.bml_card_pay_already_paid, Toast.LENGTH_LONG).show()
return
}
if (verifiedCardCandidates().isEmpty()) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
draft.bmlCardMerchant = page
showCardMerchant(page)
// Default to a verified card if nothing suitable is already selected.
if (currentSource()?.let { isCardVerified(it) } != true) {
clearSource()
val candidates = verifiedCardCandidates()
val default = CredentialStore(ctx).getDefaultCardAccountNumber()
(candidates.firstOrNull { it.accountNumber == default } ?: candidates.firstOrNull())
?.let { selectSource(it) }
}
}
private fun isCardVerified(account: BankAccount): Boolean =
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
hideToRow()
binding.tvToAccountName.text = page.merchantName
binding.tvToBankBic.text = page.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(page.merchantName, "#0066A1"))
binding.cardToInfo.visibility = View.VISIBLE
binding.etAmount.setText("%.2f".format(page.amount))
fragment.setAmountLocked(true)
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
}
/** Drops the loaded card merchant and unlocks the amount/remarks fields. */
fun clearCardMerchant() {
if (cardMerchant == null) return
draft.bmlCardMerchant = null
fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
/** Confirm-then-pay for the loaded card merchant, using the selected verified card. */
fun submitCardPayment() {
val page = cardMerchant ?: return
val src = currentSource()
if (src == null || !isCardVerified(src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
confirmCardMerchant(page, src)
}
private fun confirmCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount
) {
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
val warnings = listOf(
"⚠ ${page.merchantName} does not support BML Pay. This transaction will be paid via card. " +
"Card payments can be less reliable, and this can take up to a minute to complete. " +
"Please keep the app open and don't retry if it seems slow."
)
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = page.merchantName,
toNumber = "",
toDetail = page.merchantAddress.ifBlank { "BML Merchant" },
warningTexts = warnings
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${page.currency} ${"%.2f".format(page.amount)} → ${page.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeCardMerchant(page, src, dialog, frame)
}
)
}
private fun executeCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) {
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
val loginId = src.loginTag.removePrefix("bml_")
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
val expiry = stored?.expiry?.split("/") // "MM/YY"
if (stored == null || otpSeed == null || expiry?.size != 2) {
dialog.dismiss()
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = src.accountBriefName
)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
}.getOrElse {
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
}
}
if (fragment.view == null) return@launch
when (result) {
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
toName = page.merchantName
) {
fragment.clearForm()
host?.triggerRefresh()
}
is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss()
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
}
}
}
}
// ─── Personal-profile transfer (token OTP, no user interaction) ────────── // ─── Personal-profile transfer (token OTP, no user interaction) ──────────
/** /**
@@ -36,6 +36,9 @@ class TransferDraft {
var remarks = "" var remarks = ""
var toText = "" var toText = ""
// BML card-only merchant payment (merchant without BML Pay, paid by verified card + 3-D Secure)
var bmlCardMerchant: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage? = null
// BML merchant QR // BML merchant QR
var bmlQrInfo: BmlQrPayInfo? = null var bmlQrInfo: BmlQrPayInfo? = null
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */ /** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
@@ -0,0 +1,65 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONObject
/**
* Full card details the user has verified (via NFC tap or manual entry), encrypted at rest
* with the shared AndroidKeyStore key. Keyed by the card's identity in the cards screen
* (e.g. "bml:<accountNumber>", "mib:<cardId>").
*/
object VerifiedCardStore {
private const val PREFS = "verified_cards"
data class VerifiedCard(
val pan: String,
val expiry: String, // MM/YY
val cvv: String,
val method: String, // METHOD_NFC or METHOD_MANUAL
val verifiedAt: Long
)
const val METHOD_NFC = "nfc"
const val METHOD_MANUAL = "manual"
fun save(context: Context, cardKey: String, card: VerifiedCard) {
val json = JSONObject().apply {
put("pan", card.pan)
put("expiry", card.expiry)
put("cvv", card.cvv)
put("method", card.method)
put("verifiedAt", card.verifiedAt)
}
prefs(context).edit().putString(cardKey, CacheEncryption.encrypt(json.toString())).apply()
}
fun load(context: Context, cardKey: String): VerifiedCard? {
val raw = prefs(context).getString(cardKey, null) ?: return null
return try {
val o = JSONObject(CacheEncryption.decrypt(raw))
VerifiedCard(
pan = o.getString("pan"),
expiry = o.optString("expiry"),
cvv = o.optString("cvv"),
method = o.optString("method"),
verifiedAt = o.optLong("verifiedAt")
)
} catch (_: Exception) { null }
}
fun isVerified(context: Context, cardKey: String): Boolean = prefs(context).contains(cardKey)
/** All stored card keys (e.g. "bml:<accountNumber>"). */
fun keys(context: Context): Set<String> = prefs(context).all.keys
fun remove(context: Context, cardKey: String) {
prefs(context).edit().remove(cardKey).apply()
}
fun clear(context: Context) {
prefs(context).edit().clear().apply()
}
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material "credit_score": card with a check mark -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h5v-2H4v-6h18V6C22,4.89 21.11,4 20,4zM20,8H4V6h16V8zM14.93,19.17l-2.83,-2.83l-1.41,1.41L14.93,22L22,14.93l-1.41,-1.41L14.93,19.17z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,5L4,5c-1.1,0 -1.99,0.9 -1.99,2L2,17c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,7c0,-1.1 -0.9,-2 -2,-2zM11,8h2v2h-2L11,8zM11,11h2v2h-2v-2zM8,8h2v2L8,10L8,8zM8,11h2v2L8,13v-2zM7,13L5,13v-2h2v2zM7,10L5,10L5,8h2v2zM16,17L8,17v-2h8v2zM16,13h-2v-2h2v2zM16,10h-2L14,8h2v2zM19,13h-2v-2h2v2zM19,10h-2L17,8h2v2z" />
</vector>
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="12dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilName"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/card_verify_name_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCardNumber"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_verify_number_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCardNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="0123456789 "
android:inputType="number"
android:maxLength="23"
android:autofillHints="creditCardNumber" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilExpiry"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:hint="@string/card_verify_expiry_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etExpiry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="0123456789/"
android:inputType="number"
android:maxLength="5"
android:autofillHints="creditCardExpirationDate" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCvv"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:hint="@string/card_verify_cvv_hint"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCvv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberPassword"
android:maxLength="4"
android:autofillHints="creditCardSecurityCode" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -87,10 +87,19 @@
<!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom --> <!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom -->
<View <View
android:id="@+id/bottomSpacer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<!-- Card verification animation (verify mode only); takes the spacer's place -->
<FrameLayout
android:id="@+id/flVerifyArea"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone" />
<!-- Divider --> <!-- Divider -->
<View <View
android:id="@+id/divider" android:id="@+id/divider"
@@ -265,6 +274,73 @@
app:iconGravity="top" app:iconGravity="top"
app:iconPadding="6dp" /> app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_verify"
android:textSize="12sp"
app:icon="@drawable/ic_card_verify"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout>
<!-- Card verification actions (verify mode only); styled like llManageButtons -->
<LinearLayout
android:id="@+id/llVerifyButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCancelVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_verify_cancel"
android:textSize="12sp"
app:icon="@drawable/ic_close"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnManualVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_verify_manual"
android:textSize="12sp"
app:icon="@drawable/ic_keyboard"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+26
View File
@@ -297,6 +297,8 @@
<string name="bml_qr_looking_up">Looking up merchant…</string> <string name="bml_qr_looking_up">Looking up merchant…</string>
<string name="bml_qr_lookup_failed">Could not load merchant details</string> <string name="bml_qr_lookup_failed">Could not load merchant details</string>
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string> <string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
<string name="bml_card_pay_already_paid">This payment has already been completed.</string>
<string name="bml_qr_payment_success">Payment Successful</string> <string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string> <string name="bml_qr_select_account">Select a BML account to pay from</string>
@@ -387,6 +389,30 @@
<string name="card_action_freeze">Freeze</string> <string name="card_action_freeze">Freeze</string>
<string name="card_action_unfreeze">Unfreeze</string> <string name="card_action_unfreeze">Unfreeze</string>
<string name="card_action_block">Block</string> <string name="card_action_block">Block</string>
<string name="card_action_verify">Verify</string>
<string name="card_action_verified">Verified</string>
<string name="card_verify_already">Card already verified. Press and hold to update.</string>
<string name="card_verify_title">Verify Card</string>
<string name="card_verify_tap">Tap card to verify</string>
<string name="card_verify_reading">Reading card… hold still</string>
<string name="card_verify_matched">Card matched</string>
<string name="card_verify_read_failed">Couldn\'t read the card, try again</string>
<string name="card_verify_mismatch">Card ending %1$s doesn\'t match</string>
<string name="card_verify_cancel">Cancel Verification</string>
<string name="card_verify_manual">Manually Verify</string>
<string name="card_verify_manual_title">Enter Your Card Details</string>
<string name="card_verify_nfc_disabled_message">Turn on NFC to verify your card by tapping it, or enter the details manually.</string>
<string name="card_verify_cvv_title">Card ending %1$s</string>
<string name="card_verify_cvv_hint">CVV</string>
<string name="card_verify_cvv_invalid">Enter a 3 or 4 digit CVV</string>
<string name="card_verify_confirm">Verify</string>
<string name="card_verify_name_hint">Name on card</string>
<string name="card_verify_number_hint">Card number</string>
<string name="card_verify_expiry_hint">Expiry (MM/YY)</string>
<string name="card_verify_number_invalid">Enter a valid card number</string>
<string name="card_verify_number_mismatch">Number must end in %1$s</string>
<string name="card_verify_expiry_invalid">Enter a valid expiry, e.g. 08/29</string>
<string name="card_verify_success">Card verified</string>
<string name="card_freeze_confirm_title">Freeze card?</string> <string name="card_freeze_confirm_title">Freeze card?</string>
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string> <string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
<string name="card_unfreeze_confirm_title">Unfreeze card?</string> <string name="card_unfreeze_confirm_title">Unfreeze card?</string>
+260
View File
@@ -0,0 +1,260 @@
# Merchant Card Payment (no BML Pay)
BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`,
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
merchant has enabled:
| Merchant capability | How it is paid | Doc |
|---|---|---|
| **BML Pay** (`bml_mpos`) enabled | Fetch the merchant's QR text, pay it via the normal QR flow | [QR Payment](13-qr-payment.md) |
| **Card only** (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | **this doc** |
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
challenge make in a browser. Reconstructed from `docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har`.
> ⚠️ This flow is **scraped browser/ACS traffic**, not a stable API. See
> [Fragility](#fragility--what-can-break) before relying on it.
---
## Hosts
| Purpose | Base URL | Notes |
|---|---|---|
| Payment page (`/paynow`) | `https://transaction.merchants.bankofmaldives.com.mv` | Behind Cloudflare — **browser User-Agent required** |
| Merchant API | `https://api.merchants.bankofmaldives.com.mv` | Tolerates non-browser UA |
| Card tokenisation (Pomelo CDE) | `https://api.pay.pomelopay.com` | `bin-lookup` |
| 3-D Secure ACS (Wibmo) | `https://secure-acs2ui-bk2-<dc>.wibmo.com` | Behind Cloudflare; `<dc>` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) |
| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS |
---
## Detecting the merchant type
`GET /<id>/paynow` returns server-rendered HTML with everything inline in a
`window.appData = {…}` script. Parse that JSON (the code reads between `window.appData = ` and the
next `</script>`):
| `window.appData` field | Meaning |
|---|---|
| `transaction.state` | `QR_CODE_GENERATED` normally; `CONFIRMED` if already paid |
| `transaction.payAmount` / `transaction.amount` | Amount in **cents** (payAmount preferred; falls back to amount) |
| `transaction.payCurrency` / `transaction.currency` | e.g. `MVR` |
| `merchant.tradingName` / `registeredName` | Display name |
| `availableProviders[]` | Contains `{value:"bml_mpos", enabled:true}` **iff BML Pay is enabled** |
| `pomeloJsProviders[]` | Contains `"mpgs"` when card entry is offered |
| `pomeloJsKey` | `pk_production_…` — the card form's auth token (a JWT carrying the merchant id) |
**Decision:** `supportsBmlPay = availableProviders` contains an enabled `bml_mpos`;
`supportsCard = pomeloJsKey present && pomeloJsProviders` contains `mpgs`.
Route to the card flow only when **`!supportsBmlPay && supportsCard`**.
> The `/paynow` host is fronted by Cloudflare and returns **403** to the `okhttp/*` User-Agent.
> Send a browser UA (`BML_WEB_USER_AGENT`) + `Accept: text/html…`. The `api.merchants…` host is
> not UA-gated, which is why the PATCHes below work with the default client.
---
## Flow overview
```
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
PATCH transactions/<id> {fx:"reset"} ─┘
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
POST <ACS creq url> creq → OTP channel picker
POST <ACS creq url> destValue=token… → OTP entry page
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
POST transactions/mpgsNotification/<id> → records the verdict
POST …next-action POLL → TRANSACTION_CONFIRMED
```
---
## 1. Announce browser
Two unauthenticated PATCHes the page sends on load (needed by `fx`/state bookkeeping). `Origin` /
`Referer` are the transaction host.
```
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
Content-Type: application/json
{"activeBrowserId":"<id>_<epoch-millis>"}
```
```
PATCH …/transactions/<id>
{"fx":"reset"}
```
---
## 2. Credentials
```
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
Authorization: <pomeloJsKey> # the pk_production_… from the page
```
```json
{
"publicKey": {
"publicKeyId": "3edf1db0-…",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
},
"apiKey": "UU8a9m4Q…",
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
}
```
---
## 3. Tokenise the card (`bin-lookup`)
The card number, CVV and expiry are **RSA-OAEP(SHA-1)** encrypted with `publicKeyPem`, Base64
(no-wrap) encoded. The Pomelo JS uses WebCrypto `{name:"RSA-OAEP", hash:"SHA-1"}` over the plain
strings — the Java equivalent is `RSA/ECB/OAEPPadding` with
`OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT)`.
| Plaintext encrypted | Field |
|---|---|
| PAN (digits only) | `encryptedCardNumber` |
| CVV | `encryptedCardSecurityCode` |
| `YYMM` (year then month) | `encryptedCardExpiry` |
```
POST https://api.pay.pomelopay.com/bin-lookup
Content-Type: application/json
tenant: bankofmaldives
x-api-key: <apiKey>
x-tenant-id:
{
"encryptedCardNumber":"<b64>",
"encryptedCardSecurityCode":"<b64>",
"encryptedCardExpiry":"<b64>",
"externalId":"<id>",
"cardHolderName":"NAME ON CARD",
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
"encryptedCardExpiryYear":"28",
"encSerialId":"<publicKeyId>"
}
```
```json
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
```
---
## 4. Rate options → 3-D Secure URL
All `next-action` calls POST to the merchant API with `Authorization: <pomeloJsKey>`.
```
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
Authorization: <pomeloJsKey>
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
```
Response `action` values:
| `action` | Meaning | Do |
|---|---|---|
| `WAIT` | Processing | Poll (below) |
| `POLL` | Keep polling | Poll |
| `THREEDS` + `3dsUrl` | Challenge required | Run [§5](#5-3-d-secure-challenge) |
| `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done |
| `TRANSACTION_FAILED` | Declined | Fail |
Poll body (every **5 s**, no browser-info):
```
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
```
> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with
> `3dsUrl = …/modirum/render-tds?transactionId=<id>`.
---
## 5. 3-D Secure challenge (Wibmo ACS)
A chain of auto-submitting HTML forms. **Only the `render-tds` form and the final gateway /
notification forms carry an `action` attribute** — the ACS's channel-picker and OTP forms have
no `action`; their JavaScript posts back to the **same creq URL**. So the creq URL (the
`render-tds` form's action) is the fallback action for every subsequent form.
1. **`GET <3dsUrl>`** (`render-tds`) → a form posting `creq` to
`https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>`. Capture that
URL as the ACS creq URL.
2. **POST creq** → the **channel picker**: radios `destValue ∈ {mobile, email, token}`, plus hidden
`creq`, `authMethod`, `otpDest`, `selectChannel`, `otpChannels`, `formReqType`. The BML token /
authenticator is the **`token`** channel. Submit:
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
(keep the hidden `creq` / `otpChannels`).
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
Cookies (`__cf_bm`, `_cfuvid`) are set by the ACS and must be carried across these POSTs — the
Cloudflare-fronted ACS also requires a browser User-Agent.
---
## 6. Confirm
Poll `next-action` until the recorded verdict surfaces:
| `action` | Result |
|---|---|
| `TRANSACTION_CONFIRMED` | Success |
| `TRANSACTION_FAILED` | Declined |
The merchant's own backend is also notified out-of-band (e.g.
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
---
## Fragility — what can break
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you
learn of breakage from a failed live payment.
| Area | Breaks when | Symptom |
|---|---|---|
| **ACS HTML scraping** (most fragile) | Wibmo changes field names (`destValue`/`otpValue`/`creq`), the `"token"` channel label, the error wording, or the form layout | "Unexpected authentication page" / wrong-OTP loop |
| **Cloudflare** | `/paynow` or `wibmo.com` adds a JS/managed challenge or TLS-fingerprint check | 403; **not fixable by UA alone** |
| **TOTP seed assumption** | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
| **Pomelo crypto/contract** | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | `bin-lookup` rejects the card |
| **`next-action` states** | New/renamed actions, or browser-info becomes validated | Poll never resolves |
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
gitignored HARs under `docs/bmlapi/tmp/` as reference fixtures to diff against.
---
&nbsp;
---
**Related:** [QR Payment](13-qr-payment.md) · App side:
[Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md)
[← Card Freeze](15-card-freeze.md)
+1
View File
@@ -193,6 +193,7 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow | | 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling | | 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card | | 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
| 16 | [Merchant Card Payment](16-card-payment.md) | Pay a card-only BML Merchant Services link — Pomelo tokenise + 3-D Secure |
--- ---
@@ -0,0 +1,159 @@
# Card Verification & Merchant Card Payment
Two linked features:
1. **Card verification** — on the [Cards](22-cards.md) manage screen, a **Verify** action reads the
physical card over NFC (or takes it by hand), checks it matches the on-screen card, and stores the
full card details (PAN, expiry, CVV) encrypted on-device.
2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
> security/PCI liability. See the API doc's
> [Fragility](../bmlapi/16-card-payment.md#fragility--what-can-break) section.
---
## Card verification
### Entry — the Verify button
In manage mode the action row has **Change PIN · Freeze · Block · Verify**
(`fragment_cards.xml`, icon `ic_card_verify`). The button reads **Verified** once the selected card
has a stored entry (`bindManageCardData` in `PayWithCardFragment.kt`).
`onVerifyClicked(item)` (`PayWithCardFragment.kt:296`) branches on NFC:
| Device state | Behaviour |
|---|---|
| No NFC hardware | Straight to manual entry (`showCardDetailsDialog`) |
| NFC off | Dialog: **NFC Settings** / **Manually Verify** / Cancel |
| NFC ready | Enter verify mode (tap animation) |
### Verify mode
`setVerifyMode(enabled, item)` (`PayWithCardFragment.kt:314`) swaps the manage action buttons for
**Cancel Verification** / **Manually Verify**, and draws `CardVerifyAnimationView`
(`ui/home/CardVerifyAnimationView.kt`) in the empty area — a flat card tapping a phone with NFC
waves, matching the [Tap to Pay](23-tap-to-pay.md) style, with `WAITING / READING / SUCCESS / ERROR`
states.
`startVerifyReader()` (`PayWithCardFragment.kt:346`) uses `NfcAdapter.enableReaderMode` (reader,
not HCE). On tap, `EmvCardReader.read(tag)` (`nfc/EmvCardReader.kt:24`) runs a minimal contactless
EMV read (PPSE → SELECT AID → GPO → read AFL records) and returns `CardData(pan, expiry)` from tags
`5A` / `57` (Track 2) and `5F24`. `onVerifyCardRead` (`:367`) compares the **last 4 digits** against
the managed card:
- **match** → success check mark → `showCardDetailsDialog(item, nfcData)` for the CVV;
- **mismatch / unreadable** → error state, then back to waiting.
### Card details dialog
`showCardDetailsDialog(item, nfcData?)` (`PayWithCardFragment.kt:406`, layout
`dialog_card_manual_verify.xml`):
- The **name** is always prefilled read-only from the API-provided holder name (`accountBriefName`
for BML, `cardHolderName` for MIB) — never read off the chip.
- **After an NFC tap** (`nfcData != null`): card number + expiry are prefilled and **locked**; only
the CVV is entered. Title shows `Card ending <4>`.
- **Manual entry**: number + expiry + CVV entered; validated with a Luhn check (`luhnValid`,
`:500`), last-4 match, a not-in-the-past expiry (`normalizeExpiry`, `:489`), and a 3–4 digit CVV.
`saveVerifiedCard` (`PayWithCardFragment.kt:481`) writes the entry and toggles the button to
**Verified**.
### Storage — `VerifiedCardStore`
`util/VerifiedCardStore.kt`. Per-card entry keyed by the card's identity (`bml:<accountNumber>` /
`mib:<cardId>`), encrypted with the shared `CacheEncryption` AndroidKeyStore key (same as the other
caches).
```
VerifiedCard(pan, expiry /*MM/YY*/, cvv, method /*nfc|manual*/, verifiedAt)
```
`save` / `load` / `isVerified` / `keys` / `remove` / `clear`. **Not** wiped by the "clear cache" or
"remove login" paths — treated as user data (like profile images).
---
## Merchant card payment
### Routing — card-only vs BML Pay
A transaction ID / link typed into Transfer's **To** field is parsed by
`BmlMerchantTxnClient.parseTransactionId` and resolved in
`TransferFragment.lookupBmlMerchantTransaction` (`TransferFragment.kt:882`):
1. `BmlMerchantTxnClient.fetchPayPage(id)` (`api/bml/BmlMerchantTxnClient.kt:43`) loads `/paynow`
(browser UA — the host is Cloudflare-fronted) and parses `window.appData`.
2. If `!supportsBmlPay && supportsCard` → `bmlHandler().payCardMerchant(page)` (card flow).
3. Otherwise → existing QR path (`fetchQrPayload` → `bmlQrPayTarget` → `openBmlQr`), see
[Transfer Flows](20-transfer-flows.md).
### On-screen, like the QR merchant mode
`BmlTransferHandler.payCardMerchant(page)` (`ui/home/transfer/BmlTransferHandler.kt:441`) renders
into the Transfer screen rather than a one-off dialog, mirroring the BML QR merchant mode:
- `showCardMerchant(page)` (`:468`) paints the merchant as the **To** card, fills + **locks** the
amount (these links carry a fixed amount), and disables remarks.
- The **From** picker is limited to BML cards; a verified default card is auto-selected.
- State lives in `TransferDraft.bmlCardMerchant`, so it survives tab switches and theme/rotation
recreation (repainted via `restoreFromDraft`).
- The **✕** on the To card and `clearForm()` both call `clearCardMerchant()` (`:486`), which unlocks
and empties the amount and re-enables remarks.
A card is only offered when it is **both** verified **and** belongs to a BML login the app has an
OTP seed for (`verifiedCardCandidates`, `:428`; `isCardVerified`, `:463`) — the 3-D Secure step
needs that seed.
### Send
`submitCardPayment` (`:496`) → `confirmCardMerchant` (`:506`) shows the shared transfer confirm
dialog (biometric-gated), then `executeCardMerchant` (`:534`) runs, off the main thread:
```
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
```
where `card` comes from `VerifiedCardStore` (expiry split `MM/YY` → month/year) and `otpSeed` is the
card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
automatically, retrying once if the first code expired. Outcome is shown in the shared
processing/success dialog; failures surface as a toast.
### Key assumption
The 3-D Secure "Authenticator" OTP must be the **same** soft-token TOTP the app already uses for BML
transfers (`CredentialStore.loadBmlCredentials(loginId).otpSeed`). This holds for the user's own
BML-issued card on a login the app has. It does **not** work for a non-BML card, a card belonging to
another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
---
## Files
| File | Role |
|---|---|
| `ui/home/PayWithCardFragment.kt` | Verify button, verify mode, NFC reader, card details dialog |
| `ui/home/CardVerifyAnimationView.kt` | "Tap card to verify" animation |
| `nfc/EmvCardReader.kt` | Minimal contactless EMV read (PAN + expiry) |
| `util/VerifiedCardStore.kt` | Encrypted per-card store of full details |
| `res/layout/dialog_card_manual_verify.xml` | Card details form |
| `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload |
| `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment |
| `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment |
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
---
&nbsp;
---
**Related:** [Cards](22-cards.md) · [Transfer Flows](20-transfer-flows.md) · API side:
[Merchant Card Payment](../bmlapi/16-card-payment.md)
[← Settings — About](28-settings-about.md)
+1
View File
@@ -34,6 +34,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre | | [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre |
| [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start | | [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start |
| [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons | | [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons |
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
## Reference ## Reference
@@ -0,0 +1,2 @@
- Verify cards via NFC or manually
- Add support for bml gateway card payment.