Compare commits

..
8 Commits
Author SHA1 Message Date
shihaam b97d0c5a18 Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m35s
2026-09-30 21:57:09 +05:00
shihaam 4ac328cf52 App lock icon does not go behind navigation bar in landscape mode 2026-09-30 21:56:43 +05:00
shihaam c2f473a6a3 UI is more vivid when merchant sets amount
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-30 21:52:17 +05:00
shihaam 9f40c56b49 Fix UX issues with rotation/resize window and merchant info loading for card payments
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-30 21:50:57 +05:00
shihaam 0747fbdbfd add logos to top seed docs: png microsoft
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-29 12:24:49 +05:00
shihaam 528e9eeef8 add logos to top seed docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-29 12:22:22 +05:00
shihaam 0e7f329a4b release v1.0.31
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m15s
2026-09-27 00:31:12 +05:00
shihaam 2082e8fd0c Pay with BML Transaction ID 2026-09-27 00:28:12 +05:00
20 changed files with 632 additions and 149 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 31 versionCode = 33
versionName = "1.0.30" versionName = "1.0.32"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+6
View File
@@ -32,6 +32,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="true" android:exported="true"
android:label="@string/app_name"> android:label="@string/app_name">
<intent-filter> <intent-filter>
@@ -45,20 +46,24 @@
<activity <activity
android:name=".LockActivity" android:name=".LockActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false" android:exported="false"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".ui.onboarding.OnboardingActivity" android:name=".ui.onboarding.OnboardingActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false" /> android:exported="false" />
<activity <activity
android:name=".ui.login.LoginActivity" android:name=".ui.login.LoginActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false" android:exported="false"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".ui.home.HomeActivity" android:name=".ui.home.HomeActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
android:exported="false" android:exported="false"
android:windowSoftInputMode="adjustPan" /> android:windowSoftInputMode="adjustPan" />
@@ -69,6 +74,7 @@
<activity <activity
android:name=".nfc.BmlTapToPayActivity" android:name=".nfc.BmlTapToPayActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false" android:exported="false"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" /> android:theme="@style/Theme.BasedBank" />
@@ -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()
}
}
}
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
if (securitySet) lock() if (securitySet) lock()
} }
// ── Payment guard ─────────────────────────────────────────────────────────
//
// The manifest has this activity handle theme, language, font-size and display-size changes
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
// answer — the money can move with nothing left to say so. Those changes still need a
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
// flight and otherwise waits until the last payment finishes.
private var paymentsInFlight = 0
private var recreatePending = false
private var lastConfig: Configuration? = null
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
inner class PaymentGuard internal constructor() {
private var ended = false
fun end() {
if (ended) return
ended = true
paymentsInFlight--
if (paymentsInFlight == 0 && recreatePending) {
recreatePending = false
// Posted so a receipt screen committed in the same pass is saved with the state
binding.root.post { recreate() }
}
}
}
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
paymentsInFlight++
val guard = PaymentGuard()
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) = guard.end()
})
return guard
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val previous = lastConfig
lastConfig = Configuration(newConfig)
// Size and orientation changes are handled in place; these need fresh resources.
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
android.content.pm.ActivityInfo.CONFIG_LOCALE or
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
android.content.pm.ActivityInfo.CONFIG_DENSITY
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
if (paymentsInFlight > 0) recreatePending = true else recreate()
}
fun lockApp() = lock() fun lockApp() = lock()
fun notifyWheelLockTap() { fun notifyWheelLockTap() {
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
} }
setContentView(binding.root) setContentView(binding.root)
lastConfig = Configuration(resources.configuration)
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight isAppearanceLightStatusBars = isLight
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
insets insets
} }
// The app bar only pads for the status bar. In landscape the navigation bar (and any
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
// draw underneath it, out of reach.
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
val sides = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
v.updatePadding(left = sides.left, right = sides.right)
insets
}
binding.bottomNavigation.setOnItemSelectedListener { item -> binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true if (suppressBottomNavCallback) return@setOnItemSelectedListener true
val frag = when (item.itemId) { val frag = when (item.itemId) {
@@ -528,6 +592,10 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) { private fun routeSharedQrText(text: String) {
val store = CredentialStore(this) 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) val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (bmlTarget != null) { if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber())) navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
@@ -56,4 +56,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
* for HTTP 5xx server errors from specific banks. * for HTTP 5xx server errors from specific banks.
*/ */
val connectivityErrors = MutableLiveData<Set<String>>(emptySet()) val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
} }
@@ -123,15 +123,11 @@ class CardsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val screenW = resources.displayMetrics.widthPixels stackAdapter = CardStackAdapter()
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
stackAdapter = CardStackAdapter(cardWidth)
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
binding.rvCards.clipToPadding = false binding.rvCards.clipToPadding = false
applyCarouselWidth()
val snapHelper = PagerSnapHelper() val snapHelper = PagerSnapHelper()
snapHelper.attachToRecyclerView(binding.rvCards) snapHelper.attachToRecyclerView(binding.rvCards)
@@ -637,8 +633,13 @@ class CardsFragment : Fragment() {
// ── Tap-to-pay mode ──────────────────────────────────────────────────────── // ── Tap-to-pay mode ────────────────────────────────────────────────────────
/** Held while tap mode is up: recreating the activity would clear the NFC payment token. */
private var tapGuard: HomeActivity.PaymentGuard? = null
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) { private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
isTapMode = enabled isTapMode = enabled
tapGuard?.end()
tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card) requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
if (enabled) enterTapMode(item!!) else exitTapMode() if (enabled) enterTapMode(item!!) else exitTapMode()
} }
@@ -959,6 +960,25 @@ class CardsFragment : Fragment() {
} }
} }
/** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */
private fun applyCarouselWidth() {
val screenW = resources.displayMetrics.widthPixels
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
}
// HomeActivity handles size changes itself (rotation, split screen) rather than being
// recreated, so the carousel has to re-measure for the new width on its own.
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (_binding == null) return
applyCarouselWidth()
stackAdapter.notifyDataSetChanged()
binding.rvCards.scrollToPosition(currentCardPosition)
binding.rvCards.post { if (_binding != null) applyCardScales() }
}
private fun applyCardScales() { private fun applyCardScales() {
val rv = binding.rvCards val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
@@ -1055,7 +1075,7 @@ class CardsFragment : Fragment() {
_binding = null _binding = null
} }
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() { private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
private var items: List<CardItem> = emptyList() private var items: List<CardItem> = emptyList()
fun update(newItems: List<CardItem>) { fun update(newItems: List<CardItem>) {
@@ -1070,6 +1090,8 @@ class CardsFragment : Fragment() {
override fun onBindViewHolder(holder: VH, position: Int) { override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position]) holder.bind(items[position])
// Re-applied on every bind so a width change reaches recycled holders too
holder.itemView.layoutParams.width = cardWidth
// Pre-scale based on data position so initial render and off-screen cards are correct // Pre-scale based on data position so initial render and off-screen cards are correct
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f) val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
val scale = 1f - 0.18f * fraction val scale = 1f - 0.18f * fraction
@@ -39,6 +39,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
@@ -50,6 +51,7 @@ import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
import sh.sar.basedbank.ui.home.transfer.TransferDraft
import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
@@ -66,7 +68,16 @@ class TransferFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null /**
* The form lives on the activity's ViewModel (see [TransferDraft]) so a tab switch or a
* theme/language recreation repaints it rather than starting over. The properties below are
* the fragment's view of it.
*/
private val draft: TransferDraft get() = viewModel.transferDraft
private var selectedAccount: BankAccount?
get() = draft.selectedAccount
set(value) { draft.selectedAccount = value }
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account) private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
/** /**
@@ -77,21 +88,36 @@ class TransferFragment : Fragment() {
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } } private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
// Resolved recipient info — set after successful lookup or prefill // Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = "" private var resolvedAccountNumber: String
private var resolvedRecipientName = "" get() = draft.resolvedAccountNumber
private var resolvedBankName = "" set(value) { draft.resolvedAccountNumber = value }
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */ private var resolvedRecipientName: String
private var loadedToPhoto: Bitmap? = null get() = draft.resolvedRecipientName
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown set(value) { draft.resolvedRecipientName = value }
private var resolvedToOwnAccount: BankAccount? = null private var resolvedBankName: String
get() = draft.resolvedBankName
set(value) { draft.resolvedBankName = value }
private var loadedToPhoto: Bitmap?
get() = draft.loadedToPhoto
set(value) { draft.loadedToPhoto = value }
private var resolvedDestCurrency: String
get() = draft.resolvedDestCurrency
set(value) { draft.resolvedDestCurrency = value }
private var resolvedToOwnAccount: BankAccount?
get() = draft.resolvedToOwnAccount
set(value) { draft.resolvedToOwnAccount = value }
private var savedToSubtitle: String
get() = draft.toSubtitle
set(value) { draft.toSubtitle = value }
private var savedToColorHex: String
get() = draft.toColorHex
set(value) { draft.toColorHex = value }
private var savedToImageHash: String?
get() = draft.toImageHash
set(value) { draft.toImageHash = value }
// Form state preserved across view destroy/create when the fragment instance is cached /** Set when this view applied the fragment's arguments, so the accounts observer may too. */
private var savedAmount = "" private var argsAppliedThisView = false
private var savedRemarks = ""
private var savedToText = ""
private var savedToSubtitle = ""
private var savedToColorHex = "#607D8B"
private var savedToImageHash: String? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
@@ -111,6 +137,7 @@ class TransferFragment : Fragment() {
viewModel = viewModel, viewModel = viewModel,
currentSource = { selectedAccount }, currentSource = { selectedAccount },
selectSource = ::selectSourceAccount, selectSource = ::selectSourceAccount,
clearSource = ::clearSourceAccount,
onStateChanged = { updateTransferButton() }, onStateChanged = { updateTransferButton() },
onTransferSuccess = { receipt, avatar -> onTransferSuccess = { receipt, avatar ->
ReceiptStore.save(requireContext(), receipt) ReceiptStore.save(requireContext(), receipt)
@@ -177,13 +204,17 @@ class TransferFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return if (result.resultCode != Activity.RESULT_OK) return
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: 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 // BML card/gateway/POS QR — hand off to dedicated payment screen
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) { if (bmlTarget != null) {
val fromCard = selectedAccount?.takeIf { openBmlQr(bmlTarget)
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))
return return
} }
@@ -247,6 +278,9 @@ class TransferFragment : Fragment() {
private const val ARG_REMARKS_PREFILL = "remarks_prefill" private const val ARG_REMARKS_PREFILL = "remarks_prefill"
private const val ARG_BML_QR_URL = "bml_qr_url" private const val ARG_BML_QR_URL = "bml_qr_url"
private const val ARG_AUTO_SCAN = "auto_scan" private const val ARG_AUTO_SCAN = "auto_scan"
private const val ARG_BML_TXN_ID = "bml_txn_id"
/** Set once the arguments have been applied, so later views restore the draft instead. */
private const val ARG_APPLIED = "args_applied"
fun newInstanceWithAutoScan() = TransferFragment().apply { fun newInstanceWithAutoScan() = TransferFragment().apply {
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
@@ -259,6 +293,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 { fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
} }
@@ -304,6 +343,16 @@ class TransferFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// A screen opened with its own arguments (scanned QR, picked contact…) starts a fresh
// draft, once. Every later view — tab switch back, theme recreation — restores instead.
// The flag lives in the arguments so it survives the fragment being recreated too.
val args = arguments
argsAppliedThisView = args != null && !args.getBoolean(ARG_APPLIED, false)
if (argsAppliedThisView) {
viewModel.transferDraft = TransferDraft()
args!!.putBoolean(ARG_APPLIED, true)
}
qrLauncher = requireActivity().activityResultRegistry.register( qrLauncher = requireActivity().activityResultRegistry.register(
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult() qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
) { onQrScanned(it) } ) { onQrScanned(it) }
@@ -367,6 +416,19 @@ class TransferFragment : Fragment() {
binding.etAmount.addTextChangedListener { updateTransferButton() } binding.etAmount.addTextChangedListener { updateTransferButton() }
if (argsAppliedThisView) applyArguments()
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
// Repaint here, not in onViewCreated: after a recreation the framework restores the
// EditTexts' old text in between, and that setText on the To field fires its "user
// edited the recipient" listener — which would hide a To card painted earlier.
if (!argsAppliedThisView) restoreFromDraft()
}
/** First view of a screen opened with arguments: prefill from them. */
private fun applyArguments() {
// Pre-select contact if navigated from contacts page or QR scan // Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly( prefillToDirectly(
@@ -380,15 +442,34 @@ class TransferFragment : Fragment() {
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) } arguments?.getString(ARG_BML_QR_URL)?.let { openBmlQr(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) { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
launchQrScanner() launchQrScanner()
} }
}
// Restore form state when view is recreated on the cached no-args instance /**
if (arguments == null) { * Repaints a recreated view from [draft] — nothing is looked up again. The From card is
if (resolvedAccountNumber.isNotEmpty()) { * repainted by the accounts observer; a BML QR lookup that never finished is retried there.
*/
private fun restoreFromDraft() {
// Amount first: a dynamic merchant QR overwrites and locks it below
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
val bmlQr = draft.bmlQrInfo
val mfaisaQr = draft.mfaisaQrInfo
val mfaisaRecipient = draft.mfaisaRecipient
when {
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
resolvedAccountNumber.isNotEmpty() -> {
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber } val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
if (ownAccount != null) { if (ownAccount != null) {
showToCard(ownAccount) showToCard(ownAccount)
@@ -404,22 +485,40 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null) val photo = loadedToPhoto
} else if (savedToText.isNotEmpty()) { if (photo != null) {
binding.etTo.setText(savedToText, false) binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(photo)
} else {
savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) }
}
}
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
} }
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
updateTransferButton() updateTransferButton()
} }
}
/** Restores the To-input row to its default state when a QR lookup fails. */ /**
internal fun resetToFieldVisibility() { * Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
binding.cardToInfo.visibility = View.GONE * at full colour — it's the figure being paid, so it should read clearly — and a lock icon
binding.tilTo.visibility = View.VISIBLE * says why it can't be typed into.
binding.btnPickContact.visibility = View.VISIBLE */
binding.btnScanQr.visibility = View.VISIBLE internal fun setAmountLocked(locked: Boolean) {
binding.etAmount.apply {
isFocusable = !locked
isFocusableInTouchMode = !locked
isCursorVisible = !locked
isLongClickable = !locked
if (locked) clearFocus()
}
binding.tilAmount.apply {
if (locked) {
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM
endIconDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_lock)
} else {
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_NONE
}
}
} }
internal fun startLookupLoading() { internal fun startLookupLoading() {
@@ -446,14 +545,7 @@ class TransferFragment : Fragment() {
} }
private fun setupFromDropdown() { private fun setupFromDropdown() {
binding.btnClearFromInfo.setOnClickListener { binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() }
selectedAccount = null
binding.tilAmount.prefixText = null
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
updateTransferButton()
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts) accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
@@ -480,7 +572,7 @@ class TransferFragment : Fragment() {
updateTransferButton() updateTransferButton()
} }
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT) val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView }
if (fromNumber != null && selectedAccount == null) { if (fromNumber != null && selectedAccount == null) {
val match = accounts.firstOrNull { it.accountNumber == fromNumber } val match = accounts.firstOrNull { it.accountNumber == fromNumber }
if (match != null) { if (match != null) {
@@ -492,7 +584,7 @@ class TransferFragment : Fragment() {
} }
// Auto-select default account when arriving from contacts page (TO account already pre-filled) // Auto-select default account when arriving from contacts page (TO account already pre-filled)
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) { if (selectedAccount == null && argsAppliedThisView && arguments?.getString(ARG_ACCOUNT) != null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) { if (defaultNum != null) {
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum } val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
@@ -506,12 +598,9 @@ class TransferFragment : Fragment() {
} }
// On a cold start (e.g. share intent), anyBmlSession() may be null when // On a cold start (e.g. share intent), anyBmlSession() may be null when
// onViewCreated runs. Retry the lookup once sessions are available. // onViewCreated runs; a lookup can also have been cut off by leaving the tab.
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL) // Retry it once sessions are available.
if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) { draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) }
val app = requireActivity().application as BasedBankApp
if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
}
// Re-render the from card when the view is recreated on a cached instance // Re-render the from card when the view is recreated on a cached instance
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) { if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
@@ -528,7 +617,7 @@ class TransferFragment : Fragment() {
binding.tilTo.hint = getString(R.string.ooredoo_phone) binding.tilTo.hint = getString(R.string.ooredoo_phone)
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid // Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) { if (resolvedAccountNumber.isNotBlank() && draft.mfaisaRecipient == null) {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedDestCurrency = "" resolvedDestCurrency = ""
@@ -541,8 +630,8 @@ class TransferFragment : Fragment() {
binding.tilTo.hint = getString(R.string.transfer_to) 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.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
// Drop any M-Faisa-resolved recipient when switching banks // Drop any M-Faisa-resolved recipient when switching banks
if (mfaisaHandler?.recipient != null) { if (draft.mfaisaRecipient != null) {
mfaisaHandler?.clearState() mfaisaHandler().clearState()
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedDestCurrency = "" resolvedDestCurrency = ""
@@ -713,6 +802,15 @@ class TransferFragment : Fragment() {
updateTransferButton() updateTransferButton()
} }
private fun clearSourceAccount() {
selectedAccount = null
binding.tilAmount.prefixText = null
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
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 "
} }
@@ -765,7 +863,41 @@ class TransferFragment : Fragment() {
setupContactDropdown() setupContactDropdown()
} }
/**
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
* vanish and reappear.
*/
private fun openBmlQr(bmlTarget: String) {
bmlHandler().lookupQrMerchant(bmlTarget)
}
/**
* 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() { 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) // M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") { if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty()) mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
@@ -1396,6 +1528,10 @@ class TransferFragment : Fragment() {
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
dialog.setCancelable(false) dialog.setCancelable(false)
// From here until the outcome is dismissed the payment is in flight: a theme/language
// change waits rather than recreating the screen out from under it.
val guard = (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner)
dialog.setOnDismissListener { guard?.end() }
val ctx = requireContext() val ctx = requireContext()
val dp = resources.displayMetrics.density val dp = resources.displayMetrics.density
val spinner = CircularProgressDrawable(ctx).apply { val spinner = CircularProgressDrawable(ctx).apply {
@@ -1556,6 +1692,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.VISIBLE binding.tilFrom.visibility = View.VISIBLE
binding.tilAmount.prefixText = null binding.tilAmount.prefixText = null
binding.tilAmount.isEnabled = true binding.tilAmount.isEnabled = true
setAmountLocked(false)
binding.tilRemarks.isEnabled = true binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f binding.tilRemarks.alpha = 1f
binding.etAmount.setText("") binding.etAmount.setText("")
@@ -1657,15 +1794,14 @@ class TransferFragment : Fragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
// Persist form state so it can be restored when the view is recreated // Persist form state so it can be restored when the view is recreated
savedAmount = binding.etAmount.text?.toString() ?: "" draft.amount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: "" draft.remarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else "" draft.toText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// The bank handlers hold binding refs; drop them so the next view gets fresh ones. // The bank handlers hold binding refs; drop them so the next view gets fresh ones. What
// Clearing also resets any in-progress OTP flow, which cannot sensibly resume. // they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume.
bmlHandler?.clearState() bmlHandler?.clearState()
bmlHandler = null bmlHandler = null
fahipayHandler = null fahipayHandler = null
mfaisaHandler?.clearState()
mfaisaHandler = null mfaisaHandler = null
// Unregistered automatically with viewLifecycleOwner; drop the stale handle. // Unregistered automatically with viewLifecycleOwner; drop the stale handle.
qrLauncher = null qrLauncher = null
@@ -59,6 +59,8 @@ class BmlTransferHandler(
private val currentSource: () -> BankAccount?, private val currentSource: () -> BankAccount?,
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */ /** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
private val selectSource: (BankAccount) -> Unit, private val selectSource: (BankAccount) -> Unit,
/** Asks the fragment to drop the selected source and show the empty From picker. */
private val clearSource: () -> Unit,
/** Hook called whenever handler state changes in a way that affects the Send button. */ /** Hook called whenever handler state changes in a way that affects the Send button. */
private val onStateChanged: () -> Unit, private val onStateChanged: () -> Unit,
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */ /** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
@@ -74,6 +76,18 @@ class BmlTransferHandler(
/** Business-profile OTP flow. NONE means the Send button behaves normally. */ /** Business-profile OTP flow. NONE means the Send button behaves normally. */
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP } private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
private var otpState = OtpState.NONE private var otpState = OtpState.NONE
set(value) {
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
// otherwise recreate the screen between initiate and confirm.
if (field == OtpState.NONE && value != OtpState.NONE) {
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
} else if (value == OtpState.NONE) {
otpGuard?.end()
otpGuard = null
}
field = value
}
private var otpGuard: HomeActivity.PaymentGuard? = null
private var otpChannel: String? = null private var otpChannel: String? = null
private data class PendingTransfer( private data class PendingTransfer(
@@ -93,14 +107,15 @@ class BmlTransferHandler(
) )
private var pendingTransfer: PendingTransfer? = null private var pendingTransfer: PendingTransfer? = null
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
private val draft get() = viewModel.transferDraft
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */ /** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
var qrInfo: BmlQrPayInfo? = null val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
private set
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */ /** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
private var gatewayQr = false private val gatewayQr: Boolean get() = draft.bmlGatewayQr
/** Prevents re-running the lookup after the user clears the merchant. */ /** Stops the accounts observer re-firing a lookup that is already running on this view. */
var qrLookupAttempted = false private var qrLookupInFlight = false
private set
// ─── Public API the fragment calls ─────────────────────────────────────── // ─── Public API the fragment calls ───────────────────────────────────────
@@ -155,10 +170,11 @@ class BmlTransferHandler(
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */ /** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
fun clearQrMerchant() { fun clearQrMerchant() {
draft.pendingBmlQrTarget = null
if (qrInfo == null) return if (qrInfo == null) return
qrInfo = null draft.bmlQrInfo = null
gatewayQr = false draft.bmlGatewayQr = false
binding.tilAmount.isEnabled = true fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f binding.tilRemarks.alpha = 1f
binding.etAmount.setText("") binding.etAmount.setText("")
@@ -173,18 +189,25 @@ class BmlTransferHandler(
// ─── Merchant QR ───────────────────────────────────────────────────────── // ─── Merchant QR ─────────────────────────────────────────────────────────
/**
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
* retries once sessions load (cold start) or when the view comes back (tab switched away
* mid-lookup).
*/
fun lookupQrMerchant(qrUrl: String) { fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true // Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and val draft = this.draft
// need the extra pre-initiate POST; ebanking qrpay URLs do not. draft.pendingBmlQrTarget = qrUrl
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://") if (qrLookupInFlight) return
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
val session = app.anyBmlSession() ?: return val session = app.anyBmlSession() ?: return
qrLookupInFlight = true
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
// Lock the "To" input row while loading // The To row stays on screen with a spinner while loading and is only swapped for the
binding.tilTo.visibility = View.GONE // merchant card once there is a merchant to show — hiding it up front left a gap that
binding.btnPickContact.visibility = View.GONE // made the form jump twice.
binding.btnScanQr.visibility = View.GONE fragment.startLookupLoading()
host?.setRefreshing(true) host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
@@ -192,19 +215,32 @@ class BmlTransferHandler(
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) } runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
} }
host?.setRefreshing(false) host?.setRefreshing(false)
qrLookupInFlight = false
if (fragment.view == null) return@launch
if (draft !== viewModel.transferDraft) return@launch
fragment.stopLookupLoading()
// Superseded: cleared meanwhile, or another QR was opened while this one ran
val latest = draft.pendingBmlQrTarget
if (latest != qrUrl) {
latest?.let { lookupQrMerchant(it) }
return@launch
}
draft.pendingBmlQrTarget = null
val info = result.getOrNull() val info = result.getOrNull()
if (info == null) { if (info == null) {
// An expired or rejected QR is BML telling us something specific — show its own // An expired or rejected QR is BML telling us something specific — show its own
// wording and stay put with the To row restored, rather than bouncing the user out // wording and stay put with the To row as it was, rather than bouncing the user
// of the screen they just scanned from. // out of the screen they just scanned from.
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
?: ctx.getString(R.string.bml_qr_lookup_failed) ?: ctx.getString(R.string.bml_qr_lookup_failed)
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show() Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
onStateChanged() onStateChanged()
return@launch return@launch
} }
qrInfo = info draft.bmlQrInfo = info
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
if (info.amount == 0.0) { if (info.amount == 0.0) {
RecentsCache.save(ctx, RecentPick( RecentsCache.save(ctx, RecentPick(
accountNumber = "bmlqr:$qrUrl", accountNumber = "bmlqr:$qrUrl",
@@ -216,7 +252,13 @@ class BmlTransferHandler(
)) ))
} }
// Auto-select the user's default BML card if no card was pre-selected // Hide the To row before touching the source: repainting the From card re-syncs the
// picker/scan buttons to the To row's visibility.
hideToRow()
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
// the user's default card if no card was pre-selected
if (currentSource()?.let { isCard(it) } == false) clearSource()
if (currentSource() == null) { if (currentSource() == null) {
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber() val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
if (defaultNum != null) { if (defaultNum != null) {
@@ -229,7 +271,17 @@ class BmlTransferHandler(
} }
} }
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR) showQrMerchant(info)
}
}
/**
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
* recreated view restores it — no network involved.
*/
fun showQrMerchant(info: BmlQrPayInfo) {
hideToRow()
// Clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName binding.tvToAccountName.text = info.merchantName
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" } binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE binding.tvToAccountDetails.visibility = View.GONE
@@ -241,7 +293,7 @@ class BmlTransferHandler(
// Pre-fill amount if dynamic QR // Pre-fill amount if dynamic QR
if (info.amount > 0.0) { if (info.amount > 0.0) {
binding.etAmount.setText("%.2f".format(info.amount)) binding.etAmount.setText("%.2f".format(info.amount))
binding.tilAmount.isEnabled = false fragment.setAmountLocked(true)
} }
// Remarks not applicable for merchant QR payments // Remarks not applicable for merchant QR payments
@@ -250,6 +302,11 @@ class BmlTransferHandler(
onStateChanged() onStateChanged()
} }
private fun hideToRow() {
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
} }
/** /**
@@ -54,8 +54,9 @@ class FahipayTransferHandler(
private val ctx get() = fragment.requireContext() private val ctx get() = fragment.requireContext()
/** The service picked for the current recipient; null until a lookup resolves one. */ /** The service picked for the current recipient; null until a lookup resolves one. */
var service: FahipayService? = null var service: FahipayService?
private set get() = viewModel.transferDraft.fahipayService
private set(value) { viewModel.transferDraft.fahipayService = value }
/** How the confirm dialog names the destination, or "" when nothing is selected. */ /** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty() val destinationLabel: String get() = service?.destinationLabel.orEmpty()
@@ -68,16 +68,29 @@ class MfaisaTransferHandler(
private val ctx get() = fragment.requireContext() private val ctx get() = fragment.requireContext()
private val host get() = fragment.activity as? HomeActivity private val host get() = fragment.activity as? HomeActivity
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
private val draft get() = viewModel.transferDraft
/** 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?
private set get() = draft.mfaisaRecipient
private set(value) { draft.mfaisaRecipient = value }
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */ /** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
var qrInfo: MfaisaQrPayClient.QrMerchant? = null var qrInfo: MfaisaQrPayClient.QrMerchant?
private set get() = draft.mfaisaQrInfo
private set(value) { draft.mfaisaQrInfo = value }
private var lookupInFlight = false private var lookupInFlight = false
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
private var transferGuard: HomeActivity.PaymentGuard? = null
private fun endTransferFlow() {
transferGuard?.end()
transferGuard = null
}
// ─── Public API the fragment calls ─────────────────────────────────────── // ─── Public API the fragment calls ───────────────────────────────────────
/** Whether the recipient lookup has resolved — gates the Send button. */ /** Whether the recipient lookup has resolved — gates the Send button. */
@@ -154,6 +167,8 @@ class MfaisaTransferHandler(
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
(fragment.activity as? HomeActivity)?.setRefreshing(true) (fragment.activity as? HomeActivity)?.setRefreshing(true)
endTransferFlow()
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val refId = try { val refId = try {
@@ -161,6 +176,7 @@ class MfaisaTransferHandler(
} catch (e: Exception) { } catch (e: Exception) {
(fragment.activity as? HomeActivity)?.setRefreshing(false) (fragment.activity as? HomeActivity)?.setRefreshing(false)
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
endTransferFlow()
showError(e) showError(e)
return@launch return@launch
} }
@@ -183,7 +199,7 @@ class MfaisaTransferHandler(
fun clearQrMerchant() { fun clearQrMerchant() {
if (qrInfo == null) return if (qrInfo == null) return
qrInfo = null qrInfo = null
binding.tilAmount.isEnabled = true fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f binding.tilRemarks.alpha = 1f
binding.etAmount.setText("") binding.etAmount.setText("")
@@ -209,10 +225,8 @@ class MfaisaTransferHandler(
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually // Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
if (currentSource()?.bank != "MFAISA") selectSource(source) if (currentSource()?.bank != "MFAISA") selectSource(source)
// Lock the "To" input row while loading // The To row stays up with a spinner until there is a merchant to swap in
binding.tilTo.visibility = View.GONE fragment.startLookupLoading()
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
host?.setRefreshing(true) host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
@@ -227,9 +241,10 @@ class MfaisaTransferHandler(
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
host?.setRefreshing(false) host?.setRefreshing(false)
if (fragment.view == null) return@launch
fragment.stopLookupLoading()
if (merchant == null) { if (merchant == null) {
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show() Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
return@launch return@launch
} }
qrInfo = merchant qrInfo = merchant
@@ -248,7 +263,19 @@ class MfaisaTransferHandler(
)) ))
} }
// Show merchant in the "To" card — clear button is the only way to back out showQrMerchant(merchant)
}
}
/**
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
* recreated view restores it — no network involved.
*/
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
// Clear button is the only way to back out
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.tvToAccountName.text = merchant.merchantName binding.tvToAccountName.text = merchant.merchantName
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}" binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
binding.tvToAccountDetails.visibility = View.GONE binding.tvToAccountDetails.visibility = View.GONE
@@ -261,12 +288,11 @@ class MfaisaTransferHandler(
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull() val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
if (dynamicAmount != null && dynamicAmount > 0.0) { if (dynamicAmount != null && dynamicAmount > 0.0) {
binding.etAmount.setText("%.2f".format(dynamicAmount)) binding.etAmount.setText("%.2f".format(dynamicAmount))
binding.tilAmount.isEnabled = false fragment.setAmountLocked(true)
} }
onRecipientChanged() onRecipientChanged()
} }
}
/** /**
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog — * Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
@@ -397,7 +423,8 @@ class MfaisaTransferHandler(
currentSource()?.takeIf { it.bank == "MFAISA" } currentSource()?.takeIf { it.bank == "MFAISA" }
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } ?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) { /** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
// 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
// card view, so we just populate its text fields and toggle visibility. // card view, so we just populate its text fields and toggle visibility.
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn } binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
@@ -413,7 +440,7 @@ class MfaisaTransferHandler(
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
RecentsCache.save(ctx, RecentPick( if (saveRecent) RecentsCache.save(ctx, RecentPick(
accountNumber = r.msisdn, accountNumber = r.msisdn,
displayName = r.name.ifBlank { r.msisdn }, displayName = r.name.ifBlank { r.msisdn },
subtitle = "Ooredoo M-Faisa · ${r.msisdn}", subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
@@ -470,7 +497,7 @@ class MfaisaTransferHandler(
refId: String, refId: String,
errorMsg: String? errorMsg: String?
) { ) {
val view = fragment.view ?: return val view = fragment.view ?: run { endTransferFlow(); return }
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val colorMuted = MaterialColors.getColor( val colorMuted = MaterialColors.getColor(
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY) view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
@@ -559,6 +586,7 @@ class MfaisaTransferHandler(
.setNegativeButton(R.string.cancel) { d, _ -> .setNegativeButton(R.string.cancel) { d, _ ->
d.dismiss() d.dismiss()
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
endTransferFlow()
} }
.setCancelable(false) .setCancelable(false)
.show() .show()
@@ -583,6 +611,7 @@ class MfaisaTransferHandler(
try { try {
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) } withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
(fragment.activity as? HomeActivity)?.setRefreshing(false) (fragment.activity as? HomeActivity)?.setRefreshing(false)
endTransferFlow()
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
bank = "MFAISA", bank = "MFAISA",
amount = amountValue, amount = amountValue,
@@ -607,6 +636,7 @@ class MfaisaTransferHandler(
} catch (e: Exception) { } catch (e: Exception) {
(fragment.activity as? HomeActivity)?.setRefreshing(false) (fragment.activity as? HomeActivity)?.setRefreshing(false)
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
endTransferFlow()
showError(e) showError(e)
} }
} }
@@ -0,0 +1,52 @@
package sh.sar.basedbank.ui.home.transfer
import android.graphics.Bitmap
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
import sh.sar.basedbank.api.models.BankAccount
/**
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
* text and any loaded merchant QR.
*
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
* both the view (switching tabs) and the fragment instance (a theme or language change
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
* draft.
*/
class TransferDraft {
var selectedAccount: BankAccount? = null
// Resolved recipient — set after a successful lookup or prefill
var resolvedAccountNumber = ""
var resolvedRecipientName = ""
var resolvedBankName = ""
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
var loadedToPhoto: Bitmap? = null
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
var resolvedToOwnAccount: BankAccount? = null
var toSubtitle = ""
var toColorHex = "#607D8B"
var toImageHash: String? = null
// Form text, captured when the view goes away
var amount = ""
var remarks = ""
var toText = ""
// BML merchant QR
var bmlQrInfo: BmlQrPayInfo? = null
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
var bmlGatewayQr = false
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
var pendingBmlQrTarget: String? = null
// M-Faisa
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
// Fahipay
var fahipayService: FahipayService? = null
}
+1
View File
@@ -296,6 +296,7 @@
<!-- BML QR Pay --> <!-- BML QR Pay -->
<string name="bml_qr_looking_up">Looking up merchant…</string> <string name="bml_qr_looking_up">Looking up merchant…</string>
<string name="bml_qr_lookup_failed">Could not load merchant details</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_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string> <string name="bml_qr_select_account">Select a BML account to pay from</string>
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" baseProfile="basic" id="Layer_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
xml:space="preserve">
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z"/>
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z"/>
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+6 -5
View File
@@ -17,11 +17,12 @@ You get your seed in one of two ways:
### Setup ### Setup
1. [Set up BML](01-setup-bml.md) | [<img src="../../../logos/bml_logo.png" alt="BML" height="64">](01-setup-bml.md) | [<img src="../../../logos/mib_logo.png" alt="MIB" height="64">](02-setup-mib.md) |
2. [Set up MIB](02-setup-mib.md) |:---:|:---:|
| [1. Set up BML](01-setup-bml.md) | [2. Set up MIB](02-setup-mib.md) |
### Export ### Export
3. [Export from Google Authenticator](03-export-googleauthenticator.md) | [<img src="../../../logos/google_authenticator_logo.svg" alt="Google Authenticator" height="64">](03-export-googleauthenticator.md) | [<img src="../../../logos/microsoft_authenticator_logo.png" alt="Microsoft Authenticator" height="64">](04-export-microsoft.md) | [<img src="../../../logos/bitwarden_logo.png" alt="Bitwarden" height="64">](05-export-bitwarden.md) |
4. [Export from Microsoft Authenticator](04-export-microsoft.md) |:---:|:---:|:---:|
5. [Export from Bitwarden](05-export-bitwarden.md) | [3. Export from Google Authenticator](03-export-googleauthenticator.md) | [4. Export from Microsoft Authenticator](04-export-microsoft.md) | [5. Export from Bitwarden](05-export-bitwarden.md) |
@@ -0,0 +1 @@
- Pay with BML Transaction ID (On BML Pay supported transactions"
@@ -0,0 +1,4 @@
- Fixed payments restarting or showing as failed when rotating or resizing the app, and the To box flickering when loading a merchant
- Transfer screen now keeps your form and merchant when switching tabs or changing theme/language, and waits for payments to finish before applying them
- Fixed-amount merchant QR or Gateway now show the amount clearly with a lock icon instead of greying it out
- App lock icon does not go behind navigation bar in landscape mode