From c356762baa0b7adfd590bf33e42f6d9161f9547b Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Mon, 21 Sep 2026 07:13:59 +0500 Subject: [PATCH] split do bml tranfers --- .../sar/basedbank/ui/home/TransferFragment.kt | 681 ++--------------- .../ui/home/transfer/BmlTransferHandler.kt | 697 ++++++++++++++++++ 2 files changed, 752 insertions(+), 626 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 62934ea..9198c40 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -38,17 +38,10 @@ 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.BmlTransferClient -import sh.sar.basedbank.api.bml.BmlTransferResult import sh.sar.basedbank.api.bml.BmlValidateClient import sh.sar.basedbank.api.dhiraagu.DhiraaguClient import sh.sar.basedbank.api.fahipay.OoredooClient 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.MibIpsAccountInfo 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.ItemAccountDropdownBinding 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.util.AccountListParser import sh.sar.basedbank.util.CredentialStore @@ -79,9 +73,7 @@ class TransferFragment : Fragment() { private val session get() = selectedAccount ?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) } ?: (requireActivity().application as BasedBankApp).anyMibSession() - private fun bmlSessionFor(account: BankAccount?) = - account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) } - ?: (requireActivity().application as BasedBankApp).anyBmlSession() + private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account) // Resolved recipient info — set after successful lookup or prefill private var resolvedAccountNumber = "" @@ -102,39 +94,41 @@ class TransferFragment : Fragment() { private var savedToColorHex = "#607D8B" 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) private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null private val dropdownProfileImageCache = mutableMapOf() - // 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 + /** + * 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. */ private var mfaisaHandler: MfaisaTransferHandler? = null private fun mfaisaHandler(): MfaisaTransferHandler = @@ -305,7 +299,7 @@ class TransferFragment : Fragment() { childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener if (accountNumber.startsWith("bmlqr:")) { - lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:")) + bmlHandler().lookupQrMerchant(accountNumber.removePrefix("bmlqr:")) return@setFragmentResultListener } if (accountNumber.startsWith("mfaisaqr:")) { @@ -353,7 +347,7 @@ class TransferFragment : Fragment() { binding.btnTransfer.isEnabled = false binding.btnTransfer.setOnClickListener { - if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp() + if (bmlHandler().isAwaitingOtp) bmlHandler().verifyOtp() else initiateTransfer() } @@ -372,7 +366,7 @@ class TransferFragment : Fragment() { arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.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) { 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 * 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, _ -> 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" if (!isCard) { 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 // onViewCreated runs. Retry the lookup once sessions are available. val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL) - if (pendingBmlQrUrl != null && !bmlQrLookupAttempted) { + if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) { 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 @@ -878,14 +793,7 @@ class TransferFragment : Fragment() { } binding.btnClearToInfo.setOnClickListener { - if (bmlQrInfo != null) { - bmlQrInfo = null - bmlGatewayQr = false - binding.tilAmount.isEnabled = true - binding.tilRemarks.isEnabled = true - binding.tilRemarks.alpha = 1f - binding.etAmount.setText("") - } + bmlHandler().clearQrMerchant() if (mfaisaQrInfo != null) { mfaisaQrInfo = null binding.tilAmount.isEnabled = true @@ -1265,7 +1173,7 @@ class TransferFragment : Fragment() { // ── Transfer ────────────────────────────────────────────────────────────── /** Shared confirm dialog with optional biometric gate, used for both transfers and QR payments. */ - private fun showConfirmWithBiometric( + internal fun showConfirmWithBiometric( title: String, message: String? = null, customView: android.view.View? = null, @@ -1367,41 +1275,8 @@ class TransferFragment : Fragment() { } // BML QR merchant payment — uses shared confirm dialog, no receipt - bmlQrInfo?.let { info -> - val src = selectedAccount ?: run { - 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) - } - ) + if (bmlHandler().hasQrMerchant) { + bmlHandler().submitQrPayment() return } @@ -1423,7 +1298,7 @@ class TransferFragment : Fragment() { val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" 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 isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val currency = src.currencyName.ifBlank { "MVR" } @@ -1478,7 +1353,7 @@ class TransferFragment : Fragment() { if (isBmlBusiness) { // Business profile: async OTP channel selection flow — dismiss dialog first dialog.dismiss() - startBmlBusinessOtpFlow( + bmlHandler().startBusinessOtpFlow( src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar ) @@ -1490,7 +1365,7 @@ class TransferFragment : Fragment() { if (!isSrcBml) { doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) } 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 @@ -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( src: BankAccount, 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_NEGATIVE)?.visibility = View.GONE dialog.setCancelable(false) @@ -1877,7 +1685,7 @@ class TransferFragment : Fragment() { }) } - private fun showSuccessInDialog( + internal fun showSuccessInDialog( dialog: AlertDialog, frame: android.widget.FrameLayout, 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, - allContacts: List - ): Triple { - 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, - allContacts: List, - 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() - - (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) { - 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() { - if (bmlOtpState != BmlOtpState.NONE) return + if (bmlHandler().isOtpFlowActive) return 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 if (!hasAll) { binding.btnTransfer.isEnabled = false; return } val errors = viewModel.connectivityErrors.value ?: emptySet() @@ -2443,8 +1873,8 @@ class TransferFragment : Fragment() { binding.btnTransfer.isEnabled = !bankOffline } - private fun clearForm() { - resetBmlOtpState() + internal fun clearForm() { + bmlHandler().resetOtpState() mfaisaHandler?.clearState() mfaisaQrInfo = 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 bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY } val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) @@ -2566,11 +1996,10 @@ class TransferFragment : Fragment() { savedAmount = binding.etAmount.text?.toString() ?: "" savedRemarks = binding.etRemarks.text?.toString() ?: "" savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else "" - // Reset in-progress OTP flow — it cannot sensibly resume after the view is gone - bmlOtpState = BmlOtpState.NONE - pendingBmlTransfer = null - bmlOtpChannel = null - // The M-Faisa handler holds binding refs; drop it so the next view gets a fresh one. + // The bank handlers hold binding refs; drop them so the next view gets fresh ones. + // Clearing also resets any in-progress OTP flow, which cannot sensibly resume. + bmlHandler?.clearState() + bmlHandler = null mfaisaHandler?.clearState() mfaisaHandler = null _binding = null @@ -2647,7 +2076,7 @@ class TransferFragment : Fragment() { b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance b.root.alpha = when { inactive -> 0.4f - bmlQrInfo != null && !isCard -> 0.35f + bmlHandler().hasQrMerchant && !isCard -> 0.35f else -> 1f } val networkIcon = BmlCardParser.cardNetworkIcon(acc) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt new file mode 100644 index 0000000..979157c --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt @@ -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, + allContacts: List + ): Triple { + 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, + allContacts: List, + 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() + + 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) { + 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, + allContacts: List + ): Triple? { + 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" +}