From f59c2be6c4d6674db57950ca5f23dc8dfcadfcd4 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Mon, 21 Sep 2026 07:26:54 +0500 Subject: [PATCH] split mib transfer --- .../sar/basedbank/ui/home/TransferFragment.kt | 211 ++++-------------- .../ui/home/transfer/BmlTransferHandler.kt | 33 +++ .../ui/home/transfer/MibTransferHandler.kt | 173 ++++++++++++++ 3 files changed, 255 insertions(+), 162 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.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 7fb635d..cb11860 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 @@ -2,12 +2,10 @@ package sh.sar.basedbank.ui.home import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.os.Bundle -import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -41,20 +39,16 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R -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.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibIpsAccountInfo -import sh.sar.basedbank.api.mib.MibLookupException -import sh.sar.basedbank.api.mib.MibTransferClient -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.ui.home.transfer.MibTransferHandler import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.AccountInputParser @@ -64,7 +58,6 @@ import sh.sar.basedbank.util.bmlapi.BmlDashboardParser import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.ReceiptStore -import sh.sar.basedbank.util.Totp class TransferFragment : Fragment() { @@ -73,11 +66,15 @@ class TransferFragment : Fragment() { private val viewModel: HomeViewModel by activityViewModels() private var selectedAccount: BankAccount? = null - private val session get() = selectedAccount - ?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) } - ?: (requireActivity().application as BasedBankApp).anyMibSession() private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account) + /** + * Owns everything MIB-specific: session resolution, IPS destination lookup, the transfer + * itself and the avatar API. Holds no view binding, so unlike the other handlers it can + * live for the whole fragment. + */ + private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } } + // Resolved recipient info — set after successful lookup or prefill private var resolvedAccountNumber = "" private var resolvedRecipientName = "" @@ -780,20 +777,15 @@ class TransferFragment : Fragment() { } private fun loadFromPhoto(hash: String) { - val sess = session ?: return - val app = requireActivity().application as BasedBankApp + val sess = mibHandler.session ?: return viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { - try { - val base64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@launch - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch - withContext(Dispatchers.Main) { - if (_binding != null) { - binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP - binding.ivFromPhoto.setImageBitmap(bitmap) - } + val bitmap = mibHandler.fetchAvatar(sess, hash, isProfile = true) ?: return@launch + withContext(Dispatchers.Main) { + if (_binding != null) { + binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + binding.ivFromPhoto.setImageBitmap(bitmap) } - } catch (_: Exception) { } + } } } @@ -903,7 +895,7 @@ class TransferFragment : Fragment() { return } - val mibSess = session + val mibSess = mibHandler.session val bmlSess = bmlSessionFor(selectedAccount) if (mibSess == null && bmlSess == null) { Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() @@ -914,61 +906,37 @@ class TransferFragment : Fragment() { startLookupLoading() + // Both banks can resolve a destination, so whichever the source belongs to gets first + // try and the other is the fallback. Either way the result lands in MibIpsAccountInfo. viewLifecycleOwner.lifecycleScope.launch { var errorMsg: String? = null val info = withContext(Dispatchers.IO) { if (isBmlSource && bmlSess != null) { - val inputType = AccountInputParser.detect(accountNumber) - val bmlResult = try { - if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) BmlValidateClient().verifyMibAccount(bmlSess, accountNumber) - else BmlValidateClient().validateAccount(bmlSess, accountNumber) - } catch (_: Exception) { null } - if (bmlResult != null) { - val bankId = when (bmlResult.trnType) { - "IAT" -> "MALBMVMV" - else -> bmlResult.agnt ?: bmlResult.account - } + val isMibDest = AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.MIB_ACCOUNT + val bmlInfo = bmlHandler().validateDestination(bmlSess, accountNumber, verifyAsMib = isMibDest) + if (bmlInfo != null) { // BML's MIB verify endpoint doesn't return the account's currency. // Enrich via MIB lookup when a MIB session is available. - val currency = if ( - inputType == AccountInputParser.InputType.MIB_ACCOUNT && - bmlResult.currency.isBlank() && mibSess != null - ) { - try { MibTransferClient().lookup(mibSess, bmlResult.account).currency } - catch (_: Exception) { "" } - } else bmlResult.currency - MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = currency) - } else if (mibSess != null) { - try { MibTransferClient().lookup(mibSess, accountNumber) } - catch (e: MibLookupException) { errorMsg = e.message; null } - catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } + if (isMibDest && bmlInfo.currency.isBlank() && mibSess != null) + bmlInfo.copy(currency = mibHandler.lookupCurrency(bmlInfo.accountNumber)) + else bmlInfo } else { - errorMsg = getString(R.string.transfer_account_not_found); null + val outcome = mibHandler.lookupDestination(accountNumber) + errorMsg = outcome.error + outcome.info } } else { - val mibInfo = if (mibSess != null) { - try { MibTransferClient().lookup(mibSess, accountNumber) } - catch (e: MibLookupException) { errorMsg = e.message; null } - catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } - } else null - if (mibInfo != null) { - mibInfo - } else if (bmlSess != null) { - val bmlResult = try { BmlValidateClient().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null } - if (bmlResult != null) { - errorMsg = null - val bankId = when (bmlResult.trnType) { - "IAT" -> "MALBMVMV" - else -> bmlResult.agnt ?: bmlResult.account - } - MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = bmlResult.currency) - } else { - if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found) - null - } + val outcome = mibHandler.lookupDestination(accountNumber) + errorMsg = outcome.error + if (outcome.info != null) { + outcome.info } else { - if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found) - null + val bmlInfo = bmlSess?.let { + bmlHandler().validateDestination(it, accountNumber, verifyAsMib = false) + } + if (bmlInfo != null) errorMsg = null + else if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found) + bmlInfo } } } @@ -1382,7 +1350,7 @@ class TransferFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { val (ok, msg, receipt) = withContext(Dispatchers.IO) { if (!isSrcBml) { - doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) + mibHandler.doTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) } else { bmlHandler().doTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts) } @@ -1811,75 +1779,6 @@ class TransferFragment : Fragment() { } - private fun doMibTransfer( - src: BankAccount, - destAccount: String, - destName: String, - destDisplay: String, - amount: String, - remarks: String, - bankName: String - ): Triple { - val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) - val app = requireActivity().application as BasedBankApp - val loginId = src.loginTag.removePrefix("mib_") - val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed - ?.let { Totp.generate(it) } - ?: return Triple(false, "OTP unavailable", null) - val currencyCode = if (src.currencyName == "USD") "840" else "462" - val currency = if (src.currencyName == "USD") "USD" else "MVR" - val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT - val bankNo = if (isDestMib) 2 else 3 - val toBank = when { - isDestMib -> "MIB" - else -> when (bankName.uppercase()) { - "MALBMVMV" -> "BML" - "MADVMVMV" -> "MIB" - else -> bankName.ifBlank { "LOCAL" } - } - } - return try { - // Switch to the profile that owns the source account - if (src.profileId.isNotBlank()) { - val profiles = app.mibProfilesMap[loginId] ?: emptyList() - val profile = profiles.firstOrNull { it.profileId == src.profileId } - if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile) - } - val result = MibTransferClient().transfer( - session = sess, - fromAccount = src.accountNumber, - toAccount = destAccount, - amount = amount, - currencyCode = currencyCode, - benefName = destName.ifBlank { "Recipient" }, - bankNo = bankNo, - purpose = remarks, - otp = otp - ) - if (result.success) { - val receipt = TransferReceiptData( - bank = "MIB", - amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0), - currency = currency, - fromLabel = src.accountBriefName, - fromColorHex = "#FE860E", - fromProfileImageHash = src.profileImageHash, - toLabel = destDisplay.ifBlank { destName }, - toAccount = destAccount, - toBank = toBank, - remarks = remarks, - mibReferenceNo = result.trxId, - mibTransactionDate = result.date - ) - Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt) - } else { - Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null) - } - } catch (e: Exception) { - Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null) - } - } - private fun updateTransferButton() { if (bmlHandler().isOtpFlowActive) return val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 @@ -1925,24 +1824,15 @@ class TransferFragment : Fragment() { // ── Helpers ─────────────────────────────────────────────────────────────── private fun loadToPhoto(hash: String, isProfile: Boolean) { - val sess = session ?: return - val app = requireActivity().application as BasedBankApp + val sess = mibHandler.session ?: return viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { - try { - val base64 = if (isProfile) { - app.anyMibFlow()?.fetchProfileImage(sess, hash) - } else { - MibContactsClient().fetchProfileImageBase64(sess, hash) - } ?: return@launch - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch - withContext(Dispatchers.Main) { - if (_binding != null) { - binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP - binding.ivToPhoto.setImageBitmap(bitmap) - } + val bitmap = mibHandler.fetchAvatar(sess, hash, isProfile) ?: return@launch + withContext(Dispatchers.Main) { + if (_binding != null) { + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + binding.ivToPhoto.setImageBitmap(bitmap) } - } catch (_: Exception) { } + } } } @@ -2163,15 +2053,12 @@ class TransferFragment : Fragment() { } else { imageView.setImageResource(R.drawable.mib_logo) if (hash != null) { - val app = requireActivity().application as BasedBankApp + // Dropdown rows can belong to any MIB login, so this uses any + // session rather than the selected source's. + val sess = mibHandler.anySession viewLifecycleOwner.lifecycleScope.launch { val bitmap = withContext(Dispatchers.IO) { - try { - val sess = app.anyMibSession() ?: return@withContext null - val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null - val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT) - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - } catch (_: Exception) { null } + sess?.let { mibHandler.fetchAvatar(it, hash, isProfile = true) } } if (bitmap != null) { dropdownProfileImageCache[hash] = bitmap 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 index 979157c..e2f61bd 100644 --- 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 @@ -21,8 +21,10 @@ 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.bml.BmlValidateClient import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankContact +import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeViewModel @@ -118,6 +120,37 @@ class BmlTransferHandler( return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business" } + /** + * Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the + * regular alias/account validation. Blocking; call from IO. Returns null when BML can't + * resolve it, so the caller can fall back to the MIB IPS lookup. + * + * The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common + * "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify + * endpoint leaves `currency` blank — the caller enriches it via + * [MibTransferHandler.lookupCurrency] when a MIB session is available. + */ + fun validateDestination( + session: BmlSession, + accountNumber: String, + verifyAsMib: Boolean + ): MibIpsAccountInfo? { + val result = try { + if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber) + else BmlValidateClient().validateAccount(session, accountNumber) + } catch (_: Exception) { null } ?: return null + val bankId = when (result.trnType) { + "IAT" -> "MALBMVMV" + else -> result.agnt ?: result.account + } + return MibIpsAccountInfo( + accountName = result.name, + accountNumber = result.account, + bankId = bankId, + currency = result.currency + ) + } + /** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */ fun clearQrMerchant() { if (qrInfo == null) return diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt new file mode 100644 index 0000000..a5c6cb4 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt @@ -0,0 +1,173 @@ +package sh.sar.basedbank.ui.home.transfer + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.fragment.app.Fragment +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibContactsClient +import sh.sar.basedbank.api.mib.MibIpsAccountInfo +import sh.sar.basedbank.api.mib.MibLookupException +import sh.sar.basedbank.api.mib.MibSession +import sh.sar.basedbank.api.mib.MibTransferClient +import sh.sar.basedbank.api.models.BankAccount +import sh.sar.basedbank.ui.home.TransferReceiptData +import sh.sar.basedbank.util.AccountInputParser +import sh.sar.basedbank.util.CredentialStore +import sh.sar.basedbank.util.Totp + +/** + * Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup, + * the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact + * avatar API that every bank's "To" card borrows. + * + * Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and + * paints nothing — every method is either a session accessor or a blocking API call meant for + * `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and + * the view updates, so this handler can outlive a view recreation. + */ +class MibTransferHandler( + private val fragment: Fragment, + /** Reads the fragment's currently-selected source account. */ + private val currentSource: () -> BankAccount?, +) { + + private val app get() = fragment.requireActivity().application as BasedBankApp + private val ctx get() = fragment.requireContext() + + /** The selected source's MIB session, falling back to any logged-in MIB session. */ + val session: MibSession? + get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession() + + /** Any logged-in MIB session, ignoring which source is selected. */ + val anySession: MibSession? get() = app.anyMibSession() + + // ─── Destination lookup ────────────────────────────────────────────────── + + /** Either a resolved destination or the message to show the user — never both. */ + data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?) + + /** + * IPS lookup for a destination account/alias. Blocking — call from IO. + * A missing MIB session is reported as "account not found", same as a failed lookup. + */ + fun lookupDestination(accountNumber: String): LookupOutcome { + val sess = session ?: return LookupOutcome(null, notFound()) + return try { + LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null) + } catch (e: MibLookupException) { + LookupOutcome(null, e.message) + } catch (_: Exception) { + LookupOutcome(null, notFound()) + } + } + + /** + * Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint + * resolves the name but not the currency. Blocking; returns "" when it can't be determined. + */ + fun lookupCurrency(accountNumber: String): String { + val sess = session ?: return "" + return try { MibTransferClient().lookup(sess, accountNumber).currency } + catch (_: Exception) { "" } + } + + private fun notFound() = ctx.getString(R.string.transfer_account_not_found) + + // ─── Avatars ───────────────────────────────────────────────────────────── + + /** + * Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image. + * Blocking — call from IO. Returns null on any failure, including a missing image. + * + * The session is passed in rather than read from [session] because callers differ: the "To" + * card uses the selected source's session, the account dropdown uses [anySession]. + */ + fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try { + val base64 = if (isProfile) { + app.anyMibFlow()?.fetchProfileImage(session, hash) + } else { + MibContactsClient().fetchProfileImageBase64(session, hash) + } + if (base64 == null) null else { + val bytes = Base64.decode(base64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + } catch (_: Exception) { null } + + // ─── Transfer ──────────────────────────────────────────────────────────── + + /** + * Switches to the profile that owns [src], then runs the transfer with a freshly generated + * TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is + * "CONNECTIVITY" when the network was unreachable. + */ + fun doTransfer( + src: BankAccount, + destAccount: String, + destName: String, + destDisplay: String, + amount: String, + remarks: String, + bankName: String + ): Triple { + val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null) + val loginId = src.loginTag.removePrefix("mib_") + val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed + ?.let { Totp.generate(it) } + ?: return Triple(false, "OTP unavailable", null) + val currencyCode = if (src.currencyName == "USD") "840" else "462" + val currency = if (src.currencyName == "USD") "USD" else "MVR" + val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT + val bankNo = if (isDestMib) 2 else 3 + val toBank = when { + isDestMib -> "MIB" + else -> when (bankName.uppercase()) { + "MALBMVMV" -> "BML" + "MADVMVMV" -> "MIB" + else -> bankName.ifBlank { "LOCAL" } + } + } + return try { + // Switch to the profile that owns the source account + if (src.profileId.isNotBlank()) { + val profiles = app.mibProfilesMap[loginId] ?: emptyList() + val profile = profiles.firstOrNull { it.profileId == src.profileId } + if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile) + } + val result = MibTransferClient().transfer( + session = sess, + fromAccount = src.accountNumber, + toAccount = destAccount, + amount = amount, + currencyCode = currencyCode, + benefName = destName.ifBlank { "Recipient" }, + bankNo = bankNo, + purpose = remarks, + otp = otp + ) + if (result.success) { + val receipt = TransferReceiptData( + bank = "MIB", + amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0), + currency = currency, + fromLabel = src.accountBriefName, + fromColorHex = "#FE860E", + fromProfileImageHash = src.profileImageHash, + toLabel = destDisplay.ifBlank { destName }, + toAccount = destAccount, + toBank = toBank, + remarks = remarks, + mibReferenceNo = result.trxId, + mibTransactionDate = result.date + ) + Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt) + } else { + Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null) + } + } catch (e: Exception) { + Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null) + } + } +}