This commit is contained in:
@@ -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
|
withContext(Dispatchers.Main) {
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
if (_binding != null) {
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
withContext(Dispatchers.Main) {
|
binding.ivFromPhoto.setImageBitmap(bitmap)
|
||||||
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
|
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 {
|
|
||||||
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
|
val bmlInfo = bmlSess?.let {
|
||||||
null
|
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 {
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
if (_binding != null) {
|
||||||
} else {
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
binding.ivToPhoto.setImageBitmap(bitmap)
|
||||||
} ?: 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user