diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt new file mode 100644 index 0000000..c3d0b84 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt @@ -0,0 +1,82 @@ +package sh.sar.basedbank.api.bml + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/`), + * e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it + * can go through the regular BML QR payment flow. + */ +class BmlMerchantTxnClient { + + private val client = newBmlApiClient() + + /** + * Returns the transaction's EMV QR payload (`vendorQrCode`). + * + * A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the + * page itself sends need no auth and return the full transaction: + * - on load, `activeBrowserId` (`_`); + * - on picking "BML" as the payment method, `provider: bml_mpos`. + * + * A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects + * one. Links already opened with BML chosen return it from the first. + */ + fun fetchQrPayload(transactionId: String): String { + val browserId = JSONObject() + .put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}") + patch(transactionId, browserId).vendorQrCode()?.let { return it } + // Whether the provider PATCH returns the QR itself or it is generated a moment later has + // not been observed, so re-read a few times before giving up. Re-reads use the load PATCH: + // each provider PATCH counts as another payment attempt. + var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML)) + repeat(3) { + txn.vendorQrCode()?.let { return it } + Thread.sleep(1000) + txn = patch(transactionId, browserId) + } + return txn.vendorQrCode() ?: throw Exception("Transaction has no QR") + } + + private fun patch(transactionId: String, body: JSONObject): JSONObject { + val request = Request.Builder() + .url("$API_BASE/transactions/$transactionId") + .patch(body.toString().toRequestBody("application/json".toMediaType())) + .header("Accept", "*/*") + .header("Origin", PAGE_ORIGIN) + .header("Referer", "$PAGE_ORIGIN/") + .build() + return client.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + if (!response.isSuccessful || !text.trimStart().startsWith("{")) + throw Exception("Transaction lookup failed (HTTP ${response.code})") + JSONObject(text) + } + } + + /** + * No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup + * already rejects a paid or expired QR with its own message. `isNull` first — `optString` + * turns a JSON null into the string "null". + */ + private fun JSONObject.vendorQrCode(): String? = + if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null } + + companion object { + private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv" + private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv" + private const val PROVIDER_BML = "bml_mpos" + private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$") + private val TXN_ID = Regex("^[0-9a-fA-F]{24}$") + + /** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */ + fun parseTransactionId(input: String): String? { + val s = input.trim() + val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1) + return id?.lowercase() + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 947b654..8397dfc 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -528,6 +528,10 @@ fun applyNavLabelVisibility() { private fun routeSharedQrText(text: String) { val store = CredentialStore(this) + sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let { + navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it)) + return + } val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text) if (bmlTarget != null) { navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber())) 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 65b6c11..6e40c2d 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 @@ -39,6 +39,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlMerchantTxnClient import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.mib.MibIpsAccountInfo @@ -177,13 +178,17 @@ class TransferFragment : Fragment() { if (result.resultCode != Activity.RESULT_OK) return val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return + // BML Merchant Services payment link — resolve it to the QR its page would show + BmlMerchantTxnClient.parseTransactionId(raw)?.let { + binding.etTo.setText(it) + lookupBmlMerchantTransaction(it) + return + } + // BML card/gateway/POS QR — hand off to dedicated payment screen val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) if (bmlTarget != null) { - val fromCard = selectedAccount?.takeIf { - it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" - } - (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber)) + openBmlQr(bmlTarget) return } @@ -247,6 +252,7 @@ class TransferFragment : Fragment() { private const val ARG_REMARKS_PREFILL = "remarks_prefill" private const val ARG_BML_QR_URL = "bml_qr_url" private const val ARG_AUTO_SCAN = "auto_scan" + private const val ARG_BML_TXN_ID = "bml_txn_id" fun newInstanceWithAutoScan() = TransferFragment().apply { arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } @@ -259,6 +265,11 @@ class TransferFragment : Fragment() { } } + /** Opens on a BML Merchant Services transaction ID, which is resolved to its QR on load. */ + fun newInstanceFromBmlTxn(transactionId: String) = TransferFragment().apply { + arguments = Bundle().apply { putString(ARG_BML_TXN_ID, transactionId) } + } + fun newInstanceFrom(account: BankAccount) = TransferFragment().apply { arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } } @@ -381,6 +392,11 @@ class TransferFragment : Fragment() { arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) } + arguments?.getString(ARG_BML_TXN_ID)?.let { + // Shown in the To field so a failed lookup leaves the ID there to retry or correct. + binding.etTo.setText(it) + lookupBmlMerchantTransaction(it) + } if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { launchQrScanner() @@ -765,7 +781,40 @@ class TransferFragment : Fragment() { setupContactDropdown() } + /** Reopens the Transfer screen in BML merchant-QR mode, keeping a selected BML card as the source. */ + private fun openBmlQr(bmlTarget: String) { + val fromCard = selectedAccount?.takeIf { + it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" + } + (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber)) + } + + /** + * A BML Merchant Services transaction ID (or its payment link) typed into the To field: fetch + * the QR the payment page would show and pay it like a scanned one. + */ + private fun lookupBmlMerchantTransaction(transactionId: String) { + startLookupLoading() + viewLifecycleOwner.lifecycleScope.launch { + val target = withContext(Dispatchers.IO) { + runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) } + .getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) } + } + if (_binding == null) return@launch + stopLookupLoading() + if (target == null) { + binding.tilTo.error = getString(R.string.transfer_bml_txn_lookup_failed) + return@launch + } + openBmlQr(target) + } + } + private fun searchTo() { + BmlMerchantTxnClient.parseTransactionId(binding.etTo.text?.toString().orEmpty())?.let { + lookupBmlMerchantTransaction(it) + return + } // M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails) if (selectedAccount?.bank == "MFAISA") { mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcd6185..f226fa9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -296,6 +296,7 @@ Looking up merchant… Could not load merchant details + Could not load BML payment for this transaction ID Payment Successful Select a BML account to pay from