Compare commits

..
8 Commits
Author SHA1 Message Date
shihaam 9e45e36c0a Merge branch 'feat/login-sort-order'
Auto Tag on Version Change / check-version (push) Failing after 4s
2026-09-25 04:02:12 +05:00
shihaam c883b6b521 added margin between icon and edge of selection in login list 2026-09-25 03:52:07 +05:00
shihaam 5dc37aadf0 update docs 2026-09-25 03:46:35 +05:00
shihaam 1e3cf3420b login sorting fixed 2026-09-25 03:43:58 +05:00
flamexode b48022d249 add ability to reorder logins
fixes #47
2026-09-21 17:14:06 +05:00
shihaam 7574a5e8b0 release v1.0.25 - idk numbers
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m33s
2026-09-21 16:30:19 +05:00
shihaam 992c8d4364 release v1.0.24
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m19s
2026-09-21 15:26:40 +05:00
shihaam 80fd238195 fix BML POS QRs 2026-09-21 15:26:18 +05:00
33 changed files with 488 additions and 94 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 24
versionName = "1.0.23"
versionCode = 26
versionName = "1.0.25"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
*/
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("accept", "application/json")
.header("x-app-version", BML_APP_VERSION)
.header("user-agent", BML_USER_AGENT)
.header("authorization", "Bearer ${session.accessToken}")
.build()
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
val currency: String
)
/**
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
* parse failures stay plain exceptions so the generic message is used for those instead.
*/
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
data class BmlQrPayResult(
val success: Boolean,
val merchant: String = "",
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.bml
import android.util.Base64
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -10,17 +11,27 @@ class BmlQrPayClient {
private val client = newBmlApiClient()
/**
* Resolves a BML QR URL to merchant details.
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
* `35` → `20` → `01` reference.
*
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
* resolves too, so this is parity rather than a requirement.
*/
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
val key = Base64.encodeToString(
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response")
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
if (!body.trimStart().startsWith("{"))
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
val json = JSONObject(body)
if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
throw BmlQrPayLookupException(
json.optInt("code"),
json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim()
@@ -5,7 +5,6 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.util.Base64
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
}
private fun lookupMerchant(qrUrl: String) {
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
catch (_: Exception) { null }
}
if (_binding == null) return@launch
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
.map { CardItem.Bml(it) }
val all = bmlItems + mibItems
val rank = credStore.loginRank()
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
notifyDataSetChanged()
}
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
expandedPositions.clear()
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
val mibItems = mibDeals.map { Item.Mib(it) }
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
notifyDataSetChanged()
}
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding
import sh.sar.basedbank.util.CredentialStore
class FinancingFragment : Fragment() {
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans)
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
val order = CredentialStore(requireContext()).getLoginOrder()
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
@@ -528,9 +528,9 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) {
val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
return
}
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
@@ -1,7 +1,8 @@
package sh.sar.basedbank.ui.home
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.CredentialStore
sealed class CardItem {
data class Mib(val card: MibCard) : CardItem()
data class Bml(val account: BankAccount) : CardItem()
abstract val loginTag: String
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
}
class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
class HomeViewModel(application: Application) : AndroidViewModel(application) {
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
override fun setValue(value: List<BankAccount>?) {
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
}
}
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
/** BML loan details keyed by account internalId. */
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
val mibCards = MutableLiveData<List<MibCard>?>(null)
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
override fun setValue(value: List<MibCard>?) {
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
}
}
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
/** Re-applies the login order after the user changes it. */
fun resortByLoginOrder() {
accounts.value = accounts.value
mibCards.value = mibCards.value
}
val hideAmounts = MutableLiveData<Boolean>(false)
@@ -86,17 +86,19 @@ class OtpFragment : Fragment() {
val store = CredentialStore(requireContext())
val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>()
val rank = store.loginRank()
val tagged = mutableListOf<Pair<String, OtpEntry>>()
for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
}
for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
}
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
val adapter = OtpAdapter(entries)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
val rank = store.loginRank()
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.annotation.SuppressLint
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
@@ -359,65 +362,86 @@ class SettingsLoginsFragment : Fragment() {
_binding = null
}
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
private fun buildLoginsSection() {
val ctx = requireContext()
val store = CredentialStore(ctx)
val container = binding.loginsContainer
container.removeAllViews()
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
val mfaisaLoginIds = store.getMfaisaLoginIds()
val entries = mutableListOf<LoginEntry>()
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
for (loginId in mibLoginIds) {
for (loginId in store.getMibLoginIds()) {
val profile = store.loadMibUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val mibProfiles = store.loadMibProfiles(loginId)
addLoginRow(container, R.drawable.mib_logo, displayName) {
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
showMibLoginDetails(store, loginId, profile, mibProfiles)
}
}
for (loginId in bmlLoginIds) {
for (loginId in store.getBmlLoginIds()) {
val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val bmlProfiles = store.loadBmlProfiles(loginId)
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
}
}
for (loginId in fahipayLoginIds) {
for (loginId in store.getFahipayLoginIds()) {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
showFahipayLoginDetails(store, loginId, profile)
}
}
for (loginId in mfaisaLoginIds) {
for (loginId in store.getMfaisaLoginIds()) {
val profile = store.loadMfaisaUserProfile(loginId)
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
showMfaisaLoginDetails(store, loginId, profile)
}
}
val rank = store.loginRank()
val sorted = entries.sortedBy { rank(it.key) }
val draggable = sorted.size > 1
for (entry in sorted) {
addLoginRow(
container, entry.logoRes, entry.displayName, entry.onClick,
dragTag = entry.key.takeIf { draggable },
onReordered = { order ->
store.setLoginOrder(order)
viewModel.resortByLoginOrder()
}
)
}
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
}
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
private fun addLoginRow(
container: LinearLayout,
logoRes: Int,
displayName: String,
onClick: () -> Unit,
dragTag: String? = null,
onReordered: (List<String>) -> Unit = {}
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
isClickable = true; isFocusable = true
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
setOnClickListener { onClick() }
tag = dragTag
}
val logo = ImageView(ctx).apply {
setImageResource(logoRes)
@@ -430,9 +454,82 @@ class SettingsLoginsFragment : Fragment() {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
row.addView(logo); row.addView(tvName)
if (dragTag != null) {
val handle = ImageView(ctx).apply {
setImageResource(R.drawable.ic_reorder_lines)
scaleType = ImageView.ScaleType.CENTER_INSIDE
contentDescription = getString(R.string.drag_to_reorder)
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
}
row.addView(handle)
attachDragHandle(handle, row, container, onReordered)
}
container.addView(row)
}
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
@SuppressLint("ClickableViewAccessibility")
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
val ctx = handle.context
val lift = 8 * ctx.resources.displayMetrics.density
val dragBg = android.graphics.drawable.ColorDrawable(
com.google.android.material.color.MaterialColors.getColor(
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
var lastY = 0f
var startIndex = 0
var savedBg: android.graphics.drawable.Drawable? = null
handle.setOnTouchListener { v, ev ->
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
v.parent.requestDisallowInterceptTouchEvent(true)
lastY = ev.rawY
startIndex = group.indexOfChild(row)
row.animate().cancel()
savedBg = row.background
row.background = dragBg
row.translationZ = lift
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
MotionEvent.ACTION_MOVE -> {
row.translationY += ev.rawY - lastY
lastY = ev.rawY
var index = group.indexOfChild(row)
while (index < group.childCount - 1) {
val next = group.getChildAt(index + 1)
if (row.translationY <= next.height / 2f) break
group.removeView(next); group.addView(next, index)
row.translationY -= next.height
next.translationY = row.height.toFloat()
next.animate().translationY(0f).setDuration(150).start()
index++
}
while (index > 0) {
val prev = group.getChildAt(index - 1)
if (row.translationY >= -prev.height / 2f) break
group.removeView(prev); group.addView(prev, index)
row.translationY += prev.height
prev.translationY = -row.height.toFloat()
prev.animate().translationY(0f).setDuration(150).start()
index--
}
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
v.parent.requestDisallowInterceptTouchEvent(false)
row.animate().translationY(0f).translationZ(0f).setDuration(150)
.withEndAction { row.background = savedBg }
.start()
if (group.indexOfChild(row) != startIndex) {
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
}
}
}
true
}
}
private fun showMibLoginDetails(
store: CredentialStore,
loginId: String,
@@ -172,13 +172,13 @@ class TransferFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
// BML card/gateway QR — hand off to dedicated payment screen
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
// 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(bmlUrl ?: raw, fromCard?.accountNumber))
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
return
}
@@ -18,6 +18,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
import sh.sar.basedbank.api.bml.BmlQrPayResult
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlTransferClient
@@ -31,6 +32,7 @@ import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.ui.home.TransferReceiptData
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
@@ -173,9 +175,10 @@ class BmlTransferHandler(
fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
// 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.
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading
@@ -185,14 +188,20 @@ class BmlTransferHandler(
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
catch (_: Exception) { null }
val result = withContext(Dispatchers.IO) {
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
}
host?.setRefreshing(false)
val info = result.getOrNull()
if (info == null) {
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
// 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
// of the screen they just scanned from.
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
?: ctx.getString(R.string.bml_qr_lookup_failed)
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
onStateChanged()
return@launch
}
qrInfo = info
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
class CredentialStore(context: Context) {
companion object {
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
}
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
private val keyAlias = "basedbank_credential_key"
private val transformation = "AES/GCM/NoPadding"
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
data class FahipayCredentials(val idCard: String, val password: String)
data class MfaisaCredentials(val msisdn: String, val pin: String)
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
fun getLoginOrder(): List<String> {
val all = getMibLoginIds().map { loginKey("mib", it) } +
getBmlLoginIds().map { loginKey("bml", it) } +
getFahipayLoginIds().map { loginKey("fahipay", it) } +
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
val saved = try {
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
return saved.filter { it in all } + all.filter { it !in saved }
}
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
fun loginRank(): (String) -> Int {
val order = getLoginOrder()
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
}
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
fun setLoginOrder(keys: List<String>) {
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
setMibLoginIds(idsFor("mib"))
setBmlLoginIds(idsFor("bml"))
setFahipayLoginIds(idsFor("fahipay"))
setMfaisaLoginIds(idsFor("mfaisa"))
}
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
fun getMibLoginIds(): List<String> {
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
fun setMibLoginIds(order: List<String>) {
if (order.toSet() != getMibLoginIds().toSet()) return
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (loginId !in ids) {
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
fun setBmlLoginIds(order: List<String>) {
if (order.toSet() != getBmlLoginIds().toSet()) return
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList()
if (loginId !in ids) {
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
fun setFahipayLoginIds(order: List<String>) {
if (order.toSet() != getFahipayLoginIds().toSet()) return
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (loginId !in ids) {
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
fun setMfaisaLoginIds(order: List<String>) {
if (order.toSet() != getMfaisaLoginIds().toSet()) return
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addMfaisaLoginId(loginId: String) {
val ids = getMfaisaLoginIds().toMutableList()
if (loginId !in ids) {
@@ -9,22 +9,58 @@ data class PaymvQrData(
object PaymvQrParser {
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
/**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
*
* Three shapes exist:
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
* `35` → `20` → `01`;
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
*
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
* that follow it.
*/
fun extractBmlGatewayUrl(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
return try {
val root = parseTlv(raw)
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
} catch (_: Exception) {
null
}
fun bmlQrPayTarget(raw: String): String? {
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
val ref = bmlQrReference(raw) ?: return null
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
}
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
private fun supplementaryDomain(raw: String): String? = try {
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
} catch (_: Exception) {
null
}
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
private fun bmlQrReference(raw: String): String? = try {
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
inner?.get("01")?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
/**
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
* "Unsupported payment link").
*/
fun bmlPayRequestKey(target: String): String {
if (target.startsWith("https://")) return target
return bmlQrReference(target) ?: target
}
fun parse(raw: String): PaymvQrData? {
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
</vector>
@@ -9,7 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="16dp"
android:clipToPadding="false">
<TextView
android:id="@+id/tvLoginsTitle"
@@ -20,10 +21,12 @@
android:layout_marginBottom="8dp"
android:visibility="gone" />
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
<LinearLayout
android:id="@+id/loginsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="-12dp"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
+1
View File
@@ -145,4 +145,5 @@
<!-- Connectivity banner -->
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
</resources>
+1
View File
@@ -391,4 +391,5 @@
<!-- Connectivity banner -->
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
<string name="connectivity_server_error">Connectivity issue with %s</string>
<string name="drag_to_reorder">Drag to reorder</string>
</resources>
+31 -1
View File
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
### 3. POS QR (`mv.com.bml.qtr`)
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
understands it. See
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
---
## PayMV QR Format (TLV)
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
```
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
rather than relying on either being required.
### Headers
| Header | Value |
|---|---|
| `accept` | `application/json` — **required in practice**, see below |
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
--header 'accept: application/json' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
@@ -116,6 +136,16 @@ curl --request GET \
| `amount` | Payment amount (`"0.00"` for static QRS) |
| `currency` | Currency code (typically `"MVR"`) |
### Failure Responses
`success: false` comes back with a code and a user-facing message; the client shows BML's own
wording and keeps the user on the Transfer screen.
| Code | Message |
|---|---|
| `103` | The payment request has expired |
| `112` | Unsupported payment link |
---
## Step 2 — Pay (3-Step TOTP Flow)
+9
View File
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
### Login Order
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
---
## MIB Session KeepAlive
+1 -1
View File
@@ -30,7 +30,7 @@ Standard RFC 6238 TOTP:
## Supported Banks
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`.
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
| Bank | Seed source | Card label |
|---|---|---|
+3 -1
View File
@@ -2,7 +2,9 @@
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`).
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
---
+2
View File
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
---
## MIB Deals Section
+14
View File
@@ -33,6 +33,20 @@ Each profile entry shows:
- Profile image (circular avatar)
- Visibility toggle switch
### Login Order
All logins are shown as one list, across banks. When there is more than one login, each row gets a three-line handle (`ic_reorder_lines`) on the right; press and drag it to move the login up or down (`SettingsLoginsFragment.attachDragHandle()`). Tapping the rest of the row still opens the login details.
On drop the order is saved with `CredentialStore.setLoginOrder()`, which:
- writes the combined order to the `login_order` pref as a JSON array of login tags (`mib_<id>`, `bml_<id>`, `fahipay_<id>`, `mfaisa_<id>` — the same format as `BankAccount.loginTag`)
- rewrites each bank's own list (`mib_login_ids`, `bml_login_ids`, …) in the same relative order, so code that walks one bank's logins stays consistent
It then calls `HomeViewModel.resortByLoginOrder()` so already-open screens re-sort immediately.
`CredentialStore.getLoginOrder()` returns the effective order: saved tags that still exist, followed by any logins not yet ordered (e.g. newly added) in the default MIB → BML → Fahipay → M-Faisa order. `loginRank()` turns that into a sort key; unknown tags sort last.
See [Login Order](00-app-overview.md#login-order) for where the order is applied.
### Visibility Toggle
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
+63 -1
View File
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `00` | Format indicator | Always `"01"` |
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
| `26` | Merchant account information | Container — see sub-tags below |
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only |
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
| `52` | Merchant category code | `"0000"` (generic) |
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
| `58` | Country code | `"MV"` |
| `59` | Merchant / recipient name | Max 25 characters |
| `60` | Merchant city / store code | BML POS QRs only |
| `62` | Additional data field | Container — see sub-tags below |
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
| `80` | Supplementary data | Container — timestamp and domain |
@@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
---
## BML POS QR (`mv.com.bml.qtr`)
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
(tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01`
holds a bare reference instead of a URL.
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
```
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
```
| TLV path | Value | Notes |
|---|---|---|
| `00` | `01` | Format indicator |
| `01` | `12` | Dynamic QR |
| `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) |
| `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
| `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) |
| `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) |
| `52` | `0000` | MCC |
| `53` | `462` | MVR |
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
| `62`→`05` | `dtyams497d` | Reference / bill number |
| `62`→`08` | `POPE` | Purpose / terminal label |
| `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
| `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp |
### Pay-Request Lookup Key
The lookup key is the **bare reference**, Base64-encoded without padding:
```
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
```
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
carry is `accept: application/json` — see
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
than with merchant details — but `103` means the reference itself resolved):
| Key tried | Response |
|---|---|
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
exactly one request is made either way.
---
## Example Payload
+15 -8
View File
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount |
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
---
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
## BML QR Merchant Payment Flow
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner.
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
Two sub-modes:
Three sub-modes:
| Mode | Trigger | Extra step |
| Mode | `bmlQrPayTarget()` returns | Extra step |
|---|---|---|
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate |
| Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
| Gateway QR | the QR text, or the URL at TLV `35`→`20`→`01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
| POS QR | the whole EMV payload, for QRs whose tag `80`→`00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35`→`20`→`01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
Flow:
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
3. Remarks field is locked (not applicable for merchant payments).
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
---
## Transfer Button Enable Conditions
@@ -238,7 +245,7 @@ Flow:
The transfer button is only enabled when all of the following are true:
- A source account is selected
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set)
- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
- Amount is greater than `0`
- No connectivity error for `NO_INTERNET` or for the source bank
+1
View File
@@ -29,6 +29,7 @@ A horizontal `RecyclerView` with `LinearSnapHelper` that paginates BML cards + M
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
- Cards are sorted by the user's [login order](00-app-overview.md#login-order)
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
+1 -1
View File
@@ -29,7 +29,7 @@ A horizontal `RecyclerView` driven by `CardStackAdapter` with a `PagerSnapHelper
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
- MIB: `viewModel.mibCards`
The combined order is `bmlActive + mibActive + bmlInactive + mibInactive`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
Active cards come first, then inactive ones; within each group cards are sorted by the user's [login order](00-app-overview.md#login-order) via `CardItem.loginTag`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
### Card Art
+2 -2
View File
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
| Caller | Result handling |
|---|---|
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` |
| [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
| [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
### Share-to-Scan Fast Path
+1 -1
View File
@@ -22,7 +22,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
| [14 — Settings](14-settings.md) | Settings hub: Logins, Appearance, Privacy & Security, Notifications, Storage, About |
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |