diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaQrPayClient.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaQrPayClient.kt new file mode 100644 index 0000000..00de8b5 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaQrPayClient.kt @@ -0,0 +1,199 @@ +package sh.sar.basedbank.api.mfaisa + +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.api.models.BankServerException +import java.security.SecureRandom +import java.util.concurrent.TimeUnit +import java.util.zip.Adler32 + +/** + * M-Faisa merchant QR payment ("smart pay") flow: + * 1. [fetchQrDetails] POST /QRCodeUtility/fetchQRCodeById — resolve qrCodeId to merchant + * 2. [initiatePurchase] POST /initiateNewBuy — start the purchase, returns referenceId. + * Server returns 2FARequired=NONE for wallet QR pay, + * so no OTP is required. + * 3. [confirmPurchase] POST /confirmNewBuy — settles the purchase. `transactionAuthDetails` + * is sent as the literal string "null". + * + * Anti-replay scheme is the same as [MfaisaTransferClient]: rndValue = encryptPin(timestampStr), + * csValue = Adler32(formDataJson + timestampStr). The server responds with `[{...}]` envelopes + * for both success and error — callers must check the `success` flag. + */ +class MfaisaQrPayClient { + + private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web" + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + private val random = SecureRandom() + + /** Resolved merchant for a scanned M-Faisa QR. */ + data class QrMerchant( + val qrCodeId: String, + val merchantId: String, // customerId from the lookup response + val merchantName: String, // commercialName + val merchantMsisdn: String, // mobileNumber — already includes "960" prefix + val currencyCode: String, // e.g. "MVR" + /** Pre-set amount for a dynamic QR; null for a static QR (user enters amount). */ + val txnAmount: String?, + val status: String // "Active" for usable QRs + ) + + // ─── Step 1: resolve qrCodeId → merchant details ───────────────────────── + + fun fetchQrDetails(session: MfaisaSession, qrCodeId: String): QrMerchant { + val formData = JSONObject() + .put("qrCodeId", qrCodeId) + .put("tenantCode", "ooredoo") + .toString().matchGsonHtmlSafe() + + val (rnd, cs) = makeAntiReplay(formData) + // Note: fetchQRCodeById uses role=R01 (not RETAIL_SUBSCRIBER like the other two endpoints). + val body = FormBody.Builder() + .add("role", "R01") + .add("channel", "C03") + .add("rndValue", rnd) + .add("formData", formData) + .add("loginExchangeKey", session.loginExchangeKey) + .add("csValue", cs) + .build() + + val first = postAndUnwrap("$baseUrl/QRCodeUtility/fetchQRCodeById", body, "QR lookup failed") + val response = first.optJSONArray("response")?.optJSONObject(0) + ?: throw Exception("QR code not found") + if (!response.optString("status").equals("Active", ignoreCase = true)) { + throw Exception("QR code is not active") + } + + // The lookup response stores absent values as the literal JSON null (decoded by org.json as + // `JSONObject.NULL`) — optString surfaces that as the string "null". Guard against both. + fun strOrNull(name: String): String? = response.opt(name) + ?.takeIf { it != JSONObject.NULL } + ?.toString() + ?.takeIf { it.isNotBlank() && it != "null" } + + return QrMerchant( + qrCodeId = strOrNull("qrCodeId") ?: qrCodeId, + merchantId = strOrNull("customerId") ?: throw Exception("Merchant id missing"), + merchantName = strOrNull("commercialName") ?: throw Exception("Merchant name missing"), + merchantMsisdn = strOrNull("mobileNumber") ?: throw Exception("Merchant number missing"), + currencyCode = strOrNull("currencyCode") ?: "MVR", + txnAmount = strOrNull("txnAmount"), + status = response.optString("status") + ) + } + + // ─── Step 2: initiate the purchase ─────────────────────────────────────── + + /** Returns the `referenceId` to be passed to [confirmPurchase]. */ + fun initiatePurchase( + session: MfaisaSession, + sourcePocketId: String, + sourceMsisdn: String, // user's "960..." MSISDN + merchant: QrMerchant, + amount: String, + description: String = "" + ): String { + val formData = JSONObject() + .put("channel", "SubscriberApp") + .put("commodityType", "WALLET") + .put("description", description) + .put("merchantId", merchant.merchantId) + .put("mobileNumber", merchant.merchantMsisdn) + .put("sourceDetails", JSONObject() + .put("MDNId", sourceMsisdn) + .put("actorRoleType", "RETAIL_SUBSCRIBER") + .put("pocketId", sourcePocketId)) + .put("transactionAmount", amount) + .put("transactionCurrency", merchant.currencyCode) + .put("transactionType", "PURCHASE") + .toString().matchGsonHtmlSafe() + + val (rnd, cs) = makeAntiReplay(formData) + val body = FormBody.Builder() + .add("role", "RETAIL_SUBSCRIBER") + .add("channel", "C03") + .add("rndValue", rnd) + .add("formData", formData) + .add("loginExchangeKey", session.loginExchangeKey) + .add("csValue", cs) + .build() + + val first = postAndUnwrap("$baseUrl/initiateNewBuy", body, "Payment initiation failed") + // We've only seen 2FARequired=NONE for wallet QR pay. If the server ever asks for OTP we + // surface a clear error instead of silently completing a no-op confirm. + val twoFa = first.optString("2FARequired").ifBlank { "NONE" } + if (!twoFa.equals("NONE", ignoreCase = true)) { + throw Exception("This QR requires 2FA ($twoFa) which is not yet supported") + } + val responseObj = first.optJSONArray("response")?.optJSONObject(0)?.optJSONObject("responseObject") + ?: throw Exception("Missing responseObject") + val refId = responseObj.optString("referenceId") + if (refId.isBlank()) throw Exception("Server did not return a referenceId") + return refId + } + + // ─── Step 3: confirm (no OTP) ──────────────────────────────────────────── + + fun confirmPurchase(session: MfaisaSession, referenceId: String) { + val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe() + val (rnd, cs) = makeAntiReplay(formData) + val body = FormBody.Builder() + .add("role", "RETAIL_SUBSCRIBER") + .add("channel", "C03") + .add("rndValue", rnd) + // Literal string "null" — matches the captured request from the official app. + .add("transactionAuthDetails", "null") + .add("formData", formData) + .add("loginExchangeKey", session.loginExchangeKey) + .add("csValue", cs) + .build() + + postAndUnwrap("$baseUrl/confirmNewBuy", body, "Payment confirmation failed") + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /** POSTs [body] to [url], unwraps the `[{...}]` envelope, throws on non-success / session expiry. */ + private fun postAndUnwrap(url: String, body: okhttp3.RequestBody, fallbackError: String): JSONObject { + val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute() + val code = resp.code + val raw = resp.body?.string() ?: throw Exception("Empty response") + resp.close() + if (code in 500..599) throw BankServerException("Ooredoo M-Faisa") + + val arr = JSONArray(raw.trimStart()) + val first = arr.optJSONObject(0) ?: throw Exception(fallbackError) + handleSessionExpiry(first) + if (!first.optBoolean("success", false)) { + val errObj = first.optJSONArray("error")?.optJSONObject(0) + throw Exception(errObj?.optString("errorMessage")?.ifBlank { null } + ?: first.optString("message").ifBlank { fallbackError }) + } + return first + } + + private fun handleSessionExpiry(envelope: JSONObject?) { + val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue") + val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode") + if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException() + } + + private fun makeAntiReplay(formJson: String): Pair { + val offset = (random.nextInt(5) + 10) xor 0xE + val nonceStr = (System.currentTimeMillis() + offset).toString() + val rndValue = MfaisaCrypto.encryptPin(nonceStr) + val csValue = Adler32().apply { + update((formJson + nonceStr).toByteArray(Charsets.UTF_8)) + }.value.toString() + return rndValue to csValue + } + + private fun String.matchGsonHtmlSafe(): String = + replace("\\/", "/").replace("=", "\\u003d") +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index f738b42..3c271ba 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -172,6 +172,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { bundle.putString(KEY_SUBTITLE, "BML QR Merchant") bundle.putString(KEY_COLOR, "#0066A1") } + accountNumber.startsWith("mfaisaqr:") -> { + bundle.putString(KEY_SUBTITLE, "M-Faisa QR Merchant") + bundle.putString(KEY_COLOR, "#ED1C24") + } account != null -> { bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}") bundle.putString(KEY_COLOR, "#FE860E") @@ -195,6 +199,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { val hide = viewModel.hideAmounts.value ?: false val items = mutableListOf() + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } + val fromCurrency = fromAccount?.currencyName ?: "" + val fromLoginTag = fromAccount?.loginTag ?: "" + val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT" + val fromIsMfaisa = fromAccount?.bank == "MFAISA" + // TODO: when M-Faisa-supported contacts are wired up, swap this for a per-row check + // (e.g. is the recipient also an M-Faisa wallet) instead of disabling everything. + val mfaisaInactive = if (fromIsMfaisa) "Unsupported recipient from M-Faisa" else null + if (tabTag == RECENTS_TAG) { val recents = RecentsCache.load(requireContext()) val filtered = if (search.isBlank()) recents else recents.filter { @@ -208,19 +223,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { subtitle = r.subtitle, colorHex = r.colorHex, isSameAsFrom = r.accountNumber == fromAccountNumber, - imageHash = r.imageHash + imageHash = r.imageHash, + // A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out + // when the source is M-Faisa. + inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive )) } return items } - val accounts = viewModel.accounts.value ?: emptyList() - val contacts = viewModel.contacts.value ?: emptyList() - val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } - val fromCurrency = fromAccount?.currencyName ?: "" - val fromLoginTag = fromAccount?.loginTag ?: "" - val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT" - if (tabTag == MY_ACCOUNTS_TAG) { val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" } @@ -251,9 +262,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { colorHex = "#FE860E", isSameAsFrom = isSame, imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" }, - inactiveReason = if (isSame) null - else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" - else currencyMismatchReason(fromCurrency, acc.currencyName), + inactiveReason = when { + isSame -> null + mfaisaInactive != null && acc.bank != "MFAISA" -> mfaisaInactive + fromIsCard && acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account" + else -> currencyMismatchReason(fromCurrency, acc.currencyName) + }, balance = balance, bankLogoRes = logoRes )) @@ -283,10 +297,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { colorHex = "#FE860E", isSameAsFrom = isSame, imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" }, - inactiveReason = if (isSame) null - else if (!isActive) acc.statusDesc - else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" - else currencyMismatchReason(fromCurrency, acc.currencyName), + inactiveReason = when { + isSame -> null + mfaisaInactive != null -> mfaisaInactive + !isActive -> acc.statusDesc + acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account" + else -> currencyMismatchReason(fromCurrency, acc.currencyName) + }, balance = balance, bankLogoRes = logoRes )) @@ -311,7 +328,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { colorHex = contact.bankColor, isSameAsFrom = contact.benefAccount == fromAccountNumber, imageHash = contact.customerImgHash, - inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc) + inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc) )) } return items diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index b9888ec..62934ea 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -106,6 +106,10 @@ class TransferFragment : Fragment() { private var bmlQrInfo: BmlQrPayInfo? = null private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step) private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant + + // 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() // BML business profile OTP flow state @@ -178,6 +182,17 @@ class TransferFragment : Fragment() { return@registerForActivityResult } + // M-Faisa merchant QR — content is just the numeric qrCodeId. Only attempt the lookup + // when the user actually has an M-Faisa wallet logged in; otherwise fall through to + // PayMV parsing (which will toast "invalid" as before). + val trimmedRaw = raw.trim() + val app = requireActivity().application as BasedBankApp + if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } && + app.mfaisaSessions.isNotEmpty()) { + lookupMfaisaQrMerchant(trimmedRaw) + return@registerForActivityResult + } + val qr = PaymvQrParser.parse(raw) if (qr == null || qr.accountNumber == null) { Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() @@ -293,6 +308,19 @@ class TransferFragment : Fragment() { lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:")) return@setFragmentResultListener } + if (accountNumber.startsWith("mfaisaqr:")) { + // 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. + lookupMfaisaQrMerchant(accountNumber.removePrefix("mfaisaqr:")) + return@setFragmentResultListener + } + // MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the + // basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled. + if (selectedAccount?.bank == "MFAISA") { + binding.etTo.setText(accountNumber) + mfaisaHandler().searchRecipient(accountNumber) + return@setFragmentResultListener + } val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B" @@ -457,6 +485,99 @@ 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. */ + private fun resetToFieldVisibility() { + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.btnPickContact.visibility = View.VISIBLE + binding.btnScanQr.visibility = View.VISIBLE + } + private fun startLookupLoading() { val spinner = CircularProgressDrawable(requireContext()).apply { setStyle(CircularProgressDrawable.DEFAULT) @@ -504,6 +625,11 @@ class TransferFragment : Fragment() { return@setOnItemClickListener } } + if (mfaisaQrInfo != null && picked.bank != "MFAISA") { + Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show() + binding.actvFrom.setText("", false) + return@setOnItemClickListener + } selectedAccount = picked updateAmountPrefix(picked) showFromCard(picked) @@ -557,9 +683,6 @@ class TransferFragment : Fragment() { if (mfaisa) { binding.tilTo.hint = getString(R.string.ooredoo_phone) binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE - // Phone-only flow — hide the QR scanner + contact picker icons next to the To field - binding.btnPickContact.visibility = View.GONE - binding.btnScanQr.visibility = View.GONE // Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) { resolvedAccountNumber = "" @@ -573,8 +696,6 @@ class TransferFragment : Fragment() { } else { binding.tilTo.hint = getString(R.string.transfer_to) binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - binding.btnPickContact.visibility = View.VISIBLE - binding.btnScanQr.visibility = View.VISIBLE // Drop any M-Faisa-resolved recipient when switching banks if (mfaisaHandler?.recipient != null) { mfaisaHandler?.clearState() @@ -587,6 +708,12 @@ class TransferFragment : Fragment() { binding.etTo.setText("") } } + // The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with + // tilTo: when a To-card is rendered (tilTo GONE), they must be GONE too — otherwise + // they end up floating above the rendered merchant/recipient card. + val showToAffordances = binding.tilTo.visibility == View.VISIBLE + binding.btnPickContact.visibility = if (showToAffordances) View.VISIBLE else View.GONE + binding.btnScanQr.visibility = if (showToAffordances) View.VISIBLE else View.GONE } private fun showFromCard(account: BankAccount) { @@ -759,6 +886,13 @@ class TransferFragment : Fragment() { binding.tilRemarks.alpha = 1f binding.etAmount.setText("") } + if (mfaisaQrInfo != null) { + mfaisaQrInfo = null + binding.tilAmount.isEnabled = true + binding.tilRemarks.isEnabled = true + binding.tilRemarks.alpha = 1f + binding.etAmount.setText("") + } resolvedAccountNumber = "" resolvedRecipientName = "" resolvedDestCurrency = "" @@ -1187,6 +1321,45 @@ class TransferFragment : Fragment() { } private fun initiateTransfer() { + // M-Faisa merchant QR — same confirm popup as other transfers. The /initiateNewBuy + + // /confirmNewBuy pair does NOT require OTP for wallet QR pay (2FARequired=NONE). + mfaisaQrInfo?.let { merchant -> + 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 + } + // M-Faisa source: the entire flow (initiate + OTP + confirm) lives in the handler. if (selectedAccount?.bank == "MFAISA") { mfaisaHandler().submit() @@ -1401,7 +1574,7 @@ class TransferFragment : Fragment() { ) } - private fun buildTransferConfirmView( + internal fun buildTransferConfirmView( amountCurrency: String, amountValue: String, fromName: String, @@ -1583,6 +1756,93 @@ 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(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() + } + ) + } + } + private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE @@ -2174,7 +2434,7 @@ class TransferFragment : Fragment() { private fun updateTransferButton() { if (bmlOtpState != BmlOtpState.NONE) return val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 - val recipientReady = if (bmlQrInfo != null) bmlQrInfo != null else resolvedAccountNumber.isNotBlank() + val recipientReady = bmlQrInfo != null || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank() val hasAll = selectedAccount != null && recipientReady && amount > 0 if (!hasAll) { binding.btnTransfer.isEnabled = false; return } val errors = viewModel.connectivityErrors.value ?: emptySet() @@ -2186,11 +2446,15 @@ class TransferFragment : Fragment() { private fun clearForm() { resetBmlOtpState() mfaisaHandler?.clearState() + mfaisaQrInfo = null selectedAccount = null binding.actvFrom.setText("", false) binding.cardFromInfo.visibility = View.GONE binding.tilFrom.visibility = View.VISIBLE binding.tilAmount.prefixText = null + binding.tilAmount.isEnabled = true + binding.tilRemarks.isEnabled = true + binding.tilRemarks.alpha = 1f binding.etAmount.setText("") binding.etRemarks.setText("") resolvedAccountNumber = "" @@ -2468,6 +2732,10 @@ class TransferFragment : Fragment() { } imageView.visibility = View.VISIBLE } + acc.bank == "MFAISA" -> { + b.ivDropdownCardLogo.setImageResource(R.drawable.ooredoo_logo) + b.ivDropdownCardLogo.visibility = View.VISIBLE + } else -> b.ivDropdownCardLogo.visibility = View.GONE } b.root diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt index 829c28b..f497a3c 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt @@ -19,4 +19,10 @@ data class TransferReceiptData( val bmlReference: String = "", val bmlTimestamp: String = "", val bmlMessage: String = "", + // M-Faisa receipt fields + val mfaisaTransactionType: String = "", + val mfaisaFromName: String = "", + val mfaisaFromMsisdn: String = "", + val mfaisaToMsisdn: String = "", + val mfaisaTimestamp: Long = 0L, ) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt index fe8d3db..8b8f647 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt @@ -38,6 +38,7 @@ import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding +import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding import sh.sar.basedbank.databinding.FragmentReceiptMibBinding import java.io.File import java.io.FileOutputStream @@ -68,6 +69,11 @@ class TransferReceiptFragment : Fragment() { private const val ARG_BML_REFERENCE = "bml_reference" private const val ARG_BML_TIMESTAMP = "bml_timestamp" private const val ARG_BML_MESSAGE = "bml_message" + private const val ARG_MFAISA_TXN_TYPE = "mfaisa_txn_type" + private const val ARG_MFAISA_FROM_NAME = "mfaisa_from_name" + private const val ARG_MFAISA_FROM_MSISDN = "mfaisa_from_msisdn" + private const val ARG_MFAISA_TO_MSISDN = "mfaisa_to_msisdn" + private const val ARG_MFAISA_TIMESTAMP = "mfaisa_timestamp" // Holds the already-rendered to-avatar bitmap from TransferFragment var pendingToAvatarBitmap: Bitmap? = null @@ -91,22 +97,36 @@ class TransferReceiptFragment : Fragment() { putString(ARG_BML_REFERENCE, data.bmlReference) putString(ARG_BML_TIMESTAMP, data.bmlTimestamp) putString(ARG_BML_MESSAGE, data.bmlMessage) + putString(ARG_MFAISA_TXN_TYPE, data.mfaisaTransactionType) + putString(ARG_MFAISA_FROM_NAME, data.mfaisaFromName) + putString(ARG_MFAISA_FROM_MSISDN, data.mfaisaFromMsisdn) + putString(ARG_MFAISA_TO_MSISDN, data.mfaisaToMsisdn) + putLong(ARG_MFAISA_TIMESTAMP, data.mfaisaTimestamp) } } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB" - return if (bank == "MIB") { - val binding = FragmentReceiptMibBinding.inflate(inflater, container, false) - bindMib(binding) - _receiptCard = binding.receiptCard - binding.root - } else { - val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false) - bindBml(binding) - _receiptCard = binding.receiptCard - binding.root + return when (bank) { + "MIB" -> { + val binding = FragmentReceiptMibBinding.inflate(inflater, container, false) + bindMib(binding) + _receiptCard = binding.receiptCard + binding.root + } + "MFAISA" -> { + val binding = FragmentReceiptMfaisaBinding.inflate(inflater, container, false) + bindMfaisa(binding) + _receiptCard = binding.receiptCard + binding.root + } + else -> { + val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false) + bindBml(binding) + _receiptCard = binding.receiptCard + binding.root + } } } @@ -250,6 +270,49 @@ class TransferReceiptFragment : Fragment() { ) } + private fun bindMfaisa(binding: FragmentReceiptMfaisaBinding) { + val args = requireArguments() + val currency = args.getString(ARG_CURRENCY, "MVR") + val amountStr = args.getString(ARG_AMOUNT, "") + + val formattedAmount = try { + val d = amountStr.toDouble() + val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 } + intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3) + } catch (_: Exception) { amountStr } + + binding.tvAmount.text = "$currency $formattedAmount" + binding.tvTransactionType.text = args.getString(ARG_MFAISA_TXN_TYPE, "") + .ifBlank { "Transfer to mobile" } + binding.tvFromName.text = args.getString(ARG_MFAISA_FROM_NAME, "") + .ifBlank { args.getString(ARG_FROM_LABEL, "") } + binding.tvFromMsisdn.text = args.getString(ARG_MFAISA_FROM_MSISDN, "") + binding.tvToName.text = args.getString(ARG_TO_LABEL, "") + binding.tvToMsisdn.text = args.getString(ARG_MFAISA_TO_MSISDN, "") + .ifBlank { args.getString(ARG_TO_ACCOUNT, "") } + binding.tvDateTime.text = formatMfaisaTimestamp(args.getLong(ARG_MFAISA_TIMESTAMP, 0L)) + + val remarks = args.getString(ARG_REMARKS, "") + if (!remarks.isNullOrBlank()) { + binding.tvRemarks.text = remarks + binding.remarksDivider.visibility = View.VISIBLE + binding.remarksRow.visibility = View.VISIBLE + } + + copyOnLongClick( + binding.tvAmount, binding.tvStatus, binding.tvTransactionType, + binding.tvFromName, binding.tvFromMsisdn, + binding.tvToName, binding.tvToMsisdn, + binding.tvDateTime, binding.tvRemarks + ) + } + + private fun formatMfaisaTimestamp(millis: Long): String { + val effective = if (millis > 0) millis else System.currentTimeMillis() + val sdf = java.text.SimpleDateFormat("EEEE d MMMM yyyy HH:mm:ss z", Locale.US) + return sdf.format(java.util.Date(effective)) + } + // ── Share / Save ────────────────────────────────────────────────────────── private fun shareReceipt() { @@ -366,14 +429,22 @@ class TransferReceiptFragment : Fragment() { setBackgroundColor(Color.BLACK) } - val cardView = if (bank == "MIB") { - val binding = FragmentReceiptMibBinding.inflate(layoutInflater) - bindMib(binding) - binding.receiptCard - } else { - val binding = FragmentReceiptBmlBinding.inflate(layoutInflater) - bindBml(binding) - binding.receiptCard + val cardView = when (bank) { + "MIB" -> { + val binding = FragmentReceiptMibBinding.inflate(layoutInflater) + bindMib(binding) + binding.receiptCard + } + "MFAISA" -> { + val binding = FragmentReceiptMfaisaBinding.inflate(layoutInflater) + bindMfaisa(binding) + binding.receiptCard + } + else -> { + val binding = FragmentReceiptBmlBinding.inflate(layoutInflater) + bindBml(binding) + binding.receiptCard + } } (cardView.parent as? ViewGroup)?.removeView(cardView) cardView.setOnClickListener { dialog.dismiss() } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt index 2554007..dc8aead 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt @@ -1,10 +1,20 @@ package sh.sar.basedbank.ui.home.transfer +import android.content.Context import android.graphics.Bitmap +import android.graphics.Color +import android.view.Gravity import android.view.View +import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -19,7 +29,11 @@ import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeViewModel +import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferReceiptData +import sh.sar.basedbank.util.AccountInputParser +import sh.sar.basedbank.util.RecentPick +import sh.sar.basedbank.util.RecentsCache /** * Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup, @@ -56,8 +70,11 @@ class MfaisaTransferHandler( /** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */ fun searchRecipient(rawInput: String) { if (lookupInFlight) return - val phone = rawInput.trim().removePrefix("960") - if (phone.isEmpty() || phone.length < 7) { + // Reuse the shared normalizer so "+960", "960", and embedded spaces work the same as + // they do for MIB/BML lookup. The result is a bare 7-digit MSISDN when input was a + // local phone number, untouched otherwise. + val phone = AccountInputParser.normalize(rawInput) + if (AccountInputParser.detect(phone) != AccountInputParser.InputType.PHONE) { binding.tilTo.error = "Enter a valid mobile number" return } @@ -166,6 +183,16 @@ class MfaisaTransferHandler( binding.btnPickContact.visibility = View.GONE binding.btnScanQr.visibility = View.GONE binding.cardToInfo.visibility = View.VISIBLE + + RecentsCache.save(ctx, RecentPick( + accountNumber = r.msisdn, + displayName = r.name.ifBlank { r.msisdn }, + subtitle = "Ooredoo M-Faisa · ${r.msisdn}", + colorHex = "#ED1C24", + imageHash = null, + isProfileImage = false, + bank = "MFAISA" + )) } /** Initiate with one automatic retry if the session has expired. */ @@ -197,6 +224,15 @@ class MfaisaTransferHandler( } } + /** + * Shows the unified "confirm + enter OTP" dialog. Body is the standard transfer-confirm + * view (amount + from/to blocks via [TransferFragment.buildTransferConfirmView]) plus an + * OTP input. The Confirm button stays disabled until a 6-digit code is entered. Biometric + * gating + invalid-OTP re-prompt + session-refresh retry are all preserved. + * + * The displayed "code sent to" line uses the SOURCE M-Faisa login's MSISDN (where the SMS + * was actually sent) — the old standalone OTP dialog mistakenly showed the recipient. + */ private fun promptForOtp( source: BankAccount, r: MfaisaTransferClient.Recipient, @@ -205,32 +241,113 @@ class MfaisaTransferHandler( refId: String, errorMsg: String? ) { + val tf = fragment as? TransferFragment ?: return + val view = fragment.view ?: return val dp = ctx.resources.displayMetrics.density - val input = android.widget.EditText(ctx).apply { - hint = "Enter SMS code" + val colorMuted = MaterialColors.getColor( + view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY) + val colorOutline = MaterialColors.getColor( + view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY) + + val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr } + val confirmView = tf.buildTransferConfirmView( + amountCurrency = "MVR", + amountValue = amountValue, + fromName = source.accountBriefName, + fromNumber = source.accountNumber, + fromDetail = "M-Faisa", + toName = r.name.ifBlank { r.msisdn }, + toNumber = r.msisdn, + toDetail = "Ooredoo M-Faisa" + ) + + // The user's own M-Faisa MSISDN (where the SMS is sent). The session stores the bare + // 7 digits; prefix with 960 for display. + val userMsisdn = app.mfaisaSessionFor(source)?.msisdn + ?.takeIf { it.isNotBlank() } + ?.let { "960$it" } + ?: "your registered number" + + val otpHeader = TextView(ctx).apply { + text = "A 6-digit verification code has been sent to $userMsisdn" + textSize = 13f + setTextColor(colorMuted) + gravity = Gravity.CENTER + } + val otpInput = android.widget.EditText(ctx).apply { + hint = "Enter 6-digit code" inputType = android.text.InputType.TYPE_CLASS_NUMBER filters = arrayOf(android.text.InputFilter.LengthFilter(6)) - setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt()) + textSize = 20f + gravity = Gravity.CENTER + letterSpacing = 0.3f } - val message = buildString { - append("A 6-digit verification code has been sent to ${r.msisdn.replaceBefore('-', "")}.") - append("\n\nEnter it to complete the MVR $amountStr transfer to ${r.name}.") - if (errorMsg != null) append("\n\n$errorMsg") + val errorView = errorMsg?.let { + TextView(ctx).apply { + text = it + textSize = 13f + setTextColor(Color.RED) + gravity = Gravity.CENTER + } } + val divider = View(ctx).apply { + setBackgroundColor(colorOutline) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).apply { + topMargin = (8 * dp).toInt() + } + } + val otpSection = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setPadding((20 * dp).toInt(), (12 * dp).toInt(), (20 * dp).toInt(), (4 * dp).toInt()) + addView(otpHeader, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)) + addView(otpInput, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = (8 * dp).toInt() + }) + if (errorView != null) { + addView(errorView, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = (8 * dp).toInt() + }) + } + } + val container = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + addView(confirmView) + addView(divider) + addView(otpSection) + } + + // Hide any previously-open keyboard so the OTP field can claim focus cleanly. + val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + val dialog = MaterialAlertDialogBuilder(ctx) - .setTitle("Enter verification code") - .setMessage(message) - .setView(input) - .setPositiveButton(R.string.verify, null) + .setTitle(R.string.transfer) + .setView(container) + .setPositiveButton(R.string.transfer_confirm, null) .setNegativeButton(R.string.cancel) { d, _ -> d.dismiss() binding.btnTransfer.isEnabled = true } .setCancelable(false) .show() - dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val otp = input.text?.toString()?.trim().orEmpty() - if (otp.length != 6) { input.error = "Enter 6 digits"; return@setOnClickListener } + + val confirmBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE) + confirmBtn.isEnabled = false + otpInput.addTextChangedListener { text -> + confirmBtn.isEnabled = (text?.length ?: 0) == 6 + } + + val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE) + val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false) + val canAuth = BiometricManager.from(ctx) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS + + val runConfirm: () -> Unit = { + val otp = otpInput.text?.toString()?.trim().orEmpty() dialog.dismiss() (fragment.activity as? HomeActivity)?.setRefreshing(true) @@ -240,7 +357,7 @@ class MfaisaTransferHandler( (fragment.activity as? HomeActivity)?.setRefreshing(false) val receipt = TransferReceiptData( bank = "MFAISA", - amount = "%.2f".format(amountStr.toDouble()), + amount = amountValue, currency = "MVR", fromLabel = source.accountBriefName, fromColorHex = "#ED1C24", @@ -248,14 +365,16 @@ class MfaisaTransferHandler( toAccount = r.msisdn, toBank = "Ooredoo M-Faisa", remarks = remarks, - bmlFromName = source.accountBriefName, - bmlReference = refId, - bmlMessage = "Transfer Completed Successfully" + mfaisaTransactionType = "Transfer to mobile", + mfaisaFromName = source.accountBriefName, + mfaisaFromMsisdn = source.accountNumber, + mfaisaToMsisdn = r.msisdn, + mfaisaTimestamp = System.currentTimeMillis() ) onTransferSuccess(receipt, null) } catch (e: MfaisaInvalidOtpException) { (fragment.activity as? HomeActivity)?.setRefreshing(false) - // Server kept the referenceId alive — re-prompt without restarting initiate + // Server kept the referenceId alive — re-prompt without restarting initiate. promptForOtp(source, r, amountStr, remarks, refId, e.message) } catch (e: Exception) { (fragment.activity as? HomeActivity)?.setRefreshing(false) @@ -264,6 +383,36 @@ class MfaisaTransferHandler( } } } + + confirmBtn.setOnClickListener { + val otp = otpInput.text?.toString()?.trim().orEmpty() + if (otp.length != 6) { otpInput.error = "Enter 6 digits"; return@setOnClickListener } + if (biometricTransferConfirm && canAuth) { + val prompt = BiometricPrompt(fragment, ContextCompat.getMainExecutor(ctx), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + runConfirm() + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (errorCode != BiometricPrompt.ERROR_CANCELED && + errorCode != BiometricPrompt.ERROR_USER_CANCELED && + errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) { + Toast.makeText(ctx, errString, Toast.LENGTH_SHORT).show() + } + } + override fun onAuthenticationFailed() { /* keep dialog open */ } + }) + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle(ctx.getString(R.string.biometric_transfer_title)) + .setSubtitle("MVR $amountValue → ${r.name.ifBlank { r.msisdn }}") + .setNegativeButtonText(ctx.getString(android.R.string.cancel)) + .build() + ) + } else { + runConfirm() + } + } } private fun showError(e: Exception) { diff --git a/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt b/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt index 19b12c2..fbcc986 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt @@ -43,7 +43,12 @@ object ReceiptStore { bmlFromName = o.optString("bmlFromName"), bmlReference = o.optString("bmlReference"), bmlTimestamp = o.optString("bmlTimestamp"), - bmlMessage = o.optString("bmlMessage") + bmlMessage = o.optString("bmlMessage"), + mfaisaTransactionType = o.optString("mfaisaTransactionType"), + mfaisaFromName = o.optString("mfaisaFromName"), + mfaisaFromMsisdn = o.optString("mfaisaFromMsisdn"), + mfaisaToMsisdn = o.optString("mfaisaToMsisdn"), + mfaisaTimestamp = o.optLong("mfaisaTimestamp", 0L) ), savedAt = o.optLong("savedAt", 0L) ) @@ -75,6 +80,11 @@ object ReceiptStore { put("bmlReference", d.bmlReference) put("bmlTimestamp", d.bmlTimestamp) put("bmlMessage", d.bmlMessage) + put("mfaisaTransactionType", d.mfaisaTransactionType) + put("mfaisaFromName", d.mfaisaFromName) + put("mfaisaFromMsisdn", d.mfaisaFromMsisdn) + put("mfaisaToMsisdn", d.mfaisaToMsisdn) + put("mfaisaTimestamp", d.mfaisaTimestamp) put("savedAt", ts) }) File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString())) diff --git a/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt index c6a1665..490054f 100644 --- a/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt @@ -10,7 +10,10 @@ data class RecentPick( val subtitle: String, val colorHex: String, val imageHash: String?, - val isProfileImage: Boolean + val isProfileImage: Boolean, + /** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide + * per-bank selectability. Null for legacy entries; treated as unspecified. */ + val bank: String? = null ) object RecentsCache { @@ -34,6 +37,7 @@ object RecentsCache { put("colorHex", r.colorHex) if (r.imageHash != null) put("imageHash", r.imageHash) put("isProfileImage", r.isProfileImage) + if (r.bank != null) put("bank", r.bank) }) } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -51,6 +55,7 @@ object RecentsCache { put("colorHex", r.colorHex) if (r.imageHash != null) put("imageHash", r.imageHash) put("isProfileImage", r.isProfileImage) + if (r.bank != null) put("bank", r.bank) }) } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -75,7 +80,8 @@ object RecentsCache { subtitle = o.getString("subtitle"), colorHex = o.getString("colorHex"), imageHash = o.optString("imageHash").takeIf { it.isNotBlank() }, - isProfileImage = o.optBoolean("isProfileImage", false) + isProfileImage = o.optBoolean("isProfileImage", false), + bank = o.optString("bank").takeIf { it.isNotBlank() } ) } } catch (_: Exception) { diff --git a/app/src/main/res/drawable/bg_mfaisa_receipt_gradient_bottom.xml b/app/src/main/res/drawable/bg_mfaisa_receipt_gradient_bottom.xml new file mode 100644 index 0000000..e45ca31 --- /dev/null +++ b/app/src/main/res/drawable/bg_mfaisa_receipt_gradient_bottom.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_mfaisa_receipt_gradient_top.xml b/app/src/main/res/drawable/bg_mfaisa_receipt_gradient_top.xml new file mode 100644 index 0000000..2242785 --- /dev/null +++ b/app/src/main/res/drawable/bg_mfaisa_receipt_gradient_top.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_mfaisa_receipt_check.xml b/app/src/main/res/drawable/ic_mfaisa_receipt_check.xml new file mode 100644 index 0000000..cc70ee8 --- /dev/null +++ b/app/src/main/res/drawable/ic_mfaisa_receipt_check.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/mfaisaa_logo_with_text.xml b/app/src/main/res/drawable/mfaisaa_logo_with_text.xml new file mode 100644 index 0000000..98ee968 --- /dev/null +++ b/app/src/main/res/drawable/mfaisaa_logo_with_text.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/receipt_mfaisa_bottom.xml b/app/src/main/res/drawable/receipt_mfaisa_bottom.xml new file mode 100644 index 0000000..17e5084 --- /dev/null +++ b/app/src/main/res/drawable/receipt_mfaisa_bottom.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/receipt_mfaisa_top.xml b/app/src/main/res/drawable/receipt_mfaisa_top.xml new file mode 100644 index 0000000..b3a4dcb --- /dev/null +++ b/app/src/main/res/drawable/receipt_mfaisa_top.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_receipt_mfaisa.xml b/app/src/main/res/layout/fragment_receipt_mfaisa.xml new file mode 100644 index 0000000..3fdb0b8 --- /dev/null +++ b/app/src/main/res/layout/fragment_receipt_mfaisa.xml @@ -0,0 +1,393 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +