Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 30
|
||||
versionName = "1.0.29"
|
||||
versionCode = 32
|
||||
versionName = "1.0.31"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -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/<id>`),
|
||||
* 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` (`<id>_<epoch millis>`);
|
||||
* - 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
val isDark = prefs.getString("theme", "system") == "dark"
|
||||
updatePitchBlackState(isDark)
|
||||
|
||||
// Receipts
|
||||
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
|
||||
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
|
||||
}
|
||||
|
||||
// Accent color
|
||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
binding.accentToggle.check(when (savedPreset) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -168,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
|
||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||
saveReceipt()
|
||||
}
|
||||
|
||||
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("always_fullscreen_receipt", false)
|
||||
if (alwaysFullScreen) {
|
||||
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
|
||||
// and leave the receipt entirely when the full-screen view is closed
|
||||
view.alpha = 0f
|
||||
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data binding ──────────────────────────────────────────────────────────
|
||||
@@ -483,11 +492,11 @@ class TransferReceiptFragment : Fragment() {
|
||||
return bm
|
||||
}
|
||||
|
||||
private fun showFullScreenReceipt() {
|
||||
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||
val ctx = requireContext()
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
if (bank == "BML") { showBmlFullScreenReceipt(); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(); return }
|
||||
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
|
||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||
|
||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||
@@ -529,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||
if (closePageOnDismiss) closeReceiptPage()
|
||||
}
|
||||
dialog.show()
|
||||
dialog.window?.let { win ->
|
||||
@@ -545,7 +555,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||
* Follows the app theme (light/dark).
|
||||
*/
|
||||
private fun showBmlFullScreenReceipt() {
|
||||
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||
@@ -557,6 +567,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
))
|
||||
|
||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
|
||||
@@ -594,7 +605,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
* (visible) status bar, a floating close button top-right just below the status bar,
|
||||
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
||||
*/
|
||||
private fun showMibFullScreenReceipt() {
|
||||
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||
@@ -607,6 +618,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
))
|
||||
|
||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
|
||||
@@ -647,6 +659,12 @@ class TransferReceiptFragment : Fragment() {
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
|
||||
private fun closeReceiptPage() {
|
||||
if (!isAdded || parentFragmentManager.isStateSaved) return
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
|
||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||
for (tv in views) {
|
||||
tv.setOnLongClickListener {
|
||||
|
||||
@@ -304,6 +304,34 @@
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_receipts"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_always_fullscreen_receipt"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchFullscreenReceipt"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -157,6 +157,8 @@
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="settings_pitch_black">Pitch Black</string>
|
||||
<string name="settings_accent_color">Accent Color</string>
|
||||
<string name="settings_receipts">Receipts</string>
|
||||
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
|
||||
<string name="accent_blue">Blue</string>
|
||||
<string name="accent_orange">Red</string>
|
||||
<string name="accent_green">Green</string>
|
||||
@@ -294,6 +296,7 @@
|
||||
<!-- BML QR Pay -->
|
||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- New setting to always show recipt full screen
|
||||
@@ -0,0 +1 @@
|
||||
- Pay with BML Transaction ID (On BML Pay supported transactions"
|
||||
Reference in New Issue
Block a user