Compare commits

...
11 Commits
Author SHA1 Message Date
quillfires aba66c3e34 feat: add Dhivehi localization
Add a complete Dhivehi translation for the app's user-facing strings.

- Add the `values-b+dv/strings.xml` resource
- Localize onboarding, login, security, navigation, dashboard, transfers, contacts, financing, cards, settings, and related UI
- Adapt wording naturally for Dhivehi rather than using literal English translations
- Preserve existing string keys, format arguments, and Android resource structure
2026-10-03 00:30:49 +05:00
shihaam c0944d3809 prep for reload/raastas part 3: add GST notice, max and min values and make contact picker and contacts page behave correctly for fahipay contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-02 19:51:52 +05:00
shihaam b05ee44715 prep for reload/raastas part 2
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-10-02 19:12:21 +05:00
shihaam 877147959a Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 6m0s
2026-10-01 06:21:59 +05:00
shihaam 68583465de Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-10-01 06:17:26 +05:00
shihaam 1bf63ed55b Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 7s
Build and Release APK / build (push) Successful in 4m38s
2026-10-01 06:11:03 +05:00
shihaam 3350c84a33 press and hold to update verified card 2026-10-01 06:10:22 +05:00
shihaam 449c27ced2 update docs: card payments 2026-10-01 06:06:57 +05:00
shihaam 3c5d3ff883 add warning when paying via card 2026-10-01 06:06:41 +05:00
shihaam 25b5c80c49 inital tests for pay via card 2026-10-01 06:00:23 +05:00
shihaam 2b2fd59543 Gateway payments via card, Step 1: verify cards
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-01 05:08:17 +05:00
40 changed files with 3317 additions and 382 deletions
+12 -3
View File
@@ -24,11 +24,20 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
if git tag -l | grep -q "^v${VERSION}$"; then
echo "Tag v${VERSION} already exists, skipping"
BEFORE="${{ github.event.before }}"
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BEFORE="HEAD~1"
fi
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
echo "versionCode unchanged (${VERSION_CODE}), skipping"
echo "should_release=false" >> $GITHUB_OUTPUT
elif git tag -l | grep -q "^v${VERSION}$"; then
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
exit 1
else
echo "New version detected: v${VERSION}"
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "should_release=true" >> $GITHUB_OUTPUT
fi
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 33
versionName = "1.0.32"
versionCode = 34
versionName = "1.0.33"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
/** Browser User-Agent used for BML's web/Cloudflare-fronted endpoints (login, merchant pay page, ACS). */
internal val BML_WEB_USER_AGENT = "Mozilla/5.0 (Android ${Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
internal const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
@@ -27,7 +27,7 @@ class BmlLoginFlow {
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val WEB_USER_AGENT = BML_WEB_USER_AGENT
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
@@ -0,0 +1,301 @@
package sh.sar.basedbank.api.bml
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
import java.util.concurrent.TimeUnit
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
import android.util.Base64
/**
* Pays a BML Merchant Services payment link by card, for merchants that don't have BML Pay
* enabled. It performs the same request sequence the link's own card form (Pomelo JS) and the
* issuer's 3-D Secure page perform in a browser:
*
* 1. `GET public-client/credentials/<id>` (auth header: the page's `pomeloJsKey`) → RSA public
* key + Pomelo API key.
* 2. `POST api.pay.pomelopay.com/bin-lookup` with the PAN, CVV and `YYMM` expiry, each
* RSA-OAEP(SHA-1) encrypted with that key → a card `tokenId`.
* 3. `POST public-client/transactions/next-action` RATE_OPTIONS, polling while the server says
* WAIT, until it returns a `3dsUrl`.
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
* 5. Poll next-action until TRANSACTION_CONFIRMED.
*
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies.
*/
class BmlMerchantCardPayClient {
data class Card(
val pan: String,
val expiryMonth: String, // "07"
val expiryYear: String, // "28"
val cvv: String,
val holderName: String
)
sealed class Result {
object Success : Result()
data class Failure(val message: String) : Result()
}
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(45, TimeUnit.SECONDS)
// Credentials/next-action tolerate okhttp, but the Cloudflare-fronted ACS does not — send a
// browser UA on everything (only when the caller didn't set one).
.addInterceptor { chain ->
val req = chain.request()
chain.proceed(
if (req.header("User-Agent") == null)
req.newBuilder().header("User-Agent", BML_WEB_USER_AGENT).build()
else req
)
}
.cookieJar(object : CookieJar {
override fun saveFromResponse(url: HttpUrl, newCookies: List<Cookie>) {
val list = cookies.getOrPut(url.host) { mutableListOf() }
for (c in newCookies) { list.removeAll { it.name == c.name }; list.add(c) }
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
cookies.values.flatten().filter { it.matches(url) }
})
.build()
/**
* Runs the whole payment. [otp] returns the current BML token code; it is called again with
* `retry = true` if the ACS rejects a code (it can expire between generating and submitting).
*/
fun pay(page: BmlMerchantTxnClient.PayPage, card: Card, otp: (retry: Boolean) -> String): Result {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
// 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
val keyInfo = creds.getJSONObject("publicKey")
val publicKey = parsePublicKey(keyInfo.getString("publicKeyPem"))
val binBody = JSONObject()
.put("encryptedCardNumber", encrypt(publicKey, card.pan))
.put("encryptedCardSecurityCode", encrypt(publicKey, card.cvv))
.put("encryptedCardExpiry", encrypt(publicKey, card.expiryYear + card.expiryMonth))
.put("externalId", txnId)
.put("cardHolderName", card.holderName)
.put("encryptedCardExpiryMonth", card.expiryMonth)
.put("encryptedCardExpiryYear", card.expiryYear)
.put("encSerialId", keyInfo.getString("publicKeyId"))
val binReq = Request.Builder()
.url(creds.optString("binLookupUrl").ifBlank { "https://api.pay.pomelopay.com/bin-lookup" })
.post(binBody.toString().toRequestBody(JSON))
.header("tenant", "bankofmaldives")
.header("x-api-key", creds.getString("apiKey"))
.header("x-tenant-id", creds.optString("tid"))
.build()
val bin = execJson(binReq)
val tokenId = bin.optString("tokenId").ifBlank { return Result.Failure("Card was not accepted") }
// 3. Rate options → poll while WAIT → 3-D Secure URL
val cardFields = JSONObject()
.put("transactionId", txnId)
.put("tokenId", tokenId)
.put("bin8", bin.optString("bin8"))
.put("cardBrand", bin.optString("brand"))
for ((from, to) in listOf("issuer" to "cardIssuer", "country" to "cardCountry",
"cardCategory" to "cardCategory", "isCommercial" to "isCommercial",
"isPrepaid" to "isPrepaid", "isReloadable" to "isReloadable", "paddedPan" to "paddedPan")) {
if (bin.has(from) && !bin.isNull(from)) cardFields.put(to, bin.get(from))
}
var action = nextAction(pk, copy(cardFields).put("action", "RATE_OPTIONS").withBrowserInfo())
val resolved = setOf("WAIT", "POLL", "TRANSACTION_CONFIRMED", "TRANSACTION_FAILED")
if (action.optString("action") !in resolved && action.optString("3dsUrl").isBlank()) {
action = nextAction(pk, copy(cardFields).put("action", "THREEDS").withBrowserInfo())
}
var threeDsUrl: String? = null
for (attempt in 0..MAX_POLLS) {
when (action.optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
if (threeDsUrl != null) break
if (attempt == MAX_POLLS) return Result.Failure("Timed out waiting for the bank")
Thread.sleep(POLL_MS)
action = poll(pk, txnId)
}
// 4. 3-D Secure challenge (handles the authenticator channel + TOTP)
runThreeDs(threeDsUrl!!, otp)?.let { return it }
// 5. Wait for the gateway's verdict to reach BML
repeat(MAX_POLLS * 2) {
when (poll(pk, txnId).optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
Thread.sleep(POLL_MS / 2)
}
return Result.Failure("Payment status unknown — check with the merchant before retrying")
}
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
// issuer's ACS. The ACS's own channel/OTP forms carry no action attribute — their JS posts
// back to this same creq URL — so it is the fallback action for everything that follows.
var form = AcsForm.parse(execText(get(threeDsUrl)), null)
?: return Result.Failure("Couldn't start card authentication")
val acsUrl = form.action
var html = execText(form.toRequest())
// Channel picker (Mobile / Email / Authenticator). The BML token is the "token" channel.
if (html.contains("name=\"destValue\"")) {
form = AcsForm.parse(html, acsUrl) ?: return Result.Failure("Unexpected authentication page")
form.fields["destValue"] = "token"
form.fields["selectChannel"] = "token"
form.fields["authMethod"] = "OOB"
form.fields["otpDest"] = ""
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
}
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
var retry = false
for (attempt in 0..1) {
form = AcsForm.parse(html, acsUrl) ?: break
if (!form.fields.containsKey("otpValue")) break
form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
retry = true
}
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
repeat(3) {
val next = AcsForm.parse(html, acsUrl) ?: return null
if (next.fields.keys.none { it == "cres" || it == "order.id" }) return null
html = execText(next.toRequest())
}
return null
}
// ── next-action helpers ──────────────────────────────────────────────────
private fun nextAction(pk: String, body: JSONObject): JSONObject =
execJson(Request.Builder()
.url("$API_BASE/public-client/transactions/next-action")
.post(body.toString().toRequestBody(JSON))
.header("Authorization", pk)
.build())
private fun poll(pk: String, txnId: String): JSONObject =
nextAction(pk, JSONObject().put("action", "POLL").put("transactionId", txnId))
private fun JSONObject.withBrowserInfo(): JSONObject = this
.put("javaEnabled", false).put("javascriptEnabled", true)
.put("language", "en-US").put("colorDepth", 24)
.put("screenHeight", 1850).put("screenWidth", 1080)
.put("tz", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / -60000)
.put("userAgent", "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile)")
private fun copy(o: JSONObject) = JSONObject(o.toString())
// ── HTTP ─────────────────────────────────────────────────────────────────
private fun get(url: String) = Request.Builder().url(url).build()
private fun getJson(url: String, auth: String): JSONObject =
execJson(Request.Builder().url(url).header("Authorization", auth).header("Accept", "application/json").build())
private fun execJson(request: Request): JSONObject = client.newCall(request).execute().use { r ->
val text = r.body?.string().orEmpty()
if (!r.isSuccessful) throw Exception("Request failed (HTTP ${r.code})")
if (text.isBlank()) JSONObject() else JSONObject(text)
}
private fun execText(request: Request): String = client.newCall(request).execute().use { r ->
r.body?.string().orEmpty()
}
// ── RSA-OAEP(SHA-1), matching the Pomelo JS crypto.subtle config ──────────
private fun parsePublicKey(pem: String): java.security.PublicKey {
val der = Base64.decode(pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(Regex("\\s"), ""), Base64.DEFAULT)
return KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
}
private fun encrypt(key: java.security.PublicKey, value: String): String {
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, OAEPParameterSpec(
"SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT))
return Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
}
/**
* One `application/x-www-form-urlencoded` form scraped from an ACS HTML page: its POST target
* plus every `<input>` name/value. [fields] is mutable so the caller can fill in the chosen
* channel and the OTP before re-submitting.
*/
private class AcsForm(val action: String, val fields: MutableMap<String, String>) {
fun toRequest(): Request {
val body = FormBody.Builder()
for ((k, v) in fields) body.add(k, v)
return Request.Builder().url(action).post(body.build()).build()
}
companion object {
private val FORM = Regex("<form\\b[^>]*>", RegexOption.IGNORE_CASE)
private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
private val INPUT = Regex("<input\\b[^>]*>", RegexOption.IGNORE_CASE)
private val NAME = Regex("name\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
private val VALUE = Regex("value\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE)
/**
* The first `<form>` and its inputs. The form's `action` is used when present;
* otherwise [defaultAction] (the ACS pages set it via JS to the current creq URL).
* Null only when there is no form, or no action at all.
*/
fun parse(html: String, defaultAction: String?): AcsForm? {
val form = FORM.find(html) ?: return null
val action = ACTION.find(form.value)?.groupValues?.get(1)?.let { unescape(it) }
?: defaultAction ?: return null
val fields = linkedMapOf<String, String>()
for (m in INPUT.findAll(html)) {
val name = NAME.find(m.value)?.groupValues?.get(1) ?: continue
fields[unescape(name)] = unescape(VALUE.find(m.value)?.groupValues?.get(1) ?: "")
}
return AcsForm(action, fields)
}
private fun unescape(s: String) = s
.replace("&amp;", "&").replace("&quot;", "\"")
.replace("&#34;", "\"").replace("&#39;", "'").replace("&lt;", "<").replace("&gt;", ">")
}
}
companion object {
private val JSON = "application/json".toMediaType()
private const val POLL_MS = 5_000L
private const val MAX_POLLS = 10
}
}
@@ -3,17 +3,89 @@ package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
* can go through the regular BML QR payment flow.
* e.g. the bill links Fenaka sends. Merchants with BML Pay enabled get their QR's text fetched so it
* can go through the regular BML QR payment flow; card-only merchants are paid by
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
*/
class BmlMerchantTxnClient {
private val client = newBmlApiClient()
/** What the payment page knows about a transaction, from its embedded `window.appData`. */
data class PayPage(
val transactionId: String,
val merchantName: String,
val merchantAddress: String,
/** Major units (the page's amounts are in cents). */
val amount: Double,
val currency: String,
val state: String,
/** BML Pay (`bml_mpos`) is offered: pay through [fetchQrPayload] and the QR flow. */
val supportsBmlPay: Boolean,
/** Card entry (MPGS via Pomelo) is offered: pay with [BmlMerchantCardPayClient]. */
val supportsCard: Boolean,
/** `pk_production_…` key the page's card form authenticates with. */
val pomeloKey: String?
) {
val isPaid get() = state == "CONFIRMED"
}
/**
* Loads `/<id>/paynow`. The page is server-rendered with everything inline: the transaction,
* the merchant, `availableProviders` (lists `bml_mpos` when BML Pay is enabled — empty for
* card-only merchants) and the card form's `pomeloJsKey` / `pomeloJsProviders`.
*/
fun fetchPayPage(transactionId: String): PayPage {
val request = Request.Builder()
.url("$PAGE_ORIGIN/$transactionId/paynow")
// The page host is behind Cloudflare, which 403s non-browser User-Agents.
.header("User-Agent", BML_WEB_USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.build()
val html = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("Payment page failed (HTTP ${response.code})")
response.body?.string().orEmpty()
}
val start = html.indexOf(APP_DATA_PREFIX).takeIf { it >= 0 }
?.let { it + APP_DATA_PREFIX.length } ?: throw Exception("Payment page has no app data")
val end = html.indexOf("</script>", start).takeIf { it >= 0 } ?: throw Exception("Payment page has no app data")
val data = JSONObject(html.substring(start, end))
val txn = data.optJSONObject("transaction") ?: throw Exception("Payment page has no transaction")
val merchant = data.optJSONObject("merchant")
val providers = data.optJSONArray("availableProviders") ?: JSONArray()
val bmlPay = (0 until providers.length()).any {
val p = providers.optJSONObject(it)
p?.optString("value") == PROVIDER_BML && p.optBoolean("enabled", true)
}
val pomeloProviders = data.optJSONArray("pomeloJsProviders") ?: JSONArray()
val pomeloKey = data.optString("pomeloJsKey").ifBlank { null }
val card = pomeloKey != null && (0 until pomeloProviders.length()).any { pomeloProviders.optString(it) == "mpgs" }
val cents = if (txn.isNull("payAmount")) txn.optLong("amount") else txn.optLong("payAmount")
return PayPage(
transactionId = transactionId,
merchantName = merchant?.optString("tradingName")?.ifBlank { null }
?: merchant?.optString("registeredName").orEmpty(),
merchantAddress = listOfNotNull(
merchant?.optString("address1")?.ifBlank { null },
merchant?.optString("city")?.ifBlank { null }
).joinToString(", "),
amount = cents / 100.0,
currency = txn.optString("payCurrency").ifBlank { txn.optString("currency", "MVR") },
state = txn.optString("state"),
supportsBmlPay = bmlPay,
supportsCard = card,
pomeloKey = pomeloKey
)
}
/**
* Returns the transaction's EMV QR payload (`vendorQrCode`).
*
@@ -41,6 +113,12 @@ class BmlMerchantTxnClient {
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
}
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */
fun announceBrowser(transactionId: String) {
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
patch(transactionId, JSONObject().put("fx", "reset"))
}
private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId")
@@ -66,8 +144,9 @@ class BmlMerchantTxnClient {
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
companion object {
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
internal const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
internal const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
private const val APP_DATA_PREFIX = "window.appData = "
private const val PROVIDER_BML = "bml_mpos"
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
@@ -0,0 +1,186 @@
package sh.sar.basedbank.nfc
import android.nfc.Tag
import android.nfc.tech.IsoDep
import java.io.ByteArrayOutputStream
/**
* Minimal contactless EMV reader: selects the payment app, runs GPO and reads the
* AFL records until it finds the PAN (tag 5A / Track 2 tag 57) and expiry (5F24 / Track 2).
*/
object EmvCardReader {
/** [expiry] is "MM/YY". */
data class CardData(val pan: String, val expiry: String?)
private class Collected {
var pan: String? = null
var expiry: String? = null
val complete get() = pan != null && expiry != null
fun result() = pan?.let { CardData(it, expiry) }
}
/** Returns the card data, or null if the PAN couldn't be read. Blocking — call off the main thread. */
fun read(tag: Tag): CardData? {
val iso = IsoDep.get(tag) ?: return null
val c = Collected()
iso.use {
it.connect()
it.timeout = 5000
val aids = selectPpse(it).ifEmpty { KNOWN_AIDS }
for (aid in aids) {
val fci = transceive(it, selectApdu(aid)) ?: continue
val pdol = findTag(fci, 0x9F38)
val gpo = transceive(it, gpoApdu(pdol)) ?: continue
collect(gpo, c)
if (c.complete) return c.result()
// Format 1 (tag 80): AIP (2 bytes) + AFL. Format 2 (tag 77): AFL in tag 94.
val afl = findTag(gpo, 0x94)
?: findTag(gpo, 0x80)?.let { b -> if (b.size > 2) b.copyOfRange(2, b.size) else null }
?: continue
for (i in 0 until afl.size / 4) {
val sfi = (afl[i * 4].toInt() and 0xFF) shr 3
val first = afl[i * 4 + 1].toInt() and 0xFF
val last = afl[i * 4 + 2].toInt() and 0xFF
for (rec in first..last) {
val data = transceive(it, readRecordApdu(sfi, rec)) ?: continue
collect(data, c)
if (c.complete) return c.result()
}
}
if (c.pan != null) return c.result()
}
}
return c.result()
}
private val KNOWN_AIDS = listOf(
"A0000000031010", // Visa
"A0000000041010", // Mastercard
"A0000000043060", // Maestro
"A000000025010801", // Amex
"A0000003330101", // UnionPay
).map { hex(it) }
private fun selectPpse(iso: IsoDep): List<ByteArray> {
val resp = transceive(iso, selectApdu("2PAY.SYS.DDF01".toByteArray())) ?: return emptyList()
return findAllTags(resp, 0x4F)
}
private fun collect(data: ByteArray, c: Collected) {
findTag(data, 0x5A)?.let { c.pan = c.pan ?: toHex(it).trimEnd('F') }
findTag(data, 0x57)?.let { raw ->
val t2 = toHex(raw)
c.pan = c.pan ?: t2.substringBefore('D')
// Track 2: PAN 'D' YYMM service-code ...
val yymm = t2.substringAfter('D', "").take(4)
if (c.expiry == null && yymm.length == 4) c.expiry = "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}"
}
findTag(data, 0x5F24)?.let { raw ->
val yymmdd = toHex(raw)
if (yymmdd.length >= 4) c.expiry = "${yymmdd.substring(2, 4)}/${yymmdd.substring(0, 2)}"
}
}
private fun selectApdu(aid: ByteArray): ByteArray =
byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00)
private fun readRecordApdu(sfi: Int, rec: Int): ByteArray =
byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00)
/** Builds GPO with the PDOL filled in: sensible TTQ/country/currency/date, zeros otherwise. */
private fun gpoApdu(pdol: ByteArray?): ByteArray {
val out = ByteArrayOutputStream()
if (pdol != null) {
var i = 0
while (i < pdol.size) {
var tag = pdol[i].toInt() and 0xFF
i++
if (tag and 0x1F == 0x1F) {
do {
tag = (tag shl 8) or (pdol[i].toInt() and 0xFF)
} while (pdol[i++].toInt() and 0x80 != 0 && i < pdol.size)
}
if (i >= pdol.size) break
val len = pdol[i++].toInt() and 0xFF
val value = when (tag) {
0x9F66 -> hex("B620C000") // TTQ: contactless qVSDC, online capable
0x9F1A, 0x5F2A -> hex("0462") // Maldives / MVR
0x9A -> hex("260101")
0x9C -> hex("00")
0x9F37 -> hex("12345678")
else -> ByteArray(len)
}
out.write(value.copyOf(len))
}
}
val pdolData = out.toByteArray()
val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData
return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00)
}
/** Sends an APDU, returning the response data on 9000 (following 61xx / 6Cxx), else null. */
private fun transceive(iso: IsoDep, apdu: ByteArray): ByteArray? {
var resp = iso.transceive(apdu)
if (resp.size < 2) return null
var sw1 = resp[resp.size - 2].toInt() and 0xFF
if (sw1 == 0x6C) {
val retry = apdu.copyOf()
retry[retry.size - 1] = resp[resp.size - 1]
resp = iso.transceive(retry)
sw1 = resp[resp.size - 2].toInt() and 0xFF
}
if (sw1 == 0x61) {
resp = iso.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1]))
sw1 = resp[resp.size - 2].toInt() and 0xFF
}
val sw2 = resp[resp.size - 1].toInt() and 0xFF
return if (sw1 == 0x90 && sw2 == 0x00) resp.copyOf(resp.size - 2) else null
}
// ── BER-TLV ──────────────────────────────────────────────────────────────
private fun findTag(data: ByteArray, target: Int): ByteArray? = findAllTags(data, target).firstOrNull()
private fun findAllTags(data: ByteArray, target: Int): List<ByteArray> {
val found = mutableListOf<ByteArray>()
walk(data, 0, data.size, target, found)
return found
}
private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList<ByteArray>) {
var i = start
while (i < end) {
val b0 = data[i].toInt() and 0xFF
if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // padding
val constructed = b0 and 0x20 != 0
var tag = b0
i++
if (b0 and 0x1F == 0x1F) {
while (i < end) {
val b = data[i++].toInt() and 0xFF
tag = (tag shl 8) or b
if (b and 0x80 == 0) break
}
}
if (i >= end) return
var len = data[i++].toInt() and 0xFF
if (len and 0x80 != 0) {
val n = len and 0x7F
len = 0
repeat(n) { if (i < end) len = (len shl 8) or (data[i++].toInt() and 0xFF) }
}
if (len < 0 || i + len > end) return
if (tag == target) found.add(data.copyOfRange(i, i + len))
if (constructed) walk(data, i, i + len, target, found)
i += len
}
}
private fun hex(s: String): ByteArray =
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
private fun toHex(b: ByteArray): String = b.joinToString("") { "%02X".format(it) }
}
@@ -0,0 +1,236 @@
package sh.sar.basedbank.ui.home
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.os.SystemClock
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.OvershootInterpolator
import com.google.android.material.color.MaterialColors
import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin
/**
* "Tap card to verify" animation: a bank card swings onto the back of a phone, NFC waves
* ripple out from the contact point, then it lifts away and repeats. Has reading / success /
* error states so the fragment can reflect what the reader is doing.
*/
class CardVerifyAnimationView(context: Context) : View(context) {
enum class State { WAITING, READING, SUCCESS, ERROR }
private var state = State.WAITING
private var stateStart = SystemClock.uptimeMillis()
private var label: String = ""
/** Text shown under the animation while waiting (and restored after an error). */
var waitingLabel: String = ""
set(value) { field = value; if (state == State.WAITING) label = value; invalidate() }
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
private val rect = RectF()
private val path = Path()
private val easeInOut = AccelerateDecelerateInterpolator()
private val overshoot = OvershootInterpolator(2.2f)
// Drives redraws only; all motion is derived from elapsed time in the current state.
private val ticker = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1000
repeatCount = ValueAnimator.INFINITE
addUpdateListener { invalidate() }
}
private val revertToWaiting = Runnable { setState(State.WAITING) }
fun setState(newState: State, text: String? = null) {
removeCallbacks(revertToWaiting)
state = newState
stateStart = SystemClock.uptimeMillis()
label = text ?: if (newState == State.WAITING) waitingLabel else label
if (newState == State.ERROR) postDelayed(revertToWaiting, ERROR_HOLD_MS)
invalidate()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ticker.start()
}
override fun onDetachedFromWindow() {
ticker.cancel()
removeCallbacks(revertToWaiting)
super.onDetachedFromWindow()
}
override fun onDraw(canvas: Canvas) {
val w = width.toFloat(); val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val dp = resources.displayMetrics.density
val colorOnSurface = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, 0xFF000000.toInt())
val colorPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, 0xFF3F51B5.toInt())
val colorOnPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, 0xFFFFFFFF.toInt())
val colorSurfaceVariant = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, 0xFFDDDDDD.toInt())
val colorError = MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, 0xFFB3261E.toInt())
// Artwork is laid out in a DESIGN_W x DESIGN_H dp box, scaled to fit the available area.
val textArea = 36 * dp
val scale = min(min(w / (DESIGN_W * dp), (h - textArea) / (DESIGN_H * dp)), 1.3f).coerceAtLeast(0.3f)
val u = dp * scale
val cx = w / 2f
val top = ((h - textArea) - DESIGN_H * u) / 2f
val elapsed = SystemClock.uptimeMillis() - stateStart
// ── Card motion: 0 = resting away from phone, 1 = held on phone ─────────
val contact = when (state) {
State.WAITING -> {
val p = (elapsed % CYCLE_MS) / CYCLE_MS.toFloat()
when {
p < 0.35f -> easeInOut.getInterpolation(p / 0.35f)
p < 0.70f -> 1f
p < 1.00f -> 1f - easeInOut.getInterpolation((p - 0.70f) / 0.30f)
else -> 0f
}
}
else -> 1f
}
val shake = if (state == State.ERROR && elapsed < 500)
sin(elapsed / 500f * 6 * PI).toFloat() * (1f - elapsed / 500f) * 8 * u else 0f
// Phone
val phoneW = 64 * u; val phoneH = 112 * u
val phoneL = cx - phoneW / 2f; val phoneT = top + 44 * u
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
rect.set(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH)
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = colorOnSurface
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
// Camera bump (we're looking at the back of the phone)
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 60
rect.set(phoneL + 8 * u, phoneT + 8 * u, phoneL + 26 * u, phoneT + 30 * u)
canvas.drawRoundRect(rect, 5 * u, 5 * u, paint)
paint.alpha = 255
// Contact point where the NFC antenna sits
val touchX = cx; val touchY = phoneT + phoneH * 0.42f
// ── NFC waves (behind the card) ────────────────────────────────────────
val waveStrength = when (state) {
State.WAITING -> ((contact - 0.85f) / 0.15f).coerceIn(0f, 1f)
State.READING -> 1f
else -> 0f
}
if (waveStrength > 0f) {
val period = if (state == State.READING) 700f else 1100f
val base = (elapsed % period.toLong()) / period
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * u
for (i in 0..2) {
val p = (base + i / 3f) % 1f
val r = 58 * u + p * 46 * u
paint.color = colorPrimary
paint.alpha = ((1f - p) * 220 * waveStrength).toInt().coerceIn(0, 255)
rect.set(touchX - r, touchY - r * 0.72f, touchX + r, touchY + r * 0.72f)
canvas.drawOval(rect, paint)
}
paint.alpha = 255
}
// ── Card ───────────────────────────────────────────────────────────────
val cardW = 104 * u; val cardH = 66 * u
val restX = cx + 58 * u; val restY = top + 48 * u
val cardCx = restX + (touchX - restX) * contact + shake
val cardCy = restY + (touchY - restY) * contact
val rotation = 18f * (1f - contact)
val lift = 1f + 0.08f * (1f - contact)
canvas.save()
canvas.translate(cardCx, cardCy)
canvas.rotate(rotation)
canvas.scale(lift, lift)
// Same flat look as the phone: surface-variant body, on-surface outline, primary tint for the chip
val outline = if (state == State.ERROR) colorError else colorOnSurface
rect.set(-cardW / 2, -cardH / 2, cardW / 2, cardH / 2)
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = outline
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
// Chip
rect.set(-cardW / 2 + 12 * u, -9 * u, -cardW / 2 + 30 * u, 5 * u)
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
paint.alpha = 255
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * u; paint.color = outline
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
canvas.drawLine(rect.left, rect.centerY(), rect.right, rect.centerY(), paint)
// Contactless symbol on the card
paint.strokeWidth = 1.8f * u; paint.strokeCap = Paint.Cap.ROUND
for (i in 0..2) {
val r = (5 + i * 4.5f) * u
rect.set(cardW / 2 - 28 * u - r, -14 * u - r, cardW / 2 - 28 * u + r, -14 * u + r)
canvas.drawArc(rect, -45f, 90f, false, paint)
}
// Number + name placeholders
paint.strokeWidth = 3f * u; paint.alpha = 150
for (g in 0..3) {
val x = -cardW / 2 + 12 * u + g * 21 * u
canvas.drawLine(x, 16 * u, x + 15 * u, 16 * u, paint)
}
paint.alpha = 100; paint.strokeWidth = 2.5f * u
canvas.drawLine(-cardW / 2 + 12 * u, 26 * u, -cardW / 2 + 48 * u, 26 * u, paint)
paint.alpha = 255; paint.strokeCap = Paint.Cap.BUTT
canvas.restore()
// ── Success badge ──────────────────────────────────────────────────────
if (state == State.SUCCESS) {
val t = (elapsed / 450f).coerceIn(0f, 1f)
val badgeR = 22 * u * overshoot.getInterpolation(t)
val bx = touchX + cardW / 2 - 6 * u; val by = touchY - cardH / 2 + 4 * u
paint.style = Paint.Style.FILL; paint.color = colorPrimary
canvas.drawCircle(bx, by, badgeR, paint)
val checkT = ((elapsed - 200) / 350f).coerceIn(0f, 1f)
if (checkT > 0f) {
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3.5f * u
paint.strokeCap = Paint.Cap.ROUND; paint.color = colorOnPrimary
val x0 = bx - 9 * u; val y0 = by
val x1 = bx - 3 * u; val y1 = by + 7 * u
val x2 = bx + 10 * u; val y2 = by - 7 * u
path.reset(); path.moveTo(x0, y0)
if (checkT < 0.4f) {
val k = checkT / 0.4f
path.lineTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k)
} else {
val k = (checkT - 0.4f) / 0.6f
path.lineTo(x1, y1); path.lineTo(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k)
}
canvas.drawPath(path, paint)
paint.strokeCap = Paint.Cap.BUTT
}
}
// ── Label ──────────────────────────────────────────────────────────────
textPaint.textSize = 16 * dp
textPaint.color = if (state == State.ERROR) colorError else colorOnSurface
textPaint.alpha = when (state) {
State.WAITING -> (170 + 60 * sin(elapsed / 600.0).toFloat()).toInt().coerceIn(0, 255)
else -> 230
}
canvas.drawText(label, cx, h - textArea / 2f + textPaint.textSize / 3f, textPaint)
}
companion object {
private const val DESIGN_W = 240f
private const val DESIGN_H = 170f
private const val CYCLE_MS = 2600L
private const val ERROR_HOLD_MS = 1800L
}
}
@@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
class ContactPickerAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onItemClick: (accountNumber: String, label: String) -> Unit,
private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit,
private val onSameAsFrom: () -> Unit,
private val onImageNeeded: ((hash: String) -> Unit)? = null,
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
@@ -33,7 +33,9 @@ class ContactPickerAdapter(
val imageHash: String? = null,
val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
val bankLogoRes: Int? = null,
/** The saved contact's category, for rows that are contacts; null otherwise. */
val categoryId: String? = null
) : PickerItem()
}
@@ -125,7 +127,7 @@ class ContactPickerAdapter(
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
item.isSameAsFrom ->
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
else -> onItemClick(item.accountNumber, item.displayName)
else -> onItemClick(item.accountNumber, item.displayName, item.categoryId)
}
}
binding.root.setOnLongClickListener { view ->
@@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
ContactPickerAdapter(
imageCache = sharedImageCache,
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) },
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
onItemLongClick = { accountNumber, anchor ->
@@ -161,12 +161,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
}.also { it.attach() }
}
private fun handlePickerSelection(accountNumber: String, label: String) {
private fun handlePickerSelection(accountNumber: String, label: String, categoryId: String?) {
val contacts = viewModel.contacts.value ?: emptyList()
val accounts = viewModel.accounts.value ?: emptyList()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
categoryId?.let { bundle.putString(KEY_CATEGORY, it) }
when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
@@ -226,7 +227,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
imageHash = r.imageHash,
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
// when the source is M-Faisa.
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive,
categoryId = r.contactCategory
))
}
return items
@@ -328,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromAccountNumber,
imageHash = contact.customerImgHash,
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc),
categoryId = contact.benefCategoryId
))
}
return items
@@ -396,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
const val KEY_SUBTITLE = "subtitle"
const val KEY_COLOR = "color"
const val KEY_IMAGE_HASH = "imageHash"
/** The picked contact's category (`BankContact.benefCategoryId`); absent for non-contacts. */
const val KEY_CATEGORY = "category"
private const val ARG_FROM_ACCOUNT = "fromAccount"
private const val RECENTS_TAG = "__recents__"
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
@@ -188,7 +188,8 @@ class ContactsFragment : Fragment() {
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.imageHash
imageHash = contact.imageHash,
contactCategory = contact.categoryId
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
@@ -45,15 +45,18 @@ import sh.sar.basedbank.api.bml.BmlCardClient
import sh.sar.basedbank.api.bml.BmlTapToPayClient
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.nfc.EmvCardReader
import sh.sar.basedbank.api.mib.MibCard
import android.text.InputType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.VerifiedCardStore
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
@@ -265,6 +268,253 @@ class CardsFragment : Fragment() {
}
}
binding.btnBlock.setOnClickListener(wip)
binding.btnVerify.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
// Already-verified cards: a tap only informs; long-press re-verifies to update.
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) {
Toast.makeText(requireContext(), R.string.card_verify_already, Toast.LENGTH_SHORT).show()
} else {
onVerifyClicked(item)
}
}
binding.btnVerify.setOnLongClickListener {
cards.getOrNull(currentCardPosition)?.let { onVerifyClicked(it) }
true
}
binding.btnCancelVerify.setOnClickListener { setVerifyMode(false) }
binding.btnManualVerify.setOnClickListener {
verifyItem?.let { showCardDetailsDialog(it) }
}
}
// ── Card verification (NFC tap or manual entry) ───────────────────────────
private var isVerifyMode = false
private var verifyItem: CardItem? = null
private var verifyAnimView: CardVerifyAnimationView? = null
/** True while the CVV / manual dialog is up; the NFC reader stays off meanwhile. */
private var verifyDialogOpen = false
private fun cardLast4(item: CardItem): String {
val number = when (item) {
is CardItem.Bml -> item.account.accountNumber
is CardItem.Mib -> item.card.maskedCardNumber
}
return number.filter { it.isDigit() }.takeLast(4)
}
private fun onVerifyClicked(item: CardItem) {
val ctx = requireContext()
val adapter = android.nfc.NfcAdapter.getDefaultAdapter(ctx)
when {
adapter == null -> showCardDetailsDialog(item)
!adapter.isEnabled -> MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.nfc_disabled_title)
.setMessage(R.string.card_verify_nfc_disabled_message)
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
startActivity(Intent(android.provider.Settings.ACTION_NFC_SETTINGS))
}
.setNeutralButton(R.string.card_verify_manual) { _, _ -> showCardDetailsDialog(item) }
.setNegativeButton(R.string.cancel, null)
.show()
else -> setVerifyMode(true, item)
}
}
private fun setVerifyMode(enabled: Boolean, item: CardItem? = null) {
if (enabled == isVerifyMode) return
isVerifyMode = enabled
verifyItem = if (enabled) item else null
verifyDialogOpen = false
requireActivity().title = getString(if (enabled) R.string.card_verify_title else R.string.card_manage)
val manageVisibility = if (enabled) View.GONE else View.VISIBLE
binding.llManageButtons.visibility = manageVisibility
binding.llDefaultCardRow.visibility = manageVisibility
binding.llHideDashboardRow.visibility = manageVisibility
binding.bottomSpacer.visibility = manageVisibility
binding.flVerifyArea.visibility = if (enabled) View.VISIBLE else View.GONE
binding.llVerifyButtons.visibility = if (enabled) View.VISIBLE else View.GONE
binding.flVerifyArea.removeAllViews()
if (enabled) {
val anim = CardVerifyAnimationView(requireContext()).apply {
waitingLabel = getString(R.string.card_verify_tap)
alpha = 0f
}
verifyAnimView = anim
binding.flVerifyArea.addView(anim, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
anim.animate().alpha(1f).setDuration(300).start()
startVerifyReader()
} else {
verifyAnimView = null
stopVerifyReader()
}
}
private fun startVerifyReader() {
if (!isVerifyMode || verifyDialogOpen || !isResumed) return
val activity = requireActivity()
val adapter = android.nfc.NfcAdapter.getDefaultAdapter(activity) ?: return
adapter.enableReaderMode(activity, { tag ->
// Binder thread: fine to block on the card here.
view?.post {
if (isVerifyMode) verifyAnimView?.setState(
CardVerifyAnimationView.State.READING, getString(R.string.card_verify_reading))
}
val data = runCatching { EmvCardReader.read(tag) }.getOrNull()
view?.post { onVerifyCardRead(data) }
}, android.nfc.NfcAdapter.FLAG_READER_NFC_A or android.nfc.NfcAdapter.FLAG_READER_NFC_B or
android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null)
}
private fun stopVerifyReader() {
val activity = activity ?: return
android.nfc.NfcAdapter.getDefaultAdapter(activity)?.disableReaderMode(activity)
}
private fun onVerifyCardRead(data: EmvCardReader.CardData?) {
val item = verifyItem
if (!isVerifyMode || item == null || _binding == null || verifyDialogOpen) return
val anim = verifyAnimView
val expected = cardLast4(item)
when {
data == null -> anim?.setState(CardVerifyAnimationView.State.ERROR,
getString(R.string.card_verify_read_failed))
data.pan.takeLast(4) != expected -> anim?.setState(CardVerifyAnimationView.State.ERROR,
getString(R.string.card_verify_mismatch, data.pan.takeLast(4)))
else -> {
anim?.setState(CardVerifyAnimationView.State.SUCCESS, getString(R.string.card_verify_matched))
verifyDialogOpen = true
stopVerifyReader()
// Let the check mark land before the dialog covers it
binding.root.postDelayed({
if (isVerifyMode && verifyItem === item && _binding != null) showCardDetailsDialog(item, data)
}, 750)
}
}
}
private fun resumeWaitingForTap() {
verifyDialogOpen = false
if (!isVerifyMode) return
verifyAnimView?.setState(CardVerifyAnimationView.State.WAITING)
startVerifyReader()
}
private fun cardHolderName(item: CardItem): String = when (item) {
is CardItem.Bml -> item.account.accountBriefName
is CardItem.Mib -> item.card.cardHolderName
}
/**
* Card details form. With [nfcData] (after a matching tap) the number and expiry read from the
* chip are prefilled and locked, so only the CVV is asked for; without it everything but the
* name is entered manually. The name always comes from the bank API and is read-only.
*/
private fun showCardDetailsDialog(item: CardItem, nfcData: EmvCardReader.CardData? = null) {
val ctx = requireContext()
val expected = cardLast4(item)
val b = DialogCardManualVerifyBinding.inflate(layoutInflater)
b.etName.setText(cardHolderName(item))
b.tilName.isEnabled = false
// Auto-insert the "/" in MM/YY while typing forwards
b.etExpiry.addTextChangedListener(object : android.text.TextWatcher {
private var deleting = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { deleting = after < count }
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable) {
if (!deleting && s.length == 2 && !s.contains('/')) s.append('/')
}
})
if (nfcData != null) {
b.etCardNumber.setText(nfcData.pan.chunked(4).joinToString(" "))
b.tilCardNumber.isEnabled = false
nfcData.expiry?.let {
b.etExpiry.setText(it)
b.tilExpiry.isEnabled = false
}
}
if (isVerifyMode) {
verifyDialogOpen = true
stopVerifyReader()
}
var saved = false
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(if (nfcData != null) getString(R.string.card_verify_cvv_title, nfcData.pan.takeLast(4))
else getString(R.string.card_verify_manual_title))
.setView(b.root)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.card_verify_confirm, null)
.setOnDismissListener { if (!saved && isVerifyMode) resumeWaitingForTap() }
.create()
dialog.setOnShowListener {
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
b.tilCardNumber.error = null; b.tilExpiry.error = null; b.tilCvv.error = null
val pan = b.etCardNumber.text?.toString().orEmpty().filter { it.isDigit() }
val expiry = normalizeExpiry(b.etExpiry.text?.toString().orEmpty())
val cvv = b.etCvv.text?.toString().orEmpty()
var ok = true
if (pan.length !in 12..19 || !luhnValid(pan)) {
b.tilCardNumber.error = getString(R.string.card_verify_number_invalid); ok = false
} else if (pan.takeLast(4) != expected) {
b.tilCardNumber.error = getString(R.string.card_verify_number_mismatch, expected); ok = false
}
if (expiry == null) { b.tilExpiry.error = getString(R.string.card_verify_expiry_invalid); ok = false }
if (!cvv.matches(Regex("\\d{3,4}"))) { b.tilCvv.error = getString(R.string.card_verify_cvv_invalid); ok = false }
if (!ok) return@setOnClickListener
saved = true
saveVerifiedCard(item, VerifiedCardStore.VerifiedCard(
pan = pan,
expiry = expiry!!,
cvv = cvv,
method = if (nfcData != null) VerifiedCardStore.METHOD_NFC else VerifiedCardStore.METHOD_MANUAL,
verifiedAt = System.currentTimeMillis()
))
dialog.dismiss()
}
// Focus the first field the user actually has to fill in
val firstEditable = listOf(b.tilCardNumber to b.etCardNumber, b.tilExpiry to b.etExpiry, b.tilCvv to b.etCvv)
.first { it.first.isEnabled }.second
firstEditable.requestFocus()
}
dialog.window?.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
dialog.show()
}
private fun saveVerifiedCard(item: CardItem, card: VerifiedCardStore.VerifiedCard) {
VerifiedCardStore.save(requireContext(), cardItemKey(item), card)
Toast.makeText(requireContext(), R.string.card_verify_success, Toast.LENGTH_SHORT).show()
setVerifyMode(false)
if (isManageMode) cards.getOrNull(currentCardPosition)?.let { bindManageCardData(it) }
}
/** Accepts "MMYY" or "MM/YY"; returns "MM/YY" if it's a valid, unexpired month. */
private fun normalizeExpiry(raw: String): String? {
val m = Regex("^(0[1-9]|1[0-2])/?(\\d{2})$").find(raw.trim()) ?: return null
val month = m.groupValues[1].toInt()
val year = 2000 + m.groupValues[2].toInt()
val now = java.util.Calendar.getInstance()
val nowYear = now.get(java.util.Calendar.YEAR)
val nowMonth = now.get(java.util.Calendar.MONTH) + 1
if (year < nowYear || (year == nowYear && month < nowMonth)) return null
return "%02d/%02d".format(month, year % 100)
}
private fun luhnValid(pan: String): Boolean {
var sum = 0
pan.reversed().forEachIndexed { i, c ->
var d = c - '0'
if (i % 2 == 1) { d *= 2; if (d > 9) d -= 9 }
sum += d
}
return sum % 10 == 0
}
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
@@ -400,6 +650,7 @@ class CardsFragment : Fragment() {
}
private fun setManageMode(enabled: Boolean) {
if (!enabled) setVerifyMode(false)
isManageMode = enabled
if (!enabled) managedCardKey = null
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
@@ -441,6 +692,10 @@ class CardsFragment : Fragment() {
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
binding.btnChangePin.isEnabled = !mibFrozen
binding.btnBlock.isEnabled = !mibFrozen
binding.btnVerify.setText(
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) R.string.card_action_verified
else R.string.card_action_verify
)
}
private fun rebindManagedCardIfNeeded() {
@@ -1038,6 +1293,10 @@ class CardsFragment : Fragment() {
}
fun onBackPressed(): Boolean {
if (isVerifyMode) {
setVerifyMode(false)
return true
}
if (isTapMode) {
setTapMode(false)
return true
@@ -1051,6 +1310,7 @@ class CardsFragment : Fragment() {
override fun onPause() {
super.onPause()
if (isVerifyMode) stopVerifyReader()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
@@ -1059,7 +1319,9 @@ class CardsFragment : Fragment() {
override fun onResume() {
super.onResume()
if (isVerifyMode) startVerifyReader()
requireActivity().title = getString(when {
isVerifyMode -> R.string.card_verify_title
isTapMode -> R.string.card_pay_nfc
isManageMode -> R.string.card_manage
else -> R.string.nav_pay_with_card
@@ -1067,6 +1329,7 @@ class CardsFragment : Fragment() {
}
override fun onDestroyView() {
if (isVerifyMode) stopVerifyReader()
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
@@ -35,6 +35,8 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
@@ -47,11 +49,14 @@ import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
import sh.sar.basedbank.ui.home.transfer.TransferDraft
import sh.sar.basedbank.ui.home.transfer.TransferType
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser
@@ -273,6 +278,7 @@ class TransferFragment : Fragment() {
private const val ARG_SUBTITLE = "contact_subtitle"
private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_CONTACT_CATEGORY = "contact_category"
private const val ARG_FROM_ACCOUNT = "from_account"
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
@@ -307,7 +313,9 @@ class TransferFragment : Fragment() {
displayName: String,
subtitle: String,
colorHex: String,
imageHash: String?
imageHash: String?,
/** The contact's category; a Fahipay favourites list opens as that payout service. */
contactCategory: String? = null
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
@@ -315,6 +323,7 @@ class TransferFragment : Fragment() {
putString(ARG_SUBTITLE, subtitle)
putString(ARG_COLOR, colorHex)
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
if (contactCategory != null) putString(ARG_CONTACT_CATEGORY, contactCategory)
}
}
@@ -389,6 +398,9 @@ class TransferFragment : Fragment() {
return@setFragmentResultListener
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
return@setFragmentResultListener
}
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
@@ -431,9 +443,11 @@ class TransferFragment : Fragment() {
private fun applyArguments() {
// Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account ->
val name = arguments?.getString(ARG_NAME) ?: account
if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
prefillToDirectly(
accountNumber = account,
displayName = arguments?.getString(ARG_NAME) ?: account,
displayName = name,
subtitle = arguments?.getString(ARG_SUBTITLE) ?: account,
colorHex = arguments?.getString(ARG_COLOR) ?: "#607D8B",
imageHash = arguments?.getString(ARG_IMAGE_HASH)
@@ -463,9 +477,11 @@ class TransferFragment : Fragment() {
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
val bmlQr = draft.bmlQrInfo
val bmlCardMerchant = draft.bmlCardMerchant
val mfaisaQr = draft.mfaisaQrInfo
val mfaisaRecipient = draft.mfaisaRecipient
when {
bmlCardMerchant != null -> bmlHandler().showCardMerchant(bmlCardMerchant)
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
@@ -495,6 +511,7 @@ class TransferFragment : Fragment() {
}
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
}
if (transferTypePending) showTransferTypePicker()
updateTransferButton()
}
@@ -537,6 +554,117 @@ class TransferFragment : Fragment() {
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
}
// ── Transfer type ─────────────────────────────────────────────────────────
/** The open "Transfer Type" picker, if any — dismissed when the options go away. */
private var transferTypeDialog: AlertDialog? = null
/**
* Offers [types] for [number]. A lone option is applied straight away; with more, the
* "Transfer Type" picker pops up with nothing preselected — the user has to pick, and Send
* stays disabled until they do. Either way the pick also switches the source to an account
* that can pay it (see [applyTransferType]).
*/
internal fun offerTransferTypes(number: String, types: List<TransferType>) {
draft.transferTypes = types
draft.transferTypeNumber = number
draft.transferType = null
types.singleOrNull()?.let { applyTransferType(it); return }
updateTransferButton()
showTransferTypePicker()
}
/**
* Pops up the offered transfer types. Before anything is picked the user must choose or
* cancel, and cancelling gives up on the number: the options are dropped and the "To" field
* is back for editing. Reopened to change a pick, cancelling just keeps the current one.
*/
private fun showTransferTypePicker() {
val types = draft.transferTypes
if (types.isEmpty() || transferTypeDialog?.isShowing == true) return
// Up to three tiles a row; fewer options just share the width
val grid = androidx.recyclerview.widget.RecyclerView(requireContext()).apply {
layoutManager = androidx.recyclerview.widget.GridLayoutManager(context, types.size.coerceIn(1, 3))
val pad = (20 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
clipToPadding = false
overScrollMode = View.OVER_SCROLL_NEVER
}
grid.adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter<androidx.recyclerview.widget.RecyclerView.ViewHolder>() {
override fun getItemCount() = types.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
object : androidx.recyclerview.widget.RecyclerView.ViewHolder(
ItemTransferTypeBinding.inflate(layoutInflater, parent, false).root) {}
override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) {
val tile = ItemTransferTypeBinding.bind(holder.itemView)
val type = types[position]
tile.ivIcon.setImageResource(type.iconRes)
val badge = type.badgeRes
tile.ivBadge.visibility = if (badge != null) View.VISIBLE else View.GONE
badge?.let { tile.ivBadge.setImageResource(it) }
tile.tvLabel.text = type.label
tile.tvSubtitle.text = type.subtitle
tile.root.setOnClickListener {
transferTypeDialog?.dismiss()
applyTransferType(type)
}
}
}
transferTypeDialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.transfer_type_for, draft.transferTypeNumber))
.setView(grid)
.setNegativeButton(android.R.string.cancel) { _, _ -> if (transferTypePending) clearRecipient() }
.setCancelable(!transferTypePending)
.setOnDismissListener { transferTypeDialog = null }
.show()
}
/** Forgets the offered types and closes the picker if it's up. */
internal fun resetTransferTypes() {
draft.transferTypes = emptyList()
draft.transferTypeNumber = ""
draft.transferType = null
fahipayHandler?.clearState()
transferTypeDialog?.dismiss()
updateTransferButton()
}
/** Options are on offer but the user hasn't picked one yet. */
private val transferTypePending: Boolean
get() = draft.transferTypes.isNotEmpty() && draft.transferType == null
/**
* Applies a picked transfer type: switches the source to one that can pay it, unless the
* selected one already can, then fills the recipient card.
*/
private fun applyTransferType(type: TransferType) {
draft.transferType = type
val number = draft.transferTypeNumber
val source = selectedAccount
if (source == null || !type.worksFrom(source)) {
val accounts = viewModel.accounts.value ?: emptyList()
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val pick = when (type) {
// The default account when it can do Favara, otherwise leave the user to choose
is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) }
is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom)
}
if (pick != null) selectSourceAccount(pick)
else {
clearSourceAccount()
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
}
}
when (type) {
is TransferType.Favara -> {
fahipayHandler?.clearState()
showFavaraRecipient(type.info)
}
is TransferType.Fahipay -> fahipayHandler().applyService(type, number)
}
updateTransferButton()
}
// ── Sensitive masking helpers ─────────────────────────────────────────────
private fun maskAmount(formatted: String): String {
@@ -553,7 +681,7 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
if (bmlHandler().hasQrMerchant) {
if (bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) {
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
@@ -566,6 +694,16 @@ class TransferFragment : Fragment() {
binding.actvFrom.setText("", false)
return@setOnItemClickListener
}
// A source that can't pay the picked transfer type: drop the pick and its
// recipient card, and ask again for the same number. A lone option would only
// switch the source straight back, so then the recipient goes altogether.
if (draft.transferType?.worksFrom(picked) == false) {
if (draft.transferTypes.size > 1) {
clearRecipient(keepTransferTypes = true)
draft.transferType = null
showTransferTypePicker()
} else clearRecipient()
}
selectedAccount = picked
updateAmountPrefix(picked)
showFromCard(picked)
@@ -824,27 +962,18 @@ class TransferFragment : Fragment() {
} else false
}
binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant()
mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null
fahipayHandler().clearState()
mfaisaHandler?.clearState()
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
updateTransferButton()
binding.btnClearToInfo.setOnClickListener { clearRecipient() }
// Tapping the recipient card changes the transfer type, when the lookup offered more than one
binding.cardToInfo.setOnClickListener {
if (draft.transferTypes.size > 1) showTransferTypePicker()
}
binding.etTo.addTextChangedListener {
binding.tilTo.error = null
// Options looked up for a different number no longer apply
val offeredFor = draft.transferTypeNumber
if (offeredFor.isNotEmpty() && AccountInputParser.normalize(it?.toString().orEmpty().trim()) != offeredFor)
resetTransferTypes()
if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
@@ -863,6 +992,32 @@ class TransferFragment : Fragment() {
setupContactDropdown()
}
/**
* Drops the resolved recipient and brings the "To" field back. The transfer type options go
* too unless [keepTransferTypes] — the source changed under a picked type, and the user
* should be able to choose again for the same number.
*/
private fun clearRecipient(keepTransferTypes: Boolean = false) {
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null
fahipayHandler?.clearState()
if (!keepTransferTypes) resetTransferTypes()
mfaisaHandler?.clearState()
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
updateTransferButton()
}
/**
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
@@ -879,6 +1034,20 @@ class TransferFragment : Fragment() {
private fun lookupBmlMerchantTransaction(transactionId: String) {
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
// Load the payment page first: it says whether the merchant takes BML Pay (QR flow)
// or only cards (Pomelo + 3-D Secure flow).
val page = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchPayPage(transactionId) }.getOrNull()
}
if (_binding == null) return@launch
if (page != null && !page.supportsBmlPay && page.supportsCard) {
stopLookupLoading()
bmlHandler().payCardMerchant(page)
return@launch
}
// BML Pay (or unknown): resolve the QR and pay it like a scanned merchant QR.
val target = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
@@ -931,6 +1100,9 @@ class TransferFragment : Fragment() {
binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
return@setOnItemClickListener
}
prefillToDirectly(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
@@ -943,6 +1115,19 @@ class TransferFragment : Fragment() {
}
}
/**
* A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so
* that service is applied as the only transfer type, with no carrier lookup. That switches
* the source to the Fahipay wallet and brings in the service's amount rules, the same as a
* searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list.
*/
private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean {
val service = FahipayService.fromContactCategory(categoryId) ?: return false
clearRecipient()
offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number })))
return true
}
/** Picks the user's default account as the source when a recipient is chosen before any source. */
private fun selectDefaultSourceIfNone() {
if (selectedAccount != null) return
@@ -952,6 +1137,15 @@ class TransferFragment : Fragment() {
}
private fun lookupAccount() {
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
// No source yet and a phone number: look it up every way it could be paid and let the
// user pick the transfer type, which then picks the source
if (selectedAccount == null && AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupPhoneForAnySource(accountNumber)
return
}
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
@@ -969,7 +1163,6 @@ class TransferFragment : Fragment() {
return
}
}
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return
@@ -996,100 +1189,147 @@ class TransferFragment : Fragment() {
return
}
val mibSess = mibHandler.session
val bmlSess = bmlSessionFor(selectedAccount)
if (mibSess == null && bmlSess == null) {
if (mibHandler.session == null && bmlSessionFor(selectedAccount) == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val isBmlSource = selectedAccount?.bank == "BML"
val preferBml = selectedAccount?.bank == "BML"
startLookupLoading()
// Both banks can resolve a destination, so whichever the source belongs to gets first
// try and the other is the fallback. Either way the result lands in MibIpsAccountInfo.
viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
if (isBmlSource && bmlSess != null) {
val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest)
if (bmlInfo != null) {
// BML's MIB verify endpoint doesn't return the account's currency.
// Enrich via MIB lookup when a MIB session is available.
if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null)
bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber))
else bmlInfo
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
outcome.info
}
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
if (outcome.info != null) {
outcome.info
} else {
val bmlInfo = bmlSess?.let {
bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false)
}
if (bmlInfo != null) errorMsg = null
else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
bmlInfo
}
val (info, errorMsg) = withContext(Dispatchers.IO) { lookupFavara(accountNumber, preferBml) }
stopLookupLoading()
if (info != null) showFavaraRecipient(info)
else Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
}
}
/**
* A phone number searched before any source is picked. Runs the Favara lookup (through MIB
* or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu /
* Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types.
*/
private fun lookupPhoneForAnySource(number: String) {
val accounts = viewModel.accounts.value ?: emptyList()
val hasFahipay = accounts.any { it.bank == "FAHIPAY" }
val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null
if (!hasFavara && !hasFahipay) {
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
return
}
// Favara goes through the default account's bank first, as it would once that's the source
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML"
val fahipay = fahipayHandler()
resetTransferTypes()
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
val (favara, fahipayTypes) = withContext(Dispatchers.IO) {
coroutineScope {
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() }
favara.await() to fahipayTypes.await()
}
}
stopLookupLoading()
if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber }
val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B"
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) {
showToCard(matchedAcc)
} else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
}
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
focusAmount()
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedCont?.customerImgHash != null ->
loadToPhoto(matchedCont.customerImgHash, isProfile = false)
}
} else {
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes
if (types.isEmpty()) {
Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show()
return@launch
}
offerTransferTypes(number, types)
}
}
/**
* Favara / IPS lookup of [accountNumber], returning the resolved recipient or the error to
* show. Both banks can resolve a destination, so [preferBml] decides which gets first try
* and the other is the fallback. Either way the result lands in MibIpsAccountInfo.
* Blocking — call from IO.
*/
private fun lookupFavara(accountNumber: String, preferBml: Boolean): Pair<MibIpsAccountInfo?, String?> {
val mibSess = mibHandler.session
val bmlSess = bmlSessionFor(selectedAccount)
var errorMsg: String? = null
val info = if (preferBml && bmlSess != null) {
val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest)
if (bmlInfo != null) {
// BML's MIB verify endpoint doesn't return the account's currency.
// Enrich via MIB lookup when a MIB session is available.
if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null)
bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber))
else bmlInfo
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
outcome.info
}
} else {
val outcome = mibHandler.lookupDestination(accountNumber)
errorMsg = outcome.error
if (outcome.info != null) {
outcome.info
} else {
val bmlInfo = bmlSess?.let {
bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false)
}
if (bmlInfo != null) errorMsg = null
else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
bmlInfo
}
}
return info to errorMsg
}
/** Fills the recipient card with a Favara / IPS lookup result. */
private fun showFavaraRecipient(info: MibIpsAccountInfo) {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber }
val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B"
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) {
showToCard(matchedAcc)
} else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
}
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
focusAmount()
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedCont?.customerImgHash != null ->
loadToPhoto(matchedCont.customerImgHash, isProfile = false)
}
}
@@ -1098,7 +1338,9 @@ class TransferFragment : Fragment() {
displayName: String,
subtitle: String,
colorHex: String,
imageHash: String?
imageHash: String?,
/** Saved with the recent, so picking it again pays the same way (Fahipay services). */
contactCategory: String? = null
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
@@ -1149,7 +1391,8 @@ class TransferFragment : Fragment() {
subtitle = subtitle,
colorHex = colorHex,
imageHash = recentImageHash,
isProfileImage = recentIsProfileImage
isProfileImage = recentIsProfileImage,
contactCategory = contactCategory
))
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
}
@@ -1244,6 +1487,12 @@ class TransferFragment : Fragment() {
return
}
// BML card-only merchant payment (no BML Pay) — verified card + 3-D Secure
if (bmlHandler().hasCardMerchant) {
bmlHandler().submitCardPayment()
return
}
val src = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
@@ -1670,10 +1919,15 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() {
// The handler is dropped with the view, but a picked Fahipay service lives on in the
// draft — build it so the amount rules still apply after the view is recreated.
val fahipay = if (draft.fahipayService != null) fahipayHandler() else null
fahipay?.syncAmountField()
if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val amountOk = amount > 0 && fahipay?.amountProblem == null
val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
val bankOffline = "NO_INTERNET" in errors ||
@@ -1684,6 +1938,7 @@ class TransferFragment : Fragment() {
internal fun clearForm() {
bmlHandler().resetOtpState()
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler?.clearState()
mfaisaHandler?.clearQrMerchant()
selectedAccount = null
@@ -1702,7 +1957,7 @@ class TransferFragment : Fragment() {
resolvedBankName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null
fahipayHandler().clearState()
resetTransferTypes()
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
@@ -1803,6 +2058,10 @@ class TransferFragment : Fragment() {
bmlHandler = null
fahipayHandler = null
mfaisaHandler = null
// Re-shown from the draft by the next view if still unanswered
transferTypeDialog?.setOnDismissListener(null)
transferTypeDialog?.dismiss()
transferTypeDialog = null
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
qrLauncher = null
_binding = null
@@ -1879,7 +2138,7 @@ class TransferFragment : Fragment() {
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when {
inactive -> 0.4f
bmlHandler().hasQrMerchant && !isCard -> 0.35f
(bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) && !isCard -> 0.35f
else -> 1f
}
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
@@ -417,6 +418,176 @@ class BmlTransferHandler(
}
}
// ─── Card-only merchant payment (no BML Pay) ─────────────────────────────
/** A card-only BML merchant is loaded — the fragment treats it like the QR merchant mode. */
val hasCardMerchant: Boolean get() = cardMerchant != null
private val cardMerchant get() = draft.bmlCardMerchant
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
private fun verifiedCardCandidates(): List<BankAccount> {
val store = CredentialStore(ctx)
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
return (viewModel.accounts.value ?: emptyList())
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
}
/**
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
* a card payment: paints the merchant as the recipient, locks the amount, and limits the source
* to the user's verified BML cards. Send then runs the Pomelo + 3-D Secure flow.
*/
fun payCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
if (page.isPaid) {
Toast.makeText(ctx, R.string.bml_card_pay_already_paid, Toast.LENGTH_LONG).show()
return
}
if (verifiedCardCandidates().isEmpty()) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
draft.bmlCardMerchant = page
showCardMerchant(page)
// Default to a verified card if nothing suitable is already selected.
if (currentSource()?.let { isCardVerified(it) } != true) {
clearSource()
val candidates = verifiedCardCandidates()
val default = CredentialStore(ctx).getDefaultCardAccountNumber()
(candidates.firstOrNull { it.accountNumber == default } ?: candidates.firstOrNull())
?.let { selectSource(it) }
}
}
private fun isCardVerified(account: BankAccount): Boolean =
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
hideToRow()
binding.tvToAccountName.text = page.merchantName
binding.tvToBankBic.text = page.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(page.merchantName, "#0066A1"))
binding.cardToInfo.visibility = View.VISIBLE
binding.etAmount.setText("%.2f".format(page.amount))
fragment.setAmountLocked(true)
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
}
/** Drops the loaded card merchant and unlocks the amount/remarks fields. */
fun clearCardMerchant() {
if (cardMerchant == null) return
draft.bmlCardMerchant = null
fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
/** Confirm-then-pay for the loaded card merchant, using the selected verified card. */
fun submitCardPayment() {
val page = cardMerchant ?: return
val src = currentSource()
if (src == null || !isCardVerified(src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
confirmCardMerchant(page, src)
}
private fun confirmCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount
) {
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
val warnings = listOf(
"⚠ ${page.merchantName} does not support BML Pay. This transaction will be paid via card. " +
"Card payments can be less reliable, and this can take up to a minute to complete. " +
"Please keep the app open and don't retry if it seems slow."
)
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = page.merchantName,
toNumber = "",
toDetail = page.merchantAddress.ifBlank { "BML Merchant" },
warningTexts = warnings
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${page.currency} ${"%.2f".format(page.amount)} → ${page.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeCardMerchant(page, src, dialog, frame)
}
)
}
private fun executeCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) {
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
val loginId = src.loginTag.removePrefix("bml_")
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
val expiry = stored?.expiry?.split("/") // "MM/YY"
if (stored == null || otpSeed == null || expiry?.size != 2) {
dialog.dismiss()
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = src.accountBriefName
)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
}.getOrElse {
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
}
}
if (fragment.view == null) return@launch
when (result) {
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
toName = page.merchantName
) {
fragment.clearForm()
host?.triggerRefresh()
}
is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss()
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
}
}
}
}
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
/**
@@ -1,6 +1,7 @@
package sh.sar.basedbank.ui.home.transfer
import android.view.View
import android.text.InputType
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -12,27 +13,63 @@ import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
* dialog's "To" block.
* a given number; [label] names it in the "Transfer Type" picker and the recipient card,
* [destinationLabel] in the confirm dialog's "To" block.
*
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
* exhaustive `when`s over this enum will point at every site that needs updating.
*/
enum class FahipayService(val label: String, val destinationLabel: String) {
RAASTAS("Raastas", "Ooredoo · Raastas"),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
enum class FahipayService(
val label: String,
val destinationLabel: String,
@param:DrawableRes val iconRes: Int,
/** Smallest amount the service accepts, in MVR. */
val minAmount: Int,
/** Largest amount the service accepts, in MVR, or null for no limit. */
val maxAmount: Int?,
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean,
/**
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
*/
val contactCategory: String,
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
val gstPercent: Int? = null,
) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
contactCategory = "FAHIPAY_DHIRAAGU_BILL");
companion object {
/**
* The service a saved Fahipay favourite or recent pays with, from its [contactCategory],
* or null when [categoryId] isn't one of them.
*/
fun fromContactCategory(categoryId: String?): FahipayService? =
entries.firstOrNull { it.contactCategory == categoryId }
}
}
/**
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
* number into a set of payable services, the chip picker shown when more than one applies, and
* the selected service that the confirm dialog labels the destination with.
* number into a set of payable services, offering those in the fragment's "Transfer Type"
* picker, and the selected service that the confirm dialog labels the destination with.
*
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
@@ -75,10 +112,104 @@ class FahipayTransferHandler(
lookupCarrier(rawInput)
}
/** Clears the selected service and hides the chip picker. */
/**
* Forgets the selected service and gives back the amount and reference fields it took over.
* The picker itself is the fragment's to reset.
*/
fun clearState() {
if (service == null) return
service = null
binding.layoutServiceSelector.visibility = View.INVISIBLE
binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
}
/**
* Why the typed amount can't be sent with the selected service, or null when it can (or the
* field is empty, or no service is selected). Checks the service's minimum, maximum and
* whether it takes decimals.
*/
val amountProblem: String?
get() {
val svc = service ?: return null
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/**
* Keeps the amount and reference fields in step with the selected service: the amount
* error, the GST note under the amount, and the reference box disabled (none of the
* payouts take one). Idempotent — the fragment calls it on every form change. A no-op
* when no service is selected; [clearState] puts the fields back.
*/
fun syncAmountField() {
val svc = service ?: return
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = amountProblem
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* What the recipient is credited once GST comes out, for services that charge it. The
* amount paid is GST-inclusive, so the credit is amount / (1 + rate), rounded down.
*/
private fun gstNote(svc: FahipayService): String? {
val gst = svc.gstPercent ?: return null
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
}
/**
* Fills the recipient card for a picked Fahipay transfer type. The fragment has already
* switched the source to a Fahipay account.
*/
fun applyService(type: TransferType.Fahipay, number: String) {
service = type.service
// None of the payouts take a reference; syncAmountField() disables the box
binding.etRemarks.setText("")
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${type.label} · $number",
colorHex = "#FF6B00",
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
/**
* The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is
* the only carrier that hands back an owner name. Blocking — call from IO.
*/
fun lookupServices(number: String): List<TransferType.Fahipay> {
val result = queryCarriers(number)
val ownerName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
return servicesFor(result).map { TransferType.Fahipay(it, ownerName) }
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
@@ -89,24 +220,13 @@ class FahipayTransferHandler(
)
private fun lookupCarrier(number: String) {
fragment.resetTransferTypes()
fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
val types = withContext(Dispatchers.IO) { lookupServices(number) }
fragment.stopLookupLoading()
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
val services = servicesFor(result)
if (services.isEmpty()) return@launch
// Only one option — auto-select, no chip UI needed
if (services.size == 1) {
selectService(services[0], number, dhiraaguName)
return@launch
}
// Multiple options (Ooredoo HYBRID) — show chips
showServiceChips(services, number, dhiraaguName)
if (types.isEmpty()) return@launch
fragment.offerTransferTypes(number, types)
}
}
@@ -144,57 +264,8 @@ class FahipayTransferHandler(
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
}
// ─── Service picker ──────────────────────────────────────────────────────
private fun showServiceChips(
services: List<FahipayService>,
number: String,
dhiraaguName: String?
) {
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
binding.layoutServiceSelector.visibility = View.VISIBLE
binding.chipGroupService.clearCheck()
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
// resolve their display name from saved contacts instead.
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
}
private fun bindChip(
chip: com.google.android.material.chip.Chip,
picked: FahipayService,
number: String,
ownerName: String?
) {
chip.setOnCheckedChangeListener { _, checked ->
if (checked) {
selectService(picked, number, ownerName)
binding.layoutServiceSelector.visibility = View.INVISIBLE
}
}
}
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
service = picked
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${picked.label} · $number",
colorHex = "#FF6B00",
imageHash = null
)
fragment.focusAmount()
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
}
@@ -36,6 +36,9 @@ class TransferDraft {
var remarks = ""
var toText = ""
// BML card-only merchant payment (merchant without BML Pay, paid by verified card + 3-D Secure)
var bmlCardMerchant: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage? = null
// BML merchant QR
var bmlQrInfo: BmlQrPayInfo? = null
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
@@ -47,6 +50,11 @@ class TransferDraft {
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
// Transfer type — options the "To" lookup offered for [transferTypeNumber], and the pick
var transferTypes: List<TransferType> = emptyList()
var transferTypeNumber = ""
var transferType: TransferType? = null
// Fahipay
var fahipayService: FahipayService? = null
}
@@ -0,0 +1,42 @@
package sh.sar.basedbank.ui.home.transfer
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.models.BankAccount
/**
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
* the "To" field. Picking one also decides the source account — see [worksFrom].
*
* Add new kinds (BML verified card, …) as subclasses; the exhaustive `when`s over this type
* point at every site that needs updating.
*/
sealed interface TransferType {
val label: String
/** Second line in the picker: who gets paid, and how. */
val subtitle: String
@get:DrawableRes val iconRes: Int
/** Small logo on the icon's bottom corner — the wallet that pays it — or null for none. */
@get:DrawableRes val badgeRes: Int? get() = null
/** Whether [account] can be the source for this transfer type. */
fun worksFrom(account: BankAccount): Boolean
/** A bank transfer to the account the Favara ID (phone number) resolved to. */
data class Favara(val info: MibIpsAccountInfo) : TransferType {
override val label get() = "Favara Transfer"
override val subtitle get() = info.accountName
override val iconRes get() = R.drawable.favara_logo
override fun worksFrom(account: BankAccount) = account.bank == "MIB" || account.bank == "BML"
}
/** A Fahipay payout (reload, Raastas, bill pay) to the phone number. */
data class Fahipay(val service: FahipayService, val ownerName: String?) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "via Fahipay").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.fahipay_logo
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
}
}
@@ -13,7 +13,10 @@ data class RecentPick(
val isProfileImage: Boolean,
/** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide
* per-bank selectability. Null for legacy entries; treated as unspecified. */
val bank: String? = null
val bank: String? = null,
/** The contact category the pick was paid as — set for Fahipay payouts (e.g.
* "FAHIPAY_RAASTAS") so picking the recent again applies the same service. */
val contactCategory: String? = null
)
object RecentsCache {
@@ -38,6 +41,7 @@ object RecentsCache {
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -56,6 +60,7 @@ object RecentsCache {
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -81,7 +86,8 @@ object RecentsCache {
colorHex = o.getString("colorHex"),
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
isProfileImage = o.optBoolean("isProfileImage", false),
bank = o.optString("bank").takeIf { it.isNotBlank() }
bank = o.optString("bank").takeIf { it.isNotBlank() },
contactCategory = o.optString("contactCategory").takeIf { it.isNotBlank() }
)
}
} catch (_: Exception) {
@@ -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)
}
@@ -1,6 +1,7 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
@@ -20,7 +21,8 @@ object FahipayContactParser {
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
// Each favourites list is one payout service; the Transfer screen picks it from categoryId
canTransfer = FahipayService.fromContactCategory(contact.benefCategoryId) != null,
canEdit = false,
canDelete = false
)
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Dhiraagu mark alone, cropped square from dhiraagu_long.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48.491"
android:viewportHeight="48.491">
<group
android:translateX="0.086"
android:translateY="3.591">
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M22.1561 2.71843C21.9696 2.07394 21.5428 1.54073 20.9538 1.21798C20.3648 0.894216 19.6856 0.818335 19.0398 1.0045C18.394 1.19168 17.8598 1.61763 17.5354 2.20445C17.2109 2.79228 17.1349 3.47016 17.3225 4.11466L20.1397 14.2748C20.4489 15.3422 21.4424 16.0879 22.5556 16.0879C22.7918 16.0879 23.027 16.0545 23.2561 15.9887C23.9019 15.8025 24.4361 15.3756 24.7605 14.7887C25.0849 14.2009 25.16 13.523 24.9734 12.8785L22.1561 2.71843Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M13.7864 19.9944C14.4382 20.0257 15.0546 19.7475 15.528 19.2922C16.0116 18.8268 16.2863 18.2015 16.2985 17.5307C16.3117 16.8599 16.0613 16.2246 15.596 15.7409L9.39471 9.38302C8.43263 8.38542 6.83695 8.35506 5.83737 9.31523C5.3538 9.78064 5.08008 10.4059 5.0669 11.0757C5.05372 11.7465 5.30412 12.3819 5.76945 12.8645L11.9707 19.2234C11.9707 19.2234 12.655 19.9387 13.7864 19.9934V19.9944Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M9.72223 23.0954L3.10228 21.5596C2.44941 21.4038 1.77424 21.511 1.20145 21.8621C0.62867 22.2132 0.227216 22.7656 0.0700814 23.4182C-0.0860399 24.0708 0.020406 24.7446 0.372186 25.3163C0.723966 25.8879 1.27749 26.2886 1.93137 26.4444L8.55132 27.9802C8.74394 28.0258 8.94162 28.049 9.1383 28.049C10.3062 28.049 11.3118 27.2568 11.5835 26.1216C11.7396 25.469 11.6322 24.7952 11.2804 24.2236C10.9286 23.6519 10.3751 23.2513 9.72122 23.0954H9.72223Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M47.6209 19.5174C47.1373 19.052 46.4996 18.8031 45.8285 18.8163C45.1564 18.8294 44.5299 19.1026 44.0635 19.5852L38.4188 25.3563C37.9525 25.8399 37.7031 26.4753 37.7153 27.1461C37.7284 27.8169 38.0022 28.4422 38.4867 28.9076C38.9571 29.3599 39.5776 29.6098 40.2315 29.6098C40.9218 29.6098 41.5656 29.3366 42.0441 28.8398L47.6888 23.0687C48.1551 22.5851 48.4045 21.9497 48.3913 21.2799C48.3782 20.6091 48.1044 19.9839 47.6209 19.5184V19.5174Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M24.497 18.6561H28.1151C28.1151 18.6561 28.329 23.4933 24.4179 23.4933C23.1608 23.4933 21.8997 23.9344 20.8413 24.7863C18.4539 26.7097 17.9662 30.1881 19.7343 32.6902C21.6544 35.4068 25.4418 35.9694 28.0675 33.9711C29.5618 32.8329 30.3211 31.2131 30.3657 29.3676C30.3657 29.3646 30.3667 29.3615 30.3688 29.3575V2.46688C30.3688 1.06761 31.5366 -0.0787268 32.9356 0.0042377C34.2039 0.0801199 35.2085 1.13034 35.2085 2.41527V28.6361C35.2085 29.9048 35.0433 31.1696 34.6945 32.3897C34.4593 33.2103 34.1289 34.0703 33.6625 34.8625C32.9894 35.9724 32.1003 36.9771 31.0034 37.8128C26.3096 41.3873 19.6004 40.4859 16.0188 35.8004C12.4371 31.1149 13.3404 24.4201 18.0342 20.8455C18.0342 20.8455 19.0804 20.026 20.5585 19.4149C21.6189 18.9778 22.7431 18.7309 23.9475 18.6702L24.497 18.6561Z" />
</group>
</vector>
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="169dp"
android:height="40dp"
android:viewportWidth="169"
android:viewportHeight="40">
<!-- Mark -->
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M22.1561 2.71843C21.9696 2.07394 21.5428 1.54073 20.9538 1.21798C20.3648 0.894216 19.6856 0.818335 19.0398 1.0045C18.394 1.19168 17.8598 1.61763 17.5354 2.20445C17.2109 2.79228 17.1349 3.47016 17.3225 4.11466L20.1397 14.2748C20.4489 15.3422 21.4424 16.0879 22.5556 16.0879C22.7918 16.0879 23.027 16.0545 23.2561 15.9887C23.9019 15.8025 24.4361 15.3756 24.7605 14.7887C25.0849 14.2009 25.16 13.523 24.9734 12.8785L22.1561 2.71843Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M13.7864 19.9944C14.4382 20.0257 15.0546 19.7475 15.528 19.2922C16.0116 18.8268 16.2863 18.2015 16.2985 17.5307C16.3117 16.8599 16.0613 16.2246 15.596 15.7409L9.39471 9.38302C8.43263 8.38542 6.83695 8.35506 5.83737 9.31523C5.3538 9.78064 5.08008 10.4059 5.0669 11.0757C5.05372 11.7465 5.30412 12.3819 5.76945 12.8645L11.9707 19.2234C11.9707 19.2234 12.655 19.9387 13.7864 19.9934V19.9944Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M9.72223 23.0954L3.10228 21.5596C2.44941 21.4038 1.77424 21.511 1.20145 21.8621C0.62867 22.2132 0.227216 22.7656 0.0700814 23.4182C-0.0860399 24.0708 0.020406 24.7446 0.372186 25.3163C0.723966 25.8879 1.27749 26.2886 1.93137 26.4444L8.55132 27.9802C8.74394 28.0258 8.94162 28.049 9.1383 28.049C10.3062 28.049 11.3118 27.2568 11.5835 26.1216C11.7396 25.469 11.6322 24.7952 11.2804 24.2236C10.9286 23.6519 10.3751 23.2513 9.72122 23.0954H9.72223Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M47.6209 19.5174C47.1373 19.052 46.4996 18.8031 45.8285 18.8163C45.1564 18.8294 44.5299 19.1026 44.0635 19.5852L38.4188 25.3563C37.9525 25.8399 37.7031 26.4753 37.7153 27.1461C37.7284 27.8169 38.0022 28.4422 38.4867 28.9076C38.9571 29.3599 39.5776 29.6098 40.2315 29.6098C40.9218 29.6098 41.5656 29.3366 42.0441 28.8398L47.6888 23.0687C48.1551 22.5851 48.4045 21.9497 48.3913 21.2799C48.3782 20.6091 48.1044 19.9839 47.6209 19.5184V19.5174Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M24.497 18.6561H28.1151C28.1151 18.6561 28.329 23.4933 24.4179 23.4933C23.1608 23.4933 21.8997 23.9344 20.8413 24.7863C18.4539 26.7097 17.9662 30.1881 19.7343 32.6902C21.6544 35.4068 25.4418 35.9694 28.0675 33.9711C29.5618 32.8329 30.3211 31.2131 30.3657 29.3676C30.3657 29.3646 30.3667 29.3615 30.3688 29.3575V2.46688C30.3688 1.06761 31.5366 -0.0787268 32.9356 0.0042377C34.2039 0.0801199 35.2085 1.13034 35.2085 2.41527V28.6361C35.2085 29.9048 35.0433 31.1696 34.6945 32.3897C34.4593 33.2103 34.1289 34.0703 33.6625 34.8625C32.9894 35.9724 32.1003 36.9771 31.0034 37.8128C26.3096 41.3873 19.6004 40.4859 16.0188 35.8004C12.4371 31.1149 13.3404 24.4201 18.0342 20.8455C18.0342 20.8455 19.0804 20.026 20.5585 19.4149C21.6189 18.9778 22.7431 18.7309 23.9475 18.6702L24.497 18.6561Z" />
<!-- Wordmark -->
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M65.8877 16.7821H67.9984C67.9984 16.7821 68.1069 19.873 65.7143 19.873C65.2196 19.873 64.4927 20.1098 63.925 20.5671C62.6426 21.6001 62.38 23.4688 63.3299 24.8135C64.361 26.2734 66.3956 26.5749 67.8058 25.5015C68.6087 24.8903 69.0172 24.0202 69.0395 23.0287C69.0395 23.0267 69.0395 23.0257 69.0416 23.0236V20.6956L69.0365 12.2565H72.1346V22.6038C72.1346 23.3434 72.0383 24.0819 71.8345 24.7932C71.6976 25.2708 71.505 25.7736 71.2333 26.235C70.84 26.8825 70.322 27.4693 69.6823 27.956C66.9441 30.0412 63.0299 29.5151 60.9415 26.7823C58.8521 24.0496 59.3793 20.1432 62.1175 18.0589C62.1175 18.0589 62.7278 17.5814 63.5905 17.2242C64.2079 16.9693 64.8648 16.8256 65.5673 16.7902L65.8877 16.7821Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M116.569 29.4027H118.68C118.68 29.4027 118.787 26.3117 116.396 26.3117C115.901 26.3117 115.174 26.075 114.607 25.6177C113.323 24.5846 113.062 22.7159 114.012 21.3713C115.043 19.9113 117.077 19.6098 118.487 20.6843C119.29 21.2954 119.699 22.1655 119.722 23.1571C119.722 23.1591 119.722 23.1601 119.724 23.1621V25.4902L119.719 29.2893H122.817V23.581C122.817 22.8414 122.721 22.1028 122.517 21.3915C122.38 20.913 122.187 20.4111 121.915 19.9498C121.522 19.3022 121.004 18.7154 120.364 18.2288C117.626 16.1435 113.712 16.6696 111.623 19.4024C109.534 22.1352 110.061 26.0416 112.799 28.1258C112.799 28.1258 113.409 28.6034 114.272 28.9605C114.891 29.2155 115.546 29.3591 116.248 29.3946L116.569 29.4027Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M132.317 29.4027H134.428C134.428 29.4027 134.535 26.3117 132.144 26.3117C131.649 26.3117 130.922 26.075 130.354 25.6177C129.071 24.5846 128.809 22.7159 129.759 21.3713C130.79 19.9113 132.825 19.6098 134.235 20.6843C135.038 21.2954 135.447 22.1655 135.47 23.1571C135.47 23.1591 135.47 23.1601 135.472 23.1621V25.4902L135.467 29.2893H138.565V23.581C138.565 22.8414 138.469 22.1028 138.265 21.3915C138.128 20.913 137.934 20.4111 137.663 19.9498C137.269 19.3022 136.75 18.7154 136.112 18.2288C133.374 16.1435 129.459 16.6696 127.371 19.4024C125.282 22.1352 125.809 26.0416 128.547 28.1258C128.547 28.1258 129.157 28.6034 130.02 28.9605C130.638 29.2155 131.294 29.3591 131.996 29.3946L132.317 29.4027Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M83.4124 16.9528C82.0955 16.9528 80.8029 17.14 80.0233 18.1376V12.2572H76.9272V29.2892H80.0233V23.3279C80.0233 20.9917 81.1446 19.9456 82.9247 19.9456C84.5579 19.9456 85.6315 20.7975 85.6315 23.0608V29.2892H88.7276V22.8655C88.7276 19.3375 86.9231 16.9528 83.4124 16.9528Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M165.904 17.1637V23.3931C165.904 25.5006 164.973 26.3829 163.527 26.4942C162.079 26.3829 161.148 25.5006 161.148 23.3931V17.1637H158.052V23.5874C158.052 27.1154 159.856 29.5001 163.366 29.5001C163.42 29.5001 163.473 29.5001 163.526 29.4981C163.578 29.4981 163.631 29.5001 163.685 29.5001C167.195 29.5001 169 27.1154 169 23.5874V17.1637H165.904Z" />
<path
android:fillColor="#F15625"
android:pathData="M95.9721 17.1224H92.876V29.2889H95.9721V17.1224Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M106.412 16.9529C105.472 16.9529 104.531 17.1684 103.687 17.5761C103.543 17.6449 103.405 17.7208 103.269 17.8007L103.267 17.8028C102.563 18.2105 101.953 18.7467 101.451 19.4034C101.218 19.7089 101.018 20.0327 100.848 20.3676C100.411 21.1628 100.173 22.0623 100.173 22.9891V29.3318H103.27V23.2491C103.255 22.6015 103.451 21.9459 103.849 21.3804C104.435 20.5507 105.393 20.056 106.413 20.056H107.834V16.9549H106.413L106.412 16.9529Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M94.3459 12.6085C95.2451 12.6085 95.973 13.335 95.973 14.2324C95.973 15.1298 95.2451 15.8563 94.3459 15.8563C93.4466 15.8563 92.7188 15.1288 92.7188 14.2324C92.7188 13.336 93.4466 12.6085 94.3459 12.6085Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M154.484 26.5717V23.5809C154.484 22.8413 154.387 22.1027 154.184 21.3915C154.047 20.9129 153.853 20.4111 153.582 19.9497C153.19 19.3022 152.671 18.7154 152.031 18.2287C149.293 16.1435 145.379 16.6686 143.291 19.4023C141.201 22.1351 141.728 26.0415 144.467 28.1258C144.467 28.1258 145.077 28.6043 145.94 28.9595C146.558 29.2154 147.214 29.3591 147.916 29.3945L148.237 29.4026H150.348C150.348 29.4026 150.456 26.3107 148.063 26.3107C147.57 26.3107 146.843 26.0739 146.275 25.6166C144.993 24.5846 144.731 22.7149 145.68 21.3712C146.711 19.9113 148.746 19.6098 150.156 20.6832C150.959 21.2943 151.367 22.1655 151.39 23.157C151.39 23.159 151.39 23.16 151.391 23.162V23.6052H151.386V26.5727V28.8906C151.371 30.6349 150.186 31.5202 148.427 31.5202C147.069 31.5445 145.977 31.157 144.813 30.5762L143.067 32.4399C144.376 33.5306 146.196 34.3289 148.428 34.3289C152.214 34.3289 154.625 31.8369 154.478 28.1551V26.5727H154.484V26.5717Z" />
</vector>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Favara mark alone, cropped square from favara_long.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="317.610"
android:viewportHeight="317.610">
<group
android:translateX="-123.226"
android:translateY="-123.588">
<path
android:fillColor="#005CA3"
android:pathData="M262.476 321.671C254.92 326.026 245.261 323.404 240.951 315.848C236.596 308.314 239.212 298.678 246.735 294.323L301.63 262.653C309.197 258.287 318.845 260.903 323.167 268.47C327.521 275.988 324.905 285.636 317.333 290.001L262.476 321.671ZM272.472 371.936L272.438 371.98L238.014 391.849L237.932 391.894H237.899V391.927H237.854L233.616 394.394C232.887 394.78 232.153 395.167 231.419 395.437C227.368 397.518 223.421 399.212 219.602 400.266C210.043 402.844 201.366 401.828 193.882 394.824C185.962 387.329 178.24 379.646 170.717 371.743C163.195 363.883 155.865 355.77 148.695 347.446C132.016 328.029 123.952 305.157 123.952 282.054C123.952 258.663 132.253 235.172 148.298 215.071C158.569 202.217 169.371 190.146 180.751 178.804C191.945 167.616 203.756 157.118 216.174 147.283C236.16 131.47 258.927 123.588 281.838 123.588C304.831 123.588 327.792 131.503 347.926 147.167C360.207 156.693 372.073 167.196 383.537 178.655C394.852 189.919 405.653 202.101 415.875 215.187C431.809 235.476 440.11 259.049 440.11 282.434C440.11 305.466 432.118 328.195 415.411 347.48C404.759 359.755 394.046 371.323 383.189 382.169C372.355 392.97 360.819 403.727 348.544 414.346C334.238 426.72 317.84 434.514 300.819 437.792C283.389 441.198 265.291 439.763 248.28 433.598C240.095 430.623 235.851 421.56 238.826 413.342C241.801 405.118 250.863 400.873 259.049 403.882C270.645 408.093 282.958 409.053 294.88 406.735C306.575 404.494 317.918 399.058 327.88 390.459C338.908 380.894 349.858 370.666 360.786 359.755C371.698 348.837 381.947 337.832 391.49 326.843C402.959 313.607 408.435 298.098 408.435 282.434C408.435 265.959 402.452 249.219 390.993 234.626C381.66 222.671 371.731 211.522 361.283 201.063C350.978 190.769 340.028 181.166 328.493 172.164C314.06 160.943 297.888 155.302 281.838 155.302C265.87 155.302 249.864 160.833 235.74 172.059C224.509 180.917 213.602 190.654 203.016 201.218C192.646 211.61 182.639 222.754 173.069 234.742C161.622 249.142 155.666 265.689 155.666 282.054C155.666 297.828 161.219 313.53 172.721 326.876C179.482 334.746 186.425 342.435 193.612 349.958C199.854 356.509 206.267 362.956 212.857 369.242C214.712 368.541 216.909 367.432 219.348 366.118L301.553 318.63C309.12 314.253 318.801 316.853 323.167 324.409C327.521 331.921 324.977 341.613 317.41 345.967L272.472 371.936ZM262.476 265.689C254.92 270.016 245.261 267.427 240.951 259.871C236.596 252.304 239.212 242.657 246.735 238.329L301.63 206.626C309.197 202.305 318.845 204.893 323.167 212.449C327.521 220.016 324.905 229.653 317.333 233.975L262.476 265.689Z" />
</group>
</vector>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material "credit_score": card with a check mark -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h5v-2H4v-6h18V6C22,4.89 21.11,4 20,4zM20,8H4V6h16V8zM14.93,19.17l-2.83,-2.83l-1.41,1.41L14.93,22L22,14.93l-1.41,-1.41L14.93,19.17z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,5L4,5c-1.1,0 -1.99,0.9 -1.99,2L2,17c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,7c0,-1.1 -0.9,-2 -2,-2zM11,8h2v2h-2L11,8zM11,11h2v2h-2v-2zM8,8h2v2L8,10L8,8zM8,11h2v2L8,13v-2zM7,13L5,13v-2h2v2zM7,10L5,10L5,8h2v2zM16,17L8,17v-2h8v2zM16,13h-2v-2h2v2zM16,10h-2L14,8h2v2zM19,13h-2v-2h2v2zM19,10h-2L17,8h2v2z" />
</vector>
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="12dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilName"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/card_verify_name_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCardNumber"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_verify_number_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCardNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="0123456789 "
android:inputType="number"
android:maxLength="23"
android:autofillHints="creditCardNumber" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilExpiry"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:hint="@string/card_verify_expiry_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etExpiry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="0123456789/"
android:inputType="number"
android:maxLength="5"
android:autofillHints="creditCardExpirationDate" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCvv"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:hint="@string/card_verify_cvv_hint"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCvv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberPassword"
android:maxLength="4"
android:autofillHints="creditCardSecurityCode" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -87,10 +87,19 @@
<!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom -->
<View
android:id="@+id/bottomSpacer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Card verification animation (verify mode only); takes the spacer's place -->
<FrameLayout
android:id="@+id/flVerifyArea"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone" />
<!-- Divider -->
<View
android:id="@+id/divider"
@@ -265,6 +274,73 @@
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_verify"
android:textSize="12sp"
app:icon="@drawable/ic_card_verify"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout>
<!-- Card verification actions (verify mode only); styled like llManageButtons -->
<LinearLayout
android:id="@+id/llVerifyButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCancelVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_verify_cancel"
android:textSize="12sp"
app:icon="@drawable/ic_close"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnManualVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_verify_manual"
android:textSize="12sp"
app:icon="@drawable/ic_keyboard"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout>
</LinearLayout>
@@ -266,66 +266,6 @@
</com.google.android.material.card.MaterialCardView>
<!-- Service selector: invisible to reserve space, prevents amount/remarks from shifting -->
<LinearLayout
android:id="@+id/layoutServiceSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp"
android:visibility="invisible">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/transfer_select_service"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupService"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipDhiraaguReload"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Dhiraagu Reload"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipDhiraaguBill"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Dhiraagu Bill Pay"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipRaastas"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Raastas"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipOoredooBill"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ooredoo Bill Pay"
android:visibility="gone" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
<View
android:id="@+id/spacerTo"
android:layout_width="match_parent"
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One tile in the Transfer Type picker grid -->
<com.google.android.material.card.MaterialCardView
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"
android:layout_margin="4dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:cardBackgroundColor="@android:color/transparent"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="128dp"
android:orientation="vertical"
android:gravity="center_horizontal"
android:padding="12dp">
<!-- Logo, with an optional badge in the bottom corner naming the wallet it pays from.
The badge's ring matches the dialog background (the tile itself is transparent),
so it reads as a cut-out over the logo. -->
<FrameLayout
android:layout_width="56dp"
android:layout_height="56dp">
<ImageView
android:id="@+id/ivIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:scaleType="fitCenter"
android:importantForAccessibility="no" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivBadge"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:visibility="gone"
android:importantForAccessibility="no"
app:contentPadding="1dp"
app:strokeWidth="2dp"
app:strokeColor="?attr/colorSurfaceContainerHigh"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Badge" />
</FrameLayout>
<TextView
android:id="@+id/tvLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+389 -107
View File
@@ -1,149 +1,431 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ތިޖޫރީ</string>
<!-- Onboarding -->
<string name="onboarding_supported_services">ހިދުމަތްތައް</string>
<string name="select_language">ބަސް ހިޔާލު ކުރޭ</string>
<string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string>
<string name="onboarding_desc_1">ތިޖޫރީ ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string>
<string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string>
<string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string>
<string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string>
<string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.\n\nDhiraagu އާއި Ooredoo ގެ API ބޭނުންކޮށްގެން ފޯން ނަންބަރުގެ ތަފްސީލު ބެލޭ.\n\nމި އެޕް ތިޔައާ ބެހޭ ތަފްސީލެއް ނެހެދޭ، ޑިވެލޮޕަރަށް ވެސް ނުފޮނުވާ. ހުރިހާ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string>
<string name="coming_soon">ފަހުން ލިބޭ</string>
<string name="next">ދެން</string>
<string name="get_started">ފަށާ</string>
<string name="onboarding_supported_services">ޚިދުމަތްތައް</string>
<string name="select_language">ބަސް އިޚްތިޔާރުކުރޭ</string>
<string name="onboarding_title_1">ހުރިހާ ބޭންކެއް އެއް އެޕަކުން</string>
<string name="onboarding_desc_1">ރާއްޖޭގެ ބޭންކުތަކުގައިވާ ތިޔަބޭފުޅާގެ އެކައުންޓްތައް އެއްތަނަކުން ބަލާލެވޭ. އެކި އެޕްތަކަށް ވަންނަންނުޖެހި ބެލެންސް ބެލުމާއި އެކައުންޓްތަކުގެ މަޢުލޫމާތު ފަސޭހަކަމާއެކު ބަލާލެވޭ.</string>
<string name="onboarding_title_2">އިތުރު ބޭންކުތައް ވަރަށް އަވަހަށް</string>
<string name="onboarding_desc_2">އިތުރު ބޭންކުތަކުގެ ޚިދުމަތްތައް މި އެޕަށް އިތުރުކުރުމުގެ މަސައްކަތް ދަނީ ކުރެވެމުންނެވެ.</string>
<string name="onboarding_title_3">ސަމާލުކަމަށް</string>
<string name="onboarding_desc_3">ތިޖޫރީ އަކީ އެއްވެސް ބޭންކަކާ ނުވަތަ މާލީ އިދާރާއަކާ ގުޅުމެއްނެތި އަމިއްލައަށް އުފައްދާފައިވާ އެޕެކެވެ.\n\nމި އެޕް މަސައްކަތްކުރަނީ ތިޔަބޭފުޅާގެ އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ބޭނުންކޮށްގެން ސީދާ ބޭންކުގެ އޭޕީއައިތަކާ ގުޅިގެންނެވެ. ބޭންކުތަކުގެ ސިސްޓަމްތަކަށް އަންނަ ބަދަލަކުން އެޕްގެ މަސައްކަތަށް ބުރޫއަރާފާނެއެވެ.\n\nފޯނު ނަންބަރުގެ މަޢުލޫމާތު ހޯދުމަށް ދިރާގާއި އުރީދޫގެ އޭޕީއައި ބޭނުންކުރެވެއެވެ.\n\nމި އެޕުން ތިޔަބޭފުޅާގެ އެއްވެސް އަމިއްލަ މަޢުލޫމާތެއް ނުވަތަ އެޕް ބޭނުންކުރާ ގޮތުގެ މަޢުލޫމާތެއް އެއްނުކުރާނެއެވެ. ހުރިހާ މަޢުލޫމާތެއް ރައްކާކުރެވޭނީ ހަމައެކަނި ތިޔަބޭފުޅާގެ ފޯނުގައެވެ.\n\nކުރިއަށް ވަޑައިގަތުމުން އަންނަނިވި ކަންކަމަށް އެއްބަސްވެވޭނެއެވެ:\n\n• ޚިދުމަތަށް މެދުކެނޑުމެއް ނުވަތަ މައްސަލައެއް ދިމާވެދާނެކަން\n• ބޭންކުން ތިޔަބޭފުޅާގެ އެކައުންޓަށް ފިޔަވަޅެއް އަޅާފާނެކަން\n• އެޕް ބޭނުންކުރުމުން ދިމާވެދާނެ އެއްވެސް ގެއްލުމަކަށް ޑިވެލޮޕަރު ޒިންމާނުވާނެކަން\n• މި އެޕް ބޭނުންކުރަނީ އަމިއްލަ ޒިންމާގައިކަން</string>
<string name="coming_soon">ވަރަށް އަވަހަށް އަންނީ</string>
<string name="next">ކުރިއަށް</string>
<string name="get_started">ފަށަމާ</string>
<!-- Login -->
<string name="select_bank">ބޭންކެއް ހިޔާރު ކުރޭ</string>
<string name="select_bank_desc">ލޮގިން ވާ ބޭންކު ހިޔާރު ކުރޭ.</string>
<string name="mib_name">Maldives Islamic Bank</string>
<string name="mib_desc">ފައިސާނެޓް މޯބައިލް ބޭންކިން</string>
<string name="bml_name">Bank of Maldives</string>
<string name="bml_desc">BML އިންޓަނެޓް ބޭންކިން</string>
<string name="sign_in">ލޮގިން ވޭ</string>
<string name="sign_in_desc">Maldives Islamic Bank ގެ ކްރެޑެންޝަލް ދިވޭ.</string>
<string name="username">ޔޫޒަރ ނޭމް</string>
<string name="select_bank">ބޭންކު އިޚްތިޔާރުކުރޭ</string>
<string name="select_bank_desc">ލޮގިންވާން ބޭނުންވާ ބޭންކު ނަގާ</string>
<string name="mib_name">މޯލްޑިވްސް އިސްލާމިކް ބޭންކް</string>
<string name="mib_desc">ފައިސާނެޓް މޮބައިލް ބޭންކިންގ</string>
<string name="bml_name">ބޭންކް އޮފް މޯލްޑިވްސް</string>
<string name="bml_desc">ބީއެމްއެލް އިންޓަނެޓް ބޭންކިންގ</string>
<string name="fahipay_name">ފަހިޕޭ</string>
<string name="fahipay_desc">ޑިޖިޓަލް ވޮލެޓް</string>
<string name="fahipay_sign_in_desc">އައިޑީ ކާޑު ނަންބަރާއި ޕާސްވޯޑް ޖަހާ</string>
<string name="fahipay_id_card">އައިޑީ ކާޑު ނަންބަރު</string>
<string name="fahipay_totp_code">އޮތެންޓިކޭޓަރ ކޯޑު (6 ނަންބަރު)</string>
<string name="fahipay_totp_hint">އޮތެންޓިކޭޓަރ އެޕުން ލިބޭ ކޯޑު ޖަހާ</string>
<string name="fahipay_verify">ޔަގީން</string>
<string name="ooredoo_name">އޯރިޑޫ އެމް-ފައިސާ</string>
<string name="ooredoo_desc">މޮބައިލް ވޮލެޓް</string>
<string name="ooredoo_sign_in_desc">މޮބައިލް ނަންބަރާއި 4 އަދަދުގެ އެމް-ޕިން ޖަހާ</string>
<string name="ooredoo_phone">މޮބައިލް ނަންބަރު</string>
<string name="ooredoo_pin">އެމް-ޕިން</string>
<string name="sign_in">ލޮގިން</string>
<string name="sign_in_desc">އެމްއައިބީ އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ޖަހާ</string>
<string name="bml_sign_in_desc">ބީއެމްއެލް އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ޖަހާ</string>
<string name="username">ޔޫސަރނޭމް</string>
<string name="password">ޕާސްވޯޑް</string>
<string name="otp_seed">OTP ސީޑް (TOTP ސިއްރު)</string>
<string name="otp_seed_hint">ތިޔަ އޮތެންޓިކޭޓަ ދިން Base32 ސިއްރު</string>
<string name="otp_seed">އޯޓީޕީ ސީޑް (ޓީއޯޓީޕީ ސީކްރެޓް)</string>
<string name="otp_seed_hint">އޮތެންޓިކޭޓަރ ސެޓަޕުން ލިބުނު ސީކްރެޓް ކޯޑު</string>
<string name="scan_otp_qr">އޯޓީޕީ ކިއުއާރު ސުކޭންކޮއްލާ</string>
<string name="login">ލޮގިން</string>
<!-- Lock screen -->
<string name="unlock_app">ތިޖޫރީ ހުޅުވާ</string>
<string name="unlock_pin_subtitle">PIN ޖަހާ</string>
<string name="unlock_pattern_subtitle">ހުޅުވާ ޕެޓަން ކަހާ</string>
<string name="use_biometrics">ބަޔޮމެޓްރިކް ބޭނުން ކުރޭ</string>
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
<string name="unlock_attempts_remaining">ދިމައެއް ނުވި — %d ފަހަރު ބާކީ</string>
<string name="unlock_locked_out">ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ.</string>
<string name="unlock_pin_subtitle">ޕިން ނަންބަރު ޖަހާ</string>
<string name="unlock_pattern_subtitle">ޕެޓަރން ކުރަހާ</string>
<string name="use_biometrics">ބަޔޯމެޓްރިކްސް ބޭނުންކުރޭ</string>
<string name="biometric_prompt_subtitle">އިނގިލީގެ ނިޝާން ނުވަތަ ފޭސް އައިޑީ ބޭނުންކުރޭ</string>
<string name="biometric_negative_btn">ޕިން / ޕެޓަން ބޭނުންކުރޭ</string>
<string name="unlock_failed">ނުބައި - އަލުން މަސައްކަތްކޮއްލާ</string>
<string name="unlock_attempts_remaining">ނުބައި - ބާކީ %d ފުރުޞަތު</string>
<string name="unlock_locked_out">ގިނަފަހަރު ނުބައިކޮށް ޖެހުމުން ބްލޮކްވެއްޖެ. %d ސިކުންތު ފަހުން އަލުން މަސައްކަތްކޮއްލާ.</string>
<!-- Security setup -->
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
<string name="security_setup_desc">ތިޖޫރީ ހުޅުވަން ބޭނުންވާ ގޮތެއް ހިޔާރު ކުރޭ.</string>
<string name="method_pin">PIN ކޯޑް</string>
<string name="method_pin_desc">4–8 ރިޔަލެއްގެ ނަންބަރު PIN</string>
<string name="method_pattern">ޕެޓަން ކަހާ</string>
<string name="method_pattern_desc">4 ނުވަތަ އެއަށްވުރެ ގިނަ ތިކި ގުޅުވާ</string>
<string name="enter_pin">PIN ޖަހާ</string>
<string name="confirm_pin">PIN ކަށަވަރު ކުރޭ</string>
<string name="pin_min_digits">މަދުވެގެން 4 ރިޔަލ، ގިނަވެގެން 8</string>
<string name="pin_no_match">PIN ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
<string name="draw_pattern">ޕެޓަން ކަހާ</string>
<string name="confirm_pattern">ޕެޓަން ކަށަވަރު ކުރޭ</string>
<string name="pattern_min_dots">މަދުވެގެން 4 ތިކި ގުޅުވާ</string>
<string name="pattern_draw_again">އެ ޕެޓަން އަލުން ކަހާ</string>
<string name="pattern_no_match">ޕެޓަން ދިމައެއް ނުވި — އަލުން ކަހާ</string>
<string name="biometric_title">ބަޔޮމެޓްރިކް ބޭނުން ކުރަންތަ؟</string>
<string name="biometric_desc">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވޭ.</string>
<string name="biometric_security_note">ބަޔޮމެޓްރިކް ފަސޭހަ ނަމަވެސް PIN ނުވަތަ ޕެޓަނަށްވުރެ ތަންކޮޅެއް ކަށަވަރެއް ނޫން. ރައްކާތެރިކަން ބޭނުން ނަމަ PIN ނުވަތަ ޕެޓަން ހިޔާރު ކުރޭ.</string>
<string name="enable_biometrics">ބަޔޮމެޓްރިކް ހިންގާ</string>
<string name="skip_biometrics">ސްކިޕް — PIN/ޕެޓަން ބޭނުން ކުރޭ</string>
<string name="security_setup">އެޕް ރައްކާތެރިކުރޭ</string>
<string name="security_setup_desc">އެޕް ތަޅުލުމަށް ބޭނުންކުރާނެ ގޮތެއް އިޚްތިޔާރުކުރޭ.</string>
<string name="security_already_configured">އެޕް ލޮކް ކުރެވިފައި</string>
<string name="security_already_configured_desc">އެޕް ތަޅުލާނެ ގޮތް ވަނީ ސެޓްކުރެވިފައި.</string>
<string name="method_pin">ޕިން ކޯޑު</string>
<string name="method_pin_desc">4 އާއި 8 އަދަދާ ދެމެދުގެ ނަންބަރެއް</string>
<string name="method_pattern">ޕެޓަރން</string>
<string name="method_pattern_desc">މަދުވެގެން 4 ތިކި ގުޅުވައިގެން ޕެޓަރން ކުރަހާ</string>
<string name="enter_pin">ޕިން ކޯޑެއް ޚިޔާރުކުރޭ</string>
<string name="confirm_pin">ޕިން ކޯޑު ޔަގީންކުރޭ</string>
<string name="pin_min_digits">މަދުވެގެން 4 އަދަދު، ގިނަވެގެން 8 އަދަދު</string>
<string name="pin_no_match">ޕިން ކޯޑު ދިމައެއްނުވޭ - އަލުން ޖަހާ</string>
<string name="draw_pattern">ޕެޓަރންއެއް ކުރަހާ</string>
<string name="confirm_pattern">ޕެޓަރން ޔަގީންކުރޭ</string>
<string name="pattern_min_dots">މަދުވެގެން 4 ތިކި ގުޅުވާލާ</string>
<string name="pattern_draw_again">އަލުން އެ ޕެޓަން ކުރަހާ</string>
<string name="pattern_no_match">ޕެޓަން ދިމައެއްނުވޭ - އަލުން ކުރަހާ</string>
<string name="biometric_title">ބަޔޯމެޓްރިކްސް ބޭނުންކުރަންތޯ؟</string>
<string name="biometric_desc">ޕިން ނުވަތަ ޕެޓަންގެ ބަދަލުގައި އިނގިލީގެ ނިޝާން ނުވަތަ ފޭސް އައިޑީއިން އެޕް ހުޅުވާލެވޭނެ.</string>
<string name="biometric_security_note">ބަޔޯމެޓްރިކްސްއަކީ ފަސޭހަ ގޮތެއް ނަމަވެސް، އެންމެ ރައްކާތެރި ގޮތަކީ ޕިން ނުވަތަ ޕެޓަން ބޭނުންކުރުން.</string>
<string name="enable_biometrics">ބަޔޯމެޓްރިކްސް އޮންކުރޭ</string>
<string name="skip_biometrics">ބޭނުމެއްނޫން - ހަމައެކަނި ޕިން/ޕެޓަން</string>
<string name="back">ފަހަތަށް</string>
<!-- Navigation -->
<string name="nav_dashboard">ޑޭޝްބޯޑް</string>
<string name="nav_add_account">އެކައުންޓް އިތުރު ކުރޭ</string>
<string name="nav_dashboard">ޑޭޝްބޯޑު</string>
<string name="nav_add_account">އެކައުންޓެއް އިތުރުކުރޭ</string>
<string name="nav_accounts">އެކައުންޓްތައް</string>
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
<string name="nav_activities">ހަރަކާތްތައް</string>
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string>
<string name="nav_finances">ފައިނޭންސް</string>
<string name="nav_pay_with_card">ކާޑްތައް</string>
<string name="nav_desc_pay_with_card">ކާޑް މެނޭޖްކޮށް ފައިސާ ދައްކާ</string>
<string name="nav_settings">ސެޓިންގ</string>
<string name="nav_desc_accounts">ހުރިހާ ބޭންކް އެކައުންޓްތައް ބަލާ</string>
<string name="nav_desc_contacts">ޓްރާންސްފަ ކޮންޓެކްޓްތައް މެނޭޖް ކުރޭ</string>
<string name="nav_desc_transfer">ކޮންޓެކްޓަކަށް ފައިސާ ފޮނުވާ</string>
<string name="nav_desc_pay_mv_qr">PayMV QR ކޯޑް ސްކޭން ނުވަތަ ތައްޔާރު ކުރޭ</string>
<string name="nav_desc_activities">ފަހުގެ ޓްރާންސްފަތައް ބަލާ</string>
<string name="nav_desc_transfer_history">އެކައުންޓް ތަކުގެ ޓްރާންސެކްޝަން ތާރީހް</string>
<string name="nav_desc_finances">ލޯން އަދި ފައިނޭންސިންގ</string>
<string name="nav_desc_otp">OTP ކޯޑް ތައްޔާރު ކުރޭ</string>
<string name="nav_desc_settings">އެޕްލިކޭޝަންގެ ތަރުތީބު</string>
<string name="nav_activities">އެންމެ ފަހުގެ މުޢާމަލާތްތައް</string>
<string name="nav_transfer_history">ހިސްޓްރީ</string>
<string name="nav_finances">ފައިނޭންސިންގ</string>
<string name="nav_card_settings">ކާޑު ސެޓިންގްސް</string>
<string name="nav_otp">އޯޓީޕީ ކޯޑު</string>
<string name="nav_settings">ސެޓިންގްސް</string>
<string name="nav_more">އިތުރު</string>
<string name="nav_desc_accounts">ހުރިހާ އެކައުންޓެއްގެ މަޢުލޫމާތު</string>
<string name="nav_desc_contacts">ފައިސާ ފޮނުވާ ކޮންޓެކްޓްތައް</string>
<string name="nav_desc_transfer">ފައިސާ ފޮނުއްވުމަށް</string>
<string name="nav_desc_pay_mv_qr">ޕޭއެމްވީ ކިއުއާރު ހެދުމަށް ނުވަތަ ސުކޭން ކުރުމަށް</string>
<string name="nav_desc_activities">އެންމެ ފަހުން ފޮނުވި ފައިސާގެ ތަފްޞީލު</string>
<string name="nav_desc_transfer_history">އެކައުންޓްތަކުގެ ފުރިހަމަ ހިސްޓްރީ</string>
<string name="nav_desc_finances">ލޯނާއި ފައިނޭންސިންގ މަޢުލޫމާތު</string>
<string name="nav_desc_pay_with_card">ކާޑުތައް ބަލަހައްޓުމާއި ކާޑުން ފައިސާ ދެއްކުން</string>
<string name="nav_desc_otp">އޮތެންޓިކޭޝަނަށް އޯޓީޕީ ކޯޑު ހޯދުން</string>
<string name="nav_desc_settings">އެޕްގެ ސެޓިންގްސް ބަދަލުކުރުމަށް</string>
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
<string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string>
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
<string name="work_in_progress">މަސައްކަތް ކުރިއަށްދަނީ</string>
<string name="press_back_to_exit">އެޕުން ނުކުންނެވުމަށް އަނެއްކާވެސް ފަހަތަށް އޮބާލާ</string>
<!-- Dashboard -->
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
<string name="balance_mvr">ޖުމްލަ MVR</string>
<string name="balance_usd">ޖުމްލަ USD</string>
<string name="card_support_wip">ކާޑް ސަޕޯޓް</string>
<string name="transfer">ޓްރާންސްފަ</string>
<string name="pay_mv_qr">PayMV QR</string>
<string name="dashboard_pending_finances">ނުދައްކާ ހުރި ފައިނޭންސް</string>
<string name="dashboard_quick_actions">އަވަސް ޚިދުމަތްތައް</string>
<string name="balance_mvr">ޖުމްލަ ދިވެހި ރުފިޔާ</string>
<string name="balance_usd">ޖުމްލަ ޔޫއެސް ޑޮލަރު</string>
<string name="balance_mvr_credit">ލިބެންހުރި ކްރެޑިޓް (ރުފިޔާ)</string>
<string name="balance_usd_credit">ލިބެންހުރި ކްރެޑިޓް (ޑޮލަރު)</string>
<string name="card_support_wip">ކާޑުގެ ޚިދުމަތް</string>
<string name="transfer">ޓްރާންސްފަރ</string>
<string name="pay_mv_qr">ޕޭއެމްވީ ކިއުއާރު</string>
<!-- PayMV QR Generator -->
<string name="paymvqr_select_account">އެކައުންޓް ނަގާ</string>
<string name="paymvqr_amount_hint">އަދަދު (ބޭނުންނަމަ)</string>
<string name="paymvqr_share">ޙިއްޞާކޮއްލާ</string>
<string name="paymvqr_save_image">ފޮޓޯ ސޭވްކޮއްލާ</string>
<string name="paymvqr_saved">ކިއުއާރު ފޮޓޯ ގެލަރީއަށް ސޭވްކުރެވިއްޖެ</string>
<string name="paymvqr_save_failed">ފޮޓޯ ސޭވެއް ނުކުރެވުނު</string>
<string name="paymvqr_include_phone">ފޯނު ނަންބަރު ހިމަނާ</string>
<string name="paymvqr_reference_hint">ރެފަރެންސް (ބޭނުންނަމަ)</string>
<!-- Toolbar -->
<string name="action_lock">އެޕް ތަޅުލާ</string>
<string name="action_hide_amounts">މަޢުލޫމާތު ފޮރުވާ</string>
<string name="action_show_amounts">މަޢުލޫމާތު ދައްކާ</string>
<string name="autolock_warning_title">އެޕް ތަޅުލެވެނީ</string>
<string name="autolock_warning_message">%d ސިކުންތު ތެރޭގައި ތަޅުލެވޭނެ</string>
<string name="autolock_stay">ނުތަޅުލާ މަޑުކުރޭ</string>
<string name="autolock_lock_now">މިހާރު ތަޅުލާ</string>
<!-- Settings -->
<string name="settings_bottom_bar_shortcuts">ތިރި ބާ ޝޯޓްކަޓްތައް</string>
<string name="settings_bottom_bar_select">ބަޓަން ހިޔާރު ކުރޭ</string>
<string name="settings_bottom_bar_slot_1">ތަން 1</string>
<string name="settings_bottom_bar_slot_2">ތަން 2</string>
<string name="settings_bottom_bar_slot_3">ތަން 3</string>
<string name="settings_security">ރައްކާތެރިކަން</string>
<string name="settings_change_lock">ޕިން / ޕެޓަން ބަދަލުކުރުމަށް</string>
<string name="settings_biometrics">ބަޔޯމެޓްރިކްސް ބޭނުންކުރުމަށް</string>
<string name="settings_biometrics_unavailable">މި ފޯނުގައި ބަޔޯމެޓްރިކްސް ސެޓްއަޕްކޮށްފައެއް ނެތް</string>
<string name="settings_biometrics_unlock">އެޕް ހުޅުވުމަށް</string>
<string name="settings_biometrics_transfer">ފައިސާ ފޮނުވުން ކަށަވަރުކުރުމަށް</string>
<string name="biometric_transfer_title">ޓްރާންސްފަރ ކަށަވަރުކުރުމަށް</string>
<string name="settings_autolock">އަމިއްލައަށް ތަޅުލެވުން</string>
<string name="autolock_off">އަމިއްލައަށް ތަޅެއްނުލެވޭނެ</string>
<string name="autolock_30s">30 ސިކުންތު</string>
<string name="autolock_1m">1 މިނެޓު</string>
<string name="autolock_3m">3 މިނެޓު</string>
<string name="autolock_5m">5 މިނެޓު</string>
<string name="theme">ތީމް</string>
<string name="theme_system">ސިސްޓަމް</string>
<string name="theme_light">ލައިޓް</string>
<string name="theme_dark">ޑާކް</string>
<string name="theme_system">ފޯނުގެ ސެޓިންގްސްއާ އެއްގޮތަށް</string>
<string name="theme_light">އަލި</string>
<string name="theme_dark">އަނދިރި</string>
<string name="settings_pitch_black">ގަދަ އަނދިރި (އޯލެޑް)</string>
<string name="settings_accent_color">މައި ކުލަ</string>
<string name="settings_receipts">ރަސީދު</string>
<string name="settings_always_fullscreen_receipt">މުޅި ސްކްރީނުން ރަސީދު ދައްކާ</string>
<string name="accent_blue">ނޫ</string>
<string name="accent_orange">ރަތް</string>
<string name="accent_green">ފެހި</string>
<string name="accent_custom">ބޭނުން ކުލައެއް</string>
<string name="accent_custom_pick">ބޭނުން ކުލައެއް އިޚްތިޔާރުކުރޭ</string>
<string name="accent_custom_hint">#RRGGBB ކުލައިގެ ކޯޑު</string>
<string name="accent_invalid_color">ނުބައި ކުލަ ކޯޑެއް - ރަނގަޅު ކޯޑެއް ޖަހާ</string>
<string name="language">ބަސް</string>
<string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string>
<string name="settings_privacy">ޕްރައިވެސީ</string>
<string name="settings_auto_unlock_pin">ރަނގަޅު ޕިން އެޅުމުން ހުޅުވޭ</string>
<string name="settings_auto_unlock_pin_desc">ޕިންގެ ދިގުމިނާ އެއްވަރަށް ޑިޖިޓް ލިޔުމުން ހުޅުވިދާ</string>
<string name="settings_block_screenshots">ސްކްރީންޝޮޓް ބްލޮކްކުރޭ</string>
<string name="settings_block_screenshots_desc">ރިސެންޓްސް ސްކްރީނުންނާއި ސްކްރީން ކެޕްޗާ ހުއްޓުވައިދޭ</string>
<string name="settings_privacy">ޕްރައިވަސީ</string>
<string name="settings_hide_amounts">ފައިސާގެ އަދަދުތައް ނިވާކުރުން</string>
<string name="settings_hide_amounts_desc">އެޕްގެ ހުރިހާ ތަނަކުން އެކައުންޓް ބެލެންސާއި ފައިސާގެ އަދަދުތައް ނިވާކޮށްދޭނެ</string>
<string name="settings_auto_unlock_pin">ރަނގަޅު ޕިން ޖެހުމުން އަމިއްލައަށް ހުޅުވޭ</string>
<string name="settings_auto_unlock_pin_desc">ޕިން ކޯޑު ހަމަވުމާއެކު އަމިއްލައަށް އެޕް ހުޅުވޭނެ</string>
<string name="settings_block_screenshots">ސުކްރީންޝޮޓް ނެގުން ހުއްޓުވާ</string>
<string name="settings_block_screenshots_desc">އެޕްގެ ސުކްރީންޝޯޓް ނެގުމާއި ރީސެންޓްސްއިން ފެނުން ހުއްޓުވޭނެ</string>
<string name="settings_cache">ކޭޝް</string>
<string name="settings_clear_cache">ކޭޝް ސާފުކުރޭ</string>
<string name="settings_clear_cache">ކޭޝް ސާފުކޮއްލާ</string>
<string name="settings_cache_cleared">ކޭޝް ސާފުކުރެވިއްޖެ</string>
<string name="settings_navigation">ނެވިގޭޝަން</string>
<string name="settings_nav_drawer">ސައިޑް މެނޫ</string>
<string name="settings_nav_bottom">ތިރި މެނޫ</string>
<string name="settings_nav_circular">ބުރު މެނޫ</string>
<string name="settings_appearance">ފެންނަ ގޮތް</string>
<string name="settings_circular_shortcuts">ބުރު މެނޫ ޝޯޓްކަޓް</string>
<string name="settings_bottom_bar_shortcuts">ތިރި މެނޫ ޝޯޓްކަޓް</string>
<string name="settings_bottom_bar_show_labels">ތިރި މެނޫގެ ނަންތައް އަބަދުވެސް ދައްކާ</string>
<string name="settings_bottom_bar_select">ފިއްތައް އިޚްތިޔާރުކުރޭ</string>
<string name="settings_bottom_bar_slot_1">ޖާގަ 1</string>
<string name="settings_bottom_bar_slot_2">ޖާގަ 2</string>
<string name="settings_bottom_bar_slot_3">ޖާގަ 3</string>
<string name="settings_privacy_security">ޕްރައިވަސީ އާއި ރައްކާތެރިކަން</string>
<string name="settings_storage">ސްޓޯރޭޖް</string>
<string name="settings_logins">ލޮގިންތައް</string>
<string name="settings_desc_logins">ބޭންކް ލޮގިންތައް މެނޭޖް ކުރޭ</string>
<string name="settings_desc_appearance">ތީމް، ބަސް، އަދި ދައްކުވާ ގޮތް</string>
<string name="settings_desc_privacy_security">އެޕް ލޮކް، ޕިން، އަދި ސަލާމަތީ ސެޓިންގ</string>
<string name="settings_desc_storage">ކޭޝް ޑޭޓާ އަދި ސްޓޯރޭޖް</string>
<string name="settings_desc_logins">ބޭންކު އެކައުންޓްތަކުގެ ލޮގިން މެނޭޖްކުރުމަށް</string>
<string name="settings_desc_appearance">ތީމް، ބަސް، އަދި ފެންނަ ގޮތުގެ ސެޓިންގްސް</string>
<string name="settings_desc_privacy_security">އެޕް ލޮކް، ޕިން، އަދި ރައްކާތެރިކަމުގެ ސެޓިންގްސް</string>
<string name="settings_desc_storage">ކޭޝް ކުރެވިފައިވާ މަޢުލޫމާތާއި ސްޓޯރޭޖް މެނޭޖްކުރުމަށް</string>
<string name="settings_notifications">ނޯޓިފިކޭޝަން</string>
<string name="settings_desc_notifications">އަލަށް ހިނގާ ފައިސާގެ މުޢާމަލާތްތަކުގެ ނޯޓިފިކޭޝަން</string>
<string name="settings_notif_section">ބެކްގްރައުންޑް ޗެކް</string>
<string name="settings_notif_enable">ބެކްގްރައުންޑް ނޯޓިފިކޭޝަން އޮންކުރޭ</string>
<string name="settings_notif_enable_desc">އާ މުޢާމަލާތްތައް ހިނގުމުން ވަގުތުން ނޯޓިފިކޭޝަން ލިބޭނެ</string>
<string name="settings_notif_description">އެޕް ބެކްގްރައުންޑްގައި ހިނގަމުންދާނެއެވެ. އަދި އާ މުޢާމަލާތެއް ހިނގައިފިނަމަ ނޮޓިފިކޭޝަން ފޮނުވާނެއެވެ. މިކަން ހިނގާއިރު ސްޓޭޓަސް ބާގައި ނޯޓިފިކޭޝަނެއް އިންނާނެއެވެ.</string>
<string name="settings_notif_open_system">ނޯޓިފިކޭޝަން ސެޓިންގްސް</string>
<string name="settings_notif_channels_desc">އަޑާއި ނޯޓިފިކޭޝަން ސެޓިންގްސް ބަދަލުކުރުމަށް</string>
<string name="notif_service_title">ތިޖޫރީ</string>
<string name="notif_service_desc">އާ މުޢާމަލާތްތައް ޗެކްކުރެވެނީ...</string>
<string name="notif_channel_service">ބެކްގްރައުންޑް ޚިދުމަތް</string>
<string name="settings_about">އެޕާ ބެހޭ</string>
<string name="settings_desc_about">އެޕްގެ މަޢުލޫމާތާއި، ވާޝަން، އަދި ގާނޫނީ</string>
<string name="about_version">ވާޝަން %s</string>
<string name="about_short_desc">ތިޖޫރީ އަކީ ރާއްޖޭގެ ބޭންކިންގ ޚިދުމަތްތަކަށް ޚާއްޞަކޮށްގެން ތައްޔާރުކުރެވިފައިވާ އެންޑްރޮއިޑް އެޕެއް.</string>
<string name="about_terms">ޚިދުމަތުގެ އުޞޫލުތައް</string>
<string name="about_donate_title">އެޕް ކުރިއެރުވުމަށް އެހީތެރިވެދެއްވާ</string>
<string name="about_donate_desc">މި އެޕަކީ ތިޔަބޭފުޅާއަށް ފައިދާހުރި އެޕެއްނަމަ، އެޕް އިތުރަށް ތަރައްޤީކުރުމަށް ކުޑަ އެހީއެއް ވެދެއްވާ.</string>
<string name="about_donate_mvr">ދިވެހި ރުފިޔާއިން އެހީވުމަށް</string>
<string name="about_donate_usd">ޑޮލަރުން އެހީވުމަށް</string>
<string name="about_legal">ތިޖޫރީ އަކީ އެއްވެސް ބޭންކަކާ ނުވަތަ މާލީ އިދާރާއަކާ ގުޅުމެއްނެތި އަމިއްލައަށް އުފައްދާފައިވާ އެޕެކެވެ.\n\nމި އެޕް މަސައްކަތްކުރަނީ ތިޔަބޭފުޅާގެ އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ބޭނުންކޮށްގެން ސީދާ ބޭންކުގެ އޭޕީއައިތަކާ ގުޅިގެންނެވެ. ބޭންކުތަކުގެ ސިސްޓަމްތަކަށް އަންނަ ބަދަލަކުން އެޕްގެ މަސައްކަތަށް ބުރޫއަރާފާނެއެވެ.\n\nފޯނު ނަންބަރުގެ މަޢުލޫމާތު ހޯދުމަށް ދިރާގާއި އޯރިޑޫގެ އޭޕީއައި ބޭނުންކުރެވެއެވެ.\n\nމި އެޕުން ތިޔަބޭފުޅާގެ އެއްވެސް އަމިއްލަ މަޢުލޫމާތެއް ނުވަތަ އެޕް ބޭނުންކުރާ ގޮތުގެ މަޢުލޫމާތެއް އެއްނުކުރާނެއެވެ. ހުރިހާ މަޢުލޫމާތެއް ރައްކާކުރެވޭނީ ހަމައެކަނި ތިޔަބޭފުޅާގެ ފޯނުގައެވެ.</string>
<string name="settings_logout">ލޮގްއައުޓް</string>
<string name="settings_logout_confirm_title">%s އިން ލޮގްއައުޓް ވަންތަ؟</string>
<string name="settings_logout_confirm_message">ހުރިހާ ކޭޝް ޑޭޓާ ސާފުވެ، ބާކީ ހުރި އެކައުންޓްތައް އަލުން ލޯޑްވާނެ.</string>
<string name="settings_logout_confirm_title">%s އިން ލޮގްއައުޓްވާންވީތޯ؟</string>
<string name="settings_logout_confirm_message">ރައްކާކުރެވިފައިވާ ހުރިހާ މަޢުލޫމާތެއް ފުހެވި، ބާކީ ހުރި އެކައުންޓްތައް ރީފްރެޝްކުރެވޭނެ.</string>
<string name="login_detail_name">ނަން</string>
<string name="login_detail_username">ޔޫޒަރ ނޭމް</string>
<string name="login_detail_username">ޔޫސަރނޭމް</string>
<string name="login_detail_email">އީމެއިލް</string>
<string name="login_detail_mobile">މޮބައިލް</string>
<string name="login_detail_customer_id">ކަސްޓަމަ ID</string>
<string name="login_detail_id_card">ID ކާޑް</string>
<string name="login_detail_mobile">މޮބައިލް ނަންބަރު</string>
<string name="login_detail_customer_id">ކަސްޓަމަރ އައިޑީ</string>
<string name="login_detail_id_card">އައިޑީ ކާޑު</string>
<string name="login_detail_profiles">ޕްރޮފައިލްތައް</string>
<string name="close">ބަންދު</string>
<string name="profile_image_title">ޕްރޮފައިލް ފޮޓޯ</string>
<string name="profile_image_select">ފޮޓޯ އިޚްތިޔާރުކުރޭ</string>
<string name="profile_image_camera">ކެމެރާއިން ފޮޓޯއެއް ނަގާ</string>
<string name="profile_image_remove">ފުހެލާ</string>
<string name="profile_image_uploading">ފޮޓޯ އަޕްލޯޑުކުރެވެނީ…</string>
<string name="profile_image_upload_failed">ފޮޓޯ އަޕްލޯޑެއް ނުކުރެވުނު</string>
<string name="profile_image_deleting">ފޮޓޯ ފުހެލެވެނީ…</string>
<string name="close">ލައްޕާ</string>
<string name="save">ސޭވްކުރޭ</string>
<string name="cancel">ކެންސަލް</string>
<string name="verify">ޔަގީން</string>
<string name="settings_bottom_bar_show_labels">ބޮޓަމް ބާ ލޭބަލް އަބަދުވެސް ދެއްކުން</string>
<!-- BML business OTP -->
<string name="bml_business_otp_sent">%s މެދުވެރިކޮށް އޯޓީޕީ ފޮނުވިއްޖެ</string>
<!-- Home -->
<string name="transfer_same_account">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
<string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>
<string name="cards">ކާޑުތައް</string>
<string name="available_balance">ބޭނުންކުރެވެން ހުރި ބެލެންސް</string>
<string name="account_blocked_label">%1$s ހިފެހެއްޓިފައި</string>
<string name="dashboard_blocked_mvr">ހިފެހެއްޓިފައިވާ ރުފިޔާ</string>
<string name="dashboard_blocked_usd">ހިފެހެއްޓިފައިވާ ޑޮލަރު</string>
<string name="dashboard_overdue">މުއްދަތު ހަމަވެފައިވާ ފައިނޭންސް</string>
<!-- Transfer -->
<string name="transfer_tab_quick">އަވަސް ޓްރާންސްފަރ</string>
<string name="transfer_tab_contacts">ކޮންޓެކްޓްތައް</string>
<string name="transfer_from">ފައިސާ ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
<string name="transfer_select_service">ޚިދުމަތް އިޚްތިޔާރުކުރޭ</string>
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_lookup_account">އެކައުންޓް ހޯދާ</string>
<string name="transfer_clear_recipient">ލިބޭ ފަރާތް ފުހެލާ</string>
<string name="transfer_pick_contact">ކޮންޓެކްޓެއް ނަގާ</string>
<string name="transfer_scan_qr">ކިއުއާރުން ފައިސާ ދެއްކުމަށް</string>
<string name="qr_pick_image">ފޮޓޯއެއް ނަގާ</string>
<string name="transfer_qr_invalid">މި ކިއުއާރު ކޯޑު ބޭނުމެއް ނުކުރެވޭނެ</string>
<string name="card_qr_paymv_unsupported">ކާޑުން ޕޭއެމްވީ ކިއުއާރަށް ފައިސާއެއް ނުދެއްކޭނެ - ޓްރާންސްފަރއަށް ބަދަލުކުރެވެނީ</string>
<string name="qr_camera_permission_title">ކެމެރާގެ ހުއްދަ ބޭނުންވޭ</string>
<string name="qr_camera_permission_message">ކިއުއާރު ސުކޭންކުރުމަށް ކެމެރާގެ ހުއްދަ ބޭނުންވެއެވެ. ސެޓިންގްސްއިން ހުއްދަ ދެއްވާ.</string>
<string name="camera_permission_profile_message">ފޮޓޯ ނެގުމަށް ކެމެރާގެ ހުއްދަ ބޭނުންވެއެވެ. ސެޓިންގްސްއިން ހުއްދަ ދެއްވާ.</string>
<string name="go_to_settings">ސެޓިންގްސްއަށް ދޭ</string>
<string name="transfer_select_source_first">ފުރަތަމަ ފައިސާ ފޮނުވާ އެކައުންޓް ނަގާ</string>
<string name="transfer_no_from_account">ފުރަތަމަ ފައިސާ ފޮނުވާނެ އެކައުންޓެއް އިޚްތިޔާރުކުރޭ</string>
<string name="transfer_enter_account_first">ފުރަތަމަ އެކައުންޓް ނަންބަރު ޖަހާ</string>
<string name="transfer_account_not_found">އެކައުންޓެއް ނުފެނުނު</string>
<string name="transfer_session_unavailable">ސެޝަން މުއްދަތު ހަމަވެއްޖެ - އަލުން ލޮގިންވެވަޑައިގަންނަވާ</string>
<string name="transfer_amount">އަދަދު</string>
<string name="transfer_remarks">ތަފްޞީލު / ރިމާކްސް</string>
<string name="transfer_confirm">ޔަގީން</string>
<string name="transfer_success">ފައިސާ ފޮނުވިއްޖެ</string>
<string name="transfer_bml_contact_required_title">ކޮންޓެކްޓް ސޭވްކޮއްލާ</string>
<string name="transfer_bml_contact_required_msg">ލިބޭ ފަރާތުގެ އެކައުންޓް ކަރަންސީ ކަށަވަރެއް ނުކުރެވުނެވެ.\n\nމި ފަރާތް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްކޮށް، ރަނގަޅު ކަރަންސީ އިޚްތިޔާރުކުރުމަށްފަހު އަލުން މަސައްކަތްކޮއްލާ.</string>
<string name="transfer_bml_contact_required_msg_bml_limit">ބީއެމްއެލް ނޫން އެކައުންޓަކަށް ޑޮލަރު ފޮނުވޭނީ އެ ފަރާތެއް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްކުރުމަށްފަހުގައެވެ.\n\nމި ފަރާތް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްކުރުމަށްފަހު އަލުން މަސައްކަތްކޮއްލާ.</string>
<string name="transfer_missing_internal_id">އެކައުންޓް މަޢުލޫމާތު ފުރިހަމައެއް ނޫން - އަލުން ލޮގިންވެވަޑައިގަންނަވާ.</string>
<string name="transfer_verify_payment">ފައިސާ ދެއްކުން ކަށަވަރުކޮއްލާ</string>
<string name="transfer_send_otp_via">ކޯޑު ފޮނުވާނެ ގޮތް</string>
<string name="transfer_otp_code_hint">ކަށަވަރުކުރާ ކޯޑު</string>
<!-- BML QR Pay -->
<string name="bml_qr_looking_up">ވިޔަފާރީގެ މަޢުލޫމާތު ހޯދެނީ…</string>
<string name="bml_qr_lookup_failed">ވިޔަފާރީގެ މަޢުލޫމާތު ލިބޭގޮތެއް ނުވި</string>
<string name="transfer_bml_txn_lookup_failed">މި ޓްރާންސެކްޝަން އައިޑީގެ މަޢުލޫމާތު ލިބޭގޮތެއް ނުވި</string>
<string name="bml_card_pay_no_verified">ކަށަވަރުކުރެވިފައިވާ ކާޑެއް ނެތް. ފުރަތަމަ ކާޑު މެނޭޖްކުރާ ބައިން ބީއެމްއެލް ކާޑެއް ކަށަވަރުކޮއްލާ.</string>
<string name="bml_card_pay_already_paid">މި ފައިސާ ދައްކާ ނިމިފައި</string>
<string name="bml_qr_payment_success">ފައިސާ ދެއްކިއްޖެ</string>
<string name="bml_qr_select_account">ފައިސާ ދައްކާނެ ބީއެމްއެލް އެކައުންޓެއް އިޚްތިޔާރުކުރޭ</string>
<!-- Accounts -->
<string name="accounts_empty">އެކައުންޓެއް ނުފެނުނު</string>
<!-- Contacts -->
<string name="contacts_empty">ކޮންޓެކްޓެއް ނުފެނުނު</string>
<string name="contacts_search_hint">ކޮންޓެކްޓް ހޯދާ</string>
<string name="contacts_tab_recents">އެންމެ ފަސް</string>
<string name="recents_remove">ލިސްޓުން އުނިކުރޭ</string>
<string name="contacts_tab_all">ހުރިހާ</string>
<!-- Add Contact -->
<string name="contact_add">ކޮންޓެކްޓެއް އިތުރުކުރޭ</string>
<string name="contact_save_to">އެކައުންޓް ސޭވްކުރޭ</string>
<string name="contact_alias">ނަން / ވަނަން</string>
<string name="contact_currency">ކަރަންސީ</string>
<string name="contact_group">ގްރޫޕް</string>
<string name="contact_no_group">ގްރޫޕެއް ނެތި</string>
<string name="contact_save">ކޮންޓެކްޓް ސޭވްކުރޭ</string>
<string name="contact_image">ފޮޓޯ އަޕްލޯޑުކޮއްލާ</string>
<string name="contact_saved">ކޮންޓެކްޓް ސޭވްކުރެވިއްޖެ</string>
<string name="contact_save_failed">ކޮންޓެކްޓް ސޭވްއެއް ނުކުރެވުނު</string>
<string name="contact_no_session">ބޭންކް ސެޝަނެއް ނެތް</string>
<string name="contact_lookup_failed">އެކައުންޓެއް ނުފެނުނު</string>
<string name="contact_select_destination">ފުރަތަމަ ފައިސާ ލިބޭނެ އެކައުންޓް ނަގާ</string>
<string name="contact_already_exists">މި ކޮންޓެކްޓް ސޭވް ވެފައެބައިން: %s</string>
<string name="contact_own_account">އަމިއްލަ އެކައުންޓެއް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްއެއް ނުކުރެވޭނެ</string>
<!-- Contact expand/delete -->
<string name="contact_edit">އުނިއިތުރުގެނޭ</string>
<string name="contact_delete">ފުހެލާ</string>
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
<string name="contact_deleted">ކޮންޓެކްޓް ފުހެލެވިއްޖެ</string>
<string name="contact_delete_failed">ކޮންޓެކްޓް ފުހެއެއް ނުލެވުނު</string>
<string name="contact_account_number">އެކައުންޓް ނަންބަރު</string>
<string name="contact_account_name">އެކައުންޓް ނަން</string>
<string name="contact_bank">ބޭންކު</string>
<string name="contact_qr">ކިއުއާރު</string>
<string name="contact_delete_warning">މި ކަން ނިމުމައްފަހު ބަދަލެއް ނުކުރެވޭނެ.</string>
<string name="contact_copy_account">އެކައުންޓް ނަންބަރު ކޮޕީކުރޭ</string>
<string name="contact_share_account">އެކައުންޓް މަޢުލޫމާތު ޙިއްޞާކުރޭ</string>
<string name="contact_account_copied">އެކައުންޓް ނަންބަރު ކޮޕީކުރެވިއްޖެ</string>
<!-- Financing -->
<string name="financing_empty">ފައިނޭންސިންގއެއް ނެތް</string>
<string name="financing_total">ޖުމްލަ</string>
<string name="financing_paid">ދައްކާފައި</string>
<string name="financing_unpaid">ނުދައްކާ</string>
<string name="financing_deal_date">ފެށުނު ތާރީޚު</string>
<string name="financing_installment">މަހުން މަހަށް ދައްކަންޖެހޭ</string>
<string name="financing_num_installments">ޖުމްލަ އިންސްޓޯލްމަންޓް</string>
<string name="financing_last_paid_date">އެންމެ ފަހުން ފައިސާ ދެއްކި ތާރީޚު</string>
<string name="financing_last_pay_amount">އެންމެ ފަހުން ދެއްކި އަދަދު</string>
<string name="financing_overdue">މުއްދަތު ހަމަވެފައި</string>
<string name="financing_completion_done">ފުރިހަމައަށް ދައްކާ ނިމިފައި</string>
<string name="financing_deal_no_fmt">ޑީލް #%s</string>
<string name="financing_completion_fmt">ނިމޭނީ %s</string>
<!-- BML Loans -->
<string name="loan_outstanding">ނުދައްކާ ބާކީ</string>
<string name="loan_monthly_repayment">މަހުން މަހަށް ދައްކަންޖެހޭ</string>
<string name="loan_interest_rate">އިންޓްރެސްޓް ރޭޓް</string>
<string name="loan_start_date">ފެށުނު ތާރީޚު</string>
<string name="loan_end_date">ނިމޭ ތާރީޚު</string>
<string name="loan_overdue_payments">މުއްދަތު ހަމަވެ ނުދައްކާ ހުރި</string>
<string name="loan_rate_fmt">%.2f%%</string>
<!-- Cards -->
<string name="nav_pay_with_card">ކާޑުތައް</string>
<string name="card_pay_qr">ކިއުއާރުން ފައިސާ ދެއްކުމަށް</string>
<string name="card_pay_nfc">ޖައްސާލައިގެން ފައިސާ ދެއްކުމަށް</string>
<string name="mib_qr_nfc_not_supported">އެމްއައިބީން މި ޚިދުމަތެއް ނުލިބޭ</string>
<string name="nfc_unsupported_title">މި ޚިދުމަތް ނުލިބޭ</string>
<string name="nfc_unsupported_message">މި ފޯނުން ޖައްސާލައިގެން ފައިސާ ދެއްކުމުގެ ޚިދުމަތެއް ނުލިބޭ.</string>
<string name="nfc_disabled_title">އެންއެފްސީ ނިއްވާލެވިފައި</string>
<string name="nfc_disabled_message">ޖައްސާލައިގެން ފައިސާ ދެއްކުމަށް އެންއެފްސީ އޮންކުރޭ.</string>
<string name="nfc_open_settings">އެންއެފްސީ ސެޓިންގްސް</string>
<string name="nfc_not_default_title">މައި އެޕްގެ ގޮތުގައި ހަމަޖަހާ</string>
<string name="nfc_not_default_message">ޖައްސާލައިގެން ފައިސާ ދެއްކުމަށް %1$s މައި އެޕްގެ ގޮތުގައި ހަމަޖަހާ.</string>
<string name="nfc_payment_open_settings">ޕޭމަންޓް ސެޓިންގްސް</string>
<string name="card_manage">ކާޑު މެނޭޖްކުރުން</string>
<string name="card_set_as_default">މައި ކާޑުގެ ގޮތުގައި ހަމަޖައްސާ</string>
<string name="card_hide_from_dashboard">ޑޭޝްބޯޑުން ފޮރުވާ</string>
<string name="card_action_change_pin">ޕިން ބަދަލުކުރޭ</string>
<string name="card_action_freeze">ފްރީޒްކުރޭ</string>
<string name="card_action_unfreeze">އަންފްރީޒްކުރޭ</string>
<string name="card_action_block">ބްލޮކްކުރޭ</string>
<string name="card_action_verify">ކަށަވަރުކުރޭ</string>
<string name="card_action_verified">ކަށަވަރުކުރެވިފައި</string>
<string name="card_verify_already">ކާޑު ވަނީ ކަށަވަރުކުރެވިފައި. ބަދަލުކުރުމަށް އޮއްބައިގެން ހިފަހައްޓަވާ.</string>
<string name="card_verify_title">ކާޑު ކަށަވަރުކުރޭ</string>
<string name="card_verify_tap">ކަށަވަރުކުރުމަށް ކާޑު ޖައްސާލާ</string>
<string name="card_verify_reading">ކާޑު ކިޔަނީ… މަޑުކޮށްލައްވާ</string>
<string name="card_verify_matched">ކާޑު ދިމާވެއްޖެ</string>
<string name="card_verify_read_failed">ކާޑު ކިޔައެއް ނުގަނެވުނު، އަލުން މަސައްކަތްކޮއްލާ</string>
<string name="card_verify_mismatch">ނިމޭ %1$s ކާޑާ ދިމައެއްނުވޭ</string>
<string name="card_verify_cancel">ކެންސަލް</string>
<string name="card_verify_manual">އަމިއްލައަށް މަޢުލޫމާތު ޖަހާ</string>
<string name="card_verify_manual_title">ކާޑުގެ މަޢުލޫމާތު ޖަހާ</string>
<string name="card_verify_nfc_disabled_message">ކާޑު ޖައްސާލައިގެން ކަށަވަރުކުރުމަށް އެންއެފްސީ އޮންކުރޭ. ނުވަތަ އަމިއްލައަށް މަޢުލޫމާތު ޖަހާ.</string>
<string name="card_verify_cvv_title">ނިމޭ %1$s ކާޑު</string>
<string name="card_verify_cvv_hint">ސީވީވީ (CVV)</string>
<string name="card_verify_cvv_invalid">3 ނުވަތަ 4 އަދަދުގެ ސީވީވީ ޖަހާ</string>
<string name="card_verify_confirm">ކަށަވަރުކޮއްލާ</string>
<string name="card_verify_name_hint">ކާޑުގައިވާ ނަން</string>
<string name="card_verify_number_hint">ކާޑު ނަންބަރު</string>
<string name="card_verify_expiry_hint">މުއްދަތު ހަމަވާ ތާރީޚު (މަސް/އަހަރު)</string>
<string name="card_verify_number_invalid">ރަނގަޅު ކާޑު ނަންބަރެއް ޖަހާ</string>
<string name="card_verify_number_mismatch">ނަންބަރު ނިމެންވާނީ %1$s އިން</string>
<string name="card_verify_expiry_invalid">ރަނގަޅު ތާރީޚެއް ޖަހާ (މިސާލަކަށް: 08/29)</string>
<string name="card_verify_success">ކާޑު ކަށަވަރުކުރެވިއްޖެ</string>
<string name="card_freeze_confirm_title">ކާޑު ފްރީޒްކުރަންވީތޯ؟</string>
<string name="card_freeze_confirm_message">މިކަމުގެ ސަބަބުން ކާޑުގެ ބޭނުން ވަގުތީގޮތުން މެދުކެނޑޭނެއެވެ. ބޭނުންވާ ކޮންމެ ވަގުތަކު އަލުން އަންފްރީޒް ކުރެވޭނެއެވެ.</string>
<string name="card_unfreeze_confirm_title">ކާޑު އަންފްރީޒްކުރަންވީތޯ؟</string>
<string name="card_unfreeze_confirm_message">މިކަމުގެ ސަބަބުން ކާޑު އަލުން ބޭނުންކުރެވޭނެއެވެ.</string>
<string name="card_freeze_success">ކާޑު ފްރީޒްކުރެވިއްޖެ</string>
<string name="card_unfreeze_success">ކާޑު އަންފްރީޒްކުރެވިއްޖެ</string>
<string name="card_freeze_failed">ކާޑުގެ ހާލަތު ބަދަލެއް ނުކުރެވުނު</string>
<string name="card_freeze_comments_hint">ސަބަބު (ބޭނުންނަމަ)</string>
<string name="card_status_temp_blocked">ވަގުތީގޮތުން ބްލޮކްކުރެވިފައި</string>
<string name="cards_empty">ކާޑެއް ނުފެނުނު</string>
<!-- Connectivity banner -->
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
</resources>
<string name="connectivity_no_internet">އިންޓަނެޓް ޗެކްކުރެއްވުމަށްފަހު އަލުން ތިޖޫރީ ރީލޯޑްކޮއްލާ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް އުޅޭ</string>
<string name="drag_to_reorder">ތަރުތީބު ބަދަލުކުރުމަށް ދަމާލާ</string>
</resources>
+34 -1
View File
@@ -261,7 +261,14 @@
<string name="transfer_to">Account Number or Favara ID</string>
<string name="transfer_label_from">From</string>
<string name="transfer_label_to">To</string>
<string name="transfer_select_service">Select Service</string>
<string name="transfer_type_for">Transfer type for %1$s</string>
<string name="transfer_fahipay_amount_invalid">Enter a valid amount</string>
<string name="transfer_fahipay_amount_whole">Whole amounts only, no decimals</string>
<string name="transfer_fahipay_amount_decimals">Up to 2 decimal places</string>
<string name="transfer_fahipay_amount_min">Minimum is MVR %1$d</string>
<string name="transfer_fahipay_amount_max">Maximum is MVR %1$s</string>
<string name="transfer_fahipay_gst_hint">%1$d%% GST is deducted from this amount</string>
<string name="transfer_fahipay_gst_receive">Recipient receives MVR %1$s after %2$d%% GST</string>
<string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
<string name="transfer_my_accounts">My Accounts</string>
<string name="transfer_same_as_from">This is the same account as the sender</string>
@@ -297,6 +304,8 @@
<string name="bml_qr_looking_up">Looking up merchant…</string>
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
<string name="bml_card_pay_already_paid">This payment has already been completed.</string>
<string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string>
@@ -387,6 +396,30 @@
<string name="card_action_freeze">Freeze</string>
<string name="card_action_unfreeze">Unfreeze</string>
<string name="card_action_block">Block</string>
<string name="card_action_verify">Verify</string>
<string name="card_action_verified">Verified</string>
<string name="card_verify_already">Card already verified. Press and hold to update.</string>
<string name="card_verify_title">Verify Card</string>
<string name="card_verify_tap">Tap card to verify</string>
<string name="card_verify_reading">Reading card… hold still</string>
<string name="card_verify_matched">Card matched</string>
<string name="card_verify_read_failed">Couldn\'t read the card, try again</string>
<string name="card_verify_mismatch">Card ending %1$s doesn\'t match</string>
<string name="card_verify_cancel">Cancel Verification</string>
<string name="card_verify_manual">Manually Verify</string>
<string name="card_verify_manual_title">Enter Your Card Details</string>
<string name="card_verify_nfc_disabled_message">Turn on NFC to verify your card by tapping it, or enter the details manually.</string>
<string name="card_verify_cvv_title">Card ending %1$s</string>
<string name="card_verify_cvv_hint">CVV</string>
<string name="card_verify_cvv_invalid">Enter a 3 or 4 digit CVV</string>
<string name="card_verify_confirm">Verify</string>
<string name="card_verify_name_hint">Name on card</string>
<string name="card_verify_number_hint">Card number</string>
<string name="card_verify_expiry_hint">Expiry (MM/YY)</string>
<string name="card_verify_number_invalid">Enter a valid card number</string>
<string name="card_verify_number_mismatch">Number must end in %1$s</string>
<string name="card_verify_expiry_invalid">Enter a valid expiry, e.g. 08/29</string>
<string name="card_verify_success">Card verified</string>
<string name="card_freeze_confirm_title">Freeze card?</string>
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
+4
View File
@@ -1,5 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
<style name="ShapeAppearance.Badge" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">5dp</item>
</style>
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
+260
View File
@@ -0,0 +1,260 @@
# Merchant Card Payment (no BML Pay)
BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`,
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
merchant has enabled:
| Merchant capability | How it is paid | Doc |
|---|---|---|
| **BML Pay** (`bml_mpos`) enabled | Fetch the merchant's QR text, pay it via the normal QR flow | [QR Payment](13-qr-payment.md) |
| **Card only** (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | **this doc** |
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
challenge make in a browser. Reconstructed from `docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har`.
> ⚠️ This flow is **scraped browser/ACS traffic**, not a stable API. See
> [Fragility](#fragility--what-can-break) before relying on it.
---
## Hosts
| Purpose | Base URL | Notes |
|---|---|---|
| Payment page (`/paynow`) | `https://transaction.merchants.bankofmaldives.com.mv` | Behind Cloudflare — **browser User-Agent required** |
| Merchant API | `https://api.merchants.bankofmaldives.com.mv` | Tolerates non-browser UA |
| Card tokenisation (Pomelo CDE) | `https://api.pay.pomelopay.com` | `bin-lookup` |
| 3-D Secure ACS (Wibmo) | `https://secure-acs2ui-bk2-<dc>.wibmo.com` | Behind Cloudflare; `<dc>` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) |
| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS |
---
## Detecting the merchant type
`GET /<id>/paynow` returns server-rendered HTML with everything inline in a
`window.appData = {…}` script. Parse that JSON (the code reads between `window.appData = ` and the
next `</script>`):
| `window.appData` field | Meaning |
|---|---|
| `transaction.state` | `QR_CODE_GENERATED` normally; `CONFIRMED` if already paid |
| `transaction.payAmount` / `transaction.amount` | Amount in **cents** (payAmount preferred; falls back to amount) |
| `transaction.payCurrency` / `transaction.currency` | e.g. `MVR` |
| `merchant.tradingName` / `registeredName` | Display name |
| `availableProviders[]` | Contains `{value:"bml_mpos", enabled:true}` **iff BML Pay is enabled** |
| `pomeloJsProviders[]` | Contains `"mpgs"` when card entry is offered |
| `pomeloJsKey` | `pk_production_…` — the card form's auth token (a JWT carrying the merchant id) |
**Decision:** `supportsBmlPay = availableProviders` contains an enabled `bml_mpos`;
`supportsCard = pomeloJsKey present && pomeloJsProviders` contains `mpgs`.
Route to the card flow only when **`!supportsBmlPay && supportsCard`**.
> The `/paynow` host is fronted by Cloudflare and returns **403** to the `okhttp/*` User-Agent.
> Send a browser UA (`BML_WEB_USER_AGENT`) + `Accept: text/html…`. The `api.merchants…` host is
> not UA-gated, which is why the PATCHes below work with the default client.
---
## Flow overview
```
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
PATCH transactions/<id> {fx:"reset"} ─┘
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
POST <ACS creq url> creq → OTP channel picker
POST <ACS creq url> destValue=token… → OTP entry page
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
POST transactions/mpgsNotification/<id> → records the verdict
POST …next-action POLL → TRANSACTION_CONFIRMED
```
---
## 1. Announce browser
Two unauthenticated PATCHes the page sends on load (needed by `fx`/state bookkeeping). `Origin` /
`Referer` are the transaction host.
```
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
Content-Type: application/json
{"activeBrowserId":"<id>_<epoch-millis>"}
```
```
PATCH …/transactions/<id>
{"fx":"reset"}
```
---
## 2. Credentials
```
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
Authorization: <pomeloJsKey> # the pk_production_… from the page
```
```json
{
"publicKey": {
"publicKeyId": "3edf1db0-…",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
},
"apiKey": "UU8a9m4Q…",
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
}
```
---
## 3. Tokenise the card (`bin-lookup`)
The card number, CVV and expiry are **RSA-OAEP(SHA-1)** encrypted with `publicKeyPem`, Base64
(no-wrap) encoded. The Pomelo JS uses WebCrypto `{name:"RSA-OAEP", hash:"SHA-1"}` over the plain
strings — the Java equivalent is `RSA/ECB/OAEPPadding` with
`OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT)`.
| Plaintext encrypted | Field |
|---|---|
| PAN (digits only) | `encryptedCardNumber` |
| CVV | `encryptedCardSecurityCode` |
| `YYMM` (year then month) | `encryptedCardExpiry` |
```
POST https://api.pay.pomelopay.com/bin-lookup
Content-Type: application/json
tenant: bankofmaldives
x-api-key: <apiKey>
x-tenant-id:
{
"encryptedCardNumber":"<b64>",
"encryptedCardSecurityCode":"<b64>",
"encryptedCardExpiry":"<b64>",
"externalId":"<id>",
"cardHolderName":"NAME ON CARD",
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
"encryptedCardExpiryYear":"28",
"encSerialId":"<publicKeyId>"
}
```
```json
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
```
---
## 4. Rate options → 3-D Secure URL
All `next-action` calls POST to the merchant API with `Authorization: <pomeloJsKey>`.
```
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
Authorization: <pomeloJsKey>
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
```
Response `action` values:
| `action` | Meaning | Do |
|---|---|---|
| `WAIT` | Processing | Poll (below) |
| `POLL` | Keep polling | Poll |
| `THREEDS` + `3dsUrl` | Challenge required | Run [§5](#5-3-d-secure-challenge) |
| `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done |
| `TRANSACTION_FAILED` | Declined | Fail |
Poll body (every **5 s**, no browser-info):
```
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
```
> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with
> `3dsUrl = …/modirum/render-tds?transactionId=<id>`.
---
## 5. 3-D Secure challenge (Wibmo ACS)
A chain of auto-submitting HTML forms. **Only the `render-tds` form and the final gateway /
notification forms carry an `action` attribute** — the ACS's channel-picker and OTP forms have
no `action`; their JavaScript posts back to the **same creq URL**. So the creq URL (the
`render-tds` form's action) is the fallback action for every subsequent form.
1. **`GET <3dsUrl>`** (`render-tds`) → a form posting `creq` to
`https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>`. Capture that
URL as the ACS creq URL.
2. **POST creq** → the **channel picker**: radios `destValue ∈ {mobile, email, token}`, plus hidden
`creq`, `authMethod`, `otpDest`, `selectChannel`, `otpChannels`, `formReqType`. The BML token /
authenticator is the **`token`** channel. Submit:
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
(keep the hidden `creq` / `otpChannels`).
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
Cookies (`__cf_bm`, `_cfuvid`) are set by the ACS and must be carried across these POSTs — the
Cloudflare-fronted ACS also requires a browser User-Agent.
---
## 6. Confirm
Poll `next-action` until the recorded verdict surfaces:
| `action` | Result |
|---|---|
| `TRANSACTION_CONFIRMED` | Success |
| `TRANSACTION_FAILED` | Declined |
The merchant's own backend is also notified out-of-band (e.g.
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
---
## Fragility — what can break
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you
learn of breakage from a failed live payment.
| Area | Breaks when | Symptom |
|---|---|---|
| **ACS HTML scraping** (most fragile) | Wibmo changes field names (`destValue`/`otpValue`/`creq`), the `"token"` channel label, the error wording, or the form layout | "Unexpected authentication page" / wrong-OTP loop |
| **Cloudflare** | `/paynow` or `wibmo.com` adds a JS/managed challenge or TLS-fingerprint check | 403; **not fixable by UA alone** |
| **TOTP seed assumption** | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
| **Pomelo crypto/contract** | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | `bin-lookup` rejects the card |
| **`next-action` states** | New/renamed actions, or browser-info becomes validated | Poll never resolves |
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
gitignored HARs under `docs/bmlapi/tmp/` as reference fixtures to diff against.
---
&nbsp;
---
**Related:** [QR Payment](13-qr-payment.md) · App side:
[Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md)
[← Card Freeze](15-card-freeze.md)
+1
View File
@@ -193,6 +193,7 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
| 16 | [Merchant Card Payment](16-card-payment.md) | Pay a card-only BML Merchant Services link — Pomelo tokenise + 3-D Secure |
---
+84 -4
View File
@@ -10,7 +10,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
| Factory method | Behaviour |
|---|---|
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash, contactCategory?)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate. A Fahipay favourite's `contactCategory` opens it as that payout service instead (see [Saved Fahipay favourites](#saved-fahipay-favourites)) |
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
@@ -35,7 +35,40 @@ The raw "To" field input is normalised first (spaces stripped, `+960`/`960` coun
## Recipient Lookup
Lookup behaviour depends on the **source account's bank**.
Lookup behaviour depends on the **source account's bank**, or on there being no source yet.
### Transfer Type picker
When a lookup offers exactly one transfer type, it is picked automatically and no popup is shown. When it offers more than one, a **"Transfer type for <number>"** popup opens straight away. The options are laid out as a grid of tiles, up to three per row (`item_transfer_type.xml`). Each tile shows the type's icon, its label and a subtitle (the recipient name, plus "via Fahipay" for Fahipay services). Fahipay services also show a small Fahipay logo badge on the icon's bottom corner (`TransferType.badgeRes`). Nothing is preselected, and Send stays disabled until the user picks one.
The popup can't be dismissed by tapping outside it or pressing back until a type has been picked. **Cancel** drops the options and brings the "To" field back for editing. Once a type is picked and the lookup offered more than one, tapping the recipient card reopens the popup to change it. Cancel then keeps the current pick.
Each option is a `TransferType` (`ui/home/transfer/TransferType.kt`):
| Type | Label | Icon | Pays from |
|---|---|---|---|
| `Favara(info)` | Favara Transfer | `favara_logo` | MIB or BML account |
| `Fahipay(service, ownerName)` | the service's label, e.g. Raastas | `FahipayService.iconRes`: `ooredoo_logo` / `dhiraagu_logo` | Fahipay wallet |
Picking an option also picks the source (`TransferFragment.applyTransferType`). If the selected source can't pay that type, Thijooree switches it:
- **Favara:** the default account, if it's MIB or BML. Otherwise the source is cleared and the user is asked to pick one.
- **Fahipay:** the user's Fahipay wallet.
Then the recipient card is filled in. The options, the number they were looked up for and the pick are kept in `TransferDraft`. If the view is recreated while the popup is still unanswered, it opens again.
The options are dropped when the "To" number is edited, the recipient is cleared, a new lookup starts or the form is cleared. If the user changes the source by hand to one that can't pay the picked type, the pick and the recipient card are dropped and the popup opens again for the same number. If that type was the only option, the recipient is cleared instead, so the user can search again with the new source.
### No source selected, phone number
Two lookups run in parallel:
- **Favara / IPS lookup.** Uses any logged-in MIB or BML session. The default account's bank goes first, the other is the fallback.
- **Carrier lookup** (see Fahipay source below). Only runs when the user has a Fahipay wallet.
Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services. If nothing resolves, the Favara lookup's error is shown as a toast.
Any other input with no source selected falls back to the default account as the source, and then the normal lookup for that bank runs.
### Fahipay source
@@ -55,7 +88,52 @@ The result maps to one or more Fahipay services:
| Ooredoo `PRE` or `HYBRID` | Raastas (prepaid top-up) |
| Ooredoo `POST` or `HYBRID` | Ooredoo Bill Pay |
If exactly one service matches, it is auto-selected. If multiple match (Ooredoo `HYBRID` gives two), a chip group is shown for the user to choose.
The matching services are offered in the Transfer Type picker (see above).
#### Saved Fahipay favourites
Each Fahipay favourites list is one payout service (`FahipayService.contactCategory` / `fromContactCategory`):
| Contact category | Service |
|---|---|
| `FAHIPAY_RAASTAS` | Raastas |
| `FAHIPAY_RELOAD` | Dhiraagu Reload |
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
- the "To" field's search-as-you-type dropdown
- the Contacts page: the row's transfer button and the contact details sheet's Transfer action. Fahipay favourites have `canTransfer` set when their category maps to a service.
Recents work the same way. Paying a number as a Fahipay service saves the recent with that service's category (`RecentPick.contactCategory`). The picker passes it back like a favourite's, so picking the recent pays with the same service. Recents saved before this was added have no category and are still filled in directly.
#### Amount rules
Each service has its own limits on the amount (`FahipayService.minAmount` / `maxAmount` / `decimalsAllowed`):
| Service | Min (MVR) | Max (MVR) | Decimals |
|---|---|---|---|
| Raastas | 11 | no limit | no |
| Ooredoo Bill Pay | 10 | 50,000 | yes, up to 2 places |
| Dhiraagu Reload | 8 | 1,000 | no |
| Dhiraagu Bill Pay | 10 | 5,000 | no |
The amount is checked as the user types (`FahipayTransferHandler.amountProblem`). An amount that breaks a rule shows an error on the amount field ("Minimum is MVR 11", "Maximum is MVR 5,000", "Whole amounts only, no decimals", "Up to 2 decimal places"), and Send stays disabled. A trailing `.00` counts as a whole number. Services that don't take decimals switch the amount field to a number-only keypad.
#### GST (Raastas)
Raastas charges 8% GST out of the amount paid (`FahipayService.gstPercent`), so the number credited is less than the amount deducted from the wallet. The amount is GST-inclusive, so the credit is `amount / 1.08`, rounded down to 2 decimal places. The amount field's helper text says so:
- Empty field: "8% GST is deducted from this amount"
- With an amount: "Recipient receives MVR 92.59 after 8% GST" (for MVR 100)
When the amount breaks a rule, the error replaces the helper text.
#### Reference
None of the Fahipay services take a reference. Picking one clears the Reference field and disables it, the same way BML merchant QR payments do. Clearing the service turns the field back on.
### BML source
@@ -155,7 +233,7 @@ The dialog body text is switched based on whether the destination currency was v
### No source account selected
**Condition:** user taps the lookup button or the transfer button without selecting a "From" account.
**Condition:** user taps the lookup button without selecting a "From" account, the input isn't a phone number, and there is no default account. For a phone number, the same toast appears when there's no MIB/BML session and no Fahipay wallet.
**Result:** toast: "Please select a source account first."
@@ -247,6 +325,8 @@ The transfer button is only enabled when all of the following are true:
- A source account is selected
- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
- Amount is greater than `0`
- If transfer types are on offer, one has been picked
- For a Fahipay service, the amount meets that service's rules (see [Amount rules](#amount-rules))
- No connectivity error for `NO_INTERNET` or for the source bank
---
@@ -0,0 +1,159 @@
# Card Verification & Merchant Card Payment
Two linked features:
1. **Card verification** — on the [Cards](22-cards.md) manage screen, a **Verify** action reads the
physical card over NFC (or takes it by hand), checks it matches the on-screen card, and stores the
full card details (PAN, expiry, CVV) encrypted on-device.
2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
> security/PCI liability. See the API doc's
> [Fragility](../bmlapi/16-card-payment.md#fragility--what-can-break) section.
---
## Card verification
### Entry — the Verify button
In manage mode the action row has **Change PIN · Freeze · Block · Verify**
(`fragment_cards.xml`, icon `ic_card_verify`). The button reads **Verified** once the selected card
has a stored entry (`bindManageCardData` in `PayWithCardFragment.kt`).
`onVerifyClicked(item)` (`PayWithCardFragment.kt:296`) branches on NFC:
| Device state | Behaviour |
|---|---|
| No NFC hardware | Straight to manual entry (`showCardDetailsDialog`) |
| NFC off | Dialog: **NFC Settings** / **Manually Verify** / Cancel |
| NFC ready | Enter verify mode (tap animation) |
### Verify mode
`setVerifyMode(enabled, item)` (`PayWithCardFragment.kt:314`) swaps the manage action buttons for
**Cancel Verification** / **Manually Verify**, and draws `CardVerifyAnimationView`
(`ui/home/CardVerifyAnimationView.kt`) in the empty area — a flat card tapping a phone with NFC
waves, matching the [Tap to Pay](23-tap-to-pay.md) style, with `WAITING / READING / SUCCESS / ERROR`
states.
`startVerifyReader()` (`PayWithCardFragment.kt:346`) uses `NfcAdapter.enableReaderMode` (reader,
not HCE). On tap, `EmvCardReader.read(tag)` (`nfc/EmvCardReader.kt:24`) runs a minimal contactless
EMV read (PPSE → SELECT AID → GPO → read AFL records) and returns `CardData(pan, expiry)` from tags
`5A` / `57` (Track 2) and `5F24`. `onVerifyCardRead` (`:367`) compares the **last 4 digits** against
the managed card:
- **match** → success check mark → `showCardDetailsDialog(item, nfcData)` for the CVV;
- **mismatch / unreadable** → error state, then back to waiting.
### Card details dialog
`showCardDetailsDialog(item, nfcData?)` (`PayWithCardFragment.kt:406`, layout
`dialog_card_manual_verify.xml`):
- The **name** is always prefilled read-only from the API-provided holder name (`accountBriefName`
for BML, `cardHolderName` for MIB) — never read off the chip.
- **After an NFC tap** (`nfcData != null`): card number + expiry are prefilled and **locked**; only
the CVV is entered. Title shows `Card ending <4>`.
- **Manual entry**: number + expiry + CVV entered; validated with a Luhn check (`luhnValid`,
`:500`), last-4 match, a not-in-the-past expiry (`normalizeExpiry`, `:489`), and a 3–4 digit CVV.
`saveVerifiedCard` (`PayWithCardFragment.kt:481`) writes the entry and toggles the button to
**Verified**.
### Storage — `VerifiedCardStore`
`util/VerifiedCardStore.kt`. Per-card entry keyed by the card's identity (`bml:<accountNumber>` /
`mib:<cardId>`), encrypted with the shared `CacheEncryption` AndroidKeyStore key (same as the other
caches).
```
VerifiedCard(pan, expiry /*MM/YY*/, cvv, method /*nfc|manual*/, verifiedAt)
```
`save` / `load` / `isVerified` / `keys` / `remove` / `clear`. **Not** wiped by the "clear cache" or
"remove login" paths — treated as user data (like profile images).
---
## Merchant card payment
### Routing — card-only vs BML Pay
A transaction ID / link typed into Transfer's **To** field is parsed by
`BmlMerchantTxnClient.parseTransactionId` and resolved in
`TransferFragment.lookupBmlMerchantTransaction` (`TransferFragment.kt:882`):
1. `BmlMerchantTxnClient.fetchPayPage(id)` (`api/bml/BmlMerchantTxnClient.kt:43`) loads `/paynow`
(browser UA — the host is Cloudflare-fronted) and parses `window.appData`.
2. If `!supportsBmlPay && supportsCard` → `bmlHandler().payCardMerchant(page)` (card flow).
3. Otherwise → existing QR path (`fetchQrPayload` → `bmlQrPayTarget` → `openBmlQr`), see
[Transfer Flows](20-transfer-flows.md).
### On-screen, like the QR merchant mode
`BmlTransferHandler.payCardMerchant(page)` (`ui/home/transfer/BmlTransferHandler.kt:441`) renders
into the Transfer screen rather than a one-off dialog, mirroring the BML QR merchant mode:
- `showCardMerchant(page)` (`:468`) paints the merchant as the **To** card, fills + **locks** the
amount (these links carry a fixed amount), and disables remarks.
- The **From** picker is limited to BML cards; a verified default card is auto-selected.
- State lives in `TransferDraft.bmlCardMerchant`, so it survives tab switches and theme/rotation
recreation (repainted via `restoreFromDraft`).
- The **✕** on the To card and `clearForm()` both call `clearCardMerchant()` (`:486`), which unlocks
and empties the amount and re-enables remarks.
A card is only offered when it is **both** verified **and** belongs to a BML login the app has an
OTP seed for (`verifiedCardCandidates`, `:428`; `isCardVerified`, `:463`) — the 3-D Secure step
needs that seed.
### Send
`submitCardPayment` (`:496`) → `confirmCardMerchant` (`:506`) shows the shared transfer confirm
dialog (biometric-gated), then `executeCardMerchant` (`:534`) runs, off the main thread:
```
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
```
where `card` comes from `VerifiedCardStore` (expiry split `MM/YY` → month/year) and `otpSeed` is the
card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
automatically, retrying once if the first code expired. Outcome is shown in the shared
processing/success dialog; failures surface as a toast.
### Key assumption
The 3-D Secure "Authenticator" OTP must be the **same** soft-token TOTP the app already uses for BML
transfers (`CredentialStore.loadBmlCredentials(loginId).otpSeed`). This holds for the user's own
BML-issued card on a login the app has. It does **not** work for a non-BML card, a card belonging to
another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
---
## Files
| File | Role |
|---|---|
| `ui/home/PayWithCardFragment.kt` | Verify button, verify mode, NFC reader, card details dialog |
| `ui/home/CardVerifyAnimationView.kt` | "Tap card to verify" animation |
| `nfc/EmvCardReader.kt` | Minimal contactless EMV read (PAN + expiry) |
| `util/VerifiedCardStore.kt` | Encrypted per-card store of full details |
| `res/layout/dialog_card_manual_verify.xml` | Card details form |
| `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload |
| `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment |
| `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment |
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
---
&nbsp;
---
**Related:** [Cards](22-cards.md) · [Transfer Flows](20-transfer-flows.md) · API side:
[Merchant Card Payment](../bmlapi/16-card-payment.md)
[← Settings — About](28-settings-about.md)
+1
View File
@@ -34,6 +34,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre |
| [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start |
| [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons |
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
## Reference
@@ -0,0 +1,2 @@
- Verify cards via NFC or manually
- Add support for bml gateway card payment.