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_code=$VERSION_CODE" >> $GITHUB_OUTPUT
if git tag -l | grep -q "^v${VERSION}$"; then
echo "Tag v${VERSION} already exists, skipping"
BEFORE="${{ github.event.before }}"
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
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
echo "New version detected: v${VERSION}"
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "should_release=true" >> $GITHUB_OUTPUT
fi
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 33
versionName = "1.0.32"
versionCode = 34
versionName = "1.0.33"
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 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 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 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 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 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.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* 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
* can go through the regular BML QR payment flow.
* 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; card-only merchants are paid by
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
*/
class BmlMerchantTxnClient {
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`).
*
@@ -41,6 +113,12 @@ class BmlMerchantTxnClient {
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 {
val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId")
@@ -66,8 +144,9 @@ class BmlMerchantTxnClient {
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
companion object {
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
internal const val API_BASE = "https://api.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 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}$")
@@ -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.mib.MibCardsClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.nfc.EmvCardReader
import sh.sar.basedbank.api.mib.MibCard
import android.text.InputType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.VerifiedCardStore
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
@@ -265,6 +268,253 @@ class CardsFragment : Fragment() {
}
}
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) {
@@ -400,6 +650,7 @@ class CardsFragment : Fragment() {
}
private fun setManageMode(enabled: Boolean) {
if (!enabled) setVerifyMode(false)
isManageMode = enabled
if (!enabled) managedCardKey = null
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)
binding.btnChangePin.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() {
@@ -1038,6 +1293,10 @@ class CardsFragment : Fragment() {
}
fun onBackPressed(): Boolean {
if (isVerifyMode) {
setVerifyMode(false)
return true
}
if (isTapMode) {
setTapMode(false)
return true
@@ -1051,6 +1310,7 @@ class CardsFragment : Fragment() {
override fun onPause() {
super.onPause()
if (isVerifyMode) stopVerifyReader()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
@@ -1059,7 +1319,9 @@ class CardsFragment : Fragment() {
override fun onResume() {
super.onResume()
if (isVerifyMode) startVerifyReader()
requireActivity().title = getString(when {
isVerifyMode -> R.string.card_verify_title
isTapMode -> R.string.card_pay_nfc
isManageMode -> R.string.card_manage
else -> R.string.nav_pay_with_card
@@ -1067,6 +1329,7 @@ class CardsFragment : Fragment() {
}
override fun onDestroyView() {
if (isVerifyMode) stopVerifyReader()
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
@@ -463,9 +463,11 @@ class TransferFragment : Fragment() {
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
val bmlQr = draft.bmlQrInfo
val bmlCardMerchant = draft.bmlCardMerchant
val mfaisaQr = draft.mfaisaQrInfo
val mfaisaRecipient = draft.mfaisaRecipient
when {
bmlCardMerchant != null -> bmlHandler().showCardMerchant(bmlCardMerchant)
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
@@ -553,7 +555,7 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
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"
if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
@@ -826,6 +828,7 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = ""
resolvedRecipientName = ""
@@ -879,6 +882,20 @@ class TransferFragment : Fragment() {
private fun lookupBmlMerchantTransaction(transactionId: String) {
startLookupLoading()
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) {
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
@@ -1244,6 +1261,12 @@ class TransferFragment : Fragment() {
return
}
// BML card-only merchant payment (no BML Pay) — verified card + 3-D Secure
if (bmlHandler().hasCardMerchant) {
bmlHandler().submitCardPayment()
return
}
val src = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
@@ -1672,7 +1695,7 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() {
if (bmlHandler().isOtpFlowActive) return
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
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
@@ -1684,6 +1707,7 @@ class TransferFragment : Fragment() {
internal fun clearForm() {
bmlHandler().resetOtpState()
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler?.clearState()
mfaisaHandler?.clearQrMerchant()
selectedAccount = null
@@ -1879,7 +1903,7 @@ class TransferFragment : Fragment() {
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when {
inactive -> 0.4f
bmlHandler().hasQrMerchant && !isCard -> 0.35f
(bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) && !isCard -> 0.35f
else -> 1f
}
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.BmlQrPayClient
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) ──────────
/**
@@ -36,6 +36,9 @@ class TransferDraft {
var remarks = ""
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
var bmlQrInfo: BmlQrPayInfo? = null
/** 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 -->
<View
android:id="@+id/bottomSpacer"
android:layout_width="match_parent"
android:layout_height="0dp"
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 -->
<View
android:id="@+id/divider"
@@ -265,6 +274,73 @@
app:iconGravity="top"
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>
+26
View File
@@ -297,6 +297,8 @@
<string name="bml_qr_looking_up">Looking up merchant…</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="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_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_unfreeze">Unfreeze</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_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>
+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 |
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
| 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 |
| [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 |
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
## Reference
@@ -0,0 +1,2 @@
- Verify cards via NFC or manually
- Add support for bml gateway card payment.