7 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
shihaam 04af4e1bbd add biometrics to confirm digalogbox
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-17 21:39:42 +05:00
shihaam 6197152f6e fix ui glitch, trasnfer button to take you to transfer tab in bottom bar 2026-05-17 21:09:43 +05:00
31 changed files with 568 additions and 349 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(),
@@ -31,7 +31,7 @@ class DashboardFragment : Fragment() {
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
binding.btnTransfer.setOnClickListener { binding.btnTransfer.setOnClickListener {
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment()) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer)
} }
binding.btnPayMvQr.setOnClickListener { binding.btnPayMvQr.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
@@ -55,6 +55,7 @@ class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private val viewModel: HomeViewModel by viewModels() private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
private val autolockHandler = Handler(Looper.getMainLooper()) private val autolockHandler = Handler(Looper.getMainLooper())
private var warningDialog: AlertDialog? = null private var warningDialog: AlertDialog? = null
@@ -91,6 +92,7 @@ class HomeActivity : AppCompatActivity() {
toggle.syncState() toggle.syncState()
binding.bottomNavigation.setOnItemSelectedListener { item -> binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
when (item.itemId) { when (item.itemId) {
R.id.nav_dashboard -> { show(DashboardFragment()); true } R.id.nav_dashboard -> { show(DashboardFragment()); true }
R.id.nav_accounts -> { show(AccountsFragment()); true } R.id.nav_accounts -> { show(AccountsFragment()); true }
@@ -117,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)
@@ -126,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
@@ -139,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
@@ -197,17 +202,25 @@ 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)
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) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = itemId
suppressBottomNavCallback = false
} }
} }
@@ -314,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
@@ -325,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 {
@@ -358,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))
} }
} }
} }
@@ -400,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 {
@@ -416,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))
@@ -447,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)
} }
} }
@@ -477,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 */ }
} }
@@ -494,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
@@ -525,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)
@@ -571,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))
@@ -603,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
@@ -17,6 +17,7 @@ class MoreFragment : Fragment() {
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int) private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
private val items = listOf( private val items = listOf(
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities), NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history), NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances), NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
@@ -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()
@@ -36,9 +36,23 @@ class SettingsSecurityFragment : Fragment() {
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (canUseBiometrics) { if (canUseBiometrics) {
binding.rowBiometrics.visibility = View.VISIBLE binding.rowBiometrics.visibility = View.VISIBLE
binding.switchBiometrics.isChecked = prefs.getBoolean("biometrics_enabled", false)
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
binding.switchBiometrics.isChecked = unlockEnabled
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked -> binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply() prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
binding.switchBiometricsTransfer.isEnabled = isChecked
if (!isChecked) {
binding.switchBiometricsTransfer.isChecked = false
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
}
}
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
} }
} }
@@ -19,6 +19,9 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@@ -60,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 = ""
@@ -90,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,
@@ -168,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)
}
}
} }
} }
@@ -185,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
} }
@@ -253,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
@@ -284,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
@@ -530,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()
@@ -559,35 +580,38 @@ 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}"
val dialogBuilder = AlertDialog.Builder(requireContext()) val doTransfer: () -> Unit = {
.setTitle(R.string.transfer) binding.btnTransfer.isEnabled = false
.setPositiveButton(R.string.transfer_confirm) { _, _ -> viewLifecycleOwner.lifecycleScope.launch {
binding.btnTransfer.isEnabled = false val (ok, msg, receipt) = withContext(Dispatchers.IO) {
viewLifecycleOwner.lifecycleScope.launch { if (!isSrcBml) {
val (ok, msg, receipt) = withContext(Dispatchers.IO) { doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
if (!isSrcBml) { } else {
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture) doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
} else {
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
}
}
binding.btnTransfer.isEnabled = true
if (ok && receipt != null) {
clearForm()
val activity = requireActivity() as HomeActivity
activity.refreshBalances(src)
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
} }
} }
binding.btnTransfer.isEnabled = true
if (ok && receipt != null) {
clearForm()
val activity = requireActivity() as HomeActivity
activity.refreshBalances(src)
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
}
} }
}
val dialogBuilder = AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer)
.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 {
@@ -595,18 +619,61 @@ 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)
} }
dialogBuilder.show()
val dialog = dialogBuilder.show()
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
val canAuth = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (biometricTransferConfirm && canAuth) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
dialog.dismiss()
doTransfer()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
}
}
override fun onAuthenticationFailed() { /* keep dialog open */ }
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(getString(R.string.biometric_transfer_title))
.setSubtitle("$currency $amountStr$destDisplay")
.setNegativeButtonText(getString(android.R.string.cancel))
.build()
)
}
}
} }
private fun doMibTransfer( private fun doMibTransfer(
@@ -689,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 {
@@ -698,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
@@ -707,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")
@@ -730,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 {
@@ -880,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))
@@ -890,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]
@@ -921,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(
@@ -30,23 +30,60 @@
android:id="@+id/rowBiometrics" android:id="@+id/rowBiometrics"
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="vertical"
android:gravity="center_vertical"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:visibility="gone"> android:visibility="gone">
<TextView <TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_unlock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_transfer"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometricsTransfer"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout> </LinearLayout>
@@ -12,19 +12,6 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- From label + account dropdown --> <!-- From label + account dropdown -->
<TextView <TextView
@@ -355,10 +342,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/transfer" /> android:text="@string/transfer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
+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>
+9 -3
View File
@@ -5,12 +5,18 @@
<item android:id="@+id/nav_dashboard" <item android:id="@+id/nav_dashboard"
android:icon="@drawable/ic_nav_dashboard" android:icon="@drawable/ic_nav_dashboard"
android:title="@string/nav_dashboard" /> android:title="@string/nav_dashboard" />
<item android:id="@+id/nav_accounts"
android:icon="@drawable/ic_nav_accounts"
android:title="@string/nav_accounts" />
</group> </group>
<group android:id="@+id/group_finance" android:checkableBehavior="single"> <group android:id="@+id/group_finance" android:checkableBehavior="single">
<item android:id="@+id/nav_accounts"
android:icon="@drawable/ic_nav_accounts"
android:title="@string/nav_accounts" />
<item android:id="@+id/nav_transfer"
android:icon="@drawable/ic_send"
android:title="@string/transfer" />
<item android:id="@+id/nav_pay_mv_qr"
android:icon="@drawable/ic_qr_scan"
android:title="@string/pay_mv_qr" />
<item android:id="@+id/nav_contacts" <item android:id="@+id/nav_contacts"
android:icon="@drawable/ic_contacts" android:icon="@drawable/ic_contacts"
android:title="@string/nav_contacts" /> android:title="@string/nav_contacts" />
+3
View File
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/nav_pay_mv_qr"
android:icon="@drawable/ic_qr_scan"
android:title="@string/pay_mv_qr" />
<item android:id="@+id/nav_activities" <item android:id="@+id/nav_activities"
android:icon="@drawable/ic_nav_activities" android:icon="@drawable/ic_nav_activities"
android:title="@string/nav_activities" /> android:title="@string/nav_activities" />
+4 -1
View File
@@ -103,7 +103,10 @@
<!-- Settings --> <!-- Settings -->
<string name="settings_security">Security</string> <string name="settings_security">Security</string>
<string name="settings_change_lock">Change PIN / Pattern</string> <string name="settings_change_lock">Change PIN / Pattern</string>
<string name="settings_biometrics">Use Biometrics</string> <string name="settings_biometrics">Use biometrics</string>
<string name="settings_biometrics_unlock">To unlock app</string>
<string name="settings_biometrics_transfer">Confirm transfer</string>
<string name="biometric_transfer_title">Confirm Transfer</string>
<string name="settings_autolock">Auto-lock</string> <string name="settings_autolock">Auto-lock</string>
<string name="autolock_off">Off</string> <string name="autolock_off">Off</string>
<string name="autolock_30s">30s</string> <string name="autolock_30s">30s</string>
+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>