From c9ae614fc7a207db7673e3ba5ceab53e73e5c72a Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 22 May 2026 06:21:20 +0500 Subject: [PATCH] prep support for transfers for bml business accounts) --- .../sar/basedbank/api/bml/BmlAccountClient.kt | 21 + .../basedbank/api/bml/BmlTransferClient.kt | 10 +- .../sar/basedbank/ui/home/TransferFragment.kt | 366 +++++++++++++++++- app/src/main/res/layout/fragment_transfer.xml | 45 +++ app/src/main/res/values/strings.xml | 3 + 5 files changed, 421 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt index 0ae411c..800d357 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt @@ -81,6 +81,27 @@ class BmlAccountClient { } catch (_: Exception) { null } } + fun fetchTransferChannels(session: BmlSession): List { + val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute() + val json = resp.body?.string() ?: run { resp.close(); return emptyList() } + resp.close() + return try { + val root = JSONObject(json) + if (!root.optBoolean("success")) return emptyList() + val arr = root.optJSONObject("payload") + ?.optJSONObject("transfer") + ?.optJSONArray("otpChannel") ?: return emptyList() + (0 until arr.length()).map { i -> + val ch = arr.getJSONObject(i) + BmlOtpChannel( + channel = ch.optString("channel"), + description = ch.optString("description"), + masked = ch.optString("masked") + ) + } + } catch (_: Exception) { emptyList() } + } + private fun parseDashboard( json: String, loginTag: String, diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt index 0fc6d11..5e4f509 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt @@ -17,7 +17,8 @@ class BmlTransferClient { amount: Double, transferType: String, currency: String, - bank: String? = null + bank: String? = null, + channel: String = "token" ): Boolean { val jo = JSONObject().apply { put("debitAccount", debitAccount) @@ -25,7 +26,7 @@ class BmlTransferClient { put("debitAmount", amount) put("transfertype", transferType) put("currency", currency) - put("channel", "token") + put("channel", channel) if (bank != null) put("bank", bank) } val request = Request.Builder() @@ -55,7 +56,8 @@ class BmlTransferClient { currency: String, otp: String, remarks: String = "", - bank: String? = null + bank: String? = null, + channel: String = "token" ): BmlTransferResult { val jo = JSONObject().apply { put("debitAccount", debitAccount) @@ -63,7 +65,7 @@ class BmlTransferClient { put("debitAmount", amount) put("transfertype", transferType) put("currency", currency) - put("channel", "token") + put("channel", channel) put("otp", otp) if (remarks.isNotBlank()) put("remarks", remarks) if (bank != null) put("bank", bank) 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 fd7787d..3147105 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 @@ -15,6 +15,8 @@ import android.widget.BaseAdapter import android.widget.Filter import android.widget.Filterable import android.graphics.Typeface +import android.view.Gravity +import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast @@ -36,6 +38,8 @@ 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.BmlTransferClient import sh.sar.basedbank.api.bml.BmlTransferResult import sh.sar.basedbank.api.bml.BmlValidateClient @@ -83,6 +87,28 @@ class TransferFragment : Fragment() { // Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL" private var selectedFahipayService: String? = null + // 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 val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult @@ -171,7 +197,10 @@ class TransferFragment : Fragment() { } binding.btnTransfer.isEnabled = false - binding.btnTransfer.setOnClickListener { initiateTransfer() } + binding.btnTransfer.setOnClickListener { + if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp() + else initiateTransfer() + } binding.etAmount.addTextChangedListener { updateTransferButton() } @@ -602,6 +631,7 @@ class TransferFragment : Fragment() { val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" val isSrcBml = src.bank == "BML" + val isBmlBusiness = isSrcBml && isBusinessProfile(src) val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val currency = src.currencyName.ifBlank { "MVR" } @@ -636,26 +666,34 @@ class TransferFragment : Fragment() { val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}" val doTransfer: () -> Unit = { - binding.btnTransfer.isEnabled = false - (activity as? HomeActivity)?.setRefreshing(true) - viewLifecycleOwner.lifecycleScope.launch { - val (ok, msg, receipt) = withContext(Dispatchers.IO) { - if (!isSrcBml) { - doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) - } else { - doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts) + if (isBmlBusiness) { + // Business profile: async OTP channel selection flow + startBmlBusinessOtpFlow( + src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, + isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar + ) + } else { + binding.btnTransfer.isEnabled = false + (activity as? HomeActivity)?.setRefreshing(true) + viewLifecycleOwner.lifecycleScope.launch { + val (ok, msg, receipt) = withContext(Dispatchers.IO) { + if (!isSrcBml) { + doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) + } else { + doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts) + } + } + binding.btnTransfer.isEnabled = true + (activity as? HomeActivity)?.setRefreshing(false) + if (ok && receipt != null) { + ReceiptStore.save(requireContext(), receipt) + clearForm() + val activity = requireActivity() as HomeActivity + activity.triggerRefresh() + activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) + } else if (!ok) { + Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() } - } - binding.btnTransfer.isEnabled = true - (activity as? HomeActivity)?.setRefreshing(false) - if (ok && receipt != null) { - ReceiptStore.save(requireContext(), receipt) - clearForm() - val activity = requireActivity() as HomeActivity - activity.triggerRefresh() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) - } else if (!ok) { - Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() } } } @@ -884,12 +922,300 @@ class TransferFragment : Fragment() { } } + // ── 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.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.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) + } + + 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 + disableTransferFields() + 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, 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 + 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.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 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0 } private fun clearForm() { + resetBmlOtpState() selectedAccount = null binding.actvFrom.setText("", false) binding.cardFromInfo.visibility = View.GONE diff --git a/app/src/main/res/layout/fragment_transfer.xml b/app/src/main/res/layout/fragment_transfer.xml index 359d963..3b176fd 100644 --- a/app/src/main/res/layout/fragment_transfer.xml +++ b/app/src/main/res/layout/fragment_transfer.xml @@ -336,6 +336,51 @@ + + + + + + + + + + + + + + + + Contact Required To send USD to a MIB account from BML, the recipient must be saved as a BML contact first. This is required by BML\'s API.\n\nPlease add this account as a BML contact, then try again. Account data is incomplete — please re-login to refresh. + Verify Payment + Send verification code via + Verification code No contacts found