split mib transfer
Auto Tag on Version Change / check-version (push) Successful in 3s

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