Compare commits

...
18 Commits
Author SHA1 Message Date
shihaam d4805219f4 new version number because fayaz broke build
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 3m34s
2026-10-03 01:50:27 +05:00
shihaam 4c680ad22c remove unused transfer_select_service translation 2026-10-03 01:49:59 +05:00
shihaam 494a42c1a8 build on versionname change
Auto Tag on Version Change / check-version (push) Successful in 7s
Build and Release APK / build (push) Failing after 3m3s
2026-10-03 01:43:25 +05:00
shihaam 9ace8dd724 release v1.0.34
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-03 01:40:43 +05:00
shihaam a3449167db fix build issue applyfahipaycontact 2026-10-03 01:39:19 +05:00
shihaam 1612e21471 Revert "release v1.0.34"
This reverts commit f02b05b180.
2026-10-03 01:37:51 +05:00
shihaam f02b05b180 release v1.0.34
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Failing after 1m27s
2026-10-03 01:33:22 +05:00
shihaam 5abd981096 save trsnaction type to history for reload, raastas and bill pay to autoselct card
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-03 01:32:39 +05:00
shihaam bb76f4e591 ooredoo billpay via bml card 2026-10-03 01:27:29 +05:00
shihaam 8795d5f758 ooredoo raastas via bml card
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-03 01:08:33 +05:00
quillfires 0d4f0074c7 feat: add Dhivehi localization
Auto Tag on Version Change / check-version (push) Successful in 3s
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 01:05:40 +05:00
shihaam 237d25af67 add dhiraagu bill pay
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-10-03 00:26:00 +05:00
shihaam a0c515103a update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-02 23:31:07 +05:00
shihaam 56b464b58e add support for reload via BML veried cards 2026-10-02 23:30:55 +05:00
shihaam 9ab7f979fa add support for reload via BML veried cards
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-02 23:29:28 +05:00
shihaam 7a4b7a1712 add support for reload, raastas, ooredoo and dhiraagu bill pay via FahiPay
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-10-02 22:04:05 +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
52 changed files with 3344 additions and 549 deletions
+3 -12
View File
@@ -24,20 +24,11 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
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"
if git tag -l | grep -q "^v${VERSION}$"; then
echo "Tag v${VERSION} already exists, 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 versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "New version detected: v${VERSION}"
echo "should_release=true" >> $GITHUB_OUTPUT
fi
+3
View File
@@ -17,6 +17,9 @@ docs/mibapi/tmp
docs/bmlapi/tmp
docs/fahipayapi/tmp
docs/mfaisaapi/tmp
docs/dhiraaguapi/tmp
docs/ooredooapi/tmp
docs/ooredooapi/tmp
tmp
app/key.jks
.kotlin/*
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 34
versionName = "1.0.33"
versionCode = 35
versionName = "1.0.35"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -10,6 +10,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
@@ -33,7 +34,12 @@ import android.util.Base64
* 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.
* 5. Poll next-action until TRANSACTION_CONFIRMED. A decline after 3-D Secure (e.g. insufficient
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
* checked alongside.
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
* signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
* learn they were paid. Without it the card is charged but the merchant never delivers.
*
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies.
@@ -49,7 +55,8 @@ class BmlMerchantCardPayClient {
)
sealed class Result {
object Success : Result()
/** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */
data class Success(val merchantNotified: Boolean) : Result()
data class Failure(val message: String) : Result()
}
@@ -85,7 +92,10 @@ class BmlMerchantCardPayClient {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
val txnClient = BmlMerchantTxnClient()
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
// Earlier attempts' declines are already in the history; only newer ones are ours
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
// 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
@@ -130,7 +140,7 @@ class BmlMerchantCardPayClient {
var threeDsUrl: String? = null
for (attempt in 0..MAX_POLLS) {
when (action.optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
@@ -146,14 +156,56 @@ class BmlMerchantCardPayClient {
// 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_CONFIRMED" -> return confirmed(txnId)
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
if (browserId != null && priorErrors != null) {
runCatching { txnClient.paymentErrors(txnId, browserId) }.getOrNull()
?.drop(priorErrors)?.lastOrNull()
?.let { return Result.Failure(it.message.ifBlank { "The bank declined the payment" }) }
}
Thread.sleep(POLL_MS / 2)
}
return Result.Failure("Payment status unknown — check with the merchant before retrying")
}
/** The payment went through: return to the merchant, then report success either way. */
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
/**
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
* and on to its receipt page. Some merchants' callback page instead auto-submits a form on
* load (Ooredoo's posts the result on to its own site), so those forms are submitted too.
* Retries a couple of times; true when the chain ended on a 2xx page.
*/
private fun returnToMerchant(txnId: String): Boolean {
repeat(RETURN_ATTEMPTS) { attempt ->
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
val ok = runCatching {
var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
for (hop in 0..MAX_AUTO_SUBMITS) {
val (code, html, url) = client.newCall(request).execute().use {
Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
}
if (code !in 200..299) return@runCatching false
// A page that only exists to post itself onward, like the ACS's own hops
if (hop == MAX_AUTO_SUBMITS || !AUTO_SUBMIT.containsMatchIn(html)) return@runCatching true
val form = AcsForm.parse(html, url) ?: return@runCatching true
request = browserNav(form.toRequest().newBuilder())
}
true
}.getOrDefault(false)
if (ok) return true
}
return false
}
private fun browserNav(builder: Request.Builder): Request = builder
.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()
/** 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
@@ -176,6 +228,8 @@ class BmlMerchantCardPayClient {
}
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
// A rejected code comes back as the OTP page again with "The OTP code you entered is
// incorrect Please try again."
var retry = false
for (attempt in 0..1) {
form = AcsForm.parse(html, acsUrl) ?: break
@@ -183,9 +237,10 @@ class BmlMerchantCardPayClient {
form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
if (!otpRejected(html)) break
retry = true
}
if (otpRejected(html)) return Result.Failure("The bank rejected the BML token code. Check the phone's clock and try again.")
// 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) {
@@ -196,6 +251,9 @@ class BmlMerchantCardPayClient {
return null
}
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
(html.contains("incorrect", true) || html.contains("expired", true))
// ── next-action helpers ──────────────────────────────────────────────────
private fun nextAction(pk: String, body: JSONObject): JSONObject =
@@ -297,5 +355,11 @@ class BmlMerchantCardPayClient {
private val JSON = "application/json".toMediaType()
private const val POLL_MS = 5_000L
private const val MAX_POLLS = 10
private const val RETURN_ATTEMPTS = 3
private const val RETURN_RETRY_MS = 2_000L
/** Auto-submitting merchant pages followed on the way back; Ooredoo has one. */
private const val MAX_AUTO_SUBMITS = 2
/** `<body onload="document.forms['x'].submit()">` and the like. */
private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE)
}
}
@@ -113,12 +113,36 @@ 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()}"))
/**
* The PATCHes the page sends on load: register this "browser" and clear any FX selection.
* Returns the browser id, for [paymentErrors].
*/
fun announceBrowser(transactionId: String): String {
val browserId = "${transactionId}_${System.currentTimeMillis()}"
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
patch(transactionId, JSONObject().put("fx", "reset"))
return browserId
}
/**
* The transaction's failed payment attempts, oldest first, read with the page's load PATCH
* as [browserId]. A declined card (e.g. `INSUFFICIENT_FUNDS`) lands here while the
* transaction stays payable — `state` doesn't change, `hasError` turns true.
*/
fun paymentErrors(transactionId: String, browserId: String): List<PaymentError> {
val history = patch(transactionId, JSONObject().put("activeBrowserId", browserId))
.optJSONArray("paymentErrorHistory") ?: return emptyList()
return (0 until history.length()).mapNotNull { history.optJSONObject(it) }.map {
PaymentError(
code = it.optString("code"),
message = it.optString("customerVisibleDescription").ifBlank { it.optString("reason") }
)
}
}
/** One entry of `paymentErrorHistory`: the gateway's code and the wording BML shows for it. */
data class PaymentError(val code: String, val message: String)
private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId")
@@ -0,0 +1,210 @@
package sh.sar.basedbank.api.dhiraagu
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class DhiraaguPaymentClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the reload order for [number] and the BML transaction paying for it: cart →
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
* wording when a step is refused.
*/
fun createReloadTransaction(number: String, amount: Int): String {
val topupNonce = pageNonce("$BASE/services/easy-topup")
val gst = gstOf(amount)
val cart = api("cart", "recharge", topupNonce, JSONObject()
.put("formId", FORM_RELOAD)
.put("serviceNumber", number)
.put("amount", amount)
.put("amountGST", gst.toDouble())
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
.put("gstRate", GST_RATE)
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("customerId", "").put("customerCode", "")
.put("version", 2))
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
}
/**
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
* with Dhiraagu's wording when a step is refused.
*/
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
// The cart needs the billing account the number belongs to, which only the lookup gives
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
throw Exception("Prepaid number is not allowed.")
}
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
// The page refuses some account statuses and customer types before ordering; so do we
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
val status = info.optString("accountStatus")
if (rules != null && status in rules.blockedValues("accountStatus")) {
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
}
val customerType = info.optString("customerType")
if (rules != null && customerType in rules.blockedValues("customerType")) {
throw Exception("The number is not allowed. [Customer Type: $customerType]")
}
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
.put("formId", FORM_BILL)
.put("serviceNumber", number)
.put("accountNumber", accountNumber)
.put("amount", money(amount))
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("billRef", "")
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
return payCart(FORM_BILL, cart, amount)
}
/**
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
*/
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
// The payment page carries its own nonce, used for the rest of the order
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
val payment = api("payment", "create", paymentNonce, JSONObject()
.put("formId", formId)
.put("cartId", cartId)
.put("gatewayId", GATEWAY_BML)
.put("dhiraaguPayNumber", "")
.put("amount", money(amount))
.put("paymentMerchantId", bml.getString("merchantId"))
.put("memberId", "").put("tokenize", "").put("paymentType", "")
.put("recurringFrequency", "").put("bmlTokenId", ""))
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
return TXN_URL.find(txn.optString("url"))?.groupValues?.get(1)
?: throw Exception("BML didn't create the transaction")
}
private fun money(amount: BigDecimal) =
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
private fun JSONObject.blockedValues(rule: String): Set<String> {
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
return (0 until vals.length()).map { vals.optString(it) }.toSet()
}
// ── HTTP ─────────────────────────────────────────────────────────────────
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
private fun pageNonce(url: String): String =
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
private fun page(url: String): String = client.newCall(
Request.Builder().url(url)
.header("User-Agent", UA)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
call(sub, act, nonce, body).getJSONObject("resp")
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
call(sub, act, nonce, body).getJSONArray("resp")
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
send(sub, act, nonce, body.toString().toRequestBody(JSON))
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
val text = client.newCall(
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
.apply { if (body != null) post(body) }
.header("User-Agent", UA)
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("X-Requested-With", "XMLHttpRequest")
.header("Origin", BASE)
.header("nonce", nonce)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Dhiraagu")
}
if (obj.optString("respStatus") != "OK") {
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
}
return obj
}
companion object {
private const val BASE = "https://www.dhiraagu.com.mv"
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val JSON = "application/json".toMediaType()
private val NONCE = Regex("""var nonce = "([^"]+)"""")
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
private const val FORM_BILL = 1
/** Easy TopUp's form id across cart / merchant / payment. */
private const val FORM_RELOAD = 2
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
private const val GATEWAY_BML = 1
private const val GST_RATE = 0.08
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
private const val BILL_PAYMENT = "BillPayment"
private const val BILL_WRITE_OFF = "writeOffPayments"
/**
* The GST inside a GST-inclusive reload [amount], as the page works it out:
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
*/
fun gstOf(amount: Int): BigDecimal {
val rate = BigDecimal(GST_RATE.toString())
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
}
}
}
@@ -0,0 +1,47 @@
package sh.sar.basedbank.api.fahipay
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.Buffer
/** Form-body helpers shared by the Fahipay POST endpoints (login, OTP, payments). */
internal object FahipayForm {
/** The `device[...]` fields every Fahipay POST carries. */
fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
fun body(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
}
@@ -4,11 +4,8 @@ import android.os.Build
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import org.json.JSONObject
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
@@ -74,14 +71,14 @@ class FahipayLoginFlow {
*/
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
initSession()
val body = buildFormBody(
val body = FahipayForm.body(
"email" to idCard,
"password" to password,
"grant_type" to "auth_id",
"lang" to "en",
"version" to "2.0.0",
"platform" to "BasedBank",
*deviceParts(deviceUuid)
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
@@ -109,15 +106,15 @@ class FahipayLoginFlow {
* Returns authId.
*/
fun verifyTotp(code: String, deviceUuid: String): String {
val body = buildFormBody(
val body = FahipayForm.body(
"code" to code,
"channel" to "totp",
"action" to "login",
"grant_type" to "auth_id",
"lang" to "en",
"version" to "2.0.0",
"platform" to "BasedBank",
*deviceParts(deviceUuid)
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
@@ -138,42 +135,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response")
}
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
companion object {
fun generateDeviceUuid(): String {
val bytes = ByteArray(8)
@@ -0,0 +1,71 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
/**
* Pays a phone number from the Fahipay wallet: Ooredoo Raastas, Ooredoo bill pay, Dhiraagu
* reload and Dhiraagu bill pay. All four are the same POST, only the path differs — see
* `docs/fahipayapi/09-payments.md`.
*/
class FahipayPaymentClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
/**
* A payment the server accepted. [message] is its wording, e.g. "Transaction successful." or,
* for Dhiraagu bill pay, "Transaction will be processed shortly.". [transactionId] (`tid`) is
* only returned by the reload / Raastas endpoints.
*/
data class Result(val message: String, val transactionId: String?)
/**
* POSTs the payment to [path] (e.g. `actions/payment/ooredoo/recharge/`). [amount] is sent as
* typed — the caller checks the service's limits first. Returns on success; throws with the
* server's message when it refuses, [BankServerException] on a 5xx, and IOException when the
* request doesn't get through. Blocking — call from IO.
*/
fun pay(session: FahipaySession, path: String, number: String, amount: String, deviceUuid: String): Result {
val body = FahipayForm.body(
"number" to number,
"amount" to amount,
"lang" to "en",
"version" to "2.0.2",
"build" to "329",
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
Request.Builder().url("$BASE_URL/$path")
.post(body)
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("User-Agent", UA)
.header("accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string().orEmpty()
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = try { JSONObject(json) } catch (_: Exception) {
throw Exception("Unexpected response from Fahipay (HTTP $code)")
}
val message = obj.optString("msg").ifBlank { obj.optString("title") }
if (obj.optString("type") != "success") throw Exception(message.ifBlank { "Payment failed" })
return Result(
message = message,
transactionId = obj.optString("tid").takeIf { it.isNotBlank() }
)
}
}
@@ -0,0 +1,90 @@
package sh.sar.basedbank.api.ooredoo
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.models.BankServerException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class OoredooPaymentClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
* the order is refused.
*/
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
/**
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
* transaction id. Throws with Ooredoo's wording when the order is refused.
*/
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
private fun createOrder(
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
): String {
val msisdn = "960$number"
val body = JSONObject()
.put("msisdn", msisdn)
.put("purchaseAmount", money(charged))
.put("amountWithoutGst", amountWithoutGst)
.put("receiverMsisdn", msisdn)
.put("transType", transType)
.put("serviceType", serviceType)
.put("serviceTypeDisplayName", "Mobile")
val text = client.newCall(
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
.post(body.toString().toRequestBody(JSON))
.header("User-Agent", UA)
.header("Accept", "application/json")
.header("Origin", BASE)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Ooredoo")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Ooredoo")
}
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
}
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
?: throw Exception("BML didn't create the transaction")
}
private fun money(amount: BigDecimal) =
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
companion object {
private const val BASE = "https://www.ooredoo.mv"
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val JSON = "application/json".toMediaType()
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
}
}
@@ -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)
}
@@ -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,18 @@ 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.BmlVerifiedCards
import sh.sar.basedbank.ui.home.transfer.CardPayoutService
import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler
import sh.sar.basedbank.ui.home.transfer.CarrierLookup
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
@@ -153,6 +162,12 @@ class TransferFragment : Fragment() {
private fun fahipayHandler(): FahipayTransferHandler =
fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it }
/** Lazy: created the first time a phone lookup offers carrier services by BML card. */
private var cardPayoutHandler: CardPayoutTransferHandler? = null
private fun cardPayoutHandler(): CardPayoutTransferHandler =
cardPayoutHandler ?: CardPayoutTransferHandler(this, binding, viewModel) { bmlHandler() }
.also { cardPayoutHandler = it }
/** Lazy: created the first time the user selects an MFAISA source account. */
private var mfaisaHandler: MfaisaTransferHandler? = null
private fun mfaisaHandler(): MfaisaTransferHandler =
@@ -273,6 +288,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 +323,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 +333,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 +408,9 @@ class TransferFragment : Fragment() {
return@setFragmentResultListener
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
if (applyServiceContact(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 +453,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 (applyServiceContact(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)
@@ -497,6 +521,7 @@ class TransferFragment : Fragment() {
}
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
}
if (transferTypePending) showTransferTypePicker()
updateTransferButton()
}
@@ -539,6 +564,132 @@ 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()
cardPayoutHandler?.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)
// The default card when it can pay, otherwise any card that can
is TransferType.Card -> {
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
accounts.firstOrNull { it.accountNumber == defaultCard && type.worksFrom(it) }
?: 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()
cardPayoutHandler?.clearState()
showFavaraRecipient(type.info)
}
is TransferType.Fahipay -> {
cardPayoutHandler?.clearState()
fahipayHandler().applyService(type, number)
}
is TransferType.Card -> {
fahipayHandler?.clearState()
cardPayoutHandler().applyService(type, number)
}
}
updateTransferButton()
}
// ── Sensitive masking helpers ─────────────────────────────────────────────
private fun maskAmount(formatted: String): String {
@@ -568,6 +719,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)
@@ -826,28 +987,18 @@ class TransferFragment : Fragment() {
} else false
}
binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
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 = ""
@@ -866,6 +1017,33 @@ 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()
cardPayoutHandler?.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
@@ -948,6 +1126,9 @@ class TransferFragment : Fragment() {
binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
if (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
return@setOnItemClickListener
}
prefillToDirectly(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
@@ -960,6 +1141,31 @@ class TransferFragment : Fragment() {
}
}
/**
* A saved Fahipay favourite, or a recent paid with a Fahipay or card service: its category
* ([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 or
* the default card and brings in the service's amount rules, the same as a searched number.
* Returns false, doing nothing, when [categoryId] isn't one of those.
*/
private fun applyServiceContact(number: String, categoryId: String?, name: String): Boolean {
val ownerName = name.takeIf { it.isNotBlank() && it != number }
val type = FahipayService.fromContactCategory(categoryId)?.let { TransferType.Fahipay(it, ownerName) }
?: CardPayoutService.fromContactCategory(categoryId)?.let { service ->
// The cards that could pay it then may not be payable now
val cards = cardPayoutHandler().payableCards()
if (cards.isEmpty()) {
Toast.makeText(requireContext(), R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return true
}
TransferType.Card(service, ownerName, cards)
}
?: return false
clearRecipient()
offerTransferTypes(number, listOf(type))
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
@@ -969,6 +1175,21 @@ 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
}
// A card that can pay by card: the number may be a carrier service it can pay too
if (selectedAccount?.let { BmlVerifiedCards.isPayable(requireContext(), it) } == true &&
AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupPhoneForAnySource(accountNumber)
return
}
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
@@ -986,7 +1207,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
@@ -1013,100 +1233,152 @@ 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 (or from a card that can pay by
* card). Runs the Favara lookup (through MIB or BML, whichever is logged in) and, when the
* user has a Fahipay wallet or a card that can pay by card, 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 payableCards = cardPayoutHandler().payableCards()
val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null
if (!hasFavara && !hasFahipay && payableCards.isEmpty()) {
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()
val cardPayout = cardPayoutHandler()
resetTransferTypes()
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
val (favara, carriers) = withContext(Dispatchers.IO) {
coroutineScope {
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
val carriers = async { if (hasFahipay || payableCards.isNotEmpty()) CarrierLookup.query(number) else null }
favara.await() to carriers.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 fahipayTypes = carriers?.takeIf { hasFahipay }?.let(fahipay::typesFor).orEmpty()
val cardTypes = carriers?.let { cardPayout.typesFor(it, payableCards) }.orEmpty()
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes + cardTypes
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)
}
}
@@ -1115,7 +1387,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
@@ -1166,7 +1440,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)
}
@@ -1255,6 +1530,19 @@ class TransferFragment : Fragment() {
return
}
// Fahipay source: reload / Raastas / bill pay to the picked service
if (selectedAccount?.bank == "FAHIPAY") {
fahipayHandler().submit()
return
}
// Carrier service by BML card: the carrier creates the BML transaction, then it's paid
// like a card-only merchant link
if (draft.cardPayoutService != null) {
cardPayoutHandler().submit()
return
}
// BML QR merchant payment — uses shared confirm dialog, no receipt
if (bmlHandler().hasQrMerchant) {
bmlHandler().submitQrPayment()
@@ -1547,6 +1835,23 @@ class TransferFragment : Fragment() {
}
}
/**
* A dialog showing only the processing spinner, for work that has to finish before the
* confirm dialog can be shown (e.g. a carrier creating the payment). Dismiss it when done.
*/
internal fun showProcessingDialog(title: String): AlertDialog {
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
val frame = android.widget.FrameLayout(requireContext())
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setView(frame)
.setCancelable(false)
.show()
showProcessingInDialog(dialog, frame)
return dialog
}
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
@@ -1693,10 +1998,17 @@ 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()
val cardPayout = if (draft.cardPayoutService != null) cardPayoutHandler() else null
cardPayout?.syncAmountField()
if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0
val amountOk = amount > 0 && fahipay?.amountProblem == null && cardPayout?.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 ||
@@ -1726,7 +2038,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
@@ -1826,7 +2138,12 @@ class TransferFragment : Fragment() {
bmlHandler?.clearState()
bmlHandler = null
fahipayHandler = null
cardPayoutHandler = 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
@@ -425,13 +425,8 @@ class BmlTransferHandler(
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 }
}
private fun verifiedCardCandidates(): List<BankAccount> =
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
/**
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
@@ -460,9 +455,7 @@ class BmlTransferHandler(
}
}
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
private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account)
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
@@ -503,7 +496,12 @@ class BmlTransferHandler(
confirmCardMerchant(page, src)
}
private fun confirmCardMerchant(
/**
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
* [page] with [src]. Also the send step for carrier services paid by card
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
*/
fun confirmCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount
) {
@@ -543,42 +541,45 @@ class BmlTransferHandler(
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) {
val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
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) }
BmlMerchantCardPayClient().pay(page, card) { retry ->
// A code about to roll over can expire before the ACS checks it, and a
// retry in the same window would resend the rejected code: wait for the
// next window in both cases. Runs on IO.
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
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.Success -> {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
toName = page.merchantName
) {
fragment.clearForm()
host?.triggerRefresh()
}
// Charged, but the merchant may not deliver until it's told
if (!result.merchantNotified) {
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
}
}
is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss()
@@ -962,6 +963,12 @@ class BmlTransferHandler(
}
}
private fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
private companion object {
/** The BML token's TOTP window. */
const val TOTP_WINDOW_MS = 30_000L
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
const val TOTP_MIN_LEFT_MS = 5_000L
}
}
@@ -0,0 +1,52 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.VerifiedCardStore
/**
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
* step is answered with that login's token code.
*/
object BmlVerifiedCards {
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
fun isPayable(ctx: Context, account: BankAccount): Boolean =
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
val verified = VerifiedCardStore.keys(ctx)
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
}
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
fun load(ctx: Context, account: BankAccount): Payable? {
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
val seed = otpSeed(ctx, account) ?: return null
val expiry = stored.expiry.split("/") // "MM/YY"
if (expiry.size != 2) return null
return Payable(
card = BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = account.accountBriefName
),
otpSeed = seed
)
}
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
private fun otpSeed(ctx: Context, account: BankAccount) =
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
}
@@ -0,0 +1,217 @@
package sh.sar.basedbank.ui.home.transfer
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.ooredoo.OoredooPaymentClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
* merchant gateway. The limits are the carrier website's, not Fahipay's.
*
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
* needs updating.
*/
enum class CardPayoutService(
override val label: String,
override val destinationLabel: String,
@param:DrawableRes override val iconRes: Int,
override val minAmount: Int,
override val maxAmount: Int?,
override val decimalsAllowed: Boolean,
override val gstPercent: Int?,
/**
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
* again pays the same way. Not a real contact list: card payouts have no favourites.
*/
val contactCategory: String,
override val gstAdded: Boolean = false,
) : PayoutService {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_DHIRAAGU_RELOAD") {
// Dhiraagu rounds the GST to 2 places and credits the rest
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
},
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null,
contactCategory = "CARD_DHIRAAGU_BILL"),
// Ooredoo credits the whole amount and charges the card 8% GST on top
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_RAASTAS", gstAdded = true),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
contactCategory = "CARD_OOREDOO_BILL");
companion object {
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
fun fromContactCategory(categoryId: String?): CardPayoutService? =
entries.firstOrNull { it.contactCategory == categoryId }
}
}
/**
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
* number can be paid with by card, and the picked service's amount rules.
*
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
* comes from: the carrier's website creates it for the number and amount. From there it goes
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
* payment and result as a pasted link.
*
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
* Lifetime is bound to the fragment's view.
*/
class CardPayoutTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
private val bmlHandler: () -> BmlTransferHandler,
) {
private val ctx get() = fragment.requireContext()
private val amountField = PayoutAmountField(binding) { ctx }
/** The service picked for the current recipient; null when none is. */
var service: CardPayoutService?
get() = viewModel.transferDraft.cardPayoutService
private set(value) { viewModel.transferDraft.cardPayoutService = value }
// ─── Public API the fragment calls ───────────────────────────────────────
/** Account numbers of the cards that can pay by card. Call on the main thread. */
fun payableCards(): Set<String> =
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
.map { it.accountNumber }.toSet()
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
if (cards.isEmpty()) return emptyList()
return buildList {
when (result.dhiraagu.type) {
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
DhiraaguClient.CustType.UNSUPPORTED -> {}
}
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) {
add(CardPayoutService.OOREDOO_RAASTAS)
}
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
add(CardPayoutService.OOREDOO_BILL)
}
}.map { TransferType.Card(it, result.ownerName, cards) }
}
/** Forgets the picked service and gives back the amount and reference fields. */
fun clearState() {
if (service == null) return
service = null
amountField.reset()
}
/** Why the typed amount can't be sent with the picked service, or null when it can. */
val amountProblem: String?
get() = service?.let(amountField::problem)
/** Keeps the amount and reference fields in step with the picked service. */
fun syncAmountField() {
service?.let(amountField::sync)
}
/**
* Fills the recipient card for a picked card transfer type. The fragment has already
* switched the source to one of the type's cards.
*/
fun applyService(type: TransferType.Card, 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 = "#E4002B",
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Has the carrier create the BML transaction for the number and amount, then hands its
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
val charged = svc.chargedWithGst(amount)
// Creating the order takes a few round trips; show the payment's processing box meanwhile
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
fragment.viewLifecycleOwner.lifecycleScope.launch {
val page = withContext(Dispatchers.IO) {
runCatching {
val txnId = when (svc) {
CardPayoutService.DHIRAAGU_RELOAD ->
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
CardPayoutService.DHIRAAGU_BILL ->
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
CardPayoutService.OOREDOO_RAASTAS ->
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
CardPayoutService.OOREDOO_BILL ->
OoredooPaymentClient().createBillPayTransaction(number, amount)
}
BmlMerchantTxnClient().fetchPayPage(txnId)
}
}
processing.dismiss()
if (fragment.view == null) return@launch
page.onSuccess {
when {
!it.supportsCard ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
// The carrier's order must be for exactly what was typed (plus GST, if added)
it.amount.toBigDecimal().compareTo(charged) != 0 ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
else -> bmlHandler().confirmCardMerchant(it, src)
}
}.onFailure { e ->
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
}
@@ -0,0 +1,46 @@
package sh.sar.basedbank.ui.home.transfer
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
/**
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
* Fahipay services and the BML card services each map this to what they can pay.
*/
object CarrierLookup {
data class Result(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType,
) {
/** Dhiraagu is the only carrier that hands back an owner name. */
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
}
/**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
*/
fun query(number: String): Result =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
Result(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
Result(d, o)
}
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
}
@@ -1,46 +1,86 @@
package sh.sar.basedbank.ui.home.transfer
import android.view.View
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.FahipayPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore
/**
* 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. The limits are Fahipay's — the same carrier service paid by card
* ([CardPayoutService]) has its own.
*
* 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.
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
* 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(
override val label: String,
override val destinationLabel: String,
@param:DrawableRes override val iconRes: Int,
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
val paymentPath: String,
override val minAmount: Int,
override val maxAmount: Int?,
override 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,
override val gstPercent: Int? = null,
) : PayoutService {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/recharge/",
minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/billpay/",
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/recharge/",
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/billpay/",
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.
*
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
* other handlers.
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
* and an in-dialog success screen (no receipt page yet).
*
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
@@ -58,6 +98,8 @@ class FahipayTransferHandler(
get() = viewModel.transferDraft.fahipayService
private set(value) { viewModel.transferDraft.fahipayService = value }
private val amountField = PayoutAmountField(binding) { ctx }
/** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
@@ -75,126 +117,166 @@ 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
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
private data class CarrierResult(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType
)
private fun lookupCarrier(number: String) {
fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { queryCarriers(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)
}
amountField.reset()
}
/**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
* 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).
*/
private fun queryCarriers(number: String): CarrierResult =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
CarrierResult(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
CarrierResult(d, o)
}
val amountProblem: String?
get() = service?.let(amountField::problem)
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
/**
* Keeps the amount and reference fields in step with the selected service. A no-op when no
* service is selected; [clearState] puts the fields back.
*/
fun syncAmountField() {
service?.let(amountField::sync)
}
// ─── 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
/**
* 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 = ownerName
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${picked.label} · $number",
subtitle = "${type.label} · $number",
colorHex = "#FF6B00",
imageHash = null
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
/** The Fahipay transfer types a carrier lookup [result] allows. */
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Pays the picked service: confirm dialog (with the GST note for services that charge it),
* biometric gate, then the payment POST. Success shows in the dialog; a refusal closes it
* and toasts the server's message.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount?.takeIf { it.bank == "FAHIPAY" } ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
// Whole-number services get "11", never "11.00"
val amountParam = amount.stripTrailingZeros().toPlainString()
val amountDisplay = "%,.2f".format(amount)
val toName = binding.tvToAccountName.text?.toString().orEmpty().ifBlank { number }
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = "Fahipay",
toName = toName,
toNumber = number,
toDetail = svc.destinationLabel,
warningTexts = listOfNotNull(amountField.gstNote(svc))
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "MVR $amountDisplay → ${svc.label} $number",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
pay(src, svc, number, amountParam, amountDisplay, toName, dialog, frame)
}
)
}
private fun pay(
src: BankAccount,
svc: FahipayService,
number: String,
amountParam: String,
amountDisplay: String,
toName: String,
dialog: AlertDialog,
frame: android.widget.FrameLayout,
) {
val app = fragment.requireActivity().application as BasedBankApp
val session = app.fahipaySessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val deviceUuid = CredentialStore(ctx).getOrCreateFahipayDeviceUuid()
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { FahipayPaymentClient().pay(session, svc.paymentPath, number, amountParam, deviceUuid) }
}
if (fragment.view == null) return@launch
result.onSuccess {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
toName = "$toName · ${svc.label}"
) {
fragment.clearForm()
(fragment.activity as? HomeActivity)?.triggerRefresh()
}
}.onFailure { e ->
dialog.dismiss()
binding.btnTransfer.isEnabled = true
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> "Payment failed"
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
private fun lookupCarrier(number: String) {
fragment.resetTransferTypes()
fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch {
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
fragment.stopLookupLoading()
if (types.isEmpty()) return@launch
fragment.offerTransferTypes(number, types)
}
}
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
}
}
@@ -0,0 +1,130 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import android.text.InputType
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentTransferBinding
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
*/
interface PayoutService {
/** Names it in the "Transfer Type" picker and the recipient card. */
val label: String
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
val destinationLabel: String
@get: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
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
val gstPercent: Int?
/**
* False (the usual): GST comes out of the amount, so the number is credited less
* ([creditedAfterGst]). True: the number is credited the whole amount and GST is charged on
* top of it ([chargedWithGst]).
*/
val gstAdded: Boolean get() = false
/**
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
* down. Null when the service charges no GST, or adds it on top.
*/
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
val gst = gstPercent ?: return null
if (gstAdded) return null
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
}
/**
* What is paid for [amount]: amount + round2(amount × rate) when GST is added on top,
* otherwise [amount] itself.
*/
fun chargedWithGst(amount: BigDecimal): BigDecimal {
val gst = gstPercent?.takeIf { gstAdded } ?: return amount
return amount + (amount * BigDecimal(gst).movePointLeft(2)).setScale(2, RoundingMode.HALF_UP)
}
}
/**
* Keeps the Transfer screen's amount and reference fields in step with a picked
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
*/
class PayoutAmountField(
private val binding: FragmentTransferBinding,
private val context: () -> Context,
) {
/**
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
* empty). Checks the service's minimum, maximum and whether it takes decimals.
*/
fun problem(svc: PayoutService): String? {
val ctx = context()
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
}
}
/** Idempotent — the fragment calls it on every form change. */
fun sync(svc: PayoutService) {
// 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 = problem(svc)
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* For services that charge GST: what the recipient is credited once it comes out, or what
* is paid once it's added on top.
*/
fun gstNote(svc: PayoutService): String? {
val gst = svc.gstPercent ?: return null
val ctx = context()
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (svc.gstAdded) {
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_gst_added_hint, gst)
return ctx.getString(R.string.transfer_gst_added_pay, "%,.2f".format(svc.chargedWithGst(amount)), gst)
}
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = svc.creditedAfterGst(amount) ?: return null
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
}
/** Gives back the amount and reference fields once no service is picked. */
fun reset() {
binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
}
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
}
}
@@ -50,6 +50,14 @@ 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
// Carrier service paid by verified BML card
var cardPayoutService: CardPayoutService? = null
}
@@ -0,0 +1,55 @@
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 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"
}
/**
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
*/
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.bml_logo_vector
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
}
}
@@ -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) {
@@ -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
@@ -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>
+388 -107
View File
@@ -1,149 +1,430 @@
<?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_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>
+11 -1
View File
@@ -261,7 +261,16 @@
<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_gst_added_hint">%1$d%% GST is charged on top of this amount</string>
<string name="transfer_gst_added_pay">You pay MVR %1$s with %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>
@@ -299,6 +308,7 @@
<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_card_pay_merchant_not_notified">Paid, but %1$s couldn\'t be notified. If it isn\'t credited, contact them with BML transaction %2$s.</string>
<string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</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">
+2 -2
View File
@@ -17,5 +17,5 @@
| [bmlapi/](bmlapi/README.md) | Bank of Maldives — hybrid web/OAuth login, dashboard, transfers, cards, QR payments, tap-to-pay |
| [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts |
| [fahipayapi/](fahipayapi/README.md) | Fahipay digital wallet — login, balance, history, contacts |
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay — number lookup for reload / bill pay |
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay |
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay / Easy TopUp — number lookup, reload and bill pay by BML card |
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation, Raastas and bill pay by BML card |
+56 -4
View File
@@ -73,6 +73,8 @@ 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
GET transaction…/<id>?wait=1 → 302 merchant redirectUrl (?…&state=CONFIRMED&signature=…)
→ 302 merchant receipt page
```
---
@@ -203,8 +205,10 @@ no `action`; their JavaScript posts back to the **same creq URL**. So the creq U
`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.
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page (still with `otpValue`) and
*"The OTP code you entered is incorrect Please try again."* — wait for the next TOTP window,
regenerate and retry once. Rejected twice, the payment stops ("The bank rejected the BML token
code"). The app also never sends a code with under 5 s left in its window.
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.
@@ -223,8 +227,55 @@ Poll `next-action` until the recorded verdict surfaces:
| `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`).
**A decline after 3-D Secure doesn't arrive this way.** In the Ooredoo capture, the card passed
3-D Secure (`mpgsNotification` got `result=SUCCESS`, `gatewayRecommendation=PROCEED`) and was then
declined for insufficient funds. The browser's `?wait=1` went to `?error=1` instead of the
merchant, and the transaction stayed payable: `state` still `QR_CODE_GENERATED`, `hasError: true`,
`allowRetry: true`, and a new `paymentErrorHistory` entry:
```json
{"date":"…","vendor":"mpgs","code":"INSUFFICIENT_FUNDS",
"reason":"Transaction declined due to insufficient funds",
"customerVisibleDescription":"Insufficient funds. Please use another card or payment method."}
```
The same link was then paid successfully after topping up the card. So while polling,
`BmlMerchantCardPayClient` also reads the transaction (the load PATCH,
`BmlMerchantTxnClient.paymentErrors`) and stops with `customerVisibleDescription` as soon as an
entry newer than the ones there before the attempt shows up.
## 7. Return to the merchant
The `mpgsNotification` response is a page whose script sends the browser to
`https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`. Once the transaction is
confirmed, that 302s to the merchant's `redirectUrl` with a BML-signed result, then on to the
merchant's own receipt page:
```
GET transaction…/<id>?wait=1
→ 302 https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=<sha1>
→ 302 https://www.dhiraagu.com.mv/services/reload-receipt?pyid=<paymentId> (bill pay: /services/bill-receipt)
```
(FahiPay's is `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`.)
Ooredoo's callback isn't a redirect: `my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED` is a
200 page whose `<body onload="document.forms['wtmpay'].submit()">` posts the result
(`order_id`, `bml_transaction_id`, `bml_response=CONFIRMED`, `payment_status=success`, …) on to
`www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml`, which lands on
`/payment-status?order_id=…&status=1`. `returnToMerchant` submits such auto-posting forms too
(up to 2).
**This hop is required.** It's how at least Dhiraagu learns it was paid: a test reload that
stopped at `TRANSACTION_CONFIRMED` charged the card but never topped up, and opening the
`?wait=1` URL in a browser afterwards delivered it. The signature is generated by BML, so the
hop can be replayed later from the transaction id alone.
`BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser
UA GET that follows the redirects and auto-submitted forms, up to 3 tries, success = the chain
ends on a 2xx page. The
merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got
through.
---
@@ -243,6 +294,7 @@ learn of breakage from a failed live payment.
| **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 |
| **Return to merchant** | The merchant's `redirectUrl` host blocks the client (Cloudflare) or is down | Charged but not delivered — `Success(merchantNotified = false)`, the app toasts the BML transaction id; opening `…/<id>?wait=1` in a browser delivers it |
**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
+175
View File
@@ -0,0 +1,175 @@
# Reload (Easy TopUp, paid by BML card)
Top up a Dhiraagu prepaid number through the dhiraagu.com.mv **Easy TopUp** page. Dhiraagu only
builds the order: the money moves on a **BML Merchant Services transaction** that Dhiraagu creates
for it, which is then paid exactly like any card-only BML merchant link
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
Reconstructed from `docs/dhiraaguapi/tmp/dhiraagu_reload_gateway.md` (a Firefox HAR).
---
## Flow overview
```
GET /services/easy-topup → nonce #1
POST cart&act=recharge (nonce #1) → cartId
GET /services/payment-v2?cartid=<cartId> → nonce #2
POST merchant&act=form (nonce #2) → BML gateway's merchantId
POST payment&act=create (nonce #2) → paymentId, oid
POST bml&act=createV2 (nonce #2) → BML transaction url ──┐
│
── from here: the BML card-only merchant flow ── │
GET transaction.merchants…/<id>/paynow ←─────────────────────────────┘
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
GET transaction.merchants…/<id>?wait=1 → 302 dhiraagu-bml-response.aspx (tops up)
→ 302 /services/reload-receipt
```
After the payment the browser is sent `transaction…/<id>?wait=1` →
`dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=…` →
`/services/reload-receipt?pyid=<paymentId>`. **This is what makes Dhiraagu top up the number** —
a payment that stopped at BML's `TRANSACTION_CONFIRMED` was charged but not delivered until that
URL was opened. The card flow follows it for every merchant, see
[BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
**Recovering a stuck reload:** open `https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`
in a browser. BML signs the callback, so the transaction id is all that's needed. (`curl` gets a
Cloudflare 403 on the Dhiraagu hop; a browser works.)
---
## Common
All API calls are `POST https://www.dhiraagu.com.mv/api/sdk-dhr-webapi.ashx?website_id=CA2BB809-3A22-485B-A518-DA6B6DE653A5&sub=<sub>&act=<act>`
with a JSON body and these headers:
| Header | Value |
|---|---|
| `User-Agent` | a browser UA (same as [Number Lookup](01-number-lookup.md)) |
| `Content-Type` | `application/json` |
| `X-Requested-With` | `XMLHttpRequest` |
| `Origin` | `https://www.dhiraagu.com.mv` |
| `nonce` | `var nonce = "…"` from the page that makes the call |
Every response is `{"respStatus":"OK","resp":…}` on success.
Each page has its own nonce: the cart call uses the Easy TopUp page's, the rest use the payment
page's.
---
## 1. Settings (optional)
`GET …&sub=setting&act=reload` — the page reads its limits from here. Thijooree hardcodes them.
```json
{"gstRate":0.08,"dailyLimit":3000,
"amountLimit":{"min":20,"max":1080,"message":"Enter a whole number amount between MVR 20 and 1000"},
"reloadPerDay":{"easyTopUp":4,"myAccount":6}, …}
```
| Rule | Value |
|---|---|
| Amount | whole MVR, **20 – 1000** (the message says 1000; `max` says 1080 — Thijooree uses 1000) |
| GST | 8%, **included** in the amount |
| Per day | MVR 3000, 4 Easy TopUps |
GST, as the page works it out: `gst = round2(amount × 0.08 / 1.08)`, credited `amount − gst`
(MVR 20 → GST 1.48, credited 18.52).
---
## 2. Cart
`sub=cart&act=recharge`, nonce from `GET /services/easy-topup`.
```json
{"formId":2,"serviceNumber":"7XXXXXX","amount":20,"amountGST":1.48,"amountRecharge":18.52,
"gstRate":0.08,"memberId":"","memberName":"","memberNId":"","customerId":"","customerCode":"","version":2}
```
```json
{"cartId":"002773ed-…","formId":2,"cartAmount":20.00,"cartExpiry":"…",
"paymentUrl":"https://www.dhiraagu.com.mv/services/payment-v2?cartid=002773ed-…", …}
```
The page also calls `sub=dhiraaguIO&act=infoSubscriberStatus` (`{"number"}`) before this, to show
the number's status and balance. Thijooree skips it — [Number Lookup](01-number-lookup.md) has
already confirmed a prepaid number.
---
## 3. Payment gateway
`sub=merchant&act=form`, `{"formId":2}`, nonce from `GET /services/payment-v2?cartid=<cartId>`.
Lists the gateways; **`gatewayId: 1` is Bank of Maldives** (2 = MIB, 3 = DhiraaguPay).
```json
[{"merchantId":"98de333c-…","merchantId2":"3f5cf6b7-…","formId":2,"gatewayId":1,
"gatewayName":"Bank of Maldives", …}, …]
```
---
## 4. Payment
`sub=payment&act=create`
```json
{"formId":2,"cartId":"<cartId>","gatewayId":1,"dhiraaguPayNumber":"","amount":"20.00",
"paymentMerchantId":"<BML merchantId>","memberId":"","tokenize":"","paymentType":"",
"recurringFrequency":"","bmlTokenId":""}
```
```json
{"paymentId":"3ea4351b-…","oid":"ET20260006911381","gatewayId":1,"amount":20.00,"paymentStatus":0, …}
```
---
## 5. BML transaction
`sub=bml&act=createV2`, `{"paymentId":"<paymentId>"}`. Returns the BML Merchant Services
transaction (amounts in cents):
```json
{"state":"INITIATED","amount":2000,"currency":"MVR","localId":"ET20260006911381",
"url":"https://transaction.merchants.bankofmaldives.com.mv/6abfec7afd7f4a4360fc1df4",
"redirectUrl":"https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx",
"expires":"…(10 min)…","customerReference":"WebApp - Topup", …}
```
The 24-hex id at the end of `url` is the transaction. Its `/paynow` page offers **UnionPay +
MPGS cards only, no BML Pay**, so it's paid by card + 3-D Secure.
---
## Reload record
`sub=reload&act=list`, `{"paymentId"}`, nonce from the receipt page — what the receipt page shows:
```json
{"oid":"ET20260006911381","transId":"<BML txn id>","serviceNumber":"7XXXXXX",
"amountPay":20.00,"amountTopup":18.52,"amountGST":1.48,
"paidStatus":1,"topupStatus":1,"reloadStatusDesc":"Successful", …}
```
Not used by Thijooree yet.
---
## Cloudflare
`www.dhiraagu.com.mv` is behind Cloudflare. The browser capture carries a `cf_clearance` cookie,
but [Number Lookup](01-number-lookup.md) already works from the app with plain okhttp and a browser
UA, so these calls are made the same way.
---
&nbsp;
---
**Related:** [Number Lookup](01-number-lookup.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Number Lookup](01-number-lookup.md) · [Bill Pay →](03-bill-pay.md)
+132
View File
@@ -0,0 +1,132 @@
# Bill Pay (Easy Pay, paid by BML card)
Pay a Dhiraagu postpaid bill through the dhiraagu.com.mv **Easy Pay** page. Like
[Reload](02-reload.md), Dhiraagu only builds the order and the money moves on a **BML Merchant
Services transaction** paid by card + 3-D Secure
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). From the payment page on,
the two flows are identical; only the first page, the cart call and the form id differ.
Reconstructed from `docs/dhiraaguapi/tmp/dhiraagu_billpay_gateway.har` (a Firefox HAR) and the
Easy Pay page's inline script.
---
## Flow overview
```
GET /services/easy-pay → nonce #1
GET setting&act=bill (nonce #1) → blocked account statuses / customer types
POST dhiraaguIO&act=infoUnlisted (nonce #1) → accountNumber, type, accountStatus, customerType
POST cart&act=easyPay (nonce #1) → cartId
GET /services/payment-v2?cartid=<cartId> → nonce #2
POST merchant&act=form {"formId":1} → BML gateway's merchantId
POST payment&act=create (formId 1) → paymentId, oid (EP…)
POST bml&act=createV2 → BML transaction url
── from here: the BML card-only merchant flow ──
GET transaction.merchants…/<id>?wait=1 → 302 dhiraagu-bml-response.aspx (posts the payment)
→ 302 /services/bill-receipt?pyid=<paymentId>
```
As with reload, the `?wait=1` return hop is what tells Dhiraagu it was paid.
Common headers and the `{"respStatus":"OK","resp":…}` envelope are as in
[Reload → Common](02-reload.md#common).
---
## 1. Settings
`GET …&sub=setting&act=bill` (no body, so a GET) — rules the page checks the lookup against:
```json
{"settingAppJson1":{"accountStatus":{"val":["F"],…},"customerType":{"val":["P"],…}}}
```
A number whose `accountStatus` or `customerType` is in a `val` list is refused before ordering
("Payment for this service could not be accepted… [Account Status: F]" / "The number is not
allowed. [Customer Type: P]"). Thijooree applies the same rules, skipping them if the call fails.
`setting&act=maintenance` has `public.easyPay` — `"Y"` means the page is under maintenance.
Not checked.
---
## 2. Lookup
`sub=dhiraaguIO&act=infoUnlisted`, `{"number":"7XXXXXX"}` — the same call as
[Number Lookup](01-number-lookup.md), but the bill payment needs more of its answer:
```json
{"respStatus":"OK","accountNumber":"1466154","accountStatus":"W","customerType":"S",
"type":"BillPayment","serviceDetails":[{"unlisted":"N","prepaidIndicator":"N"}],
"accountOwnerInfo":{"name":"…"}}
```
Note the fields are at the top level, not under `resp`.
| Field | Use |
|---|---|
| `accountNumber` | the billing account the cart is made out to |
| `type` | `BillPayment`, or `writeOffPayments` for a written-off account — sent as `billType` |
| `prepaidIndicator` | `"Y"` is refused ("Prepaid number is not allowed.") |
The page also accepts the account number itself in place of a service number (then
`serviceNumber` is sent empty); Thijooree only pays by phone number.
---
## 3. Cart
`sub=cart&act=easyPay`, nonce from `GET /services/easy-pay`.
```json
{"formId":1,"serviceNumber":"7XXXXXX","accountNumber":"1466154","amount":"1.05",
"memberId":"","memberName":"","memberNId":"","billRef":"","billType":"BillPayment"}
```
```json
{"cartId":"8fc33fa4-…","formId":1,"cartJson":[{"accountNumber":"1466154","serviceNumber":"7XXXXXX",
"amount":1.05,"billRef":"","billType":"BillPayment"}],"cartAmount":1.05,"cartExpiry":"…(20 min)…",
"paymentUrl":"https://www.dhiraagu.com.mv/services/payment-v2?cartid=8fc33fa4-…", …}
```
| Rule | Value |
|---|---|
| Amount | any positive amount, up to 2 decimal places (the page's only check). No min / max. |
| GST | none |
---
## 4. Payment page
Same as [Reload §3–5](02-reload.md#3-payment-gateway) with `formId: 1`:
- `merchant&act=form` lists DhiraaguPay (3), Bank of Maldives (1) and MIB (2) for "Easy Pay".
The BML `merchantId` is the same as reload's.
- `payment&act=create` returns an `oid` starting `EP` (reload's start `ET`).
- `bml&act=createV2` returns the transaction with `"customerReference":"WebApp - Easy Pay"` and
the same `redirectUrl`. Its page is card-only, no BML Pay.
---
## Bill record
`sub=bill&act=list`, `{"paymentId"}`, nonce from the receipt page — what the receipt shows:
```json
[{"oid":"EP20260006911918","transId":"<BML txn id>","accountNumber":"1466154","serviceNumber":"7XXXXXX",
"amount":1.05,"billStatus":1,"paidStatus":1,"cbsStatus":1,"cbsReceipt":"EP…-130","billType":"BillPayment", …}]
```
Not used by Thijooree yet.
---
&nbsp;
---
**Related:** [Number Lookup](01-number-lookup.md) · [Reload](02-reload.md) ·
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Reload](02-reload.md)
+2
View File
@@ -96,6 +96,8 @@ The API only returns a valid result for numbers currently on the Dhiraagu networ
| # | File | Description |
|---|---|---|
| 1 | [Number Lookup](01-number-lookup.md) | Validate a Dhiraagu number and determine account type |
| 2 | [Reload](02-reload.md) | Easy TopUp order → BML merchant transaction, paid by card |
| 3 | [Bill Pay](03-bill-pay.md) | Easy Pay order → BML merchant transaction, paid by card |
---
+2 -2
View File
@@ -25,7 +25,7 @@ POST https://fahipay.mv/api/app/login/
| `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string |
| `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) |
| `platform` | `thijooree` | Client identifier (`app` in the original Fahipay app) |
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
| `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
@@ -53,7 +53,7 @@ curl --request POST \
--form 'grant_type=auth_id' \
--form 'lang=en' \
--form 'version=2.0.0' \
--form 'platform=BasedBank' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
+2 -2
View File
@@ -34,7 +34,7 @@ POST https://fahipay.mv/api/app/otp/
| `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string |
| `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) |
| `platform` | `thijooree` | Client identifier (`app` in the original Fahipay app) |
| `device[available]` | `true` | Same device fields as login — must match |
| `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
@@ -64,7 +64,7 @@ curl --request POST \
--form 'grant_type=auth_id' \
--form 'lang=en' \
--form 'version=2.0.0' \
--form 'platform=BasedBank' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
+1 -1
View File
@@ -37,4 +37,4 @@ Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4
---
[← Saved Favourites](07-contacts.md)
[← Saved Favourites](07-contacts.md) | [Payments →](09-payments.md)
+157
View File
@@ -0,0 +1,157 @@
# Payments: Reload, Raastas & Bill Pay
Pay a Dhiraagu or Ooredoo number from the Fahipay wallet. All four services use the same request; only the path differs.
---
## Endpoints
| Service | Endpoint | Activity `subtype` |
|---|---|---|
| Ooredoo Raastas (prepaid top-up) | `POST https://fahipay.mv/actions/payment/ooredoo/recharge/` | `OORCH` |
| Ooredoo Bill Pay | `POST https://fahipay.mv/actions/payment/ooredoo/billpay/` | `OOBPY` |
| Dhiraagu Reload | `POST https://fahipay.mv/actions/payment/dhiraagu/recharge/` | `DHRCH` |
| Dhiraagu Bill Pay | `POST https://fahipay.mv/actions/payment/dhiraagu/billpay/` | `DHBPY` |
Which services a number supports comes from the carrier lookups: [Dhiraagu](../dhiraaguapi/01-number-lookup.md) and [Ooredoo](../ooredooapi/01-number-validation.md).
---
## Prerequisites
- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md)
- Valid `__Secure-sess` session cookie
There's no OTP or PIN step. The single POST moves the money.
---
## Request
### Headers
| Header | Value |
|---|---|
| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| `Content-Type` | `multipart/form-data; boundary=<boundary>` |
| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
The official app also sends a set of `x-app-*` / `x-device-*` headers and a `FahiPay-App/2.0.2 (...)` user agent. Thijooree sends `okhttp/4.12.0` like its other data calls.
### Body (`multipart/form-data`)
Thijooree builds it with lowercase `content-disposition` part headers, the way the app sends them (`FahipayForm.body`).
| Field | Example | Notes |
|---|---|---|
| `number` | `9198026` | 7-digit phone number |
| `amount` | `11` | MVR. Whole number for Raastas, Dhiraagu Reload and Dhiraagu Bill Pay. Ooredoo Bill Pay takes decimals (`10.1` seen) |
| `lang` | `en` | |
| `version` | `2.0.2` | App version |
| `build` | `329` | App build |
| `platform` | `app` | The official app sends `app`. Thijooree sends `thijooree` |
| `device[...]` | | The standard [device fields](README.md#common-form-fields-device-info) |
### Amount limits
These are enforced in Thijooree before sending (see [Transfer Flows](../thijooree/20-transfer-flows.md#amount-rules)):
| Service | Min | Max | Decimals |
|---|---|---|---|
| Raastas | 11 | none | no |
| Ooredoo Bill Pay | 10 | 50,000 | yes |
| Dhiraagu Reload | 8 | 1,000 | no |
| Dhiraagu Bill Pay | 10 | 5,000 | no |
The full `amount` is taken from the wallet. Raastas then has 8% GST taken out by Ooredoo, so the number is credited less than `amount`.
---
## curl Example
```bash
curl --request POST \
--url 'https://fahipay.mv/actions/payment/ooredoo/recharge/' \
--compressed \
--header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--form 'number=9198026' \
--form 'amount=11' \
--form 'lang=en' \
--form 'version=2.0.2' \
--form 'build=329' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]={model}' \
--form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \
--form 'device[serial]=unknown'
```
---
## Response
`200 OK`, `application/json`.
### Success: reload / Raastas
```json
{"title":"Success!","msg":"Transaction successful.","type":"success","tid":"FP202610021957143XKQ"}
```
### Success: Ooredoo Bill Pay
```json
{"title":"Success!","msg":"Transaction successful.","type":"success"}
```
### Success: Dhiraagu Bill Pay
```json
{"title":"Success!","msg":"Transaction will be processed shortly.","type":"success"}
```
| Field | Description |
|---|---|
| `type` | `success` when the payment went through |
| `title` / `msg` | Human-readable outcome |
| `tid` | Fahipay transaction ID. Only returned by the recharge endpoints. Bill pays have one too, but it's only visible in [history](05-history.md) |
### Failure
Not captured yet. Thijooree treats any `type` other than `success` as a refusal and shows `msg` (or `title`).
---
## In history
The payment shows up in [`actions/activity/`](05-history.md) straight away, with a negative `amount`:
```json
{
"date": "2026-10-02 19:57:14",
"name": "Ooredoo Raastas",
"details": "Mobile Recharge - 9198026",
"icon": "https://fahipay.mv/images/app/icons/services/oorch.png",
"transaction": "FP202610021957143XKQ",
"type": "payment",
"subtype": "OORCH",
"number": "9198026",
"amount": -11,
"success": 1,
"status": "Success"
}
```
`name` / `details` per service: `Ooredoo Raastas` / `Mobile Recharge - <number>`, `Ooredoo BillPay` / `BillPay - <number>`, `Dhiraagu Reload` / `Mobile Recharge - <number>`, `Dhiraagu BillPay` / `BillPay - <number>`.
---
&nbsp;
---
[← PayMV QR](08-paymv-qr.md)
+1
View File
@@ -128,6 +128,7 @@ Client Server
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
| 9 | [Payments](09-payments.md) | Dhiraagu reload / bill pay, Ooredoo Raastas / bill pay |
---
+1 -1
View File
@@ -129,4 +129,4 @@ Always fall back to the other provider's lookup if this API returns `custType: n
---
[← README](README.md)
[← README](README.md) · [Raastas →](02-raastas.md)
+107
View File
@@ -0,0 +1,107 @@
# Raastas (Quick Pay recharge, paid by BML card)
Recharge an Ooredoo prepaid number through the ooredoo.mv **Quick Pay** page. Ooredoo creates the
order and hands back a **BML Merchant Services transaction**, which is paid like any card-only
BML merchant link ([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
Reconstructed from `docs/ooredooapi/tmp/ooredoo_raastas_bml_card.har` (a Firefox HAR, which also
holds a rejected OTP and an insufficient-funds decline on the same transaction).
---
## Flow overview
```
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType PRE (Number Validation)
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl ──┐
│
── from here: the BML card-only merchant flow ── │
GET transaction.merchants…/<id> ←──────────────────────────────────────────────────┘
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
(200, auto-submits) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
→ /payment-status?order_id=<orderID>&statusDesc=sucess&status=1
```
Unlike Dhiraagu, there's no nonce or cart: one POST makes the order and the BML transaction.
---
## 1. Order
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `Accept` | `application/json` |
| `Origin` | `https://www.ooredoo.mv` |
```json
{"msisdn":"9609XXXXXX","purchaseAmount":"21.60","amountWithoutGst":"20",
"receiverMsisdn":"9609XXXXXX","transType":"recharge","serviceType":"prepaid",
"serviceTypeDisplayName":"Mobile"}
```
```json
{"status":"OK","msg":"Successfully generated order id","code":"2000",
"data":{"orderID":"36447930","purchaseAmount":"2160","hashSignature":"…",
"shortUrl":"https://pay.bml.com.mv/7A86qLZ1QV",
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac009f7bd9b264b80ba6abf", …}}
```
The 24-hex id at the end of `bmlUrl` is the transaction (`shortUrl` 301s to the same page). The
page is card-only, no BML Pay. The transaction's `localId` is the MSISDN, `customerReference` the
order id, and it expires after 7 days.
### GST
**Added on top**, not taken out: the number is credited `amountWithoutGst` and the card pays
`purchaseAmount = amount + toFixed2(amount × 8 / 100)` (MVR 20 → 21.60). The page's payment
method list gives `gstPercent: 8` for BML. This is the opposite of Raastas through Fahipay, where
GST comes out of the amount.
### Limits
Whole MVR, minimum **20** (before GST, so the card pays at least 21.60). The maximum isn't known;
the capture starts on the payment page.
---
## 2. Return to Ooredoo
`?wait=1` 302s to `https://my.ooredoo.mv/bml/response_new.php?transactionId=<id>&state=CONFIRMED&signature=<hash>`.
That page is a 200 that auto-submits:
```html
<body onload="document.forms['wtmpay'].submit()">
<form method="POST" action="https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml" name="wtmpay">
order_id=36447930 amount=21.60000000 msisdn=9609XXXXXX transtype=2
bml_transaction_id=<id> bml_hash=<hash> bml_response=CONFIRMED
error_code=0 payment_status=success ptype=bml
```
which ends on `https://www.ooredoo.mv/payment-status?order_id=36447930&statusDesc=sucess&status=1`
(the receipt: `totalAmount 21.6`, `amountWithoutGst 20`, `gstAmt 1.6`). The card flow submits the
form, see [BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
A declined attempt sends `?wait=1` to `transaction…/<id>?error=1` instead, and the same
transaction can be paid again.
---
## Cloudflare
`ooredoo.mv` and `my.ooredoo.mv` are behind Cloudflare. The capture carries a `cf_clearance`
cookie; okhttp from the phone gets through without one, as with
[Number Validation](01-number-validation.md).
---
&nbsp;
---
**Related:** [Number Validation](01-number-validation.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Number Validation](01-number-validation.md) · [Bill Pay →](03-bill-pay.md)
+65
View File
@@ -0,0 +1,65 @@
# Bill Pay (Quick Pay, paid by BML card)
Pay an Ooredoo postpaid bill through the ooredoo.mv **Quick Pay** bill-pay page. It is the same
single order call as [Raastas](02-raastas.md) with a different `transType` / `serviceType`, and
no GST. From the order on, it's the BML card-only merchant flow and the same return to Ooredoo.
Reconstructed from `docs/ooredooapi/tmp/ooredoo_billpay_bml_card.har` (a Firefox HAR).
---
## Flow overview
```
GET /bill-pay
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType POST (Number Validation)
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl
── BML card-only merchant flow ── → TRANSACTION_CONFIRMED
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
(200, auto-submits, transtype=1) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
→ /payment-status?statusDesc=sucess&status=1&order_id=<orderID>
```
The page doesn't look up the outstanding bill: the amount is whatever is typed.
---
## Order
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`, headers as in
[Raastas → Order](02-raastas.md#1-order).
```json
{"msisdn":"9609XXXXXX","purchaseAmount":"10.01","amountWithoutGst":"10.01",
"receiverMsisdn":"9609XXXXXX","transType":"billpay","serviceType":"Mobile",
"serviceTypeDisplayName":"Mobile"}
```
```json
{"status":"OK","msg":"Successfully generated order id","code":"2000",
"data":{"orderID":"36447962","purchaseAmount":"1001","shortUrl":"https://pay.bml.com.mv/…",
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac011e099f9d890856e2d33", …}}
```
| | Raastas | Bill Pay |
|---|---|---|
| `transType` | `recharge` | `billpay` |
| `serviceType` | `prepaid` | `Mobile` |
| `amountWithoutGst` | amount credited | = `purchaseAmount` |
| GST | 8% added on top | none |
| Return form `transtype` | `2` | `1` |
### Limits
Minimum **MVR 10**, decimals allowed (up to 2 places). The maximum isn't known.
---
&nbsp;
---
**Related:** [Number Validation](01-number-validation.md) · [Raastas](02-raastas.md) ·
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Raastas](02-raastas.md)
+2
View File
@@ -81,6 +81,8 @@ The API expects the full MSISDN including country code `960` (e.g. `9609654321`)
| # | File | Description |
|---|---|---|
| 1 | [Number Validation](01-number-validation.md) | Validate an Ooredoo number and determine account type |
| 2 | [Raastas](02-raastas.md) | Quick Pay recharge order → BML merchant transaction, paid by card |
| 3 | [Bill Pay](03-bill-pay.md) | Quick Pay bill payment order → BML merchant transaction, paid by card |
---
+39 -11
View File
@@ -1,6 +1,6 @@
# Transfer
The transfer screen initiates account-to-account fund transfers. It supports MIB, BML, and Fahipay as source banks and handles all bank-specific authentication and OTP steps.
The transfer screen initiates account-to-account fund transfers and phone payments. It supports MIB, BML, Fahipay and M-Faisa as sources and handles all bank-specific authentication and OTP steps. A phone number can also be paid as a carrier service (reload, Raastas, bill pay) from the Fahipay wallet or, for Dhiraagu Reload, a verified BML card.
---
@@ -38,11 +38,12 @@ A dropdown lists all visible accounts parsed via `AccountListParser.from(acc)?.b
## Recipient Entry
The user can specify a recipient in three ways:
The user can specify a recipient in these ways:
1. **Manual entry** — type an account number directly
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact
1. **Manual entry** — type an account number or phone number directly
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact. A Fahipay favourite opens as its payout service straight away
3. **QR scan** — launches [QrScannerActivity](25-qr-scanner.md); a PayMV QR result pre-fills the account number, amount, and remarks; a BML ebanking / pay.bml URL switches the form into [BML QR merchant payment](20-transfer-flows.md#bml-qr-merchant-payment-flow) mode
4. **BML Merchant Services transaction ID or link** — paid through BML Pay (QR flow) or, for card-only merchants, a verified card ([Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md))
---
@@ -64,10 +65,22 @@ After the user finishes entering a recipient account number, the app calls the s
- **MIB**: account name lookup via MIB API
- **BML**: beneficiary lookup via BML API
- **Fahipay**: account name resolution via Fahipay API
- **Fahipay**: phone numbers only — the Dhiraagu / Ooredoo carrier lookup decides which payout services apply
The resolved name is displayed below the account number field for the user to confirm.
### Phone numbers — Transfer Type picker
A phone number searched with no source yet (or from a BML card that can pay by card) is looked up every way it can be paid, in parallel: Favara (MIB / BML), and the carrier lookup when the user has a Fahipay wallet or a verified BML card. Each result is a **transfer type**:
| Type | Example | Pays from |
|---|---|---|
| Favara Transfer | bank account behind the number | MIB or BML account |
| Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet |
| Card service | Dhiraagu Reload, Dhiraagu Bill Pay, Raastas, Ooredoo Bill Pay (BML badge) | Verified BML card |
One option is applied straight away; with more, a picker opens and Send stays disabled until one is chosen. Picking a type also picks a source that can pay it. Fahipay and card services clear and disable the Remarks field and apply their own amount rules (minimum, maximum, whole amounts, 8% GST note). Details: [Transfer Flows → Transfer Type picker](20-transfer-flows.md#transfer-type-picker).
---
## Biometric Gate
@@ -100,18 +113,33 @@ When the source is a BML USD account and the destination is a MIB account but no
5. Re-submits with OTP
6. On success, shows `TransferReceiptFragment`
### Fahipay Transfer
### Fahipay Payout (reload, Raastas, bill pay)
1. Validates fields
2. (If biometric gate) prompts biometrics
3. Submits via Fahipay API using stored `authID` + session cookie
4. On success, shows `TransferReceiptFragment`
1. Checks the amount against the picked service's rules
2. Confirm dialog (with the GST note for Raastas), then the biometric gate if enabled
3. One POST to the service's Fahipay payment endpoint ([Fahipay API → Payments](../fahipayapi/09-payments.md))
4. On success, the result shows inside the dialog (no receipt page yet), then the form clears
See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source).
### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay, Ooredoo Raastas / Bill Pay)
1. Checks the amount against the carrier's rules (Dhiraagu reload: MVR 20–1000, whole amounts, 8% GST included; Dhiraagu bill pay: from MVR 1, up to 2 decimals, no GST; Raastas: from MVR 20, whole amounts, 8% GST added on top; Ooredoo bill pay: from MVR 10, up to 2 decimals, no GST)
2. A "Processing..." dialog shows while the carrier creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md), [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md))
3. From there it is the card-only merchant flow: the same confirm dialog and warning, biometric gate, card + 3-D Secure payment, and the return to the carrier (`?wait=1`) that tops the number up or posts the bill payment. A decline (e.g. insufficient funds) or a rejected token code ends it with the bank's message
4. On success, the result shows inside the dialog; if Dhiraagu couldn't be notified, a toast gives the BML transaction id
See [Transfer Flows → Carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card).
### BML Merchant Payment (QR / card-only link)
A BML QR, or a BML Merchant Services link whose merchant takes BML Pay, is paid from a BML card through the QR flow. A card-only merchant link is paid with a verified card (Pomelo + 3-D Secure), followed by the return to the merchant. Neither saves a receipt. See [Transfer Flows → BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) and [Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md).
---
## Transfer Receipt
On success the fragment navigates to `TransferReceiptFragment` passing the completed transfer details.
On success of a bank transfer (MIB, BML, M-Faisa) the fragment navigates to `TransferReceiptFragment` passing the completed transfer details. Fahipay payouts, card services and merchant payments show their result inside the confirm dialog instead.
---
+173 -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,44 @@ 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 |
| `Card(service, ownerName, cards)` | the service's label, e.g. Dhiraagu Reload | `CardPayoutService.iconRes`, with a BML badge | One of `cards`: verified BML cards that can pay by card (see [Carrier services by BML card](#carrier-services-by-bml-card)) |
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.
- **Card:** the default card, if it's one of the type's cards. Otherwise the first of them.
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** (`CarrierLookup.query`, see Fahipay source below). Only runs when the user has a Fahipay wallet or a BML card that can pay by card. One lookup feeds both.
Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services, then the card services.
The same lookup runs when the source is already a BML card that can pay by card and a phone number is searched. 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 +92,129 @@ 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.applyServiceContact`). 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.
#### Sending
`initiateTransfer` hands a Fahipay source to `FahipayTransferHandler.submit()`. The flow:
1. **Confirm dialog.** From is the wallet. To is the recipient name, the number and the service's `destinationLabel` (e.g. "Ooredoo · Raastas"). For Raastas, the GST line ("Recipient receives MVR X after 8% GST") is shown as a warning.
2. **Biometric gate**, as for every transfer.
3. **Payment.** `FahipayPaymentClient.pay()` POSTs to the service's `paymentPath` (see [Fahipay Payments](../fahipayapi/09-payments.md)). The amount is sent without trailing zeros (`11`, `10.1`).
4. **Result.** On success, the result shows inside the dialog (no receipt page yet), then OK clears the form and refreshes balances. A refusal closes the dialog and toasts the server's `msg`. A network failure shows the no-internet message.
#### 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.
### Carrier services by BML card
A carrier service can also be paid with a verified BML card, through the carrier's own website
and its BML merchant gateway, instead of the Fahipay wallet: **Dhiraagu Reload**, **Dhiraagu
Bill Pay**, **Ooredoo Raastas** and **Ooredoo Bill Pay** (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`).
**Which cards.** A card qualifies when it's verified and its BML login has an OTP seed, the same
rule as card-only merchant links (`BmlVerifiedCards`, see
[Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md)). The
type remembers those cards (`TransferType.Card.cards`). Picking a card that isn't one of them
drops the pick, like any other source that can't pay the picked type.
**Which services.**
| Carrier result | Service |
|---|---|
| Dhiraagu `RELOAD` | Dhiraagu Reload |
| Dhiraagu `BILL_PAY` | Dhiraagu Bill Pay |
| Ooredoo `PRE` or `HYBRID` | Raastas |
| Ooredoo `POST` or `HYBRID` | Ooredoo Bill Pay |
**Amount rules.** The carrier website's, not Fahipay's. They're checked the same way, through
the shared `PayoutAmountField`:
| Service | Min (MVR) | Max (MVR) | Decimals | GST |
|---|---|---|---|---|
| Dhiraagu Reload | 20 | 1,000 | no | 8%, included (credit = amount − round2(amount × 0.08 / 1.08)) |
| Dhiraagu Bill Pay | 1 | none | up to 2 places | none |
| Raastas | 20 | none | no | 8%, **added** (charged = amount + round2(amount × 0.08)) |
| Ooredoo Bill Pay | 10 | none | up to 2 places | none |
Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. The Ooredoo maximums
aren't known.
Raastas by card is the one service where GST is added on top (`PayoutService.gstAdded`): the
number is credited what's typed, the card pays more, and the note under the amount says what's
paid ("You pay MVR 21.60 with 8% GST"). The order check in step 2 below compares against
`chargedWithGst`.
**Reference.** None. The field is cleared and disabled, as for the Fahipay services.
**Recents.** As with Fahipay services, the recent is saved with the service's category
(`CardPayoutService.contactCategory`: `CARD_DHIRAAGU_RELOAD`, `CARD_DHIRAAGU_BILL`,
`CARD_RAASTAS`, `CARD_OOREDOO_BILL`). Picking it again applies that service with no lookup, so
the default card (or another payable card) is selected rather than the default account
(`TransferFragment.applyServiceContact`). With no payable card left, it toasts and stops. A number
keeps one recent, so paying it another way replaces the category.
**Sending.** The only part that differs from paying a card-only BML merchant link is where the
BML transaction comes from:
1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount
(`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`,
`OoredooPaymentClient.createRaastasTransaction` / `createBillPayTransaction`, see
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)
and [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md)).
Bill pay looks the number up again first, for the billing account the order is made out to.
That takes a few round trips, so the payment's "Processing..." box shows meanwhile
(`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens.
2. Its payment page is loaded (`BmlMerchantTxnClient.fetchPayPage`). If it doesn't take cards, or
its amount isn't the one typed, the payment stops with a toast.
3. The page goes to `BmlTransferHandler.confirmCardMerchant`, so from here it's the merchant-link
card flow: the same confirm dialog and warning, biometric gate, Pomelo + 3-D Secure payment,
and success / failure handling.
Nothing is charged before the confirm dialog. A cancelled confirm leaves an unpaid Dhiraagu order,
which expires on its own.
### BML source
@@ -121,6 +280,13 @@ Source: Fahipay
FAHIPAY_TRANSFER, RAASTAS, OOREDOO_BILL, DHIRAAGU_RELOAD, DHIRAAGU_BILL
```
```
Transfer type: Card (verified BML card)
└── Carrier creates a BML merchant transaction → card-only merchant flow
DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS, OOREDOO_BILL
```
---
## Rejected Combinations
@@ -155,7 +321,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 +413,9 @@ 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))
- For a carrier service by card, the amount meets that service's rules (see [Carrier services by BML card](#carrier-services-by-bml-card))
- No connectivity error for `NO_INTERNET` or for the source bank
---
@@ -7,7 +7,9 @@ Two linked features:
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)).
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). The same flow pays
[carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card) (Dhiraagu
Reload and Bill Pay, Ooredoo Raastas and Bill Pay), once the carrier has created the transaction.
> ⚠️ 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
@@ -106,23 +108,28 @@ into the Transfer screen rather than a one-off dialog, mirroring the BML QR merc
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.
OTP seed for (`BmlVerifiedCards.payable` / `isPayable`, `ui/home/transfer/BmlVerifiedCards.kt`) —
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:
`submitCardPayment` → `confirmCardMerchant` shows the shared transfer confirm dialog
(biometric-gated), then `executeCardMerchant` runs, off the main thread.
`CardPayoutTransferHandler` calls `confirmCardMerchant` directly with the page of the
transaction a carrier created:
```
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
where `card` and `otpSeed` come from `BmlVerifiedCards.load` — the `VerifiedCardStore` entry
(expiry split `MM/YY` → month/year) and 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.
automatically, retrying once if the first code expired. Once BML confirms, it loads
`<id>?wait=1` and follows the redirects to the merchant, which is how the merchant learns it was
paid (Dhiraagu doesn't top up without it). Outcome is shown in the shared processing/success
dialog; failures surface as a toast. If the merchant couldn't be reached, success also toasts
the merchant name and BML transaction id (`bml_card_pay_merchant_not_notified`).
### Key assumption
@@ -145,6 +152,8 @@ another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
| `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/transfer/BmlVerifiedCards.kt` | Which cards can pay by card; loads their details |
| `ui/home/transfer/CardPayoutTransferHandler.kt` | Carrier services by card: carrier creates the transaction, then `confirmCardMerchant` |
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
---
+3 -3
View File
@@ -15,7 +15,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [04 — Accounts](04-accounts.md) | Account list grouped display, AccountsAdapter, profile images, quick-transfer shortcut |
| [05 — Account History](05-account-history.md) | Paginated transaction history, search, infinite scroll |
| [06 — Transfer History](06-transfer-history.md) | Multi-bank merged transfer history, parallel loading |
| [07 — Transfer](07-transfer.md) | Recipient lookup, MIB/BML/Fahipay transfer flows, QR, biometric gate, BML OTP |
| [07 — Transfer](07-transfer.md) | Recipient lookup, transfer type picker, MIB/BML/Fahipay transfers, Fahipay payouts, Dhiraagu Reload by BML card, QR, biometric gate, BML OTP |
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
@@ -34,7 +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 |
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment (links and carrier services), return to merchant |
## Reference
@@ -42,5 +42,5 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|---|---|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type picker, Fahipay services, carrier services by BML card, routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
@@ -0,0 +1,11 @@
- updated dhivehi transaltions (thank you @quillfires)
- improved fahipay support
- new UI to select transfer type (Favara, Reload, Raastas, Billpay)
- Ooredoo Raastas via Fahipay
- Ooredo Billpay via Fahipay
- Dhiraagu reload via Fahipay
- Dhiraagu billpay via Fahipay
- Ooredoo Raastas via BML verified cards
- Ooredoo Billpay via BML verified cards
- Dhiraagu Reload via BML verifed cards
- Dhiraagu billpay via BML verified cards