add BML and MIB card freeze/unfreeze
Auto Tag on Version Change / check-version (push) Failing after 13m35s

This commit is contained in:
2026-06-13 17:40:09 +05:00
parent 6f8b7130fe
commit 014c002ebe
9 changed files with 552 additions and 19 deletions
@@ -0,0 +1,55 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
data class BmlCardActionResult(
val success: Boolean,
val message: String
)
class BmlCardClient {
private val client = newBmlApiClient()
/**
* Freezes or unfreezes a BML card.
* @param cardId BML card UUID (BankAccount.internalId)
* @param action "freeze" or "unfreeze"
*/
fun setCardFreezeState(session: BmlSession, cardId: String, action: String): BmlCardActionResult {
val body = JSONObject().apply {
put("card", cardId)
put("action", action)
}.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/services/card/freeze")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
val resp = client.newCall(request).execute()
val code = resp.code
val responseBody = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val json = JSONObject(responseBody ?: "")
val ok = json.optBoolean("success") && json.optInt("code") == 0
BmlCardActionResult(
success = ok,
message = json.optString("payload").ifBlank { json.optString("message") }
)
} catch (_: Exception) {
BmlCardActionResult(success = false, message = "")
}
}
}
@@ -7,10 +7,18 @@ import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class MibCardActionResult(
val success: Boolean,
val message: String,
val currentStatusCode: String
)
class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
@@ -20,7 +28,7 @@ class MibCardsClient {
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
val body = FormBody.Builder()
.add("name", "")
.add("start", "1")
@@ -32,7 +40,7 @@ class MibCardsClient {
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body)
.header("Cookie", cookieHeader(session))
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
.header("User-Agent", USER_AGENT)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
@@ -55,9 +63,42 @@ class MibCardsClient {
customerId = item.optString("customerId"),
phoneNumber = item.optString("phoneNumber"),
cardHolderName = item.optString("cardHolderName"),
loginTag = loginTag
loginTag = loginTag,
profileId = profileId
)
}
}
}
/** Freezes a MIB card. action = "freeze" or "unfreeze". */
fun setCardFreezeState(session: MibSession, cardId: String, action: String, comments: String): MibCardActionResult {
val body = FormBody.Builder()
.add("cardId", cardId)
.add("comments", comments)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxDebitCard/$action")
.post(body)
.header("Cookie", cookieHeader(session))
.header("User-Agent", USER_AGENT)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//debitCards/manage?cardId=$cardId&dashurl=1")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return MibCardActionResult(false, "", "")
val json = try { JSONObject(bodyStr) } catch (_: Exception) {
return MibCardActionResult(false, "", "")
}
MibCardActionResult(
success = json.optBoolean("success"),
message = json.optString("reasonText"),
currentStatusCode = json.optString("currentStatusCode")
)
}
}
}
@@ -55,7 +55,8 @@ data class MibCard(
val customerId: String,
val phoneNumber: String,
val cardHolderName: String,
val loginTag: String
val loginTag: String,
val profileId: String = ""
)
data class MibFinanceDeal(
@@ -116,7 +116,7 @@ class DashboardFragment : Fragment() {
val credStore = CredentialStore(requireContext())
val hidden = credStore.getHiddenDashboardCardNumbers()
val mibItems = (viewModel.mibCards.value ?: emptyList())
.filter { !hidden.contains(it.maskedCardNumber) }
.filter { CardsFragment.isMibCardActive(it.cardStatus) && !hidden.contains(it.maskedCardNumber) }
.map { CardItem.Mib(it) }
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) }
@@ -1130,14 +1130,14 @@ fun applyNavLabelVisibility() {
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null
try {
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag, src.profileName, src.profileId)
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag || it.profileId != src.profileId }
app.bmlAccounts = otherBml + accounts
accounts
} catch (_: Exception) { null }
} ?: return@launch
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag || it.profileId != src.profileId }
viewModel.accounts.postValue(otherAccounts + fresh)
} else {
val loginId = src.loginTag.removePrefix("mib_")
@@ -1220,7 +1220,7 @@ fun applyNavLabelVisibility() {
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (card in client.fetchCards(session, "mib_$loginId")) {
for (card in client.fetchCards(session, "mib_$loginId", profile.profileId)) {
if (seen.add(card.cardId)) result += card
}
} catch (_: Exception) { }
@@ -37,12 +37,19 @@ import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlCardClient
import sh.sar.basedbank.api.bml.BmlTapToPayClient
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.api.mib.MibCard
import android.text.InputType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
@@ -63,6 +70,8 @@ class CardsFragment : Fragment() {
private var cardWidth: Int = 0
private var pendingQrCardNumber: String? = null
private var isManageMode: Boolean = false
private var managedCardKey: String? = null
private var freezeInFlight: Boolean = false
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
@@ -155,8 +164,14 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
insets
}
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.mibCards.observe(viewLifecycleOwner) {
rebuildCards()
rebindManagedCardIfNeeded()
}
viewModel.accounts.observe(viewLifecycleOwner) {
rebuildCards()
rebindManagedCardIfNeeded()
}
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
@@ -247,20 +262,161 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnChangePin.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener {
when (val item = cards.getOrNull(currentCardPosition)) {
is CardItem.Bml -> confirmBmlFreezeToggle(item)
is CardItem.Mib -> confirmMibFreezeToggle(item)
null -> {}
}
}
binding.btnBlock.setOnClickListener(wip)
}
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
if (freezeInFlight) return
val frozen = isBmlFrozen(item.account.statusDesc)
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
MaterialAlertDialogBuilder(requireContext())
.setTitle(titleRes)
.setMessage(messageRes)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(confirmRes) { _, _ -> performBmlFreezeToggle(item, freeze = !frozen) }
.show()
}
private fun confirmMibFreezeToggle(item: CardItem.Mib) {
if (freezeInFlight) return
val frozen = isMibCardFrozen(item.card.cardStatus)
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
val ctx = requireContext()
val dp = resources.displayMetrics.density
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.card_freeze_comments_hint)
val pad = (16 * dp).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
maxLines = 3
}
inputLayout.addView(input)
MaterialAlertDialogBuilder(ctx)
.setTitle(titleRes)
.setMessage(messageRes)
.setView(inputLayout)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(confirmRes) { _, _ ->
val comments = input.text?.toString()?.trim().orEmpty()
performMibFreezeToggle(item, freeze = !frozen, comments = comments)
}
.show()
}
private fun performMibFreezeToggle(item: CardItem.Mib, freeze: Boolean, comments: String) {
val app = requireActivity().application as BasedBankApp
val action = if (freeze) "freeze" else "unfreeze"
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
val loginId = item.card.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId]
if (session == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val ownerProfile = profiles.firstOrNull { it.profileId == item.card.profileId }
?: profiles.firstOrNull { it.customerId == item.card.customerId }
freezeInFlight = true
binding.btnFreeze.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
app.mibMutex.withLock {
if (ownerProfile != null) {
app.mibFlowFor(loginId).switchProfile(session, ownerProfile)
}
MibCardsClient().setCardFreezeState(session, item.card.cardId, action, comments)
}
}
}
freezeInFlight = false
if (!isAdded || _binding == null) {
(activity as? HomeActivity)?.setRefreshing(false)
return@launch
}
binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
val response = result.getOrNull()
if (response?.success == true) {
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.triggerRefreshCards()
} else {
val msg = response?.message?.takeIf { it.isNotBlank() }
?: result.exceptionOrNull()?.message
?: getString(R.string.card_freeze_failed)
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun performBmlFreezeToggle(item: CardItem.Bml, freeze: Boolean) {
val app = requireActivity().application as BasedBankApp
val action = if (freeze) "freeze" else "unfreeze"
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
freezeInFlight = true
binding.btnFreeze.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val session = app.bmlSessionFor(item.account)
if (session == null) {
freezeInFlight = false
if (_binding != null) binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return@launch
}
val result = withContext(Dispatchers.IO) {
runCatching { BmlCardClient().setCardFreezeState(session, item.account.internalId, action) }
}
freezeInFlight = false
if (!isAdded || _binding == null) {
(activity as? HomeActivity)?.setRefreshing(false)
return@launch
}
binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
val response = result.getOrNull()
if (response?.success == true) {
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.refreshBalances(item.account)
} else {
val msg = response?.message?.takeIf { it.isNotBlank() }
?: result.exceptionOrNull()?.message
?: getString(R.string.card_freeze_failed)
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun setManageMode(enabled: Boolean) {
isManageMode = enabled
if (!enabled) managedCardKey = null
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
if (enabled) enterManageMode() else exitManageMode()
}
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
private fun cardItemKey(item: CardItem): String = when (item) {
is CardItem.Bml -> "bml:${item.account.accountNumber}"
is CardItem.Mib -> "mib:${item.card.cardId}"
}
// Bind card data
private fun bindManageCardData(item: CardItem) {
val cv = binding.manageCardView
when (item) {
is CardItem.Mib -> {
@@ -270,7 +426,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
else cv.ivCardImage.setImageDrawable(null)
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
cv.root.alpha = 1f
cv.root.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
}
is CardItem.Bml -> {
cv.tvCardOwner.text = item.account.accountBriefName
@@ -281,6 +437,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
cv.root.alpha = if (isActive) 1f else 0.45f
}
}
val isFrozen = when (item) {
is CardItem.Bml -> isBmlFrozen(item.account.statusDesc)
is CardItem.Mib -> isMibCardFrozen(item.card.cardStatus)
}
binding.btnFreeze.setText(if (isFrozen) R.string.card_action_unfreeze else R.string.card_action_freeze)
// MIB doesn't allow change PIN / block while a card is frozen; BML still does.
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
binding.btnChangePin.isEnabled = !mibFrozen
binding.btnBlock.isEnabled = !mibFrozen
}
private fun rebindManagedCardIfNeeded() {
if (!isManageMode) return
val key = managedCardKey ?: return
val newPos = cards.indexOfFirst { cardItemKey(it) == key }
if (newPos < 0) return
if (newPos != currentCardPosition) {
currentCardPosition = newPos
binding.rvCards.scrollToPosition(newPos)
}
bindManageCardData(cards[newPos])
}
private fun isBmlFrozen(statusDesc: String): Boolean =
statusDesc.equals("Block Plastic", ignoreCase = true)
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
managedCardKey = cardItemKey(item)
bindManageCardData(item)
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
@@ -716,7 +903,9 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
.map { CardItem.Bml(it) }
val bmlActive = bmlItems.filter { it.account.statusDesc.equals("Active", ignoreCase = true) }
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
val all: List<CardItem> = bmlActive + mibItems + bmlInactive
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -903,7 +1092,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
itemView.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
@@ -1038,9 +1227,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null
"CHST20" -> "Temporary blocked by client"
else -> cardStatus
}
fun isMibCardActive(cardStatus: String): Boolean = cardStatus == "CHST0"
fun isMibCardFrozen(cardStatus: String): Boolean = cardStatus == "CHST20"
fun bindCardStatus(tv: TextView, statusLabel: String?) {
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
@@ -23,6 +23,7 @@ object CardsCache {
put("phoneNumber", c.phoneNumber)
put("cardHolderName", c.cardHolderName)
put("loginTag", c.loginTag)
put("profileId", c.profileId)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -45,7 +46,8 @@ object CardsCache {
customerId = o.optString("customerId"),
phoneNumber = o.optString("phoneNumber"),
cardHolderName = o.optString("cardHolderName"),
loginTag = o.optString("loginTag")
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId")
)
}
} catch (_: Exception) { emptyList() }