split do bml tranfers

This commit is contained in:
2026-09-21 07:13:59 +05:00
parent 255f43db24
commit c356762baa
2 changed files with 752 additions and 626 deletions
@@ -38,17 +38,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlTransferClient
import sh.sar.basedbank.api.bml.BmlTransferResult
import sh.sar.basedbank.api.bml.BmlValidateClient import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException import sh.sar.basedbank.api.mib.MibLookupException
@@ -57,6 +50,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
@@ -79,9 +73,7 @@ class TransferFragment : Fragment() {
private val session get() = selectedAccount private val session get() = selectedAccount
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) } ?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyMibSession() ?: (requireActivity().application as BasedBankApp).anyMibSession()
private fun bmlSessionFor(account: BankAccount?) = private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession()
// Resolved recipient info — set after successful lookup or prefill // Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = "" private var resolvedAccountNumber = ""
@@ -102,39 +94,41 @@ class TransferFragment : Fragment() {
private var savedToColorHex = "#607D8B" private var savedToColorHex = "#607D8B"
private var savedToImageHash: String? = null private var savedToImageHash: String? = null
// BML QR merchant payment mode (set when navigated from a card QR scan)
private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant
// M-Faisa QR merchant payment mode (set when the scanned QR is an M-Faisa qrCodeId) // M-Faisa QR merchant payment mode (set when the scanned QR is an M-Faisa qrCodeId)
private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
// BML business profile OTP flow state
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
private var bmlOtpState = BmlOtpState.NONE
private var bmlOtpChannel: String? = null
private data class PendingBmlTransfer(
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 pendingBmlTransfer: PendingBmlTransfer? = null
private var accountDropdownAdapter: AccountDropdownAdapter? = null private var accountDropdownAdapter: AccountDropdownAdapter? = null
/**
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
* flow and merchant QR pay. Created lazily on first use and dropped in [onDestroyView]
* because it captures the view binding.
*/
private var bmlHandler: BmlTransferHandler? = null
private fun bmlHandler(): BmlTransferHandler =
bmlHandler ?: BmlTransferHandler(
fragment = this,
binding = binding,
viewModel = viewModel,
currentSource = { selectedAccount },
selectSource = { account ->
selectedAccount = account
updateAmountPrefix(account)
showFromCard(account)
updateTransferButton()
},
onStateChanged = { updateTransferButton() },
onTransferSuccess = { receipt, avatar ->
ReceiptStore.save(requireContext(), receipt)
clearForm()
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
}
).also { bmlHandler = it }
/** Lazy: created the first time the user selects an MFAISA source account. */ /** Lazy: created the first time the user selects an MFAISA source account. */
private var mfaisaHandler: MfaisaTransferHandler? = null private var mfaisaHandler: MfaisaTransferHandler? = null
private fun mfaisaHandler(): MfaisaTransferHandler = private fun mfaisaHandler(): MfaisaTransferHandler =
@@ -305,7 +299,7 @@ class TransferFragment : Fragment() {
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
if (accountNumber.startsWith("bmlqr:")) { if (accountNumber.startsWith("bmlqr:")) {
lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:")) bmlHandler().lookupQrMerchant(accountNumber.removePrefix("bmlqr:"))
return@setFragmentResultListener return@setFragmentResultListener
} }
if (accountNumber.startsWith("mfaisaqr:")) { if (accountNumber.startsWith("mfaisaqr:")) {
@@ -353,7 +347,7 @@ class TransferFragment : Fragment() {
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
binding.btnTransfer.setOnClickListener { binding.btnTransfer.setOnClickListener {
if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp() if (bmlHandler().isAwaitingOtp) bmlHandler().verifyOtp()
else initiateTransfer() else initiateTransfer()
} }
@@ -372,7 +366,7 @@ class TransferFragment : Fragment() {
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { lookupBmlQrMerchant(it) } arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
@@ -406,85 +400,6 @@ class TransferFragment : Fragment() {
} }
} }
private fun lookupBmlQrMerchant(qrUrl: String) {
bmlQrLookupAttempted = true
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp
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
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
catch (_: Exception) { null }
}
(activity as? HomeActivity)?.setRefreshing(false)
if (info == null) {
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
requireActivity().onBackPressedDispatcher.onBackPressed()
return@launch
}
bmlQrInfo = info
if (info.amount == 0.0) {
RecentsCache.save(requireContext(), 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 (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultCard = allAccounts.firstOrNull {
it.accountNumber == defaultNum &&
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") &&
it.statusDesc.equals("Active", ignoreCase = true)
}
if (defaultCard != null) {
selectedAccount = defaultCard
updateAmountPrefix(defaultCard)
showFromCard(defaultCard)
updateTransferButton()
}
}
}
// 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(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
updateTransferButton()
}
}
/** /**
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an * 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 * M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
@@ -617,7 +532,7 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ -> binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
if (bmlQrInfo != null) { if (bmlHandler().hasQrMerchant) {
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT" val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
if (!isCard) { if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
@@ -664,9 +579,9 @@ class TransferFragment : Fragment() {
// On a cold start (e.g. share intent), anyBmlSession() may be null when // On a cold start (e.g. share intent), anyBmlSession() may be null when
// onViewCreated runs. Retry the lookup once sessions are available. // onViewCreated runs. Retry the lookup once sessions are available.
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL) val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
if (pendingBmlQrUrl != null && !bmlQrLookupAttempted) { if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl) if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
} }
// Re-render the from card when the view is recreated on a cached instance // Re-render the from card when the view is recreated on a cached instance
@@ -878,14 +793,7 @@ class TransferFragment : Fragment() {
} }
binding.btnClearToInfo.setOnClickListener { binding.btnClearToInfo.setOnClickListener {
if (bmlQrInfo != null) { bmlHandler().clearQrMerchant()
bmlQrInfo = null
bmlGatewayQr = false
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
if (mfaisaQrInfo != null) { if (mfaisaQrInfo != null) {
mfaisaQrInfo = null mfaisaQrInfo = null
binding.tilAmount.isEnabled = true binding.tilAmount.isEnabled = true
@@ -1265,7 +1173,7 @@ class TransferFragment : Fragment() {
// ── Transfer ────────────────────────────────────────────────────────────── // ── Transfer ──────────────────────────────────────────────────────────────
/** Shared confirm dialog with optional biometric gate, used for both transfers and QR payments. */ /** Shared confirm dialog with optional biometric gate, used for both transfers and QR payments. */
private fun showConfirmWithBiometric( internal fun showConfirmWithBiometric(
title: String, title: String,
message: String? = null, message: String? = null,
customView: android.view.View? = null, customView: android.view.View? = null,
@@ -1367,41 +1275,8 @@ class TransferFragment : Fragment() {
} }
// BML QR merchant payment — uses shared confirm dialog, no receipt // BML QR merchant payment — uses shared confirm dialog, no receipt
bmlQrInfo?.let { info -> if (bmlHandler().hasQrMerchant) {
val src = selectedAccount ?: run { bmlHandler().submitQrPayment()
Toast.makeText(requireContext(), 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(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
val qrFromTypeLabel = AccountListParser.from(src)?.typeLabel
?: BmlDashboardParser.productLabel(src.accountTypeName)
val qrFromDetail = listOfNotNull("BML", qrFromTypeLabel.ifBlank { null }).joinToString(" · ")
val qrConfirmView = buildTransferConfirmView(
amountCurrency = info.currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = qrFromDetail,
toName = info.merchantName,
toNumber = "",
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
)
showConfirmWithBiometric(
title = getString(R.string.transfer),
customView = qrConfirmView,
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { dialog, frame ->
showProcessingInDialog(dialog, frame)
executeBmlQrPayment(src, debitAccount, info, amount, dialog, frame)
}
)
return return
} }
@@ -1423,7 +1298,7 @@ class TransferFragment : Fragment() {
val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.bank == "BML" val isSrcBml = src.bank == "BML"
val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml` val isBmlBusiness = isSrcBml && bmlHandler().isBusinessProfile(src) // to test on personal accounts: use `isSrcBml`
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" || src.profileType == "BML_DEBIT" val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" || src.profileType == "BML_DEBIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
@@ -1478,7 +1353,7 @@ class TransferFragment : Fragment() {
if (isBmlBusiness) { if (isBmlBusiness) {
// Business profile: async OTP channel selection flow — dismiss dialog first // Business profile: async OTP channel selection flow — dismiss dialog first
dialog.dismiss() dialog.dismiss()
startBmlBusinessOtpFlow( bmlHandler().startBusinessOtpFlow(
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
) )
@@ -1490,7 +1365,7 @@ class TransferFragment : Fragment() {
if (!isSrcBml) { if (!isSrcBml) {
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
} else { } else {
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts) bmlHandler().doTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
} }
} }
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
@@ -1689,73 +1564,6 @@ class TransferFragment : Fragment() {
} }
} }
private fun executeBmlQrPayment(
src: BankAccount,
debitAccount: String,
info: BmlQrPayInfo,
amount: Double,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) {
val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("bml_")
val session = bmlSessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: run { dialog.dismiss(); Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
binding.btnTransfer.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
if (bmlGatewayQr) {
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(requireContext()).loadBmlCredentials(loginId)
?.otpSeed?.let { Totp.generate(it) } ?: otp
BmlQrPayClient().confirmPayment(
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
} catch (e: Exception) {
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
}
}
if (_binding == null) return@launch
if (result == null) {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
if (result.success) {
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 }
) {
clearForm()
(activity as? HomeActivity)?.triggerRefresh()
}
} else {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
private fun executeMfaisaQrPayment( private fun executeMfaisaQrPayment(
src: BankAccount, src: BankAccount,
merchant: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant, merchant: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant,
@@ -1843,7 +1651,7 @@ class TransferFragment : Fragment() {
} }
} }
private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) { internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
dialog.setCancelable(false) dialog.setCancelable(false)
@@ -1877,7 +1685,7 @@ class TransferFragment : Fragment() {
}) })
} }
private fun showSuccessInDialog( internal fun showSuccessInDialog(
dialog: AlertDialog, dialog: AlertDialog,
frame: android.widget.FrameLayout, frame: android.widget.FrameLayout,
amountCurrency: String, amountCurrency: String,
@@ -2053,388 +1861,10 @@ class TransferFragment : Fragment() {
} }
} }
private fun doBmlTransfer(
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 = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank {
return Triple(false, getString(R.string.transfer_missing_internal_id), null)
}
// Determine type + credit account
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
val (transferType, creditAccount, bank) = 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 as String?)
}
isDestMyCard -> {
// CPA: BML CASA → own card top-up
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
}
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 Triple(false, "BML contact not found for this account", null)
Triple("DOT", contact.benefNo.removePrefix("bml_"), null as String?)
}
else -> Triple("IAT", destAccount, null as String?)
}
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(requireContext()).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)
}
}
// ── BML business profile OTP flow ─────────────────────────────────────────
private fun isBusinessProfile(account: BankAccount): Boolean {
val app = requireActivity().application as BasedBankApp
val loginId = account.loginTag.removePrefix("bml_")
val profiles = app.bmlProfilesMap[loginId] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
private fun startBmlBusinessOtpFlow(
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(requireContext(), getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
return
}
val isDestMyCard = allAccounts.any {
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
}
val (transferType, creditAccount, bank) = when {
isSrcCard -> {
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null as String?)
}
isDestMyCard -> {
val card = allAccounts.first {
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
}
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
}
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
isDestMib -> {
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
if (contact == null) {
Toast.makeText(requireContext(), "BML contact not found for this account", Toast.LENGTH_SHORT).show()
return
}
Triple("DOT", contact.benefNo.removePrefix("bml_"), null as String?)
}
else -> Triple("IAT", destAccount, null as String?)
}
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
pendingBmlTransfer = PendingBmlTransfer(
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
)
bmlOtpState = BmlOtpState.SELECTING_CHANNEL
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val sess = bmlSessionFor(src)
val channels = if (sess != null) {
withContext(Dispatchers.IO) {
try { BmlAccountClient().fetchTransferChannels(sess) }
catch (_: Exception) { emptyList() }
}
} else emptyList<BmlOtpChannel>()
(activity as? HomeActivity)?.setRefreshing(false)
if (channels.isEmpty()) {
Toast.makeText(requireContext(), "Could not load OTP channels", Toast.LENGTH_SHORT).show()
resetBmlOtpState()
updateTransferButton()
return@launch
}
showBmlChannelSelection(channels)
}
}
private fun showBmlChannelSelection(channels: List<BmlOtpChannel>) {
val ctx = requireContext()
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 { selectBmlOtpChannel(channel) }
binding.containerBmlChannels.addView(row)
}
disableTransferFields()
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
}
private fun selectBmlOtpChannel(channel: BmlOtpChannel) {
bmlOtpChannel = channel.channel
binding.layoutBmlChannelSelection.visibility = View.GONE
val pending = pendingBmlTransfer ?: return
val sess = bmlSessionFor(pending.src) ?: run {
Toast.makeText(requireContext(), getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
resetBmlOtpState()
updateTransferButton()
return
}
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
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 }
}
(activity as? HomeActivity)?.setRefreshing(false)
if (!initiated) {
Toast.makeText(requireContext(), "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
resetBmlOtpState()
updateTransferButton()
return@launch
}
bmlOtpState = BmlOtpState.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 = getString(R.string.transfer_verify_payment)
binding.btnTransfer.isEnabled = true
}
}
private fun verifyBmlOtp() {
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
if (otp.isEmpty()) {
binding.tilBmlOtp.error = "Enter the verification code"
return
}
binding.tilBmlOtp.error = null
val pending = pendingBmlTransfer ?: return
val channel = bmlOtpChannel ?: return
val sess = bmlSessionFor(pending.src) ?: run {
Toast.makeText(requireContext(), getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
return
}
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
val capturedToAvatar = pending.toAvatar
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?)
}
}
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
resetBmlOtpState()
clearForm()
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else {
binding.btnTransfer.isEnabled = true
if (msg == "CONNECTIVITY") {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
} else {
binding.tilBmlOtp.error = msg
}
}
}
}
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
}
private fun resetBmlOtpState() {
bmlOtpState = BmlOtpState.NONE
bmlOtpChannel = null
pendingBmlTransfer = null
val b = _binding ?: return
b.layoutBmlChannelSelection.visibility = View.GONE
b.tvBmlOtpSentVia.visibility = View.GONE
b.tilBmlOtp.visibility = View.GONE
b.etBmlOtp.setText("")
b.tilBmlOtp.error = null
enableTransferFields()
b.btnTransfer.text = getString(R.string.transfer)
}
private fun updateTransferButton() { private fun updateTransferButton() {
if (bmlOtpState != BmlOtpState.NONE) return if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlQrInfo != null || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank() val recipientReady = bmlHandler().hasQrMerchant || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0 val hasAll = selectedAccount != null && recipientReady && amount > 0
if (!hasAll) { binding.btnTransfer.isEnabled = false; return } if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet() val errors = viewModel.connectivityErrors.value ?: emptySet()
@@ -2443,8 +1873,8 @@ class TransferFragment : Fragment() {
binding.btnTransfer.isEnabled = !bankOffline binding.btnTransfer.isEnabled = !bankOffline
} }
private fun clearForm() { internal fun clearForm() {
resetBmlOtpState() bmlHandler().resetOtpState()
mfaisaHandler?.clearState() mfaisaHandler?.clearState()
mfaisaQrInfo = null mfaisaQrInfo = null
selectedAccount = null selectedAccount = null
@@ -2497,7 +1927,7 @@ class TransferFragment : Fragment() {
} }
} }
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap { internal fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt() val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY } val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
@@ -2566,11 +1996,10 @@ class TransferFragment : Fragment() {
savedAmount = binding.etAmount.text?.toString() ?: "" savedAmount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: "" savedRemarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else "" savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone // The bank handlers hold binding refs; drop them so the next view gets fresh ones.
bmlOtpState = BmlOtpState.NONE // Clearing also resets any in-progress OTP flow, which cannot sensibly resume.
pendingBmlTransfer = null bmlHandler?.clearState()
bmlOtpChannel = null bmlHandler = null
// The M-Faisa handler holds binding refs; drop it so the next view gets a fresh one.
mfaisaHandler?.clearState() mfaisaHandler?.clearState()
mfaisaHandler = null mfaisaHandler = null
_binding = null _binding = null
@@ -2647,7 +2076,7 @@ class TransferFragment : Fragment() {
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when { b.root.alpha = when {
inactive -> 0.4f inactive -> 0.4f
bmlQrInfo != null && !isCard -> 0.35f bmlHandler().hasQrMerchant && !isCard -> 0.35f
else -> 1f else -> 1f
} }
val networkIcon = BmlCardParser.cardNetworkIcon(acc) val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -0,0 +1,697 @@
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.BmlQrPayResult
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlTransferClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
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.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"
}
/** 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
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
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 info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
catch (_: Exception) { null }
}
host?.setRefreshing(false)
if (info == null) {
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
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"
}