Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877147959a
|
||
|
|
68583465de
|
||
|
|
1bf63ed55b
|
||
|
|
3350c84a33
|
||
|
|
449c27ced2
|
||
|
|
3c5d3ff883
|
||
|
|
25b5c80c49
|
||
|
|
2b2fd59543
|
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("&", "&").replace(""", "\"")
|
||||||
|
.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**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)
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**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)
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user