This commit is contained in:
@@ -89,9 +89,6 @@ class TransferFragment : Fragment() {
|
|||||||
private var savedToColorHex = "#607D8B"
|
private var savedToColorHex = "#607D8B"
|
||||||
private var savedToImageHash: String? = null
|
private var savedToImageHash: String? = null
|
||||||
|
|
||||||
// M-Faisa QR merchant payment mode (set when the scanned QR is an M-Faisa qrCodeId)
|
|
||||||
private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null
|
|
||||||
|
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||||
@@ -108,12 +105,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding = binding,
|
binding = binding,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
currentSource = { selectedAccount },
|
currentSource = { selectedAccount },
|
||||||
selectSource = { account ->
|
selectSource = ::selectSourceAccount,
|
||||||
selectedAccount = account
|
|
||||||
updateAmountPrefix(account)
|
|
||||||
showFromCard(account)
|
|
||||||
updateTransferButton()
|
|
||||||
},
|
|
||||||
onStateChanged = { updateTransferButton() },
|
onStateChanged = { updateTransferButton() },
|
||||||
onTransferSuccess = { receipt, avatar ->
|
onTransferSuccess = { receipt, avatar ->
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
@@ -136,6 +128,8 @@ class TransferFragment : Fragment() {
|
|||||||
fragment = this,
|
fragment = this,
|
||||||
binding = binding,
|
binding = binding,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
currentSource = { selectedAccount },
|
||||||
|
selectSource = ::selectSourceAccount,
|
||||||
onRecipientChanged = {
|
onRecipientChanged = {
|
||||||
// The handler resolved or cleared a recipient — keep the resolvedAccountNumber
|
// The handler resolved or cleared a recipient — keep the resolvedAccountNumber
|
||||||
// mirror in sync so the shared `updateTransferButton()` / clearForm() logic works.
|
// mirror in sync so the shared `updateTransferButton()` / clearForm() logic works.
|
||||||
@@ -195,7 +189,7 @@ class TransferFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } &&
|
if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } &&
|
||||||
app.mfaisaSessions.isNotEmpty()) {
|
app.mfaisaSessions.isNotEmpty()) {
|
||||||
lookupMfaisaQrMerchant(trimmedRaw)
|
mfaisaHandler().lookupQrMerchant(trimmedRaw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +315,7 @@ class TransferFragment : Fragment() {
|
|||||||
if (accountNumber.startsWith("mfaisaqr:")) {
|
if (accountNumber.startsWith("mfaisaqr:")) {
|
||||||
// M-Faisa QR merchant recent — re-run the lookup so the merchant stays current
|
// M-Faisa QR merchant recent — re-run the lookup so the merchant stays current
|
||||||
// (price / status can change) and so the source auto-switch path is taken.
|
// (price / status can change) and so the source auto-switch path is taken.
|
||||||
lookupMfaisaQrMerchant(accountNumber.removePrefix("mfaisaqr:"))
|
mfaisaHandler().lookupQrMerchant(accountNumber.removePrefix("mfaisaqr:"))
|
||||||
return@setFragmentResultListener
|
return@setFragmentResultListener
|
||||||
}
|
}
|
||||||
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
||||||
@@ -416,93 +410,8 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
|
||||||
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
|
||||||
* the amount if the QR is dynamic.
|
|
||||||
*/
|
|
||||||
private fun lookupMfaisaQrMerchant(qrCodeId: String) {
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
// Prefer the currently-selected MFAISA account's session; otherwise fall back to any session.
|
|
||||||
val source = (selectedAccount?.takeIf { it.bank == "MFAISA" }
|
|
||||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" })
|
|
||||||
?: run {
|
|
||||||
Toast.makeText(requireContext(), "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val session = app.mfaisaSessionFor(source) ?: run {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
|
||||||
if (selectedAccount?.bank != "MFAISA") {
|
|
||||||
selectedAccount = source
|
|
||||||
updateAmountPrefix(source)
|
|
||||||
showFromCard(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock the "To" input row while loading
|
|
||||||
binding.tilTo.visibility = View.GONE
|
|
||||||
binding.btnPickContact.visibility = View.GONE
|
|
||||||
binding.btnScanQr.visibility = View.GONE
|
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val merchant = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
|
||||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
|
||||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
|
||||||
?: return@withContext null
|
|
||||||
try { sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
|
||||||
catch (_: Exception) { null }
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
|
||||||
if (merchant == null) {
|
|
||||||
Toast.makeText(requireContext(), "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
|
||||||
resetToFieldVisibility()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
mfaisaQrInfo = merchant
|
|
||||||
|
|
||||||
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
|
||||||
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
|
||||||
if (merchant.txnAmount.isNullOrBlank()) {
|
|
||||||
RecentsCache.save(requireContext(), RecentPick(
|
|
||||||
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
|
||||||
displayName = merchant.merchantName,
|
|
||||||
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
|
||||||
colorHex = "#ED1C24",
|
|
||||||
imageHash = null,
|
|
||||||
isProfileImage = false,
|
|
||||||
bank = "MFAISA"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show merchant in the "To" card — clear button is the only way to back out
|
|
||||||
binding.tvToAccountName.text = merchant.merchantName
|
|
||||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
|
||||||
binding.tvToBalance.visibility = View.GONE
|
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
|
||||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
// Pre-fill + lock amount if the QR is dynamic
|
|
||||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
|
||||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
|
||||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
|
||||||
binding.tilAmount.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restores the To-input row to its default state when a QR lookup fails. */
|
/** Restores the To-input row to its default state when a QR lookup fails. */
|
||||||
private fun resetToFieldVisibility() {
|
internal fun resetToFieldVisibility() {
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
@@ -556,7 +465,7 @@ class TransferFragment : Fragment() {
|
|||||||
return@setOnItemClickListener
|
return@setOnItemClickListener
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mfaisaQrInfo != null && picked.bank != "MFAISA") {
|
if (mfaisaHandler().hasQrMerchant && picked.bank != "MFAISA") {
|
||||||
Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show()
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
return@setOnItemClickListener
|
return@setOnItemClickListener
|
||||||
@@ -789,6 +698,17 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes [account] the source and repaints everything that depends on it. Handed to the
|
||||||
|
* QR-capable handlers so a scanned merchant QR can switch the source to the right bank.
|
||||||
|
*/
|
||||||
|
private fun selectSourceAccount(account: BankAccount) {
|
||||||
|
selectedAccount = account
|
||||||
|
updateAmountPrefix(account)
|
||||||
|
showFromCard(account)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateAmountPrefix(account: BankAccount) {
|
private fun updateAmountPrefix(account: BankAccount) {
|
||||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
}
|
}
|
||||||
@@ -805,13 +725,7 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.btnClearToInfo.setOnClickListener {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
bmlHandler().clearQrMerchant()
|
bmlHandler().clearQrMerchant()
|
||||||
if (mfaisaQrInfo != null) {
|
mfaisaHandler().clearQrMerchant()
|
||||||
mfaisaQrInfo = null
|
|
||||||
binding.tilAmount.isEnabled = true
|
|
||||||
binding.tilRemarks.isEnabled = true
|
|
||||||
binding.tilRemarks.alpha = 1f
|
|
||||||
binding.etAmount.setText("")
|
|
||||||
}
|
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
@@ -1118,42 +1032,9 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initiateTransfer() {
|
private fun initiateTransfer() {
|
||||||
// M-Faisa merchant QR — same confirm popup as other transfers. The /initiateNewBuy +
|
// M-Faisa merchant QR — same confirm popup as other transfers, no OTP.
|
||||||
// /confirmNewBuy pair does NOT require OTP for wallet QR pay (2FARequired=NONE).
|
if (mfaisaHandler().hasQrMerchant) {
|
||||||
mfaisaQrInfo?.let { merchant ->
|
mfaisaHandler().submitQrPayment()
|
||||||
val src = selectedAccount ?: run {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (src.bank != "MFAISA") {
|
|
||||||
Toast.makeText(requireContext(), "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
|
||||||
val amount = amountStr.toDoubleOrNull()
|
|
||||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
|
||||||
binding.tilAmount.error = null
|
|
||||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
|
||||||
|
|
||||||
val confirmView = buildTransferConfirmView(
|
|
||||||
amountCurrency = merchant.currencyCode,
|
|
||||||
amountValue = "%.2f".format(amount),
|
|
||||||
fromName = src.accountBriefName,
|
|
||||||
fromNumber = src.accountNumber,
|
|
||||||
fromDetail = "M-Faisa",
|
|
||||||
toName = merchant.merchantName,
|
|
||||||
toNumber = merchant.merchantMsisdn,
|
|
||||||
toDetail = "Ooredoo M-Faisa merchant"
|
|
||||||
)
|
|
||||||
showConfirmWithBiometric(
|
|
||||||
title = getString(R.string.transfer),
|
|
||||||
customView = confirmView,
|
|
||||||
biometricSubtitle = "${merchant.currencyCode} ${"%.2f".format(amount)} → ${merchant.merchantName}",
|
|
||||||
onConfirmed = { dialog, frame ->
|
|
||||||
showProcessingInDialog(dialog, frame)
|
|
||||||
executeMfaisaQrPayment(src, merchant, amount, "%.2f".format(amount), remarks, dialog, frame)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1447,93 +1328,6 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeMfaisaQrPayment(
|
|
||||||
src: BankAccount,
|
|
||||||
merchant: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant,
|
|
||||||
amount: Double,
|
|
||||||
amountStr: String,
|
|
||||||
remarks: String,
|
|
||||||
dialog: AlertDialog,
|
|
||||||
frame: android.widget.FrameLayout
|
|
||||||
) {
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
|
||||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
|
||||||
dialog.dismiss()
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
|
||||||
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
|
||||||
val sourceMdn = "960${initialSession.msisdn}"
|
|
||||||
|
|
||||||
binding.btnTransfer.isEnabled = false
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val outcome = withContext(Dispatchers.IO) {
|
|
||||||
val client = sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient()
|
|
||||||
try {
|
|
||||||
val refId = try {
|
|
||||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
|
||||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
|
||||||
val fresh = app.refreshMfaisaSession(loginId)
|
|
||||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
|
||||||
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
|
||||||
}
|
|
||||||
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
|
||||||
try {
|
|
||||||
client.confirmPurchase(confirmSession, refId)
|
|
||||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
|
||||||
val fresh = app.refreshMfaisaSession(loginId)
|
|
||||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
|
||||||
client.confirmPurchase(fresh, refId)
|
|
||||||
}
|
|
||||||
Result.success(refId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure<String>(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (_binding == null) return@launch
|
|
||||||
|
|
||||||
outcome.fold(
|
|
||||||
onSuccess = { _ ->
|
|
||||||
val receipt = TransferReceiptData(
|
|
||||||
bank = "MFAISA",
|
|
||||||
amount = amountStr,
|
|
||||||
currency = merchant.currencyCode,
|
|
||||||
fromLabel = src.accountBriefName,
|
|
||||||
fromColorHex = "#ED1C24",
|
|
||||||
toLabel = merchant.merchantName,
|
|
||||||
toAccount = merchant.merchantMsisdn,
|
|
||||||
toBank = "Ooredoo M-Faisa",
|
|
||||||
remarks = remarks,
|
|
||||||
mfaisaTransactionType = "Merchant payment",
|
|
||||||
mfaisaFromName = src.accountBriefName,
|
|
||||||
mfaisaFromMsisdn = src.accountNumber,
|
|
||||||
mfaisaToMsisdn = merchant.merchantMsisdn,
|
|
||||||
mfaisaTimestamp = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
|
||||||
dialog.dismiss()
|
|
||||||
clearForm()
|
|
||||||
val activity = requireActivity() as HomeActivity
|
|
||||||
activity.triggerRefresh()
|
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, null))
|
|
||||||
},
|
|
||||||
onFailure = { e ->
|
|
||||||
dialog.dismiss()
|
|
||||||
binding.btnTransfer.isEnabled = true
|
|
||||||
val msg = when {
|
|
||||||
e is java.io.IOException -> getString(R.string.connectivity_no_internet)
|
|
||||||
!e.message.isNullOrBlank() -> e.message!!
|
|
||||||
else -> "Payment failed"
|
|
||||||
}
|
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
|
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
|
||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||||
@@ -1678,7 +1472,7 @@ class TransferFragment : Fragment() {
|
|||||||
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
|
||||||
val recipientReady = bmlHandler().hasQrMerchant || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank()
|
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||||
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||||
@@ -1690,7 +1484,7 @@ class TransferFragment : Fragment() {
|
|||||||
internal fun clearForm() {
|
internal fun clearForm() {
|
||||||
bmlHandler().resetOtpState()
|
bmlHandler().resetOtpState()
|
||||||
mfaisaHandler?.clearState()
|
mfaisaHandler?.clearState()
|
||||||
mfaisaQrInfo = null
|
mfaisaHandler?.clearQrMerchant()
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -22,6 +23,7 @@ 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.mfaisa.MfaisaInvalidOtpException
|
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||||
@@ -37,16 +39,25 @@ import sh.sar.basedbank.util.RecentsCache
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||||
* initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||||
|
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||||
|
* an M-Faisa merchant QR is loaded.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||||
|
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||||
*
|
*
|
||||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||||
*/
|
*/
|
||||||
class MfaisaTransferHandler(
|
class MfaisaTransferHandler(
|
||||||
private val fragment: Fragment,
|
private val fragment: TransferFragment,
|
||||||
private val binding: FragmentTransferBinding,
|
private val binding: FragmentTransferBinding,
|
||||||
private val viewModel: HomeViewModel,
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||||
private val onRecipientChanged: () -> Unit,
|
private val onRecipientChanged: () -> Unit,
|
||||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||||
@@ -55,11 +66,16 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
var recipient: MfaisaTransferClient.Recipient? = null
|
var recipient: MfaisaTransferClient.Recipient? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
|
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||||
|
private set
|
||||||
|
|
||||||
private var lookupInFlight = false
|
private var lookupInFlight = false
|
||||||
|
|
||||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
@@ -80,7 +96,7 @@ class MfaisaTransferHandler(
|
|||||||
}
|
}
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
|
|
||||||
val source = currentSource() ?: return
|
val source = mfaisaSource() ?: return
|
||||||
val session = app.mfaisaSessionFor(source) ?: run {
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -122,7 +138,7 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val source = currentSource() ?: return
|
val source = mfaisaSource() ?: return
|
||||||
val r = recipient ?: run {
|
val r = recipient ?: run {
|
||||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -153,20 +169,232 @@ class MfaisaTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||||
fun clearState() {
|
fun clearState() {
|
||||||
recipient = null
|
recipient = null
|
||||||
lookupInFlight = false
|
lookupInFlight = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
if (qrInfo == null) return
|
||||||
|
qrInfo = null
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||||
|
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||||
|
* the amount if the QR is dynamic.
|
||||||
|
*/
|
||||||
|
fun lookupQrMerchant(qrCodeId: String) {
|
||||||
|
val source = mfaisaSource() ?: run {
|
||||||
|
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
|
// Lock the "To" input row while loading
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val merchant = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: return@withContext null
|
||||||
|
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
if (merchant == null) {
|
||||||
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
|
fragment.resetToFieldVisibility()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
qrInfo = merchant
|
||||||
|
|
||||||
|
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||||
|
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||||
|
if (merchant.txnAmount.isNullOrBlank()) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||||
|
displayName = merchant.merchantName,
|
||||||
|
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||||
|
colorHex = "#ED1C24",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false,
|
||||||
|
bank = "MFAISA"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show merchant in the "To" card — clear button is the only way to back out
|
||||||
|
binding.tvToAccountName.text = merchant.merchantName
|
||||||
|
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill + lock amount if the QR is dynamic
|
||||||
|
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||||
|
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onRecipientChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||||
|
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||||
|
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val merchant = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (src.bank != "MFAISA") {
|
||||||
|
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||||
|
val amountValue = "%.2f".format(amount)
|
||||||
|
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = merchant.currencyCode,
|
||||||
|
amountValue = amountValue,
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = "M-Faisa",
|
||||||
|
toName = merchant.merchantName,
|
||||||
|
toNumber = merchant.merchantMsisdn,
|
||||||
|
toDetail = "Ooredoo M-Faisa merchant"
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
merchant: MfaisaQrPayClient.QrMerchant,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
dialog: AlertDialog
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||||
|
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||||
|
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||||
|
val sourceMdn = "960${initialSession.msisdn}"
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val outcome = withContext(Dispatchers.IO) {
|
||||||
|
val client = MfaisaQrPayClient()
|
||||||
|
try {
|
||||||
|
val refId = try {
|
||||||
|
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||||
|
}
|
||||||
|
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||||
|
try {
|
||||||
|
client.confirmPurchase(confirmSession, refId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.confirmPurchase(fresh, refId)
|
||||||
|
}
|
||||||
|
Result.success(refId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure<String>(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { _ ->
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MFAISA",
|
||||||
|
amount = amountStr,
|
||||||
|
currency = merchant.currencyCode,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#ED1C24",
|
||||||
|
toLabel = merchant.merchantName,
|
||||||
|
toAccount = merchant.merchantMsisdn,
|
||||||
|
toBank = "Ooredoo M-Faisa",
|
||||||
|
remarks = remarks,
|
||||||
|
mfaisaTransactionType = "Merchant payment",
|
||||||
|
mfaisaFromName = src.accountBriefName,
|
||||||
|
mfaisaFromMsisdn = src.accountNumber,
|
||||||
|
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||||
|
mfaisaTimestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
dialog.dismiss()
|
||||||
|
onTransferSuccess(receipt, null)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
showError(e, fallback = "Payment failed")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Internal ────────────────────────────────────────────────────────────
|
// ─── Internal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun currentSource(): BankAccount? =
|
/**
|
||||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||||
|
* the source away from another bank.
|
||||||
private fun sourceAccountNumberFromCard(): String =
|
*/
|
||||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
private fun mfaisaSource(): BankAccount? =
|
||||||
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
|
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||||
|
|
||||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||||
@@ -241,7 +469,6 @@ class MfaisaTransferHandler(
|
|||||||
refId: String,
|
refId: String,
|
||||||
errorMsg: String?
|
errorMsg: String?
|
||||||
) {
|
) {
|
||||||
val tf = fragment as? TransferFragment ?: return
|
|
||||||
val view = fragment.view ?: return
|
val view = fragment.view ?: return
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val colorMuted = MaterialColors.getColor(
|
val colorMuted = MaterialColors.getColor(
|
||||||
@@ -250,7 +477,7 @@ class MfaisaTransferHandler(
|
|||||||
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||||
|
|
||||||
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||||
val confirmView = tf.buildTransferConfirmView(
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
amountCurrency = "MVR",
|
amountCurrency = "MVR",
|
||||||
amountValue = amountValue,
|
amountValue = amountValue,
|
||||||
fromName = source.accountBriefName,
|
fromName = source.accountBriefName,
|
||||||
@@ -415,11 +642,11 @@ class MfaisaTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showError(e: Exception) {
|
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||||
val msg = when {
|
val msg = when {
|
||||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||||
!e.message.isNullOrBlank() -> e.message!!
|
!e.message.isNullOrBlank() -> e.message!!
|
||||||
else -> "Transfer failed"
|
else -> fallback
|
||||||
}
|
}
|
||||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user