Compare commits

...
15 Commits
Author SHA1 Message Date
shihaam 992c8d4364 release v1.0.24
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m19s
2026-09-21 15:26:40 +05:00
shihaam 80fd238195 fix BML POS QRs 2026-09-21 15:26:18 +05:00
shihaam 0357d7e0bc release v1.0.23
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m28s
2026-09-21 08:16:39 +05:00
shihaam 71dd654edc optimize mfaisa QR, fix bug that fails to switch to default account after ooredoo QR is cancelled and new BML QR is scanned
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 08:15:23 +05:00
shihaam a75d8e420a split mfaisa qr pay
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-21 07:56:46 +05:00
shihaam ca6ecc4283 split fahipay transfer
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 07:41:36 +05:00
shihaam f59c2be6c4 split mib transfer
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 07:26:54 +05:00
shihaam eb019a20a0 attempt to fix QR scanner
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-21 07:16:47 +05:00
shihaam c356762baa split do bml tranfers 2026-09-21 07:13:59 +05:00
shihaam 255f43db24 release v1.0.22
Auto Tag on Version Change / check-version (push) Successful in 6s
Build and Release APK / build (push) Failing after 19m22s
2026-06-27 19:44:36 +05:00
shihaam 01cae559cf update card image mappgin
Auto Tag on Version Change / check-version (push) Failing after 11m59s
2026-06-27 19:43:03 +05:00
shihaam 015919a4ac disable paymvQR for mfaisa accounts 2026-06-27 19:42:52 +05:00
shihaam 93a7c8bbde fix bug that flashes bottom bar before share sheet shows up
Auto Tag on Version Change / check-version (push) Failing after 12m1s
2026-06-27 19:22:59 +05:00
shihaam 8f4672f269 fix Transfer slip share from the share button #42 2026-06-27 19:20:24 +05:00
shihaam 00e6b40ee0 update docs
Auto Tag on Version Change / check-version (push) Failing after 14m24s
2026-06-27 18:35:38 +05:00
29 changed files with 2027 additions and 1267 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 22 versionCode = 23
versionName = "1.0.21" versionName = "1.0.24"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
/**
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
*/
internal fun bmlApiRequest(session: BmlSession, url: String): Request = internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url) Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}") .header("accept", "application/json")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION) .header("x-app-version", BML_APP_VERSION)
.header("user-agent", BML_USER_AGENT)
.header("authorization", "Bearer ${session.accessToken}")
.build() .build()
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
val currency: String val currency: String
) )
/**
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
* parse failures stay plain exceptions so the generic message is used for those instead.
*/
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
data class BmlQrPayResult( data class BmlQrPayResult(
val success: Boolean, val success: Boolean,
val merchant: String = "", val merchant: String = "",
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import android.util.Base64
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@@ -10,17 +11,27 @@ class BmlQrPayClient {
private val client = newBmlApiClient() private val client = newBmlApiClient()
/** /**
* Resolves a BML QR URL to merchant details. * Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
* [base64Url] is the full QR URL Base64-encoded (standard, with padding). * `35` → `20` → `01` reference.
*
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
* resolves too, so this is parity rather than a requirement.
*/ */
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo { fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
val key = Base64.encodeToString(
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
val request = bmlApiRequest(session, val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url") "$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response") val body = response.body?.string() ?: throw Exception("No response")
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
if (!body.trimStart().startsWith("{"))
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
val json = JSONObject(body) val json = JSONObject(body)
if (!json.optBoolean("success")) if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" }) throw BmlQrPayLookupException(
json.optInt("code"),
json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload") val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim() val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim() val addr3 = payload.optString("narrative3").trim()
@@ -5,7 +5,6 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
} }
private fun lookupMerchant(qrUrl: String) { private fun lookupMerchant(qrUrl: String) {
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: run { val session = app.anyBmlSession() ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) } try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
catch (_: Exception) { null } catch (_: Exception) { null }
} }
if (_binding == null) return@launch if (_binding == null) return@launch
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
) )
} else { } else {
val qr = PaymvQrParser.parse(raw) val qr = PaymvQrParser.parse(raw)
@@ -528,9 +528,9 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) { private fun routeSharedQrText(text: String) {
val store = CredentialStore(this) val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text) val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber())) navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
return return
} }
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text) val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
@@ -83,6 +83,7 @@ class PayMvQrFragment : Fragment() {
val eligible = accounts.filter { val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" && it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
it.bank != "MIB" && // TODO: MIB does not support PayMV QR it.bank != "MIB" && // TODO: MIB does not support PayMV QR
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA !(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
} }
val adapter = QrAccountAdapter(requireContext(), eligible) val adapter = QrAccountAdapter(requireContext(), eligible)
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
) )
} else { } else {
val qr = PaymvQrParser.parse(raw) val qr = PaymvQrParser.parse(raw)
File diff suppressed because it is too large Load Diff
@@ -11,17 +11,13 @@ import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.PixelCopy
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
@@ -159,9 +155,6 @@ class TransferReceiptFragment : Fragment() {
} }
}) })
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack()
}
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener { view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
shareReceipt() shareReceipt()
} }
@@ -377,21 +370,19 @@ class TransferReceiptFragment : Fragment() {
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
/** /**
* Captures the receipt card using PixelCopy, which correctly handles * Draws the receipt card to an offscreen bitmap at its natural (unscaled)
* hardware-accelerated views (avoids the black-square problem with view.draw()). * dimensions, so the captured image isn't affected by the on-screen scale
* applied to fit small viewports and doesn't pick up overlapping siblings.
*/ */
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) { private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
val view = _receiptCard ?: run { callback(null); return } val view = _receiptCard ?: run { callback(null); return }
if (view.width == 0 || view.height == 0) { callback(null); return } if (view.width == 0 || view.height == 0) { callback(null); return }
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val location = IntArray(2) val canvas = Canvas(bitmap)
view.getLocationInWindow(location) canvas.drawColor(Color.WHITE)
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height) view.draw(canvas)
callback(bitmap)
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
}, Handler(Looper.getMainLooper()))
} }
private fun formatBmlTimestamp(raw: String): String { private fun formatBmlTimestamp(raw: String): String {
@@ -494,13 +485,9 @@ class TransferReceiptFragment : Fragment() {
(activity as? HomeActivity)?.setBottomNavVisible(false) (activity as? HomeActivity)?.setBottomNavVisible(false)
} }
override fun onPause() {
super.onPause()
(activity as? HomeActivity)?.setBottomNavVisible(true)
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
(activity as? HomeActivity)?.setBottomNavVisible(true)
_receiptCard = null _receiptCard = null
pendingToAvatarBitmap = null pendingToAvatarBitmap = null
} }
@@ -0,0 +1,739 @@
package sh.sar.basedbank.ui.home.transfer
import android.graphics.Bitmap
import android.view.Gravity
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
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.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
import sh.sar.basedbank.api.bml.BmlQrPayResult
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlTransferClient
import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
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.ui.home.TransferReceiptData
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
/**
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
*
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
* BML-specific.
*
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
* the view is recreated.
*/
class BmlTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
/** Reads the fragment's currently-selected source account. */
private val currentSource: () -> BankAccount?,
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
private val selectSource: (BankAccount) -> Unit,
/** Hook called whenever handler state changes in a way that affects the Send button. */
private val onStateChanged: () -> Unit,
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
) {
private val app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext()
private val host get() = fragment.activity as? HomeActivity
// ─── State ───────────────────────────────────────────────────────────────
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
private var otpState = OtpState.NONE
private var otpChannel: String? = null
private data class PendingTransfer(
val src: BankAccount,
val debitAccount: String,
val creditAccount: String,
val amount: Double,
val amountStr: String,
val remarks: String,
val transferType: String,
val currency: String,
val bank: String?,
val destDisplay: String,
val destAccount: String,
val toBank: String,
val toAvatar: Bitmap?
)
private var pendingTransfer: PendingTransfer? = null
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
var qrInfo: BmlQrPayInfo? = null
private set
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
private var gatewayQr = false
/** Prevents re-running the lookup after the user clears the merchant. */
var qrLookupAttempted = false
private set
// ─── Public API the fragment calls ───────────────────────────────────────
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
val hasQrMerchant: Boolean get() = qrInfo != null
fun sessionFor(account: BankAccount?): BmlSession? =
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
fun isBusinessProfile(account: BankAccount): Boolean {
val loginId = account.loginTag.removePrefix("bml_")
val profiles = app.bmlProfilesMap[loginId] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
/**
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
* resolve it, so the caller can fall back to the MIB IPS lookup.
*
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
* endpoint leaves `currency` blank — the caller enriches it via
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
*/
fun validateDestination(
session: BmlSession,
accountNumber: String,
verifyAsMib: Boolean
): MibIpsAccountInfo? {
val result = try {
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
else BmlValidateClient().validateAccount(session, accountNumber)
} catch (_: Exception) { null } ?: return null
val bankId = when (result.trnType) {
"IAT" -> "MALBMVMV"
else -> result.agnt ?: result.account
}
return MibIpsAccountInfo(
accountName = result.name,
accountNumber = result.account,
bankId = bankId,
currency = result.currency
)
}
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
fun clearQrMerchant() {
if (qrInfo == null) return
qrInfo = null
gatewayQr = false
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
fun clearState() {
otpState = OtpState.NONE
otpChannel = null
pendingTransfer = null
}
// ─── Merchant QR ─────────────────────────────────────────────────────────
fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
}
host?.setRefreshing(false)
val info = result.getOrNull()
if (info == null) {
// An expired or rejected QR is BML telling us something specific — show its own
// wording and stay put with the To row restored, rather than bouncing the user out
// of the screen they just scanned from.
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
?: ctx.getString(R.string.bml_qr_lookup_failed)
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
onStateChanged()
return@launch
}
qrInfo = info
if (info.amount == 0.0) {
RecentsCache.save(ctx, RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
// Auto-select the user's default BML card if no card was pre-selected
if (currentSource() == null) {
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultCard = allAccounts.firstOrNull {
it.accountNumber == defaultNum && isCard(it) &&
it.statusDesc.equals("Active", ignoreCase = true)
}
if (defaultCard != null) selectSource(defaultCard)
}
}
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill amount if dynamic QR
if (info.amount > 0.0) {
binding.etAmount.setText("%.2f".format(info.amount))
binding.tilAmount.isEnabled = false
}
// Remarks not applicable for merchant QR payments
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
}
}
/**
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
* reports the outcome inside it — there is no receipt screen for merchant payments.
*/
fun submitQrPayment() {
val info = qrInfo ?: return
val src = currentSource() ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
val amount = amountStr.toDoubleOrNull()
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
binding.tilAmount.error = null
val debitAccount = src.internalId.ifBlank {
Toast.makeText(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = info.currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = info.merchantName,
toNumber = "",
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
}
)
}
private fun executeQrPayment(
src: BankAccount,
debitAccount: String,
info: BmlQrPayInfo,
amount: Double,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) {
val loginId = src.loginTag.removePrefix("bml_")
val session = sessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
if (gatewayQr) {
val preOk = BmlQrPayClient().preInitiatePayment(
session, debitAccount, info.requestId, amount, info.currency)
if (!preOk) return@withContext null
}
val initiated = BmlQrPayClient().initiatePayment(
session, debitAccount, info.requestId, amount, info.currency)
if (!initiated) return@withContext null
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
?.otpSeed?.let { Totp.generate(it) } ?: otp
BmlQrPayClient().confirmPayment(
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
} catch (e: Exception) {
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
}
}
if (fragment.view == null) return@launch
if (result == null) {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
if (result.success) {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = result.currency.ifBlank { info.currency },
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
fromName = src.accountBriefName,
toName = result.merchant.ifBlank { info.merchantName }
) {
fragment.clearForm()
host?.triggerRefresh()
}
} else {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
/**
* Runs initiate + confirm on the calling (IO) thread and returns
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
*/
fun doTransfer(
src: BankAccount,
destAccount: String,
destDisplay: String,
amount: Double,
amountStr: String,
remarks: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<BankAccount>,
allContacts: List<BankContact>
): Triple<Boolean, String, TransferReceiptData?> {
val loginId = src.loginTag.removePrefix("bml_")
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank {
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
}
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
?: return Triple(false, "BML contact not found for this account", null)
val (transferType, creditAccount, bank) = routed
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
// Step 1: initiate
val initiated = try {
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
// Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?: otp
return try {
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val receipt = TransferReceiptData(
bank = "BML",
amount = "%.2f".format(amount),
currency = currency,
fromLabel = src.accountBriefName,
fromColorHex = "#0066A1",
toLabel = destDisplay.ifBlank { destAccount },
toAccount = destAccount,
toBank = toBank,
remarks = remarks,
bmlFromName = src.accountBriefName,
bmlReference = result.reference,
bmlTimestamp = result.timestamp,
bmlMessage = result.message
)
val time = result.timestamp.take(19).replace("T", " ")
Triple(true, "Reference: ${result.reference}\n$time", receipt)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} catch (e: Exception) {
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
}
}
// ─── Business profile OTP flow ───────────────────────────────────────────
fun startBusinessOtpFlow(
src: BankAccount,
destAccount: String,
destDisplay: String,
amount: Double,
amountStr: String,
remarks: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<BankAccount>,
allContacts: List<BankContact>,
toAvatar: Bitmap?
) {
val debitAccount = src.internalId.ifBlank {
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
return
}
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
if (routed == null) {
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
return
}
val (transferType, creditAccount, bank) = routed
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
pendingTransfer = PendingTransfer(
src = src,
debitAccount = debitAccount,
creditAccount = creditAccount,
amount = amount,
amountStr = amountStr,
remarks = remarks,
transferType = transferType,
currency = currency,
bank = bank,
destDisplay = destDisplay,
destAccount = destAccount,
toBank = toBank,
toAvatar = toAvatar
)
otpState = OtpState.SELECTING_CHANNEL
binding.btnTransfer.isEnabled = false
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val sess = sessionFor(src)
val channels = if (sess != null) {
withContext(Dispatchers.IO) {
try { BmlAccountClient().fetchTransferChannels(sess) }
catch (_: Exception) { emptyList() }
}
} else emptyList<BmlOtpChannel>()
host?.setRefreshing(false)
if (channels.isEmpty()) {
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
resetOtpState()
onStateChanged()
return@launch
}
showChannelSelection(channels)
}
}
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
val dp = ctx.resources.displayMetrics.density
binding.containerBmlChannels.removeAllViews()
for (channel in channels) {
val iconRes = when (channel.channel) {
"email" -> R.drawable.ic_channel_email
"mobile" -> R.drawable.ic_channel_sms
else -> R.drawable.ic_channel_sms
}
val iconSize = (24 * dp).toInt()
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = (12 * dp).toInt()
}
}
textCol.addView(TextView(ctx).apply {
text = channel.description
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
text = channel.masked
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
setPadding(hp, vp, hp, vp)
}
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
LinearLayout.LayoutParams(iconSize, iconSize))
row.addView(textCol)
row.setOnClickListener { selectOtpChannel(channel) }
binding.containerBmlChannels.addView(row)
}
disableTransferFields()
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
}
private fun selectOtpChannel(channel: BmlOtpChannel) {
otpChannel = channel.channel
binding.layoutBmlChannelSelection.visibility = View.GONE
val pending = pendingTransfer ?: return
val sess = sessionFor(pending.src) ?: run {
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
resetOtpState()
onStateChanged()
return
}
binding.btnTransfer.isEnabled = false
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val initiated = withContext(Dispatchers.IO) {
try {
BmlTransferClient().initiateTransfer(
sess, pending.debitAccount, pending.creditAccount,
pending.amount, pending.transferType, pending.currency,
pending.bank, channel.channel
)
} catch (_: Exception) { false }
}
host?.setRefreshing(false)
if (!initiated) {
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
resetOtpState()
onStateChanged()
return@launch
}
otpState = OtpState.AWAITING_OTP
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
binding.tilBmlOtp.visibility = View.VISIBLE
binding.etBmlOtp.requestFocus()
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
binding.btnTransfer.isEnabled = true
}
}
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
fun verifyOtp() {
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
if (otp.isEmpty()) {
binding.tilBmlOtp.error = "Enter the verification code"
return
}
binding.tilBmlOtp.error = null
val pending = pendingTransfer ?: return
val channel = otpChannel ?: return
val sess = sessionFor(pending.src) ?: run {
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
return
}
binding.btnTransfer.isEnabled = false
host?.setRefreshing(true)
val capturedToAvatar = pending.toAvatar
fragment.viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
try {
val result = BmlTransferClient().confirmTransfer(
sess, pending.debitAccount, pending.creditAccount,
pending.amount, pending.transferType, pending.currency,
otp, pending.remarks, pending.bank, channel
)
if (result.success) {
val r = TransferReceiptData(
bank = "BML",
amount = "%.2f".format(pending.amount),
currency = pending.currency,
fromLabel = pending.src.accountBriefName,
fromColorHex = "#0066A1",
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
toAccount = pending.destAccount,
toBank = pending.toBank,
remarks = pending.remarks,
bmlFromName = pending.src.accountBriefName,
bmlReference = result.reference,
bmlTimestamp = result.timestamp,
bmlMessage = result.message
)
Triple(true, "", r)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
}
} catch (e: Exception) {
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
}
}
host?.setRefreshing(false)
if (ok && receipt != null) {
resetOtpState()
onTransferSuccess(receipt, capturedToAvatar)
} else {
binding.btnTransfer.isEnabled = true
if (msg == "CONNECTIVITY") {
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
} else {
binding.tilBmlOtp.error = msg
}
}
}
}
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
fun resetOtpState() {
otpState = OtpState.NONE
otpChannel = null
pendingTransfer = null
if (fragment.view == null) return
binding.layoutBmlChannelSelection.visibility = View.GONE
binding.tvBmlOtpSentVia.visibility = View.GONE
binding.tilBmlOtp.visibility = View.GONE
binding.etBmlOtp.setText("")
binding.tilBmlOtp.error = null
enableTransferFields()
binding.btnTransfer.text = ctx.getString(R.string.transfer)
}
private fun disableTransferFields() {
binding.tilAmount.isEnabled = false
binding.tilRemarks.isEnabled = false
binding.cardFromInfo.alpha = 0.5f
binding.btnClearFromInfo.isEnabled = false
binding.cardToInfo.alpha = 0.5f
binding.btnClearToInfo.isEnabled = false
}
private fun enableTransferFields() {
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.cardFromInfo.alpha = 1f
binding.btnClearFromInfo.isEnabled = true
binding.cardToInfo.alpha = 1f
binding.btnClearToInfo.isEnabled = true
}
// ─── Shared routing ──────────────────────────────────────────────────────
/**
* Picks the BML transfer type and the credit-account identifier the API expects.
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
* callers report as "BML contact not found".
*/
private fun routeTransfer(
destAccount: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<BankAccount>,
allContacts: List<BankContact>
): Triple<String, String, String?>? {
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
return when {
isSrcCard -> {
// CAD: card → own BML account
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
}
isDestMyCard -> {
// CPA: BML CASA → own card top-up
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
}
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
isDestMib -> {
// USD DOT: requires BML contact numeric ID
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
?: return null
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
}
else -> Triple("IAT", destAccount, null)
}
}
private fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
}
@@ -0,0 +1,198 @@
package sh.sar.basedbank.ui.home.transfer
import android.view.View
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.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
/**
* 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.
*
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
* exhaustive `when`s over this enum will point at every site that needs updating.
*/
enum class FahipayService(val label: String, val destinationLabel: String) {
RAASTAS("Raastas", "Ooredoo · Raastas"),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
}
/**
* 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.
*
* 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.
*
* 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.
*/
class FahipayTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
) {
private val ctx get() = fragment.requireContext()
/** The service picked for the current recipient; null until a lookup resolves one. */
var service: FahipayService? = null
private set
/** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
// ─── Public API the fragment calls ───────────────────────────────────────
/**
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
* else is rejected inline on the "To" field.
*/
fun lookupRecipient(rawInput: String) {
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
return
}
lookupCarrier(rawInput)
}
/** Clears the selected service and hides the chip picker. */
fun clearState() {
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)
}
}
/**
* 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.
*/
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)
}
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)
}
// ─── Service picker ──────────────────────────────────────────────────────
private fun showServiceChips(
services: List<FahipayService>,
number: String,
dhiraaguName: String?
) {
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
binding.layoutServiceSelector.visibility = View.VISIBLE
binding.chipGroupService.clearCheck()
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
// resolve their display name from saved contacts instead.
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
}
private fun bindChip(
chip: com.google.android.material.chip.Chip,
picked: FahipayService,
number: String,
ownerName: String?
) {
chip.setOnCheckedChangeListener { _, checked ->
if (checked) {
selectService(picked, number, ownerName)
binding.layoutServiceSelector.visibility = View.INVISIBLE
}
}
}
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
service = picked
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${picked.label} · $number",
colorHex = "#FF6B00",
imageHash = null
)
}
}
@@ -8,6 +8,7 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -22,6 +23,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
@@ -37,16 +39,25 @@ import sh.sar.basedbank.util.RecentsCache
/** /**
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup, * Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
* initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment] * initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source. * (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
* an M-Faisa merchant QR is loaded.
*
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
* and the form state; the handler keeps everything M-Faisa-specific.
* *
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for * Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated. * [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
*/ */
class MfaisaTransferHandler( class MfaisaTransferHandler(
private val fragment: Fragment, private val fragment: TransferFragment,
private val binding: FragmentTransferBinding, private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel, private val viewModel: HomeViewModel,
/** Reads the fragment's currently-selected source account. */
private val currentSource: () -> BankAccount?,
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
private val selectSource: (BankAccount) -> Unit,
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */ /** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
private val onRecipientChanged: () -> Unit, private val onRecipientChanged: () -> Unit,
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */ /** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
@@ -55,11 +66,16 @@ class MfaisaTransferHandler(
private val app get() = fragment.requireActivity().application as BasedBankApp private val app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext() private val ctx get() = fragment.requireContext()
private val host get() = fragment.activity as? HomeActivity
/** Set to the resolved recipient after a successful search; null otherwise. */ /** Set to the resolved recipient after a successful search; null otherwise. */
var recipient: MfaisaTransferClient.Recipient? = null var recipient: MfaisaTransferClient.Recipient? = null
private set private set
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
private set
private var lookupInFlight = false private var lookupInFlight = false
// ─── Public API the fragment calls ─────────────────────────────────────── // ─── Public API the fragment calls ───────────────────────────────────────
@@ -80,7 +96,7 @@ class MfaisaTransferHandler(
} }
binding.tilTo.error = null binding.tilTo.error = null
val source = currentSource() ?: return val source = mfaisaSource() ?: return
val session = app.mfaisaSessionFor(source) ?: run { val session = app.mfaisaSessionFor(source) ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return return
@@ -122,7 +138,7 @@ class MfaisaTransferHandler(
/** Triggered when the user taps the Send button (and source bank is MFAISA). */ /** Triggered when the user taps the Send button (and source bank is MFAISA). */
fun submit() { fun submit() {
val source = currentSource() ?: return val source = mfaisaSource() ?: return
val r = recipient ?: run { val r = recipient ?: run {
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show() Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
return return
@@ -153,20 +169,232 @@ class MfaisaTransferHandler(
} }
} }
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
val hasQrMerchant: Boolean get() = qrInfo != null
/** Called when the source account changes away from M-Faisa (or the view tears down). */ /** Called when the source account changes away from M-Faisa (or the view tears down). */
fun clearState() { fun clearState() {
recipient = null recipient = null
lookupInFlight = false lookupInFlight = false
} }
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
fun clearQrMerchant() {
if (qrInfo == null) return
qrInfo = null
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
// ─── Merchant QR ─────────────────────────────────────────────────────────
/**
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
* the amount if the QR is dynamic.
*/
fun lookupQrMerchant(qrCodeId: String) {
val source = mfaisaSource() ?: run {
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
return
}
val session = app.mfaisaSessionFor(source) ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
if (currentSource()?.bank != "MFAISA") selectSource(source)
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val merchant = withContext(Dispatchers.IO) {
try {
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
?: return@withContext null
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
catch (_: Exception) { null }
} catch (_: Exception) { null }
}
host?.setRefreshing(false)
if (merchant == null) {
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
return@launch
}
qrInfo = merchant
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
if (merchant.txnAmount.isNullOrBlank()) {
RecentsCache.save(ctx, RecentPick(
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
displayName = merchant.merchantName,
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
colorHex = "#ED1C24",
imageHash = null,
isProfileImage = false,
bank = "MFAISA"
))
}
// Show merchant in the "To" card — clear button is the only way to back out
binding.tvToAccountName.text = merchant.merchantName
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill + lock amount if the QR is dynamic
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
if (dynamicAmount != null && dynamicAmount > 0.0) {
binding.etAmount.setText("%.2f".format(dynamicAmount))
binding.tilAmount.isEnabled = false
}
onRecipientChanged()
}
}
/**
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
*/
fun submitQrPayment() {
val merchant = qrInfo ?: return
val src = currentSource() ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
if (src.bank != "MFAISA") {
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
return
}
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
val amount = amountStr.toDoubleOrNull()
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
val amountValue = "%.2f".format(amount)
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = merchant.currencyCode,
amountValue = amountValue,
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = "M-Faisa",
toName = merchant.merchantName,
toNumber = merchant.merchantMsisdn,
toDetail = "Ooredoo M-Faisa merchant"
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${merchant.currencyCode} $amountValue${merchant.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeQrPayment(src, merchant, amountValue, remarks, dialog)
}
)
}
private fun executeQrPayment(
src: BankAccount,
merchant: MfaisaQrPayClient.QrMerchant,
amountStr: String,
remarks: String,
dialog: AlertDialog
) {
val loginId = src.loginTag.removePrefix("mfaisa_")
val initialSession = app.mfaisaSessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
val sourceMdn = "960${initialSession.msisdn}"
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val outcome = withContext(Dispatchers.IO) {
val client = MfaisaQrPayClient()
try {
val refId = try {
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(loginId)
?: throw IllegalStateException("Could not refresh M-Faisa session")
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
}
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
try {
client.confirmPurchase(confirmSession, refId)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(loginId)
?: throw IllegalStateException("Could not refresh M-Faisa session")
client.confirmPurchase(fresh, refId)
}
Result.success(refId)
} catch (e: Exception) {
Result.failure<String>(e)
}
}
if (fragment.view == null) return@launch
outcome.fold(
onSuccess = { _ ->
val receipt = TransferReceiptData(
bank = "MFAISA",
amount = amountStr,
currency = merchant.currencyCode,
fromLabel = src.accountBriefName,
fromColorHex = "#ED1C24",
toLabel = merchant.merchantName,
toAccount = merchant.merchantMsisdn,
toBank = "Ooredoo M-Faisa",
remarks = remarks,
mfaisaTransactionType = "Merchant payment",
mfaisaFromName = src.accountBriefName,
mfaisaFromMsisdn = src.accountNumber,
mfaisaToMsisdn = merchant.merchantMsisdn,
mfaisaTimestamp = System.currentTimeMillis()
)
dialog.dismiss()
onTransferSuccess(receipt, null)
},
onFailure = { e ->
dialog.dismiss()
binding.btnTransfer.isEnabled = true
showError(e, fallback = "Payment failed")
}
)
}
}
// ─── Internal ──────────────────────────────────────────────────────────── // ─── Internal ────────────────────────────────────────────────────────────
private fun currentSource(): BankAccount? = /**
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() } * The M-Faisa account to act on: the selected source when it is one, otherwise the first
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable * M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
* the source away from another bank.
private fun sourceAccountNumberFromCard(): String = */
binding.tvFromAccountNumber.text?.toString().orEmpty() private fun mfaisaSource(): BankAccount? =
currentSource()?.takeIf { it.bank == "MFAISA" }
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) { private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the // Reuse the same recipient card the fragment uses for other banks. The fragment owns the
@@ -241,7 +469,6 @@ class MfaisaTransferHandler(
refId: String, refId: String,
errorMsg: String? errorMsg: String?
) { ) {
val tf = fragment as? TransferFragment ?: return
val view = fragment.view ?: return val view = fragment.view ?: return
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val colorMuted = MaterialColors.getColor( val colorMuted = MaterialColors.getColor(
@@ -250,7 +477,7 @@ class MfaisaTransferHandler(
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY) view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr } val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
val confirmView = tf.buildTransferConfirmView( val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR", amountCurrency = "MVR",
amountValue = amountValue, amountValue = amountValue,
fromName = source.accountBriefName, fromName = source.accountBriefName,
@@ -415,11 +642,11 @@ class MfaisaTransferHandler(
} }
} }
private fun showError(e: Exception) { private fun showError(e: Throwable, fallback: String = "Transfer failed") {
val msg = when { val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet) e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!! !e.message.isNullOrBlank() -> e.message!!
else -> "Transfer failed" else -> fallback
} }
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show() Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
} }
@@ -0,0 +1,173 @@
package sh.sar.basedbank.ui.home.transfer
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.fragment.app.Fragment
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibTransferClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.ui.home.TransferReceiptData
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
/**
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
* avatar API that every bank's "To" card borrows.
*
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
* paints nothing — every method is either a session accessor or a blocking API call meant for
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
* the view updates, so this handler can outlive a view recreation.
*/
class MibTransferHandler(
private val fragment: Fragment,
/** Reads the fragment's currently-selected source account. */
private val currentSource: () -> BankAccount?,
) {
private val app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext()
/** The selected source's MIB session, falling back to any logged-in MIB session. */
val session: MibSession?
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
/** Any logged-in MIB session, ignoring which source is selected. */
val anySession: MibSession? get() = app.anyMibSession()
// ─── Destination lookup ──────────────────────────────────────────────────
/** Either a resolved destination or the message to show the user — never both. */
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
/**
* IPS lookup for a destination account/alias. Blocking — call from IO.
* A missing MIB session is reported as "account not found", same as a failed lookup.
*/
fun lookupDestination(accountNumber: String): LookupOutcome {
val sess = session ?: return LookupOutcome(null, notFound())
return try {
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
} catch (e: MibLookupException) {
LookupOutcome(null, e.message)
} catch (_: Exception) {
LookupOutcome(null, notFound())
}
}
/**
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
*/
fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return ""
return try { MibTransferClient().lookup(sess, accountNumber).currency }
catch (_: Exception) { "" }
}
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
// ─── Avatars ─────────────────────────────────────────────────────────────
/**
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
* Blocking — call from IO. Returns null on any failure, including a missing image.
*
* The session is passed in rather than read from [session] because callers differ: the "To"
* card uses the selected source's session, the account dropdown uses [anySession].
*/
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash)
} else {
MibContactsClient().fetchProfileImageBase64(session, hash)
}
if (base64 == null) null else {
val bytes = Base64.decode(base64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
} catch (_: Exception) { null }
// ─── Transfer ────────────────────────────────────────────────────────────
/**
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
* "CONNECTIVITY" when the network was unreachable.
*/
fun doTransfer(
src: BankAccount,
destAccount: String,
destName: String,
destDisplay: String,
amount: String,
remarks: String,
bankName: String
): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val loginId = src.loginTag.removePrefix("mib_")
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR"
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
val bankNo = if (isDestMib) 2 else 3
val toBank = when {
isDestMib -> "MIB"
else -> when (bankName.uppercase()) {
"MALBMVMV" -> "BML"
"MADVMVMV" -> "MIB"
else -> bankName.ifBlank { "LOCAL" }
}
}
return try {
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val result = MibTransferClient().transfer(
session = sess,
fromAccount = src.accountNumber,
toAccount = destAccount,
amount = amount,
currencyCode = currencyCode,
benefName = destName.ifBlank { "Recipient" },
bankNo = bankNo,
purpose = remarks,
otp = otp
)
if (result.success) {
val receipt = TransferReceiptData(
bank = "MIB",
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency,
fromLabel = src.accountBriefName,
fromColorHex = "#FE860E",
fromProfileImageHash = src.profileImageHash,
toLabel = destDisplay.ifBlank { destName },
toAccount = destAccount,
toBank = toBank,
remarks = remarks,
mibReferenceNo = result.trxId,
mibTransactionDate = result.date
)
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} catch (e: Exception) {
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
}
}
}
@@ -9,22 +9,58 @@ data class PaymvQrData(
object PaymvQrParser { object PaymvQrParser {
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
/** /**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null. * Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR). *
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01) * Three shapes exist:
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes. * - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
* `35` → `20` → `01`;
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
*
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
* that follow it.
*/ */
fun extractBmlGatewayUrl(raw: String): String? { fun bmlQrPayTarget(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
return try { val ref = bmlQrReference(raw) ?: return null
val root = parseTlv(raw) if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null // A bare reference only means a BML POS QR when the supplementary domain says so; anything
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null // else keeps falling through to the PayMV parse, as it did before POS QRs existed.
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") } return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
} catch (_: Exception) { }
null
} /** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
private fun supplementaryDomain(raw: String): String? = try {
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
} catch (_: Exception) {
null
}
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
private fun bmlQrReference(raw: String): String? = try {
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
inner?.get("01")?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
/**
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
* "Unsupported payment link").
*/
fun bmlPayRequestKey(target: String): String {
if (target.startsWith("https://")) return target
return bmlQrReference(target) ?: target
} }
fun parse(raw: String): PaymvQrData? { fun parse(raw: String): PaymvQrData? {
@@ -36,9 +36,9 @@ object BmlCardParser {
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png" "C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
"C8101" -> "cards/bml/master_masveriyaa.png" "C8101" -> "cards/bml/master_masveriyaa.png"
"C8102" -> "cards/bml/master_odiveriyaa.png" "C8102" -> "cards/bml/master_odiveriyaa.png"
"C8010", "C8011" -> "cards/bml/master_platinum.png" "C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
"C8040", "C8044" -> "cards/bml/master_world.png" "C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png" "C8030" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png" "C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png" "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png" "C8905", "C8995" -> "cards/bml/visa_credit.png"
@@ -246,18 +246,10 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginStart="4dp"
android:text="Save" android:text="Save"
app:icon="@drawable/ic_save" /> app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Done" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -376,18 +376,10 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginStart="4dp"
android:text="Save" android:text="Save"
app:icon="@drawable/ic_save" /> app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Done" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -273,18 +273,10 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginStart="4dp"
android:text="Save" android:text="Save"
app:icon="@drawable/ic_save" /> app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Done" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+2 -2
View File
@@ -190,9 +190,9 @@ Known asset mappings:
|---|---|---| |---|---|---|
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` | | `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` | | `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` | | `C8010`, `C8011`, `C8033` | Mastercard Platinum | `master_platinum` |
| `C8020`, `C8022` | Mastercard Gold | `master_gold` | | `C8020`, `C8022` | Mastercard Gold | `master_gold` |
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` | | `C8030` | Mastercard Business Debit | `master_business_debit` |
| `C8040`, `C8044` | Mastercard World | `master_world` | | `C8040`, `C8044` | Mastercard World | `master_world` |
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` | | `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` | | `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
+31 -1
View File
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL. The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
### 3. POS QR (`mv.com.bml.qtr`)
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
`mv.com.bml.qtr`. The same TLV path (`35``20``01`) holds a bare reference such as
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
understands it. See
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
--- ---
## PayMV QR Format (TLV) ## PayMV QR Format (TLV)
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url} GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
``` ```
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding). `{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35``20``01` reference.
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
rather than relying on either being required.
### Headers ### Headers
| Header | Value | | Header | Value |
|---|---| |---|---|
| `accept` | `application/json`**required in practice**, see below |
| `Authorization` | `Bearer <access_token>` | | `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` | | `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` | | `x-app-version` | `2.1.44.348` |
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
```bash ```bash
curl --request GET \ curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \ --url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
--header 'accept: application/json' \
--header 'Authorization: Bearer <access_token>' \ --header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \ --header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' --header 'x-app-version: 2.1.44.348'
@@ -116,6 +136,16 @@ curl --request GET \
| `amount` | Payment amount (`"0.00"` for static QRS) | | `amount` | Payment amount (`"0.00"` for static QRS) |
| `currency` | Currency code (typically `"MVR"`) | | `currency` | Currency code (typically `"MVR"`) |
### Failure Responses
`success: false` comes back with a code and a user-facing message; the client shows BML's own
wording and keeps the user on the Transfer screen.
| Code | Message |
|---|---|
| `103` | The payment request has expired |
| `112` | Unsupported payment link |
--- ---
## Step 2 — Pay (3-Step TOTP Flow) ## Step 2 — Pay (3-Step TOTP Flow)
+1 -1
View File
@@ -279,4 +279,4 @@ python tmp/mfaisa_transfer.py <myMsisdn> <myMpin> <recipientMsisdn>
--- ---
> **← Back to** [Transaction History](03-history.md) | [README](README.md) > **← Back to** [Transaction History](03-history.md) | [README](README.md) | **Next →** [QR Merchant Payment](05-qr-pay.md)
+265
View File
@@ -0,0 +1,265 @@
# QR Merchant Payment ("Smart Pay")
Pay an Ooredoo M-Faisa merchant by scanning their QR. The QR encodes only a numeric `qrCodeId` (e.g. `1594103440350`) — no URL, no EMV TLV envelope. The flow is three calls and **does not require OTP** (`2FARequired=NONE`).
> **Currency / pocket constraints:** captures cover MVR→MVR purchases from the user's EMONEY pocket only. The `transactionCurrency` field is taken from the QR lookup; we have not seen a non-MVR variant.
---
## Flow
```
Client Server
| |
| POST /QRCodeUtility/fetchQRCodeById | ← user scanned a QR
| formData = { qrCodeId, tenantCode } |
|------------------------------------------------>|
| [{ success, response:[{ commercialName, |
| customerId, mobileNumber, currencyCode, |
| txnAmount, status, ... }] }] |
|<------------------------------------------------|
| |
| (show merchant, accept amount + remarks)
| |
| POST /initiateNewBuy |
| formData = { merchantId, mobileNumber, |
| sourceDetails, transactionAmount, |
| transactionType:"PURCHASE", … } |
|------------------------------------------------>|
| [{ 2FARequired:"NONE", |
| authenticationType:"NONE", |
| success:true, |
| response:[{ responseObject:{ referenceId, |
| chargeDetails, … } }] }] |
|<------------------------------------------------|
| |
| (no OTP — go straight to confirm)
| |
| POST /confirmNewBuy |
| formData = { referenceId } |
| transactionAuthDetails = "null" ← literal |
|------------------------------------------------>|
| [{ success:true, |
| message:"Payment Completed Successfully", |
| response:[{ responseObject:{ isCompleted, |
| balanceInquiryDTO, ... } }] }] |
|<------------------------------------------------|
```
All three endpoints carry the standard anti-replay pair (`rndValue` + `csValue`) derived from each request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
Unlike the transfer flow, **every endpoint here returns its envelope as a JSON array** `[{...}]` for both success and error.
---
## Step 1: `QRCodeUtility/fetchQRCodeById` — resolve merchant
### Endpoint
```
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/QRCodeUtility/fetchQRCodeById
```
### Request
**Content-Type:** `application/x-www-form-urlencoded`
| Field | Value |
|---|---|
| `role` | **`R01`** (the other two endpoints use `RETAIL_SUBSCRIBER` — this one does not) |
| `channel` | `C03` |
| `loginExchangeKey` | From login |
| `rndValue` / `csValue` | [Standard anti-replay](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
| `formData` | JSON below ([html-safe `=` escaping](01-encryption.md#html-safe-gson--escape)) |
```json
{
"qrCodeId": "<numeric id from QR>",
"tenantCode": "ooredoo"
}
```
### Response — happy path
```json
[
{
"success": true,
"message": "QRCode fetched Successfully.",
"response": [
{
"mobileNumber": "9609569506", /* merchant's '960' + msisdn */
"customerId": "72518", /* used as merchantId in step 2 */
"commercialName": "Family Room", /* merchant display name */
"qrCodeId": "1594103440350",
"qrImageString": "<base64 PNG>", /* unused */
"accountNumber": null,
"txnAmount": null, /* static QR; dynamic QRs put a number here */
"currencyCode": "MVR",
"status": "Active",
"role": "AGENT",
"tenantCode": "ooredoo",
"...": "..."
}
]
}
]
```
`accountNumber` / `txnAmount` are JSON `null` for **static QRs** (the user chooses the amount). For **dynamic QRs** the server returns the fixed amount in `txnAmount` and the client should lock the amount field.
The `mobileNumber` field already includes the `960` country prefix.
### Response — QR not found / inactive
```json
[{ "success": false, "message": "QRCode not found." }]
```
The client also rejects entries with `status != "Active"`.
---
## Step 2: `initiateNewBuy` — initiate purchase (no OTP triggered)
### Endpoint
```
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateNewBuy
```
### Request
**Content-Type:** `application/x-www-form-urlencoded`
| Field | Value |
|---|---|
| `role` | `RETAIL_SUBSCRIBER` |
| `channel` | `C03` (top-level differs from inner `formData.channel`, which is `SubscriberApp`) |
| `loginExchangeKey` | From login |
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
| `formData` | JSON below |
```json
{
"channel": "SubscriberApp",
"commodityType": "WALLET",
"description": "<remarks>", /* free text, may be empty */
"merchantId": "<customerId from step 1>",
"mobileNumber": "<merchant '960' msisdn from step 1>",
"sourceDetails": {
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
"actorRoleType": "RETAIL_SUBSCRIBER",
"pocketId": "<my source pocket id>" /* EMONEY pocket from login */
},
"transactionAmount": "<amount>", /* string, e.g. "7.56" */
"transactionCurrency": "MVR",
"transactionType": "PURCHASE"
}
```
Unlike the transfer flow's `initiateFTRequest`, this endpoint does **not** take an `identifier` header field or `tPin`.
### Response — happy path
```json
[
{
"2FARequired": "NONE", /* ← the key difference */
"authenticationType": "NONE",
"success": true,
"message": "Purchase Initiated Successfully",
"response": [
{
"requestObject": { "...": "..." },
"responseObject": {
"referenceId": "685011023630",
"transactionAmount": { "amount": 7.56, "currencyCode": "MVR" },
"netAmount": { "amount": 7.56, "currencyCode": "MVR" },
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
"isCompleted": false,
"authenticationType":"NONE",
"...": "..."
}
}
]
}
]
```
Cache `referenceId` for step 3. No SMS is sent — proceed straight to confirm.
> **Defensive check:** the Thijooree client throws if it ever sees `2FARequired != "NONE"` on this endpoint so a no-op confirm can't silently complete. If Ooredoo ever turns 2FA on for QR pay, you'll see a clear error instead of a partial transaction.
---
## Step 3: `confirmNewBuy` — settle purchase
### Endpoint
```
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmNewBuy
```
### Request
**Content-Type:** `application/x-www-form-urlencoded`
| Field | Value |
|---|---|
| `role` | `RETAIL_SUBSCRIBER` |
| `channel` | `C03` |
| `loginExchangeKey` | From login |
| `rndValue` / `csValue` | Anti-replay derived from `formData` below |
| `formData` | `{"referenceId": "<from step 2>"}` |
| `transactionAuthDetails` | **literal string `"null"`** (not a JSON null, not an empty string — the captured request sends the four-character string `null`) |
### Response — happy path
```json
[
{
"success": true,
"message": "Payment Completed Successfully",
"response": [
{
"responseObject": {
"isCompleted": true,
"balanceInquiryDTO": {
"currencyCode": "MVR",
"pocketAmount": 92.85,
"pocketId": "<source pocket id>",
"pocketBalanceMap": { "...": "..." }
},
"status": { "replyCode": 0.0 },
"...": "..."
}
}
]
}
]
```
### Session expiry
Same envelope as elsewhere — `attributeValue: "SESSION_EXPIRED"` with HTTP 200; the client throws `MfaisaSessionExpiredException`. See [03-history.md → Session expiry](03-history.md#session-expiry).
---
## Optional: `save/smart-pay-recipient` — bookkeeping
After a successful confirm the official Ooredoo app saves the merchant to a server-side "recent recipients" list:
```
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/save/smart-pay-recipient
```
This is **not** required for the payment itself — the transfer is final once `confirmNewBuy` succeeds. Thijooree skips it; the merchant is kept in the local picker `RecentsCache` under an `mfaisaqr:<qrCodeId>` synthetic accountNumber (mirroring the BML QR `bmlqr:` scheme).
---
&nbsp;
---
> **← Back to** [Transfer Money](04-transfer.md) | [README](README.md)
+1
View File
@@ -88,6 +88,7 @@ Client Server
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login | | 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
| 3 | [Transaction History](03-history.md) | Paginated history per session | | 3 | [Transaction History](03-history.md) | Paginated history per session |
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm | | 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
| 5 | [QR Merchant Payment](05-qr-pay.md) | Three-step "smart pay" scan-to-merchant: QR lookup → initiate → confirm. **No OTP** (`2FARequired=NONE`) |
--- ---
+3 -1
View File
@@ -2,7 +2,9 @@
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md). > **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve``channel: token``otp`). The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve``channel: token``otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
--- ---
+63 -1
View File
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `00` | Format indicator | Always `"01"` | | `00` | Format indicator | Always `"01"` |
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR | | `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
| `26` | Merchant account information | Container — see sub-tags below | | `26` | Merchant account information | Container — see sub-tags below |
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only | | `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
| `52` | Merchant category code | `"0000"` (generic) | | `52` | Merchant category code | `"0000"` (generic) |
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) | | `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs | | `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
| `58` | Country code | `"MV"` | | `58` | Country code | `"MV"` |
| `59` | Merchant / recipient name | Max 25 characters | | `59` | Merchant / recipient name | Max 25 characters |
| `60` | Merchant city / store code | BML POS QRs only |
| `62` | Additional data field | Container — see sub-tags below | | `62` | Additional data field | Container — see sub-tags below |
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last | | `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
| `80` | Supplementary data | Container — timestamp and domain | | `80` | Supplementary data | Container — timestamp and domain |
@@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly. Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
---
## BML POS QR (`mv.com.bml.qtr`)
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
(tag `80``00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
number to transfer to. The same `35``20` container is used as in combined QRs, but sub-tag `01`
holds a bare reference instead of a URL.
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
```
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
```
| TLV path | Value | Notes |
|---|---|---|
| `00` | `01` | Format indicator |
| `01` | `12` | Dynamic QR |
| `35``20``00` | `02` | Version / format of the container (combined QRs use the URL form) |
| `35``20``01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
| `35``20``02` | `9809724081` | Merchant identifier (10 digits) |
| `35``20``03` | `74009538` | Terminal identifier (8 digits) |
| `52` | `0000` | MCC |
| `53` | `462` | MVR |
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
| `62``05` | `dtyams497d` | Reference / bill number |
| `62``08` | `POPE` | Purpose / terminal label |
| `80``00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
| `80``01` | `2026-09-21T13:33:22.00000` | Timestamp |
### Pay-Request Lookup Key
The lookup key is the **bare reference**, Base64-encoded without padding:
```
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
```
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
carry is `accept: application/json` — see
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
than with merchant details — but `103` means the reference itself resolved):
| Key tried | Response |
|---|---|
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
exactly one request is made either way.
--- ---
## Example Payload ## Example Payload
+15 -8
View File
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
| `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)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown | | `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 | | `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount | | `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load | | `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
--- ---
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
## BML QR Merchant Payment Flow ## BML QR Merchant Payment Flow
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner. Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
Two sub-modes: Three sub-modes:
| Mode | Trigger | Extra step | | Mode | `bmlQrPayTarget()` returns | Extra step |
|---|---|---| |---|---|---|
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None | | Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate | | Gateway QR | the QR text, or the URL at TLV `35``20``01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
| POS QR | the whole EMV payload, for QRs whose tag `80``00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35``20``01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
Flow: Flow:
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row. 1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field. 2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
3. Remarks field is locked (not applicable for merchant payments). 3. Remarks field is locked (not applicable for merchant payments).
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP. 4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen. 5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
--- ---
## Transfer Button Enable Conditions ## Transfer Button Enable Conditions
@@ -238,7 +245,7 @@ Flow:
The transfer button is only enabled when all of the following are true: The transfer button is only enabled when all of the following are true:
- A source account is selected - A source account is selected
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set) - A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
- Amount is greater than `0` - Amount is greater than `0`
- No connectivity error for `NO_INTERNET` or for the source bank - No connectivity error for `NO_INTERNET` or for the source bank
+2 -2
View File
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
| Caller | Result handling | | Caller | Result handling |
|---|---| |---|---|
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` | | [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly | | [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs | | `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast | | [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card | | [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
### Share-to-Scan Fast Path ### Share-to-Scan Fast Path