5 Commits
Author SHA1 Message Date
shihaam 27c428d1f6 fix prod build
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 4m18s
2026-05-18 00:39:20 +05:00
shihaam 7b4f650f4e auto select all tab in contact picker on search bar selection
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Failing after 4m4s
2026-05-18 00:32:55 +05:00
shihaam 00e109562b add support for multiple BML accounts, and BML credit cards
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 00:29:52 +05:00
shihaam cd4b3fef8b redeisgn accounts page
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 22:34:15 +05:00
shihaam 389344a192 normaize account input
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 21:45:49 +05:00
23 changed files with 419 additions and 293 deletions
@@ -19,11 +19,19 @@ class BasedBankApp : Application() {
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList() var mibProfiles: List<MibProfile> = emptyList()
var bmlSession: BmlSession? = null /** Active BML sessions keyed by loginId (= BML username). */
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList() var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null var fahipaySession: FahipaySession? = null
var fahipayAccounts: List<MibAccount> = emptyList() var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")]
/** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex() val mibMutex = Mutex()
@@ -166,17 +166,17 @@ class BmlLoginFlow {
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId) val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session) val accounts = fetchAccounts(session, "bml_$username")
return Pair(session, accounts) return Pair(session, accounts)
} }
fun fetchAccounts(session: BmlSession): List<MibAccount> { fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute() val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code val code = resp.code
val json = resp.body?.string() val json = resp.body?.string()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
return parseDashboard(json ?: return emptyList(), "bml_${session.deviceId}") return parseDashboard(json ?: return emptyList(), loginTag)
} }
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> { fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
@@ -324,11 +324,11 @@ class BmlLoginFlow {
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false } return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
} }
fun fetchContacts(session: BmlSession): List<MibBeneficiary> { fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute() val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList() val json = resp.body?.string() ?: return emptyList()
resp.close() resp.close()
return parseContacts(json) return parseContacts(json, loginId)
} }
/** /**
@@ -634,29 +634,28 @@ class BmlLoginFlow {
internalId = internalId internalId = internalId
)) ))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
val isPrepaid = item.optBoolean("prepaid_card", false) val isPrepaid = item.optBoolean("prepaid_card", false)
if (isPrepaid) { val cardBalance = item.optJSONObject("cardBalance")
val cardBalance = item.optJSONObject("cardBalance") val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount( prepaidCards.add(MibAccount(
profileName = "Personal", profileName = "Personal",
profileType = "BML_PREPAID", profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber, accountNumber = accountNumber,
accountBriefName = product, accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency, currencyName = currency,
accountTypeName = product, accountTypeName = product,
availableBalance = "%.2f".format(available), availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0), currentBalance = "%.2f".format(current),
blockedAmount = "0.00", blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00", mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status, statusDesc = status,
profileImageHash = null, profileImageHash = null,
loginTag = loginTag, loginTag = loginTag,
internalId = internalId internalId = internalId
)) ))
} else {
// Linked debit cards have no independent balance or account link — skip
}
} }
} }
@@ -694,7 +693,7 @@ class BmlLoginFlow {
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
private fun parseContacts(json: String): List<MibBeneficiary> { private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList() if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList() val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
@@ -715,7 +714,8 @@ class BmlLoginFlow {
benefStatus = item.optString("status", "S"), benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"), transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null, customerImgHash = null,
benefCategoryId = "BML" benefCategoryId = "BML",
profileId = loginId
)) ))
} }
return result return result
@@ -34,6 +34,7 @@ class AccountHistoryAdapter(
private val iconUrlCache = mutableMapOf<String, Bitmap>() private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((MibAccount) -> Unit)? = null
fun updateImage(counterpartyName: String, bitmap: Bitmap) { fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
@@ -164,6 +165,7 @@ class AccountHistoryAdapter(
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
} }
private fun friendlyType(raw: String): String { private fun friendlyType(raw: String): String {
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
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.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
@@ -82,6 +83,9 @@ class AccountHistoryFragment : Fragment() {
adapter = AccountHistoryAdapter(account) adapter = AccountHistoryAdapter(account)
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
@@ -130,7 +134,7 @@ class AccountHistoryFragment : Fragment() {
} }
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY" private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY" private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when { private fun hasMore(): Boolean = when {
@@ -185,7 +189,7 @@ class AccountHistoryFragment : Fragment() {
} }
} }
isBmlCard() -> { isBmlCard() -> {
val session = app.bmlSession ?: return@withContext emptyList() val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset) cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
@@ -199,7 +203,7 @@ class AccountHistoryFragment : Fragment() {
) )
} }
else -> { else -> {
val session = app.bmlSession ?: return@withContext emptyList() val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session, session = session,
accountId = account.internalId, accountId = account.internalId,
@@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<MibAccount>,
@@ -21,7 +21,7 @@ class AccountsAdapter(
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String, val chip: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item() data class Account(val account: MibAccount) : Item()
data class Card(val account: MibAccount) : Item() data class Card(val account: MibAccount) : Item()
} }
@@ -35,19 +35,40 @@ class AccountsAdapter(
} }
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList { private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" } val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" } val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
if (regular.isNotEmpty()) { // Group non-prepaid accounts by their derived section title, preserving order
add(Item.SectionTitle("Accounts", "")) val groups = LinkedHashMap<String, MutableList<MibAccount>>()
regular.forEach { add(Item.Account(it)) } for (acc in nonPrepaid) {
val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc)
} }
for ((title, group) in groups) {
add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) }
}
if (prepaid.isNotEmpty()) { if (prepaid.isNotEmpty()) {
add(Item.SectionTitle("Cards", "BML")) add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) } prepaid.forEach { add(Item.Card(it)) }
} }
} }
private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
}
val bank = when {
account.profileType.startsWith("BML") -> "Bank of Maldives"
account.profileType == "FAHIPAY" -> "Fahipay"
else -> "Maldives Islamic Bank"
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
}
override fun getItemViewType(position: Int) = when (items[position]) { override fun getItemViewType(position: Int) = when (items[position]) {
is Item.SectionTitle -> TYPE_HEADER is Item.SectionTitle -> TYPE_HEADER
is Item.Account -> TYPE_ACCOUNT is Item.Account -> TYPE_ACCOUNT
@@ -57,7 +78,7 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
return when (viewType) { return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemProfileHeaderBinding.inflate(inflater, parent, false)) TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false)) TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false)) else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
} }
@@ -73,16 +94,10 @@ class AccountsAdapter(
override fun getItemCount() = items.size override fun getItemCount() = items.size
private inner class SectionViewHolder(private val binding: ItemProfileHeaderBinding) : private inner class SectionViewHolder(private val binding: ItemDateHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(item: Item.SectionTitle) { fun bind(item: Item.SectionTitle) {
binding.tvProfileName.text = item.label binding.tvDateHeader.text = item.label
if (item.chip.isNotEmpty()) {
binding.tvProfileType.text = item.chip
binding.tvProfileType.visibility = View.VISIBLE
} else {
binding.tvProfileType.visibility = View.GONE
}
} }
} }
@@ -91,18 +106,8 @@ class AccountsAdapter(
fun bind(account: MibAccount) { fun bind(account: MibAccount) {
binding.tvAccountName.text = account.accountBriefName binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber binding.tvAccountNumber.text = account.accountNumber
binding.tvPillBank.text = when { binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
account.profileType.startsWith("BML") -> "BML" binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
account.profileType == "FAHIPAY" -> "FP"
else -> null
}
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
binding.tvPillProfile.text = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
}
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber) copyToClipboard(it.context, account.accountNumber)
@@ -86,7 +86,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
for (profile in app.mibProfiles) { for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
} }
if (app.bmlSession != null) { if (app.anyBmlSession() != null) {
list.add(DestinationOption("BML · Personal", isBml = true)) list.add(DestinationOption("BML · Personal", isBml = true))
} }
return list return list
@@ -186,7 +186,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForBml(input: String): BmlAccountValidation? { private fun lookupForBml(input: String): BmlAccountValidation? {
val bmlSess = app.bmlSession ?: return null val bmlSess = app.anyBmlSession() ?: return null
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
// 1) Try BML validate // 1) Try BML validate
@@ -236,7 +236,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (mibResult != null) return mibResult if (mibResult != null) return mibResult
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate // MIB lookup failed (e.g. BML USD account) — fall back to BML validate
val bmlSess = app.bmlSession ?: return null val bmlSess = app.anyBmlSession() ?: return null
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null } return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
} }
@@ -358,7 +358,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun saveToBml(alias: String): Boolean { private fun saveToBml(alias: String): Boolean {
val bmlSess = app.bmlSession ?: return false val bmlSess = app.anyBmlSession() ?: return false
val lookup = bmlLookup ?: return false val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
val account = lookup.account val account = lookup.account
@@ -425,12 +425,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
requireActivity().lifecycleScope.launch(Dispatchers.IO) { requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try { try {
if (dest.isBml) { if (dest.isBml) {
val bmlSess = app.bmlSession ?: return@launch val bmlSess = app.anyBmlSession() ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess) val loginId = app.bmlSessions.entries.firstOrNull { it.value == bmlSess }?.key ?: ""
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged) viewModel.contacts.postValue(merged)
ContactsCache.saveBml(requireContext(), fresh) if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else { } else {
val profile = dest.mibProfile ?: return@launch val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch val mibSess = app.mibSession ?: return@launch
@@ -122,6 +122,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
attachMediator(initialPages) attachMediator(initialPages)
binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() } binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() }
binding.etSheetSearch.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
val allIndex = pagerAdapter.pages.indexOfFirst { it.tag == null }
if (allIndex >= 0) binding.viewPager.setCurrentItem(allIndex, true)
}
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
val pages = buildList { val pages = buildList {
@@ -203,11 +209,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: "" val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: "" val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
if (tabTag == MY_ACCOUNTS_TAG) { if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" } val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter { val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -158,7 +158,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.customerImgHash imageHash = contact.customerImgHash
) )
(requireActivity() as HomeActivity).showWithBackStack(fragment) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: MibBeneficiary) {
@@ -185,7 +185,7 @@ class ContactsFragment : Fragment() {
} }
private fun deleteBml(contact: MibBeneficiary): Boolean { private fun deleteBml(contact: MibBeneficiary): Boolean {
val sess = app.bmlSession ?: return false val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.benefNo.removePrefix("bml_") val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false } return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
} }
@@ -205,7 +205,11 @@ class ContactsFragment : Fragment() {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
viewModel.contacts.value = updated viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") { if (contact.benefCategoryId == "BML") {
ContactsCache.saveBml(requireContext(), updated.filter { it.benefCategoryId == "BML" }) updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId }
.forEach { (loginId, contacts) ->
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, contacts)
}
} else { } else {
ContactsCache.save( ContactsCache.save(
requireContext(), requireContext(),
@@ -119,7 +119,10 @@ class HomeActivity : AppCompatActivity() {
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged viewModel.accounts.value = merged
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts) if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts) if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
}
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts) if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts)
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
@@ -128,11 +131,12 @@ class HomeActivity : AppCompatActivity() {
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles) refreshFinancing(app.mibSession, app.mibProfiles)
if (app.bmlSession != null) refreshBmlLimits(app.bmlSession!!) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else { } else {
// Came from lock screen — show caches immediately, refresh everything in background // Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this)
val cachedMib = AccountCache.load(this) val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this) val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this) val cachedFahipay = AccountCache.loadFahipay(this)
val merged = cachedMib + cachedBml + cachedFahipay val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged
@@ -141,8 +145,7 @@ class HomeActivity : AppCompatActivity() {
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
val store = CredentialStore(this) autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store)
} }
// Show dashboard on first create // Show dashboard on first create
@@ -199,18 +202,19 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun navigateTo(itemId: Int) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> show(DashboardFragment()) R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> show(AccountsFragment()) R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> show(ContactsFragment()) R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> show(TransferFragment()) R.id.nav_transfer -> TransferFragment()
R.id.nav_transfer_history -> show(TransferHistoryFragment()) R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> show(FinancingFragment()) R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> show(OtpFragment()) R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> show(SettingsFragment()) R.id.nav_settings -> SettingsFragment()
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show() else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
} }
show(dest)
binding.navigationView.setCheckedItem(itemId) binding.navigationView.setCheckedItem(itemId)
val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more) val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more)
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) { if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) {
@@ -323,9 +327,9 @@ class HomeActivity : AppCompatActivity() {
fun relogin() { fun relogin() {
val store = CredentialStore(this) val store = CredentialStore(this)
val hasMib = store.hasMibCredentials() val hasMib = store.hasMibCredentials()
val hasBml = store.hasBmlCredentials() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val hasFahipay = store.hasFahipayCredentials()
if (!hasMib && !hasBml && !hasFahipay) { if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) {
startActivity(Intent(this, LoginActivity::class.java)) startActivity(Intent(this, LoginActivity::class.java))
finish() finish()
return return
@@ -334,24 +338,26 @@ class HomeActivity : AppCompatActivity() {
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc -> viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false
if (!hasBml && acc.profileType.startsWith("BML")) return@filter false if (acc.profileType.startsWith("BML")) {
val loginId = acc.loginTag.removePrefix("bml_")
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
true true
} }
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
} }
private fun autoRefresh( private fun autoRefresh(
mibCreds: CredentialStore.MibCredentials?, mibCreds: CredentialStore.MibCredentials?,
bmlCreds: CredentialStore.BmlCredentials?,
fahipayCreds: CredentialStore.FahipayCredentials?, fahipayCreds: CredentialStore.FahipayCredentials?,
store: CredentialStore store: CredentialStore
) { ) {
if (mibCreds == null && bmlCreds == null && fahipayCreds == null) return val bmlLoginIds = store.getBmlLoginIds()
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch { lifecycleScope.launch {
// MIB and BML login run in parallel
val mibJob = mibCreds?.let { val mibJob = mibCreds?.let {
async(Dispatchers.IO) { async(Dispatchers.IO) {
try { try {
@@ -367,39 +373,36 @@ class HomeActivity : AppCompatActivity() {
} }
} }
val bmlJob = bmlCreds?.let { // One async job per BML login, all run in parallel
async(Dispatchers.IO) { val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
val savedToken = store.loadBmlSession() val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId)
// Try cached token first
if (savedToken != null) { if (savedToken != null) {
try { try {
val session = BmlSession(savedToken.first, savedToken.second) val session = BmlSession(savedToken.first, savedToken.second)
val accounts = bmlFlow.fetchAccounts(session) val accounts = bmlFlow.fetchAccounts(session, loginTag)
val app = application as BasedBankApp val app = application as BasedBankApp
app.bmlSession = session app.bmlSessions[loginId] = session
app.bmlAccounts = accounts AccountCache.saveBml(this@HomeActivity, loginId, accounts)
AccountCache.saveBml(this@HomeActivity, accounts)
return@async Pair(session, accounts) return@async Pair(session, accounts)
} catch (_: AuthExpiredException) { } catch (_: AuthExpiredException) {
// Token expired — fall through to full login
} catch (_: Exception) { } catch (_: Exception) {
// Network or other error — fall through to full login
} }
} }
// Full login (token missing or expired)
try { try {
val (session, accounts) = bmlFlow.login(it.username, it.password, it.otpSeed) val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlSession(session.accessToken, session.deviceId) store.saveBmlSession(loginId, session.accessToken, session.deviceId)
val app = application as BasedBankApp val app = application as BasedBankApp
app.bmlSession = session app.bmlSessions[loginId] = session
app.bmlAccounts = accounts AccountCache.saveBml(this@HomeActivity, loginId, accounts)
AccountCache.saveBml(this@HomeActivity, accounts)
Pair(session, accounts) Pair(session, accounts)
} catch (_: Exception) { } catch (_: Exception) {
Pair(null, AccountCache.loadBml(this@HomeActivity)) Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
} }
} }
} }
@@ -409,7 +412,6 @@ class HomeActivity : AppCompatActivity() {
val fahipayFlow = FahipayLoginFlow() val fahipayFlow = FahipayLoginFlow()
val deviceUuid = store.getOrCreateFahipayDeviceUuid() val deviceUuid = store.getOrCreateFahipayDeviceUuid()
// Try cached session first
val savedSession = store.loadFahipaySession() val savedSession = store.loadFahipaySession()
if (savedSession != null) { if (savedSession != null) {
try { try {
@@ -425,15 +427,12 @@ class HomeActivity : AppCompatActivity() {
AccountCache.saveFahipay(this@HomeActivity, accounts) AccountCache.saveFahipay(this@HomeActivity, accounts)
return@async Pair(session, accounts) return@async Pair(session, accounts)
} catch (_: Exception) { } catch (_: Exception) {
// Session expired — fall through to full login
} }
} }
// Full re-login (only works if user has no 2FA, or 2FA was skipped)
try { try {
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid) val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
if (step.twoFactorRequired) { if (step.twoFactorRequired) {
// Can't auto-complete 2FA — use cached data
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
} }
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
@@ -456,14 +455,16 @@ class HomeActivity : AppCompatActivity() {
} }
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity) val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity)
val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity)) val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity))
val app = application as BasedBankApp
app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts) viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
val app = application as BasedBankApp for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
if (bmlSession != null) refreshBmlLimits(bmlSession)
refreshFinancing(app.mibSession, app.mibProfiles) refreshFinancing(app.mibSession, app.mibProfiles)
} }
} }
@@ -486,16 +487,21 @@ class HomeActivity : AppCompatActivity() {
} }
private fun refreshBmlContacts(app: BasedBankApp) { private fun refreshBmlContacts(app: BasedBankApp) {
val session = app.bmlSession ?: return if (app.bmlSessions.isEmpty()) return
val bmlFlow = BmlLoginFlow()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val bmlContacts = withContext(Dispatchers.IO) { bmlFlow.fetchContacts(session) } val allBmlContacts = withContext(Dispatchers.IO) {
if (bmlContacts.isNotEmpty()) { app.bmlSessions.flatMap { (loginId, session) ->
ContactsCache.saveBml(this@HomeActivity, bmlContacts) val contacts = BmlLoginFlow().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts
}
}
if (allBmlContacts.isNotEmpty()) {
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val mibContacts = ContactsCache.loadContacts(this@HomeActivity) val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity) val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), fahipayContacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, allBmlContacts), fahipayContacts))
} }
} catch (_: Exception) { /* keep cached */ } } catch (_: Exception) { /* keep cached */ }
} }
@@ -503,10 +509,11 @@ class HomeActivity : AppCompatActivity() {
fun loadAllContacts() { fun loadAllContacts() {
val app = application as BasedBankApp val app = application as BasedBankApp
val store = sh.sar.basedbank.util.CredentialStore(this)
// Populate ViewModel from cache immediately if empty // Populate ViewModel from cache immediately if empty
if (viewModel.contacts.value.isNullOrEmpty()) { if (viewModel.contacts.value.isNullOrEmpty()) {
val cached = mergeContacts( val cached = mergeContacts(
mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this)), mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this, store.getBmlLoginIds())),
ContactsCache.loadFahipay(this) ContactsCache.loadFahipay(this)
) )
if (cached.isNotEmpty()) viewModel.contacts.value = cached if (cached.isNotEmpty()) viewModel.contacts.value = cached
@@ -534,7 +541,8 @@ class HomeActivity : AppCompatActivity() {
val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) } val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) }
ContactsCache.saveFahipay(this@HomeActivity, contacts, categories) ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
val mibContacts = ContactsCache.loadContacts(this@HomeActivity) val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity) val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), contacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), contacts))
val mibCategories = ContactsCache.loadCategories(this@HomeActivity) val mibCategories = ContactsCache.loadCategories(this@HomeActivity)
viewModel.contactCategories.postValue(mibCategories + categories) viewModel.contactCategories.postValue(mibCategories + categories)
@@ -580,7 +588,8 @@ class HomeActivity : AppCompatActivity() {
} }
if (allContacts.isNotEmpty()) { if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories) ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity) val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity) val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity) val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
@@ -612,17 +621,19 @@ class HomeActivity : AppCompatActivity() {
val others = current.filter { it.profileType != "FAHIPAY" } val others = current.filter { it.profileType != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh) viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) { } else if (src.profileType.startsWith("BML")) {
val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSession ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
try { try {
val accounts = BmlLoginFlow().fetchAccounts(sess) val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
AccountCache.saveBml(this@HomeActivity, accounts) AccountCache.saveBml(this@HomeActivity, loginId, accounts)
app.bmlAccounts = accounts val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
app.bmlAccounts = otherBml + accounts
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val mibOnly = current.filter { !it.profileType.startsWith("BML") } val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
viewModel.accounts.postValue(mibOnly + fresh) viewModel.accounts.postValue(otherAccounts + fresh)
} else { } else {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null val sess = app.mibSession ?: return@withContext null
@@ -75,9 +75,10 @@ class OtpFragment : Fragment() {
val name = store.loadMibFullName() val name = store.loadMibFullName()
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
} }
store.loadBmlCredentials()?.let { creds -> for (loginId in store.getBmlLoginIds()) {
val name = store.loadBmlFullName() val creds = store.loadBmlCredentials(loginId) ?: continue
entries.add(OtpEntry(if (name != null) "BML · $name" else "BML", creds.otpSeed)) val name = store.loadBmlUserProfile(loginId)?.fullName
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
} }
val adapter = OtpAdapter(entries) val adapter = OtpAdapter(entries)
@@ -105,13 +106,14 @@ class OtpFragment : Fragment() {
} }
} }
} }
if (store.loadBmlFullName() == null) { for (loginId in store.getBmlLoginIds()) {
app.bmlSession?.let { session -> if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
val session = app.bmlSessions[loginId] ?: continue
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null } try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
} }
if (info != null) { if (info != null) {
store.saveBmlUserProfile(CredentialStore.BmlUserProfile( store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
mobile = info.mobile, mobile = info.mobile,
@@ -119,7 +121,8 @@ class OtpFragment : Fragment() {
idCard = info.idCard, idCard = info.idCard,
birthdate = info.birthdate birthdate = info.birthdate
)) ))
val idx = entries.indexOfFirst { it.seed == store.loadBmlCredentials()?.otpSeed } val seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
} }
} }
@@ -59,10 +59,10 @@ class SettingsLoginsFragment : Fragment() {
container.removeAllViews() container.removeAllViews()
val hasMib = store.hasMibCredentials() val hasMib = store.hasMibCredentials()
val hasBml = store.hasBmlCredentials() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val hasFahipay = store.hasFahipayCredentials()
binding.tvLoginsTitle.visibility = if (hasMib || hasBml || hasFahipay) View.VISIBLE else View.GONE binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE
if (hasMib) { if (hasMib) {
val profile = store.loadMibUserProfile() val profile = store.loadMibUserProfile()
@@ -86,10 +86,10 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
if (hasBml) { for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile() val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val profileNames = AccountCache.loadBml(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct() val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct()
addLoginRow(container, R.drawable.bml_logo_vector, displayName) { addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
showLoginDetails( showLoginDetails(
title = getString(R.string.bml_name), title = getString(R.string.bml_name),
@@ -105,7 +105,7 @@ class SettingsLoginsFragment : Fragment() {
profileNames.forEach { appendLine("$it") } profileNames.forEach { appendLine("$it") }
} }
}.trim(), }.trim(),
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store) } } onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
) )
} }
} }
@@ -183,11 +183,12 @@ class SettingsLoginsFragment : Fragment() {
buildLoginsSection() buildLoginsSection()
} }
private fun logoutBml(store: CredentialStore) { private fun logoutBml(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearBmlCredentials(); store.clearBmlSession() store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSession = null; app.bmlAccounts = emptyList() app.bmlSessions.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()
@@ -63,7 +63,9 @@ class TransferFragment : Fragment() {
private var selectedAccount: MibAccount? = null private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession private fun bmlSessionFor(account: MibAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession()
// Resolved recipient info — set after successful lookup or prefill // Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = "" private var resolvedAccountNumber = ""
@@ -93,6 +95,11 @@ class TransferFragment : Fragment() {
private const val ARG_SUBTITLE = "contact_subtitle" private const val ARG_SUBTITLE = "contact_subtitle"
private const val ARG_COLOR = "contact_color" private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash" private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_FROM_ACCOUNT = "from_account"
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
}
fun newInstance( fun newInstance(
accountNumber: String, accountNumber: String,
@@ -171,6 +178,16 @@ class TransferFragment : Fragment() {
updateAmountPrefix(picked) updateAmountPrefix(picked)
showFromCard(picked) showFromCard(picked)
} }
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
if (fromNumber != null && selectedAccount == null) {
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
if (match != null) {
selectedAccount = match
updateAmountPrefix(match)
showFromCard(match)
}
}
} }
} }
@@ -188,6 +205,7 @@ class TransferFragment : Fragment() {
} }
val typeLabel = when { val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card" account.profileType == "BML_PREPAID" -> "Prepaid Card"
account.profileType == "BML_CREDIT" -> "Credit Card"
account.accountTypeName.isNotBlank() -> account.accountTypeName account.accountTypeName.isNotBlank() -> account.accountTypeName
else -> account.profileType else -> account.profileType
} }
@@ -256,7 +274,7 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
return return
} }
val accountNumber = binding.etTo.text?.toString()?.trim() ?: "" val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) { if (accountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return return
@@ -287,7 +305,7 @@ class TransferFragment : Fragment() {
} }
val mibSess = session val mibSess = session
val bmlSess = bmlSession val bmlSess = bmlSessionFor(selectedAccount)
if (mibSess == null && bmlSess == null) { if (mibSess == null && bmlSess == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return return
@@ -533,7 +551,7 @@ class TransferFragment : Fragment() {
val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML") val isSrcBml = src.profileType.startsWith("BML")
val isSrcCard = src.profileType == "BML_PREPAID" val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
val allAccounts = viewModel.accounts.value ?: emptyList() val allAccounts = viewModel.accounts.value ?: emptyList()
@@ -562,6 +580,7 @@ class TransferFragment : Fragment() {
?.transferCyDesc?.ifBlank { "MVR" } ?.transferCyDesc?.ifBlank { "MVR" }
?: if (isDestMib) "MVR" else "MVR" ?: if (isDestMib) "MVR" else "MVR"
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true) val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
val isSrcCredit = src.profileType == "BML_CREDIT"
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}" val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
@@ -592,7 +611,7 @@ class TransferFragment : Fragment() {
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() } .setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
if (isUsdToMvr) { if (isUsdToMvr || isSrcCredit) {
val ctx = requireContext() val ctx = requireContext()
val dp = resources.displayMetrics.density val dp = resources.displayMetrics.density
val container = LinearLayout(ctx).apply { val container = LinearLayout(ctx).apply {
@@ -600,13 +619,24 @@ class TransferFragment : Fragment() {
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0) setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
} }
container.addView(TextView(ctx).apply { text = mainMsg }) container.addView(TextView(ctx).apply { text = mainMsg })
container.addView(TextView(ctx).apply { if (isUsdToMvr) {
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!" container.addView(TextView(ctx).apply {
setTextColor(Color.RED) text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
textSize = 16f setTextColor(Color.RED)
typeface = Typeface.DEFAULT_BOLD textSize = 16f
setPadding(0, (16 * dp).toInt(), 0, 0) typeface = Typeface.DEFAULT_BOLD
}) setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
if (isSrcCredit) {
container.addView(TextView(ctx).apply {
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
setTextColor(Color.RED)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
dialogBuilder.setView(container) dialogBuilder.setView(container)
} else { } else {
dialogBuilder.setMessage(mainMsg) dialogBuilder.setMessage(mainMsg)
@@ -726,8 +756,9 @@ class TransferFragment : Fragment() {
allAccounts: List<MibAccount>, allAccounts: List<MibAccount>,
allContacts: List<MibBeneficiary> allContacts: List<MibBeneficiary>
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val sess = bmlSession ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val loginId = src.loginTag.removePrefix("bml_")
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank { val debitAccount = src.internalId.ifBlank {
@@ -735,7 +766,7 @@ class TransferFragment : Fragment() {
} }
// Determine type + credit account // Determine type + credit account
val isDestMyCard = allAccounts.any { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount } val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
val (transferType, creditAccount, bank) = when { val (transferType, creditAccount, bank) = when {
isSrcCard -> { isSrcCard -> {
// CAD: card → own BML account // CAD: card → own BML account
@@ -744,7 +775,7 @@ class TransferFragment : Fragment() {
} }
isDestMyCard -> { isDestMyCard -> {
// CPA: BML CASA → own card top-up // CPA: BML CASA → own card top-up
val card = allAccounts.first { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount } val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?) Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
} }
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB") isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
@@ -767,7 +798,7 @@ class TransferFragment : Fragment() {
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null) if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
// Step 2: confirm with fresh OTP // Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?: otp ?.let { Totp.generate(it) } ?: otp
return try { return try {
@@ -917,8 +948,8 @@ class TransferFragment : Fragment() {
) : BaseAdapter(), Filterable { ) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList { private val items: List<Any> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" } val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
addAll(regular) addAll(regular)
if (cards.isNotEmpty()) { if (cards.isNotEmpty()) {
add(getString(R.string.cards)) add(getString(R.string.cards))
@@ -927,7 +958,7 @@ class TransferFragment : Fragment() {
} }
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount) fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
?.takeUnless { it.profileType == "BML_PREPAID" && !it.statusDesc.equals("Active", ignoreCase = true) } ?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getCount() = items.size override fun getCount() = items.size
override fun getItem(position: Int) = items[position] override fun getItem(position: Int) = items[position]
@@ -958,7 +989,7 @@ class TransferFragment : Fragment() {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false) ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = acc.profileType == "BML_PREPAID" && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
b.tvDropdownAccountName.text = acc.accountBriefName b.tvDropdownAccountName.text = acc.accountBriefName
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}" b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
@@ -62,7 +62,7 @@ class TransferHistoryFragment : Fragment() {
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
@@ -143,7 +143,6 @@ class TransferHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession val mibSession = app.mibSession
val bmlSession = app.bmlSession
lifecycleScope.launch { lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
@@ -155,8 +154,8 @@ class TransferHistoryFragment : Fragment() {
async { async {
try { try {
when { when {
state.account.profileType == "BML_PREPAID" -> { state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
val session = bmlSession ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
@@ -170,7 +169,7 @@ class TransferHistoryFragment : Fragment() {
) )
} }
else -> { else -> {
val session = bmlSession ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session, session = session,
accountId = state.account.internalId, accountId = state.account.internalId,
@@ -185,6 +185,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val loginId = username
val flow = BmlLoginFlow() val flow = BmlLoginFlow()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
try { try {
@@ -192,11 +193,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, password, otpSeed) flow.login(username, password, otpSeed)
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlCredentials(username, password, otpSeed) store.saveBmlCredentials(loginId, username, password, otpSeed)
store.saveBmlSession(session.accessToken, session.deviceId) store.saveBmlSession(loginId, session.accessToken, session.deviceId)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val info = flow.fetchUserInfo(session) val info = flow.fetchUserInfo(session)
if (info != null) store.saveBmlUserProfile( if (info != null) store.saveBmlUserProfile(
loginId,
CredentialStore.BmlUserProfile( CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
@@ -207,11 +209,11 @@ class CredentialsFragment : Fragment() {
) )
) )
} }
AccountCache.saveBml(requireContext(), accounts) AccountCache.saveBml(requireContext(), loginId, accounts)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSession = session app.bmlSessions[loginId] = session
app.bmlAccounts = accounts // Merge with any existing BML accounts from other logins
// Merge with any existing MIB accounts already in app app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
app.accounts = app.accounts + accounts app.accounts = app.accounts + accounts
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
@@ -9,9 +9,10 @@ object AccountCache {
private const val PREFS = "account_cache" private const val PREFS = "account_cache"
private const val KEY_MIB = "mib_accounts" private const val KEY_MIB = "mib_accounts"
private const val KEY_BML = "bml_accounts"
private const val KEY_FAHIPAY = "fahipay_accounts" private const val KEY_FAHIPAY = "fahipay_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
fun save(context: Context, accounts: List<MibAccount>) { fun save(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
@@ -36,7 +37,7 @@ object AccountCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
} }
fun saveBml(context: Context, accounts: List<MibAccount>) { fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -56,15 +57,14 @@ object AccountCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
} }
fun loadBml(context: Context): List<MibAccount> { fun loadBml(context: Context, loginId: String): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML, null) ?: return emptyList() .getString(bmlKey(loginId), null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw) val arr = JSONArray(CacheEncryption.decrypt(raw))
val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
@@ -84,9 +84,12 @@ object AccountCache {
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (e: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, accounts: List<MibAccount>) { fun saveFahipay(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
@@ -11,6 +11,21 @@ object AccountInputParser {
UNKNOWN UNKNOWN
} }
/**
* Strip spaces and remove a 960/+960 country code prefix, but only when
* the stripped result is exactly 7 digits (so "9603456" is left intact).
*/
fun normalize(input: String): String {
var s = input.replace(" ", "")
val stripped = when {
s.startsWith("+960") -> s.removePrefix("+960")
s.startsWith("960") -> s.removePrefix("960")
else -> null
}
if (stripped != null && stripped.matches(Regex("^\\d{7}$"))) s = stripped
return s
}
fun detect(input: String): InputType { fun detect(input: String): InputType {
val s = input.trim() val s = input.trim()
return when { return when {
@@ -84,7 +84,9 @@ object ContactsCache {
} }
} }
fun saveBml(context: Context, contacts: List<MibBeneficiary>) { private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
val arr = JSONArray() val arr = JSONArray()
for (c in contacts) { for (c in contacts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -99,18 +101,18 @@ object ContactsCache {
put("benefStatus", c.benefStatus) put("benefStatus", c.benefStatus)
put("transferCyDesc", c.transferCyDesc) put("transferCyDesc", c.transferCyDesc)
put("benefCategoryId", c.benefCategoryId) put("benefCategoryId", c.benefCategoryId)
put("profileId", c.profileId)
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString("bml_contacts", CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
} }
fun loadBml(context: Context): List<MibBeneficiary> { fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("bml_contacts", null) ?: return emptyList() .getString(bmlKey(loginId), null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw) val arr = JSONArray(CacheEncryption.decrypt(raw))
val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibBeneficiary( MibBeneficiary(
@@ -125,12 +127,16 @@ object ContactsCache {
benefStatus = o.optString("benefStatus"), benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"), transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = null, customerImgHash = null,
benefCategoryId = o.optString("benefCategoryId", "BML") benefCategoryId = o.optString("benefCategoryId", "BML"),
profileId = o.optString("profileId", "")
) )
} }
} catch (e: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) { fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
val arr = JSONArray() val arr = JSONArray()
@@ -24,7 +24,6 @@ class CredentialStore(context: Context) {
// ── MIB login credentials ───────────────────────────────────────────────── // ── MIB login credentials ─────────────────────────────────────────────────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card") fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card")
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
@@ -91,58 +90,106 @@ class CredentialStore(context: Context) {
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
// ── BML login credentials ───────────────────────────────────────────────── // ── BML login credentials (multi-login, keyed by loginId = username) ────────
fun saveBmlCredentials(username: String, password: String, otpSeed: String) { fun getBmlLoginIds(): List<String> {
val json = prefs.getString("bml_login_ids", null)
if (json != null) {
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
// One-time migration from single-slot BML storage
val oldEncUsername = prefs.getString("bml_enc_username", null) ?: return emptyList()
return try {
val key = getOrCreateKey()
val loginId = decrypt(oldEncUsername, key)
val edit = prefs.edit()
prefs.getString("bml_enc_password", null)?.let { edit.putString("bml_${loginId}_enc_password", it) }
prefs.getString("bml_enc_otp_seed", null)?.let { edit.putString("bml_${loginId}_enc_otp_seed", it) }
prefs.getString("bml_enc_token", null)?.let { edit.putString("bml_${loginId}_enc_token", it) }
prefs.getString("bml_enc_device_id", null)?.let { edit.putString("bml_${loginId}_enc_device_id", it) }
prefs.getString("bml_enc_profile", null)?.let { edit.putString("bml_${loginId}_enc_profile", it) }
edit.putString("bml_${loginId}_enc_username", oldEncUsername)
edit.remove("bml_enc_username").remove("bml_enc_password").remove("bml_enc_otp_seed")
.remove("bml_enc_token").remove("bml_enc_device_id")
.remove("bml_enc_profile").remove("bml_enc_full_name")
val ids = org.json.JSONArray(listOf(loginId)).toString()
edit.putString("bml_login_ids", ids)
edit.apply()
listOf(loginId)
} catch (_: Exception) { emptyList() }
}
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
private fun addBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveBmlCredentials(loginId: String, username: String, password: String, otpSeed: String) {
addBmlLoginId(loginId)
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("bml_enc_username", encrypt(username, key)) .putString("bml_${loginId}_enc_username", encrypt(username, key))
.putString("bml_enc_password", encrypt(password, key)) .putString("bml_${loginId}_enc_password", encrypt(password, key))
.putString("bml_enc_otp_seed", encrypt(otpSeed, key)) .putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
.apply() .apply()
} }
fun loadBmlCredentials(): BmlCredentials? { fun loadBmlCredentials(loginId: String): BmlCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encUsername = prefs.getString("bml_enc_username", null) ?: return null val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
val encPassword = prefs.getString("bml_enc_password", null) ?: return null val encPassword = prefs.getString("bml_${loginId}_enc_password", null) ?: return null
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null val encSeed = prefs.getString("bml_${loginId}_enc_otp_seed", null) ?: return null
return try { return try {
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key)) BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlCredentials() { fun clearBmlCredentials(loginId: String) {
removeBmlLoginId(loginId)
prefs.edit() prefs.edit()
.remove("bml_enc_username") .remove("bml_${loginId}_enc_username")
.remove("bml_enc_password") .remove("bml_${loginId}_enc_password")
.remove("bml_enc_otp_seed") .remove("bml_${loginId}_enc_otp_seed")
.apply() .apply()
} }
// ── BML session token ───────────────────────────────────────────────────── // ── BML session token (per loginId) ───────────────────────────────────────
fun saveBmlSession(accessToken: String, deviceId: String) { fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("bml_enc_token", encrypt(accessToken, key)) .putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
.putString("bml_enc_device_id", encrypt(deviceId, key)) .putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
.apply() .apply()
} }
fun loadBmlSession(): Pair<String, String>? { fun loadBmlSession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encToken = prefs.getString("bml_enc_token", null) ?: return null val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
val encDeviceId = prefs.getString("bml_enc_device_id", null) ?: return null val encDeviceId = prefs.getString("bml_${loginId}_enc_device_id", null) ?: return null
return try { return try {
Pair(decrypt(encToken, key), decrypt(encDeviceId, key)) Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlSession() { fun clearBmlSession(loginId: String) {
prefs.edit() prefs.edit()
.remove("bml_enc_token") .remove("bml_${loginId}_enc_token")
.remove("bml_enc_device_id") .remove("bml_${loginId}_enc_device_id")
.apply() .apply()
} }
@@ -315,16 +362,6 @@ class CredentialStore(context: Context) {
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
fun saveBmlFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("bml_enc_full_name", encrypt(name, key)).apply()
}
fun loadBmlFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveMibUserProfile(p: MibUserProfile) { fun saveMibUserProfile(p: MibUserProfile) {
val json = JSONObject().apply { val json = JSONObject().apply {
@@ -355,7 +392,7 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun saveBmlUserProfile(p: BmlUserProfile) { fun saveBmlUserProfile(loginId: String, p: BmlUserProfile) {
val json = JSONObject().apply { val json = JSONObject().apply {
put("fullName", p.fullName) put("fullName", p.fullName)
put("email", p.email) put("email", p.email)
@@ -365,13 +402,12 @@ class CredentialStore(context: Context) {
put("birthdate", p.birthdate) put("birthdate", p.birthdate)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("bml_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("bml_${loginId}_enc_profile", encrypt(json, key)).apply()
prefs.edit().putString("bml_enc_full_name", encrypt(p.fullName, key)).apply()
} }
fun loadBmlUserProfile(): BmlUserProfile? { fun loadBmlUserProfile(loginId: String): BmlUserProfile? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_profile", null) ?: return null val enc = prefs.getString("bml_${loginId}_enc_profile", null) ?: return null
return try { return try {
val o = JSONObject(decrypt(enc, key)) val o = JSONObject(decrypt(enc, key))
BmlUserProfile( BmlUserProfile(
+13 -41
View File
@@ -1,20 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp" android:orientation="vertical">
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="20dp" android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground"> android:foreground="?attr/selectableItemBackground">
@@ -43,7 +39,7 @@
</LinearLayout> </LinearLayout>
<!-- Right: segmented pill (bank | type | profile) + balance --> <!-- Right: segmented pill (bank | type) + balance -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -58,41 +54,11 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:background="@drawable/pill_segment_bg"> android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvPillBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="10dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
<View
android:layout_width="1dp"
android:layout_height="16dp"
android:background="?attr/colorOutline" />
<TextView <TextView
android:id="@+id/tvPillType" android:id="@+id/tvPillType"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="10dp" android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
<View
android:layout_width="1dp"
android:layout_height="16dp"
android:background="?attr/colorOutline" />
<TextView
android:id="@+id/tvPillProfile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="10dp"
android:paddingEnd="12dp"
android:paddingVertical="6dp" android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall" android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" /> android:textColor="?attr/colorOnSurface" />
@@ -111,4 +77,10 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> <View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="16dp"
android:background="?attr/colorOutlineVariant" />
</LinearLayout>
@@ -92,6 +92,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"> android:layout_marginTop="16dp">
<LinearLayout <LinearLayout
@@ -165,6 +166,20 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnHeaderTransfer"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minWidth="0dp"
android:minHeight="0dp"
app:icon="@drawable/ic_send"
app:iconSize="20dp"
app:iconGravity="textStart"
app:iconPadding="0dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+11 -9
View File
@@ -1,20 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp" android:orientation="vertical">
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="20dp" android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground"> android:foreground="?attr/selectableItemBackground">
@@ -89,4 +85,10 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> <View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="16dp"
android:background="?attr/colorOutlineVariant" />
</LinearLayout>
+1 -1
View File
@@ -15,5 +15,5 @@
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/> <exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/> <exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/> <exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/> <exclude domain="root" path="cache/"/>
</full-backup-content> </full-backup-content>
@@ -14,7 +14,7 @@
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/> <exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/> <exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/> <exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/> <exclude domain="root" path="cache/"/>
</cloud-backup> </cloud-backup>
<device-transfer> <device-transfer>
<exclude domain="sharedpref" path="credential_store.xml"/> <exclude domain="sharedpref" path="credential_store.xml"/>
@@ -26,6 +26,6 @@
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/> <exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/> <exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/> <exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/> <exclude domain="root" path="cache/"/>
</device-transfer> </device-transfer>
</data-extraction-rules> </data-extraction-rules>