7 Commits

Author SHA1 Message Date
b1e73533f6 version 1.0.4
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m45s
2026-05-19 18:26:19 +05:00
3a5b9459a9 customize quick actions and bottom bar
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 18:21:36 +05:00
9c9729e268 UI/edgetoedge non edge screen fixes
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 17:24:01 +05:00
399cfbf108 transfer button enables after you fill required feilds
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 16:50:49 +05:00
19f4d01015 add long bml logo
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 16:39:44 +05:00
8c40322ff0 fahipay serialized multi-login added
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 16:13:36 +05:00
782e2e7674 refactor codebase to be more module for later adding new banks.. add support for single profile mib accounts.. add suport for disabling mib profiles in settings
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-19 14:48:04 +05:00
65 changed files with 2051 additions and 729 deletions

View File

@@ -3,11 +3,11 @@
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z"> <DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" /> <DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 3
versionName = "1.0.3" versionName = "1.0.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -17,14 +17,42 @@ class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout // Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList() var accounts: List<MibAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null /** Active MIB sessions keyed by loginId (= MIB username). */
var mibProfiles: List<MibProfile> = emptyList() val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList()
/** Active BML sessions keyed by loginId (= BML username). */ /** Active BML sessions keyed by loginId (= BML username). */
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf() val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList() var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null /** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList() var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")]
/** Returns any available MIB session. */
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
/** Returns all MIB profiles across all logins. */
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
fun mibFlowFor(loginId: String): MibLoginFlow =
mibLoginFlows.getOrPut(loginId) {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSessions[loginId] = session
mibProfilesMap[loginId] = profiles
}
}
}
/** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
/** Returns the BML session for the given account (matched via loginTag). */ /** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? = fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")] bmlSessions[account.loginTag.removePrefix("bml_")]
@@ -32,18 +60,13 @@ class BasedBankApp : Application() {
/** Returns any available BML session (for non-account-specific operations). */ /** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull() fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
/** 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()
val mibLoginFlow by lazy {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles
}
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)

View File

@@ -618,6 +618,7 @@ class BmlLoginFlow {
if (accountType == "CASA") { if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0) val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount( casaAccounts.add(MibAccount(
bank = "BML",
profileName = "Personal", profileName = "Personal",
profileType = "BML", profileType = "BML",
accountNumber = accountNumber, accountNumber = accountNumber,
@@ -641,6 +642,7 @@ class BmlLoginFlow {
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 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount( prepaidCards.add(MibAccount(
bank = "BML",
profileName = "Personal", profileName = "Personal",
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber, accountNumber = accountNumber,

View File

@@ -186,6 +186,7 @@ class FahipayLoginFlow {
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount = fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount( MibAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" }, profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY", profileType = "FAHIPAY",
accountNumber = profile.walletAccount, accountNumber = profile.walletAccount,

View File

@@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
// Stored after login so the session can be silently recovered on 419 // Stored after login so the session can be silently recovered on 419
@Volatile private var loginId: String = ""
@Volatile private var storedUsername: String? = null @Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null @Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null @Volatile private var storedOtpSeed: String? = null
@@ -58,11 +59,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
* Returns list of accounts from all profiles on success. * Returns list of accounts from all profiles on success.
*/ */
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> { fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
loginId = username
storedUsername = username storedUsername = username
storedPasswordHash = passwordHash storedPasswordHash = passwordHash
storedOtpSeed = otpSeed storedOtpSeed = otpSeed
val appId = getOrCreateAppId() val appId = getOrCreateAppId()
val keys = credentialStore.loadMibKeys() val keys = credentialStore.loadMibKeys(loginId)
return if (keys != null) { return if (keys != null) {
regularLogin(username, passwordHash, appId, keys.first, keys.second) regularLogin(username, passwordHash, appId, keys.first, keys.second)
@@ -106,7 +108,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val keyData = otpResp.getJSONArray("data").getJSONObject(0) val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1") val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2") val key2 = keyData.getString("key2")
credentialStore.saveMibKeys(key1, key2) credentialStore.saveMibKeys(loginId, key1, key2)
return regularLogin(username, passwordHash, appId, key1, key2) return regularLogin(username, passwordHash, appId, key1, key2)
} }
@@ -136,10 +138,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
lastSession = session2 lastSession = session2
lastProfiles = profiles lastProfiles = profiles // keep ALL profiles so settings can show them all
return fetchAllProfiles(session2, profiles, "mib_$username")
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
// When the server already selected the profile and returned balances in A41
// (single-profile case: profileSelected=true), use those accounts directly
// without making an extra P47 call (which the server ignores or rejects).
if (loginResp.optBoolean("profileSelected", false)) {
val a41Balances = loginResp.optJSONArray("accountBalance")
if (a41Balances != null && a41Balances.length() > 0) {
val selectedId = loginResp.optString("selectedProfileId")
val profile = profiles.firstOrNull { it.profileId == selectedId }
?: profiles.firstOrNull()
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
val allAccounts = mutableListOf<MibAccount>()
for (i in 0 until a41Balances.length()) {
val a = a41Balances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
loginTag = "mib_$username",
profileId = profile.profileId
)
)
}
return allAccounts
}
}
}
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
} }
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
@@ -271,8 +315,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val a = accountBalances.getJSONObject(i) val a = accountBalances.getJSONObject(i)
allAccounts.add( allAccounts.add(
MibAccount( MibAccount(
bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
@@ -389,11 +435,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
private fun generateOtp(seed: String): String = Totp.generate(seed) private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String { private fun getOrCreateAppId(): String {
var id = credentialStore.loadMibAppId() var id = credentialStore.loadMibAppId(loginId)
if (id == null) { if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("") id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
credentialStore.saveMibAppId(id) credentialStore.saveMibAppId(loginId, id)
} }
return id return id
} }

View File

@@ -20,8 +20,10 @@ data class MibProfile(
) )
data class MibAccount( data class MibAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String, val profileName: String,
val profileType: String, val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
val accountNumber: String, val accountNumber: String,
val accountBriefName: String, val accountBriefName: String,
val currencyName: String, val currencyName: String,

View File

@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
@@ -20,7 +21,8 @@ import java.util.Date
import java.util.Locale import java.util.Locale
class AccountHistoryAdapter( class AccountHistoryAdapter(
private val account: MibAccount private val account: MibAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
@@ -138,7 +140,7 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) { when (holder) {
is HeaderVH -> holder.bind(account) is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label) is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction) is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit else -> Unit
@@ -147,37 +149,20 @@ class AccountHistoryAdapter(
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) : inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(acc: MibAccount) { fun bind(d: AccountHistoryDisplay) {
b.tvHeaderAccountName.text = acc.accountBriefName b.tvHeaderAccountName.text = d.name
b.tvHeaderAccountNumber.text = acc.accountNumber b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = when { b.tvHeaderPillBank.text = d.bankPill
acc.profileType.startsWith("BML") -> "BML" b.tvHeaderPillType.text = d.typeLabel
acc.profileType == "FAHIPAY" -> "FP" b.tvHeaderAvailable.text = d.availableBalance
else -> null b.tvHeaderBalance.text = d.workingBalance
} if (d.blockedBalance != null) {
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName) b.tvHeaderBlocked.text = d.blockedBalance
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
if (blocked > 0.0) {
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
b.llHeaderBlocked.visibility = View.VISIBLE b.llHeaderBlocked.visibility = View.VISIBLE
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) } b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
}
private fun friendlyType(raw: String): String {
val u = raw.trim().uppercase()
return when {
u.contains("SAVING") -> "Savings"
u.contains("CURRENT") -> "Current"
u.contains("WADIAH") -> "Islamic"
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
u.contains("PREPAID") -> "Prepaid"
else -> raw.trim().take(12)
}
} }
} }

View File

@@ -10,6 +10,8 @@ import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -17,23 +19,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() { class AccountHistoryFragment : Fragment() {
@@ -43,21 +40,13 @@ class AccountHistoryFragment : Fragment() {
private lateinit var adapter: AccountHistoryAdapter private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount private lateinit var account: MibAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = "" private var searchQuery = ""
private var firstPageDone = false private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>() private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>() private val pendingIconUrls = mutableSetOf<String>()
// Pagination state
private var mibNextStart = 1
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
private var bmlNextPage = 1
private var bmlTotalPages = -1
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
private var fahipayNextStart = 0
private var fahipayTotal = -1
private var isLoading = false private var isLoading = false
private val pageSize = 10 private val pageSize = 10
@@ -79,8 +68,10 @@ class AccountHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
fetcher = HistoryFetcher(account)
adapter = AccountHistoryAdapter(account) val historyDisplay = AccountHistoryParser.from(account) ?: return
adapter = AccountHistoryAdapter(account, historyDisplay)
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc -> adapter.onTransferClick = { acc ->
@@ -88,6 +79,16 @@ class AccountHistoryFragment : Fragment() {
} }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
@@ -104,11 +105,10 @@ class AccountHistoryFragment : Fragment() {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: "" searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay() filterAndDisplay()
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
} }
}) })
// Load cache immediately, then fetch fresh data in background
val cached = TransactionCache.load(requireContext(), account.accountNumber) val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
allTransactions.addAll(cached) allTransactions.addAll(cached)
@@ -133,19 +133,8 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when {
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
private fun loadNextPage() { private fun loadNextPage() {
if (isLoading || !hasMore()) return if (isLoading || !fetcher.hasMore()) return
isLoading = true isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) { if (firstPageDone && allTransactions.isNotEmpty()) {
@@ -155,68 +144,7 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) { val transactions = fetcher.fetchNextPage(app, pageSize)
when {
isFahipay() -> {
val session = app.fahipaySession ?: return@withContext emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
list
}
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
list
}
}
isBmlCard() -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
else -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
list
}
}
}
isLoading = false isLoading = false
@@ -233,7 +161,6 @@ class AccountHistoryFragment : Fragment() {
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions) TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
// Append incrementally to preserve scroll position
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
adapter.appendTransactions(sorted) adapter.appendTransactions(sorted)
binding.emptyView.visibility = View.GONE binding.emptyView.visibility = View.GONE
@@ -243,7 +170,7 @@ class AccountHistoryFragment : Fragment() {
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
} }
if (searchQuery.isNotBlank() && hasMore()) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
@@ -261,7 +188,7 @@ class AccountHistoryFragment : Fragment() {
return return
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -288,8 +215,7 @@ class AccountHistoryFragment : Fragment() {
val response = client.newCall(Request.Builder().url(url).build()).execute() val response = client.newCall(Request.Builder().url(url).build()).execute()
val bytes = response.body?.bytes() ?: return@launch val bytes = response.body?.bytes() ?: return@launch
response.close() response.close()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
?: return@launch
MerchantIconCache.save(requireContext(), url, bitmap) MerchantIconCache.save(requireContext(), url, bitmap)
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) } withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
} catch (_: Exception) { } catch (_: Exception) {

View File

@@ -8,23 +8,24 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
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.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.MibAccountParser import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {} private val onAccountClick: (MibAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item() data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount) : Item() data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
} }
private val items: MutableList<Item> = buildItems(accounts).toMutableList() private val items: MutableList<Item> = buildItems(accounts).toMutableList()
@@ -36,38 +37,38 @@ class AccountsAdapter(
} }
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList { private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val nonCards = displayed.filter { !it.second.isCard }
val cards = displayed.filter { it.second.isCard }
// Group non-prepaid accounts by their derived section title, preserving order val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
val groups = LinkedHashMap<String, MutableList<MibAccount>>() for ((acc, display) in nonCards) {
for (acc in nonPrepaid) {
val title = sectionTitle(acc) val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc) groups.getOrPut(title) { mutableListOf() }.add(acc to display)
} }
for ((title, group) in groups) { for ((title, group) in groups) {
add(Item.SectionTitle(title)) add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) } group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
} }
if (prepaid.isNotEmpty()) { if (cards.isNotEmpty()) {
add(Item.SectionTitle("Cards · Bank of Maldives")) add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) } cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
} }
} }
private fun sectionTitle(account: MibAccount): String { private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) { val bankName = when (account.bank) {
"0" -> "Personal" "BML" -> "Bank of Maldives"
"1" -> "Business" "FAHIPAY" -> "Fahipay"
else -> account.profileName "MIB" -> "Maldives Islamic Bank"
else -> account.bank
} }
val bank = when { val profileLabel = when (account.bank) {
account.profileType.startsWith("BML") -> "Bank of Maldives" "MIB" -> account.cifType.ifBlank { account.profileName }
account.profileType == "FAHIPAY" -> "Fahipay" else -> account.profileName
else -> "Maldives Islamic Bank"
} }
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
} }
override fun getItemViewType(position: Int) = when (items[position]) { override fun getItemViewType(position: Int) = when (items[position]) {
@@ -79,17 +80,17 @@ 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(ItemDateHeaderBinding.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))
} }
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) { when (val item = items[position]) {
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item) is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account) is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
is Item.Card -> (holder as CardViewHolder).bind(item.account) is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
} }
} }
@@ -104,18 +105,15 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) : private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount, display: AccountListDisplay) {
binding.tvAccountName.text = account.accountBriefName binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = account.accountNumber binding.tvAccountNumber.text = display.number
val label = if (account.profileType.startsWith("BML")) binding.tvAccountType.text = display.typeLabel
BmlDashboardParser.productLabel(account.accountTypeName) binding.tvBalance.text = display.balance
else binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
MibAccountParser.productLabel(account.accountTypeName)
binding.tvPillType.text = label
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, display.number)
true true
} }
} }
@@ -123,23 +121,22 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) : private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName)) binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = account.accountBriefName binding.tvCardName.text = display.name
binding.tvCardNumber.text = account.accountNumber binding.tvCardNumber.text = display.number
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName) binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}" binding.tvCardBalance.text = display.balance
if (display.statusLabel != null) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) binding.tvCardStatus.text = display.statusLabel
if (isActive) {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} else {
binding.tvCardStatus.text = account.statusDesc
binding.tvCardStatus.visibility = View.VISIBLE binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f binding.root.alpha = 0.45f
} else {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} }
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
} }
} }
@@ -154,13 +151,5 @@ class AccountsAdapter(
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber)) cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
} }
private fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
} }
} }

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -26,9 +28,21 @@ class AccountsFragment : Fragment() {
adapter = AccountsAdapter(emptyList()) { account -> adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account)) (activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
} }
adapter.onTransferClick = { account ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) } viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
} }

View File

@@ -50,6 +50,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val label: String, val label: String,
val isBml: Boolean, val isBml: Boolean,
val mibProfile: MibProfile? = null, val mibProfile: MibProfile? = null,
val mibLoginId: String? = null,
val bmlLoginId: String? = null, val bmlLoginId: String? = null,
val subtitle: String = "" val subtitle: String = ""
) )
@@ -91,8 +92,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun buildDestinations(): List<DestinationOption> { private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>() val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) { for ((loginId, profiles) in app.mibProfilesMap) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) for (profile in profiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
}
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) { for ((loginId, _) in app.bmlSessions) {
@@ -249,7 +252,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (mibVerified != null) return mibVerified if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML) // 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.mibSession ?: return null val mibSess = app.anyMibSession() ?: return null
return try { return try {
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
@@ -266,11 +269,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? { private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
val mibSess = app.mibSession ?: return null val loginId = dest.mibLoginId ?: return null
val mibSess = app.mibSessions[loginId] ?: return null
val profile = dest.mibProfile ?: return null val profile = dest.mibProfile ?: return null
val mibResult = try { val mibResult = try {
app.mibLoginFlow.switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
@@ -429,8 +433,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun saveToMib(alias: String): Boolean { private fun saveToMib(alias: String): Boolean {
val mibSess = app.mibSession ?: return false
val dest = selectedDest ?: return false val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false val profile = dest.mibProfile ?: return false
val account = mibLookupAccount ?: return false val account = mibLookupAccount ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR" val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
@@ -442,7 +447,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val name = bmlLookup?.name ?: "" val name = bmlLookup?.name ?: ""
return try { return try {
app.mibLoginFlow.switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact( MibContactsClient().createContact(
session = mibSess, session = mibSess,
benefType = benefType, benefType = benefType,
@@ -488,8 +493,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, 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 mibLoginId = dest.mibLoginId ?: return@launch
app.mibLoginFlow.switchProfile(mibSess, profile) val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess) val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) } .map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()

View File

@@ -36,7 +36,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private val sharedImageCache = mutableMapOf<String, Bitmap>() private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val profileImageHashes = mutableSetOf<String>() private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.anyMibSession()
private var fromAccountNumber: String = "" private var fromAccountNumber: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
@@ -291,7 +291,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (hash in profileImageHashes) { val base64 = if (hash in profileImageHashes) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch

View File

@@ -12,23 +12,23 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter( class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>, private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit, private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit, private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList() private var displayed: List<ContactDisplay> = emptyList()
private var activeCategoryId: String? = null private var activeCategoryId: String? = null
private var searchQuery: String = "" private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) { fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts allContacts = contacts
applyFilter() applyFilter()
} }
@@ -36,7 +36,7 @@ class ContactsAdapter(
fun updateImage(hash: String, bitmap: Bitmap) { fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact -> displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index) if (contact.imageHash == hash) notifyItemChanged(index)
} }
} }
@@ -48,11 +48,11 @@ class ContactsAdapter(
private fun applyFilter() { private fun applyFilter() {
displayed = allContacts.filter { contact -> displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() || val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) || contact.name.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) || contact.realName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery) contact.accountNumber.contains(searchQuery)
matchesCategory && matchesSearch matchesCategory && matchesSearch
} }
notifyDataSetChanged() notifyDataSetChanged()
@@ -76,7 +76,7 @@ class ContactsAdapter(
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
val account = displayed[pos].benefAccount val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account)) clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show() Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
@@ -88,7 +88,7 @@ class ContactsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position] val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash -> val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null } imageCache[hash] ?: run { onImageNeeded(hash); null }
} }
holder.bind(contact, cachedImage) holder.bind(contact, cachedImage)
@@ -99,21 +99,24 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) : inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) { fun bind(contact: ContactDisplay, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY" binding.tvContactName.text = contact.name
binding.tvContactName.text = contact.benefNickName binding.tvContactAccount.text = contact.accountNumber
binding.tvContactAccount.text = contact.benefAccount binding.tvRealName.text = contact.detail ?: ""
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}" binding.tvRealName.visibility =
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnTransferContact.visibility =
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) { if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo) binding.ivContactPhoto.setImageBitmap(photo)
} else { } else {
binding.ivContactPhoto.setImageBitmap( binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor) makeInitialsBitmap(contact.name, contact.bankColor)
) )
} }
} }

View File

@@ -8,7 +8,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
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
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() { class ContactsFragment : Fragment() {
@@ -38,9 +42,9 @@ class ContactsFragment : Fragment() {
private val pendingHashes = mutableSetOf<String>() private val pendingHashes = mutableSetOf<String>()
private val sharedImageCache = mutableMapOf<String, Bitmap>() private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.anyMibSession()
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
@@ -53,9 +57,9 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page -> val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter( ContactsAdapter(
imageCache = sharedImageCache, imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) }, onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) }, onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) } onTransferClick = { contact -> openTransfer(contact) }
).also { a -> ).also { a ->
a.setFilter(page.categoryId, currentSearch) a.setFilter(page.categoryId, currentSearch)
@@ -63,7 +67,7 @@ class ContactsFragment : Fragment() {
} }
} }
fun updateContacts(contacts: List<MibBeneficiary>) = fun updateContacts(contacts: List<ContactDisplay>) =
contactAdapters.forEach { it.updateContacts(contacts) } contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) = fun updateSearch(query: String) =
@@ -86,7 +90,7 @@ class ContactsFragment : Fragment() {
) )
clipToPadding = false clipToPadding = false
val p4 = (4 * density).toInt() val p4 = (4 * density).toInt()
val p80 = (80 * density).toInt() val p80 = (65 * density).toInt()
setPadding(0, p4, 0, p80) setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType] adapter = contactAdapters[viewType]
} }
@@ -113,6 +117,17 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateSearch(currentSearch) pagerAdapter.updateSearch(currentSearch)
} }
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
lp.bottomMargin = fabMarginBase + extraBottom
v.layoutParams = lp
insets
}
binding.fabAddContact.setOnClickListener { binding.fabAddContact.setOnClickListener {
AddContactSheetFragment().show(childFragmentManager, "add_contact") AddContactSheetFragment().show(childFragmentManager, "add_contact")
} }
@@ -124,8 +139,8 @@ class ContactsFragment : Fragment() {
} }
viewModel.contacts.observe(viewLifecycleOwner) { contacts -> viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
allContacts = contacts allContacts = ContactListParser.fromList(contacts)
pagerAdapter.updateContacts(contacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
} }
@@ -150,31 +165,29 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
} }
private fun openTransfer(contact: MibBeneficiary) { private fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.benefAccount, accountNumber = contact.accountNumber,
displayName = contact.benefNickName, displayName = contact.name,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}", subtitle = contact.transferSubtitle,
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.customerImgHash imageHash = contact.imageHash
) )
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: ContactDisplay) {
AlertDialog.Builder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.contact_delete_title) .setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName)) .setMessage(getString(R.string.contact_delete_message, contact.name))
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) } .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
private fun deleteContact(contact: MibBeneficiary) { private fun deleteContact(contact: ContactDisplay) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
if (success) { if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact) removeFromViewModel(contact)
@@ -184,27 +197,10 @@ class ContactsFragment : Fragment() {
} }
} }
private fun deleteBml(contact: MibBeneficiary): Boolean { private fun removeFromViewModel(contact: ContactDisplay) {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: MibBeneficiary): Boolean {
val sess = session ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.benefNo)
} catch (_: Exception) { false }
}
private fun removeFromViewModel(contact: MibBeneficiary) {
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.network == TransferNetwork.BML) {
updated.filter { it.benefCategoryId == "BML" } updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId } .groupBy { it.profileId }
.forEach { (loginId, contacts) -> .forEach { (loginId, contacts) ->
@@ -221,7 +217,6 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Check disk cache first — if hash matches we already have the image
val cached = ContactImageCache.load(requireContext(), hash) val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) { if (cached != null) {
view?.post { pagerAdapter.updateImage(hash, cached) } view?.post { pagerAdapter.updateImage(hash, cached) }

View File

@@ -1,10 +1,13 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -30,17 +33,34 @@ class DashboardFragment : Fragment() {
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) } viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
binding.btnTransfer.setOnClickListener { val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer) ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
} val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
binding.btnPayMvQr.setOnClickListener { val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.nav_dashboard) requireActivity().title = getString(R.string.nav_dashboard)
refreshQuickActions()
}
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
if (def != null) {
btn.setText(def.titleRes)
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
}
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
}
} }
private fun updateBalances(accounts: List<MibAccount>) { private fun updateBalances(accounts: List<MibAccount>) {

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -27,6 +29,15 @@ class FinancingFragment : Fragment() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
viewModel.financing.observe(viewLifecycleOwner) { deals -> viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals) adapter.updateDeals(deals)
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE

View File

@@ -9,7 +9,9 @@ import android.os.Looper
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import com.google.android.material.navigation.NavigationBarView
import android.widget.Toast import android.widget.Toast
import sh.sar.basedbank.ui.home.NavCustomization
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels import androidx.activity.viewModels
@@ -108,14 +110,21 @@ class HomeActivity : AppCompatActivity() {
binding.bottomNavigation.setOnItemSelectedListener { item -> binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true if (suppressBottomNavCallback) return@setOnItemSelectedListener true
when (item.itemId) { val frag = when (item.itemId) {
R.id.nav_dashboard -> { show(DashboardFragment()); true } R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> { show(AccountsFragment()); true } R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> { show(ContactsFragment()); true } R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> { show(TransferFragment()); true } R.id.nav_transfer -> TransferFragment()
R.id.nav_more -> { show(MoreFragment()); true } R.id.nav_more -> MoreFragment()
else -> false R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
else -> null
} }
if (frag != null) show(frag)
else Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
true
} }
applyNavMode() applyNavMode()
@@ -128,31 +137,36 @@ class HomeActivity : AppCompatActivity() {
// Load data // Load data
val app = application as BasedBankApp val app = application as BasedBankApp
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) { if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background // Came from fresh manual login — accounts ready, rest fetched in background
val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" } val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts viewModel.accounts.value = merged.filterVisibleAccounts()
viewModel.accounts.value = merged if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) { if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") } val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) } byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
} }
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts) if (app.fahipayAccounts.isNotEmpty()) {
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
}
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles) for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) 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 store = CredentialStore(this)
val cachedMib = AccountCache.load(this) val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds()) val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this) val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
@@ -160,7 +174,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
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store)
} }
// Show dashboard on first create // Show dashboard on first create
@@ -169,27 +183,29 @@ class HomeActivity : AppCompatActivity() {
binding.navigationView.setCheckedItem(R.id.nav_dashboard) binding.navigationView.setCheckedItem(R.id.nav_dashboard)
} }
// Keep MIB session alive every 25 seconds while the app is in the foreground // Keep all MIB sessions alive every 25 seconds while the app is in the foreground
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) { while (true) {
delay(25_000) delay(25_000)
val session = (application as BasedBankApp).mibSession ?: continue val sessions = (application as BasedBankApp).mibSessions.values.toList()
withContext(Dispatchers.IO) { for (session in sessions) {
try { withContext(Dispatchers.IO) {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + try {
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
val request = Request.Builder() "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive") val request = Request.Builder()
.post(ByteArray(0).toRequestBody()) .url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
.header("Cookie", cookieHeader) .post(ByteArray(0).toRequestBody())
.header("User-Agent", "okhttp/4.11.0") .header("Cookie", cookieHeader)
.header("Accept", "application/json, text/plain, */*") .header("User-Agent", "okhttp/4.11.0")
.header("Accept-Encoding", "gzip") .header("Accept", "application/json, text/plain, */*")
.header("Connection", "Keep-Alive") .header("Accept-Encoding", "gzip")
.build() .header("Connection", "Keep-Alive")
OkHttpClient().newCall(request).execute().close() .build()
} catch (_: Exception) {} OkHttpClient().newCall(request).execute().close()
} catch (_: Exception) {}
}
} }
} }
} }
@@ -203,12 +219,15 @@ class HomeActivity : AppCompatActivity() {
} }
fun applyNavMode() { fun applyNavMode() {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) { if (isBottom) {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
} else { } else {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true toggle.isDrawerIndicatorEnabled = true
@@ -217,6 +236,27 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
val slots = NavCustomization.getSlots(prefs)
val menu = binding.bottomNavigation.menu
menu.clear()
menu.add(Menu.NONE, R.id.nav_dashboard, 0, R.string.nav_dashboard)
.setIcon(R.drawable.ic_nav_dashboard)
slots.forEachIndexed { i, id ->
val item = NavCustomization.ALL_SWAPPABLE.find { it.id == id } ?: return@forEachIndexed
menu.add(Menu.NONE, item.id, i + 1, item.titleRes).setIcon(item.iconRes)
}
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more)
.setIcon(R.drawable.ic_nav_more)
}
fun applyNavLabelVisibility() {
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
binding.bottomNavigation.labelVisibilityMode =
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
else NavigationBarView.LABEL_VISIBILITY_AUTO
}
fun navigateTo(itemId: Int, fragment: Fragment? = null) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
@@ -231,11 +271,15 @@ class HomeActivity : AppCompatActivity() {
} }
show(dest) show(dest)
binding.navigationView.setCheckedItem(itemId) binding.navigationView.setCheckedItem(itemId)
val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more) if (binding.bottomNavigation.visibility == View.VISIBLE) {
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) { val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
suppressBottomNavCallback = true .map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
binding.bottomNavigation.selectedItemId = itemId val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
suppressBottomNavCallback = false if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
} }
} }
@@ -353,50 +397,47 @@ class HomeActivity : AppCompatActivity() {
fun relogin() { fun relogin() {
val store = CredentialStore(this) val store = CredentialStore(this)
val hasMib = store.hasMibCredentials() val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val fahipayLoginIds = store.getFahipayLoginIds()
if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) { if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
startActivity(Intent(this, LoginActivity::class.java)) startActivity(Intent(this, LoginActivity::class.java))
finish() finish()
return return
} }
// Immediately drop accounts for logged-out banks from the displayed list // Immediately drop accounts for logged-out logins from the displayed list
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 (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
if (acc.profileType.startsWith("BML")) { if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
val loginId = acc.loginTag.removePrefix("bml_") if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
true true
} }
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store)
} }
private fun autoRefresh( private fun autoRefresh(store: CredentialStore) {
mibCreds: CredentialStore.MibCredentials?, val mibLoginIds = store.getMibLoginIds()
fahipayCreds: CredentialStore.FahipayCredentials?,
store: CredentialStore
) {
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch { lifecycleScope.launch {
val mibJob = mibCreds?.let { // One async job per MIB login, all run in parallel
async(Dispatchers.IO) { val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
try { try {
val flow = MibLoginFlow(CredentialStore(this@HomeActivity)) val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed) val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
val app = application as BasedBankApp val app = application as BasedBankApp
app.accounts = accounts app.mibSessions[loginId] = flow.lastSession!!
app.mibSession = flow.lastSession app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibProfiles = flow.lastProfiles app.mibLoginFlows[loginId] = flow
AccountCache.save(this@HomeActivity, accounts) store.saveMibProfiles(loginId, flow.lastProfiles)
accounts accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity) } } catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
} }
} }
@@ -434,68 +475,97 @@ class HomeActivity : AppCompatActivity() {
} }
} }
val fahipayJob = fahipayCreds?.let { creds -> // One async job per Fahipay login, all run in parallel
async(Dispatchers.IO) { val fahipayJobs = fahipayLoginIds.mapNotNull { loginId ->
val creds = store.loadFahipayCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val fahipayFlow = FahipayLoginFlow() val fahipayFlow = FahipayLoginFlow()
val deviceUuid = store.getOrCreateFahipayDeviceUuid() val deviceUuid = store.getOrCreateFahipayDeviceUuid()
val loginTag = "fahipay_$loginId"
val savedSession = store.loadFahipaySession() val savedSession = store.loadFahipaySession(loginId)
if (savedSession != null) { if (savedSession != null) {
try { try {
val session = FahipaySession(savedSession.first, savedSession.second) val session = FahipaySession(savedSession.first, savedSession.second)
fahipayFlow.setSessionCookie(session.sessionCookie) fahipayFlow.setSessionCookie(session.sessionCookie)
val balance = fahipayFlow.fetchBalance(session) val balance = fahipayFlow.fetchBalance(session)
val profile = fahipayFlow.fetchProfile(session) val profile = fahipayFlow.fetchProfile(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp val app = application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = accounts AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
AccountCache.saveFahipay(this@HomeActivity, accounts) return@async accounts
return@async Pair(session, accounts) } catch (_: Exception) { }
} catch (_: Exception) {
}
} }
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) {
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
} }
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val authId = step.authId ?: return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
val cookieValue = fahipayFlow.getSessionCookieValue() ?: "" val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
val session = FahipaySession(authId, cookieValue) val session = FahipaySession(authId, cookieValue)
store.saveFahipaySession(authId, cookieValue) store.saveFahipaySession(loginId, authId, cookieValue)
val profile = fahipayFlow.fetchProfile(session) val profile = fahipayFlow.fetchProfile(session)
val balance = fahipayFlow.fetchBalance(session) val balance = fahipayFlow.fetchBalance(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp val app = application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = accounts AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
AccountCache.saveFahipay(this@HomeActivity, accounts) accounts
Pair(session, accounts)
} catch (_: Exception) { } catch (_: Exception) {
Pair(null, AccountCache.loadFahipay(this@HomeActivity)) AccountCache.loadFahipay(this@HomeActivity, loginId)
} }
} }
} }
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity) val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
val mibAccounts = mibResults.flatMap { it.second }
val bmlResults = bmlJobs.map { (_, job) -> job.await() } val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second } val bmlAccounts = bmlResults.flatMap { it.second }
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
val app = application as BasedBankApp val app = application as BasedBankApp
app.mibAccounts = mibAccounts
AccountCache.save(this@HomeActivity, mibAccounts)
app.bmlAccounts = bmlAccounts app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts) app.fahipayAccounts = fahipayAccounts
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshFinancing(app.mibSession, app.mibProfiles) for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
} }
} }
/** Filters MIB accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
val store = CredentialStore(this@HomeActivity)
return filter { acc ->
if (acc.bank != "MIB") return@filter true
val loginId = acc.loginTag.removePrefix("mib_")
val hidden = store.getHiddenMibProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
}
/** Filters MIB profiles the user has hidden for a given loginId. */
private fun List<MibProfile>.filterVisibleProfiles(loginId: String): List<MibProfile> {
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds(loginId)
if (hidden.isEmpty()) return this
return filter { it.profileId !in hidden }
}
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
fun applyProfileVisibility() {
val current = viewModel.accounts.value ?: return
viewModel.accounts.value = current.filterVisibleAccounts()
}
private fun refreshBmlLimits(session: BmlSession) { private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
lifecycleScope.launch { lifecycleScope.launch {
@@ -550,9 +620,12 @@ class HomeActivity : AppCompatActivity() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
} }
// Refresh all banks in background // Refresh all banks in background
refreshContacts(app.mibSession, app.mibProfiles) for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlContacts(app) refreshBmlContacts(app)
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!) for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
} }
private fun refreshFahipayContacts(session: FahipaySession) { private fun refreshFahipayContacts(session: FahipaySession) {
@@ -588,9 +661,9 @@ class HomeActivity : AppCompatActivity() {
return result return result
} }
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) { private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (profiles.isEmpty()) return
val flow = MibLoginFlow(CredentialStore(this)) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient() val contactsClient = MibContactsClient()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@@ -615,8 +688,8 @@ class HomeActivity : AppCompatActivity() {
} }
if (allContacts.isNotEmpty()) { if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories) ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds() val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds) val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds())
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))
@@ -630,9 +703,9 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp val app = application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
if (src.profileType == "FAHIPAY") { if (src.bank == "FAHIPAY") {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySession ?: return@withContext null val sess = app.fahipaySessionFor(src) ?: return@withContext null
try { try {
val flow = FahipayLoginFlow() val flow = FahipayLoginFlow()
flow.setSessionCookie(sess.sessionCookie) flow.setSessionCookie(sess.sessionCookie)
@@ -640,14 +713,15 @@ class HomeActivity : AppCompatActivity() {
val profile = flow.fetchProfile(sess) val profile = flow.fetchProfile(sess)
val loginTag = "fahipay_${profile.profileId}" val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(flow.buildAccount(profile, balance, loginTag)) val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
AccountCache.saveFahipay(this@HomeActivity, accounts) val loginId = src.loginTag.removePrefix("fahipay_")
app.fahipayAccounts = accounts AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val others = current.filter { it.profileType != "FAHIPAY" } val others = current.filter { it.bank != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh) viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) { } else if (src.bank == "BML") {
val loginId = src.loginTag.removePrefix("bml_") val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
@@ -659,27 +733,46 @@ class HomeActivity : AppCompatActivity() {
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag } val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
viewModel.accounts.postValue(otherAccounts + fresh) viewModel.accounts.postValue(otherAccounts + fresh)
} else { } else {
val loginId = src.loginTag.removePrefix("mib_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null val store = CredentialStore(this@HomeActivity)
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null val hidden = store.getHiddenMibProfileIds(loginId)
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) } val profiles = app.mibProfilesMap[loginId] ?: emptyList()
catch (_: Exception) { null } val allVisible = profiles.filter { hidden.isEmpty() || it.profileId !in hidden }
val sess = app.mibSessions[loginId]
if (sess != null && allVisible.isNotEmpty()) {
try {
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(sess, allVisible, src.loginTag)
if (accounts.isNotEmpty()) return@withContext accounts
} catch (_: Exception) { }
}
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
try {
val flow = MibLoginFlow(store)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
accounts.takeIf { it.isNotEmpty() }
} catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
// Replace accounts from this profile only, keep everything else // Replace accounts for this MIB login
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") } val others = current.filter { it.loginTag != src.loginTag }
val merged = others + fresh val newMibAccounts = app.mibAccounts.filter { it.loginTag != src.loginTag } + fresh
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") }) app.mibAccounts = newMibAccounts
viewModel.accounts.postValue(merged) AccountCache.save(this@HomeActivity, newMibAccounts)
viewModel.accounts.postValue(others + fresh)
} }
} }
} }
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) { private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (profiles.isEmpty()) return
val flow = MibLoginFlow(CredentialStore(this)) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val client = MibFinancingClient() val client = MibFinancingClient()
lifecycleScope.launch { lifecycleScope.launch {
try { try {

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -7,35 +8,23 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.R import sh.sar.basedbank.R
class MoreFragment : Fragment() { class MoreFragment : Fragment() {
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
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_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_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_more, container, false) inflater.inflate(R.layout.fragment_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val list = view.findViewById<LinearLayout>(R.id.moreList) val list = view.findViewById<LinearLayout>(R.id.moreList)
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
for (item in items) { for (item in items) {
val row = inflater.inflate(R.layout.item_more_nav, list, false) val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon) row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title) row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) } row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
list.addView(row) list.addView(row)
} }

View File

@@ -0,0 +1,61 @@
package sh.sar.basedbank.ui.home
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import sh.sar.basedbank.R
object NavCustomization {
data class NavItemDef(
val id: Int,
@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int
)
/** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts),
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
)
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putInt("bottom_nav_slot_1", slots[0])
.putInt("bottom_nav_slot_2", slots[1])
.putInt("bottom_nav_slot_3", slots[2])
.apply()
}
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("quick_action_1", R.id.nav_transfer),
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
)
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
prefs.edit()
.putInt("quick_action_1", ids[0])
.putInt("quick_action_2", ids[1])
.apply()
}
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots }
}
}

View File

@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@@ -16,7 +18,14 @@ class NavMoreSheetFragment : BottomSheetDialogFragment() {
inflater.inflate(R.layout.sheet_nav_more, container, false) inflater.inflate(R.layout.sheet_nav_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item -> val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
navView.menu.clear()
items.forEachIndexed { i, item ->
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
}
navView.setNavigationItemSelectedListener { item ->
dismiss() dismiss()
onNavigate?.invoke(item.itemId) onNavigate?.invoke(item.itemId)
true true

View File

@@ -71,8 +71,9 @@ class OtpFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>() val entries = mutableListOf<OtpEntry>()
store.loadMibCredentials()?.let { creds -> for (loginId in store.getMibLoginIds()) {
val name = store.loadMibFullName() val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
} }
for (loginId in store.getBmlLoginIds()) { for (loginId in store.getBmlLoginIds()) {
@@ -88,20 +89,23 @@ class OtpFragment : Fragment() {
// Fetch real names in background if not yet cached, then refresh labels // Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
var changed = false var changed = false
if (store.loadMibFullName() == null) { for (loginId in store.getMibLoginIds()) {
app.mibSession?.let { session -> if (store.loadMibFullName(loginId) == null) {
val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) { val profile = withContext(Dispatchers.IO) {
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null } try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null }
} }
if (profile != null) { if (profile != null) {
store.saveMibUserProfile(CredentialStore.MibUserProfile( store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = profile.username,
email = profile.email, email = profile.email,
mobile = profile.mobile, mobile = profile.mobile,
enrolled = profile.enrolled enrolled = profile.enrolled
)) ))
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed } val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
} }
} }

View File

@@ -1,29 +1,45 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import java.util.Collections
class SettingsAppearanceFragment : Fragment() { class SettingsAppearanceFragment : Fragment() {
private var _binding: FragmentSettingsAppearanceBinding? = null private var _binding: FragmentSettingsAppearanceBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false) _binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation mode // Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false) val isBottom = prefs.getBoolean("bottom_nav", false)
@@ -32,8 +48,35 @@ class SettingsAppearanceFragment : Fragment() {
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply() prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
(activity as? HomeActivity)?.applyNavMode() (activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility()
} }
// Quick actions
quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) {
NavCustomization.saveQuickActions(prefs, quickActions)
}
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
// Bottom bar shortcuts
slots.clear()
slots.addAll(NavCustomization.getSlots(prefs))
slotAdapter = NavItemAdapter(slots) {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
}
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
(activity as? HomeActivity)?.applyNavLabelVisibility()
}
updateShortcutsVisibility()
// Theme // Theme
val saved = prefs.getString("theme", "system") val saved = prefs.getString("theme", "system")
binding.themeToggle.check(when (saved) { binding.themeToggle.check(when (saved) {
@@ -63,6 +106,95 @@ class SettingsAppearanceFragment : Fragment() {
} }
} }
private fun setupNavItemRecyclerView(
rv: RecyclerView,
adapter: NavItemAdapter,
items: MutableList<Int>
) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0
) {
override fun onMove(
rv: RecyclerView,
from: RecyclerView.ViewHolder,
to: RecyclerView.ViewHolder
): Boolean {
val fromPos = from.adapterPosition
val toPos = to.adapterPosition
Collections.swap(items, fromPos, toPos)
adapter.notifyItemMoved(fromPos, toPos)
adapter.onSave()
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
}).attachToRecyclerView(rv)
}
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
val rows = available.map { item ->
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
listLayout.addView(row)
}
}
val scroll = ScrollView(ctx).apply { addView(listLayout) }
var dialog: androidx.appcompat.app.AlertDialog? = null
dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.settings_bottom_bar_select)
.setView(scroll)
.setNegativeButton(R.string.cancel, null)
.show()
available.forEachIndexed { i, item ->
rows[i].setOnClickListener {
items[slotIndex] = item.id
adapter.onSave()
adapter.notifyItemChanged(slotIndex)
dialog?.dismiss()
}
}
}
private inner class NavItemAdapter(
val items: MutableList<Int>,
val onSave: () -> Unit
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
else RecyclerView.LayoutParams.WRAP_CONTENT
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
return VH(view)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
holder.ivNavIcon.setImageResource(def.iconRes)
holder.tvNavLabel.setText(def.titleRes)
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.settings_appearance) requireActivity().title = getString(R.string.settings_appearance)

View File

@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -14,6 +15,7 @@ import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
@@ -58,31 +60,18 @@ class SettingsLoginsFragment : Fragment() {
val container = binding.loginsContainer val container = binding.loginsContainer
container.removeAllViews() container.removeAllViews()
val hasMib = store.hasMibCredentials() val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val fahipayLoginIds = store.getFahipayLoginIds()
binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
if (hasMib) { for (loginId in mibLoginIds) {
val profile = store.loadMibUserProfile() val profile = store.loadMibUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val profileNames = AccountCache.load(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct() val mibProfiles = store.loadMibProfiles(loginId)
addLoginRow(container, R.drawable.mib_logo, displayName) { addLoginRow(container, R.drawable.mib_logo, displayName) {
showLoginDetails( showMibLoginDetails(store, loginId, profile, mibProfiles)
title = getString(R.string.mib_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
)
} }
} }
@@ -110,8 +99,8 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
if (hasFahipay) { for (loginId in fahipayLoginIds) {
val profile = store.loadFahipayUserProfile() val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) { addLoginRow(container, R.drawable.fahipay_logo, displayName) {
showLoginDetails( showLoginDetails(
@@ -122,7 +111,7 @@ class SettingsLoginsFragment : Fragment() {
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}") if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}") if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
}.trim(), }.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store) } } onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
) )
} }
} }
@@ -154,6 +143,118 @@ class SettingsLoginsFragment : Fragment() {
container.addView(row) container.addView(row)
} }
private fun showMibLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.MibUserProfile?,
mibProfiles: List<MibProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
// Account info lines
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
})
}
if (mibProfiles.isNotEmpty()) {
if (profile != null) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.login_detail_profiles)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
}
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button
val checkboxRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
text = p.name
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
})
if (p.cifType.isNotBlank()) {
textCol.addView(TextView(ctx).apply {
text = p.cifType
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
}
val cb = CheckBox(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol)
row.addView(cb)
container.addView(row)
p to cb
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.mib_name))
.setView(scroll)
.setPositiveButton(R.string.save, null) // null — set manually after show()
.setNeutralButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.mib_name)) { logoutMib(store, loginId) }
}
.show()
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
checkboxRows.forEach { (p, cb) ->
cb.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden }
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
}
}
saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
@@ -172,12 +273,16 @@ class SettingsLoginsFragment : Fragment() {
.show() .show()
} }
private fun logoutMib(store: CredentialStore) { private fun logoutMib(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearMibCredentials() store.clearMibCredentials(loginId)
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply() ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.accounts = emptyList(); app.mibSession = null; app.mibProfiles = emptyList() app.mibSessions.remove(loginId)
app.mibProfilesMap.remove(loginId)
app.mibLoginFlows.remove(loginId)
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()
@@ -194,11 +299,12 @@ class SettingsLoginsFragment : Fragment() {
buildLoginsSection() buildLoginsSection()
} }
private fun logoutFahipay(store: CredentialStore) { private fun logoutFahipay(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearFahipayCredentials(); store.clearFahipaySession() store.clearFahipayCredentials(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.fahipaySession = null; app.fahipayAccounts = emptyList() app.fahipaySessions.remove(loginId)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != "fahipay_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()

View File

@@ -19,6 +19,7 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -49,6 +50,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
@@ -63,7 +65,9 @@ class TransferFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession private val session get() = selectedAccount
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyMibSession()
private fun bmlSessionFor(account: MibAccount?) = private fun bmlSessionFor(account: MibAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) } account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession() ?: (requireActivity().application as BasedBankApp).anyBmlSession()
@@ -146,8 +150,11 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
binding.btnTransfer.isEnabled = false
binding.btnTransfer.setOnClickListener { initiateTransfer() } binding.btnTransfer.setOnClickListener { initiateTransfer() }
binding.etAmount.addTextChangedListener { updateTransferButton() }
// Pre-select contact if navigated from contacts page // Pre-select contact if navigated from contacts page
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly( prefillToDirectly(
@@ -183,6 +190,7 @@ class TransferFragment : Fragment() {
binding.cardFromInfo.visibility = View.GONE binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false) binding.actvFrom.setText("", false)
updateTransferButton()
} }
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
@@ -194,6 +202,7 @@ class TransferFragment : Fragment() {
selectedAccount = picked selectedAccount = picked
updateAmountPrefix(picked) updateAmountPrefix(picked)
showFromCard(picked) showFromCard(picked)
updateTransferButton()
} }
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT) val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
@@ -203,22 +212,22 @@ class TransferFragment : Fragment() {
selectedAccount = match selectedAccount = match
updateAmountPrefix(match) updateAmountPrefix(match)
showFromCard(match) showFromCard(match)
updateTransferButton()
} }
} }
} }
} }
private fun showFromCard(account: MibAccount) { private fun showFromCard(account: MibAccount) {
val isBml = account.profileType.startsWith("BML") val colorHex = when (account.bank) {
val colorHex = when { "BML" -> "#0066A1"
isBml -> "#0066A1" "FAHIPAY" -> "#15BEA7"
account.profileType == "FAHIPAY" -> "#15BEA7" else -> "#FE860E"
else -> "#FE860E"
} }
val bankLabel = when { val bankLabel = when (account.bank) {
isBml -> "BML" "BML" -> "BML"
account.profileType == "FAHIPAY" -> "FP" "FAHIPAY" -> "FP"
else -> null else -> null
} }
val typeLabel = when { val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card" account.profileType == "BML_PREPAID" -> "Prepaid Card"
@@ -234,7 +243,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.GONE binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE binding.cardFromInfo.visibility = View.VISIBLE
if (!isBml && account.profileImageHash != null) { if (account.bank != "BML" && account.profileImageHash != null) {
loadFromPhoto(account.profileImageHash) loadFromPhoto(account.profileImageHash)
} }
} }
@@ -244,7 +253,7 @@ class TransferFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = app.mibLoginFlow.fetchProfileImage(sess, hash) ?: return@launch val base64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -271,6 +280,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null binding.tilTo.error = null
updateTransferButton()
} }
binding.etTo.addTextChangedListener { binding.etTo.addTextChangedListener {
@@ -282,6 +292,7 @@ class TransferFragment : Fragment() {
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE
updateTransferButton()
} }
} }
} }
@@ -312,7 +323,7 @@ class TransferFragment : Fragment() {
} }
// Fahipay source: only phone numbers are supported // Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "FAHIPAY") { if (selectedAccount?.bank == "FAHIPAY") {
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) { if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupFahipayTarget(accountNumber) lookupFahipayTarget(accountNumber)
} else { } else {
@@ -328,7 +339,7 @@ class TransferFragment : Fragment() {
return return
} }
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true val isBmlSource = selectedAccount?.bank == "BML"
startLookupLoading() startLookupLoading()
@@ -394,6 +405,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info) saveToRecents(info)
when { when {
@@ -520,6 +532,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber } val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
if (contact != null) { if (contact != null) {
@@ -567,7 +580,7 @@ class TransferFragment : Fragment() {
binding.tilAmount.error = null binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML") val isSrcBml = src.bank == "BML"
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" 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" }
@@ -578,10 +591,10 @@ class TransferFragment : Fragment() {
if (isSrcBml && isDestMib && currency == "USD") { if (isSrcBml && isDestMib && currency == "USD") {
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber } val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
if (!hasBmlContact) { if (!hasBmlContact) {
AlertDialog.Builder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer_bml_contact_required_title) .setTitle(R.string.transfer_bml_contact_required_title)
.setMessage(R.string.transfer_bml_contact_required_msg) .setMessage(R.string.transfer_bml_contact_required_msg)
.setPositiveButton("OK", null) .setPositiveButton(R.string.close, null)
.show() .show()
return return
} }
@@ -625,7 +638,7 @@ class TransferFragment : Fragment() {
} }
} }
val dialogBuilder = AlertDialog.Builder(requireContext()) val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer) .setTitle(R.string.transfer)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() } .setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@@ -706,12 +719,14 @@ class TransferFragment : Fragment() {
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("mib_")
// Switch to the profile that owns the source account // Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) { if (src.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } val profiles = app.mibProfilesMap[loginId] ?: emptyList()
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile) val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
} }
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462" val currencyCode = if (src.currencyName == "USD") "840" else "462"
@@ -848,6 +863,11 @@ class TransferFragment : Fragment() {
} }
} }
private fun updateTransferButton() {
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
}
private fun clearForm() { private fun clearForm() {
selectedAccount = null selectedAccount = null
binding.actvFrom.setText("", false) binding.actvFrom.setText("", false)
@@ -878,7 +898,7 @@ class TransferFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
@@ -1009,11 +1029,11 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isBmlAccount = acc.profileType.startsWith("BML") val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else "" val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}" b.tvDropdownAccountName.text = "$ownerPrefix${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 = AccountListParser.from(acc)?.balance ?: ""
b.root.alpha = if (inactive) 0.4f else 1f b.root.alpha = if (inactive) 0.4f else 1f
b.root b.root
} }

View File

@@ -10,6 +10,8 @@ import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -61,10 +63,10 @@ class TransferHistoryFragment : Fragment() {
var fahipayTotal: Int = -1 var fahipayTotal: Int = -1
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
} }
@@ -87,6 +89,15 @@ class TransferHistoryFragment : Fragment() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
@@ -142,14 +153,13 @@ class TransferHistoryFragment : Fragment() {
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession
lifecycleScope.launch { lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>() val results = mutableListOf<Transaction>()
// BML accounts: fetch in parallel // BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") } val bmlStates = activeStates.filter { it.account.bank == "BML" }
results.addAll(bmlStates.map { state -> results.addAll(bmlStates.map { state ->
async { async {
try { try {
@@ -187,9 +197,9 @@ class TransferHistoryFragment : Fragment() {
}.awaitAll().flatten()) }.awaitAll().flatten())
// Fahipay accounts // Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" } val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) { for (state in fahipayStates) {
val session = app.fahipaySession ?: continue val session = app.fahipaySessionFor(state.account) ?: continue
try { try {
val flow = FahipayLoginFlow() val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie) flow.setSessionCookie(session.sessionCookie)
@@ -206,27 +216,28 @@ class TransferHistoryFragment : Fragment() {
} }
// MIB accounts: serialized per profile, protected by mutex to prevent session race // MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter { val mibStates = activeStates.filter { it.account.bank == "MIB" }
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY" for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
} val session = app.mibSessions[loginId] ?: continue
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break app.mibMutex.withLock {
app.mibMutex.withLock { val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId } val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) { for (state in states) {
try { try {
val (list, total) = MibHistoryClient().fetchHistory( val (list, total) = MibHistoryClient().fetchHistory(
session = session, session = session,
accountNo = state.account.accountNumber, accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart, start = state.mibNextStart,
pageSize = pageSize pageSize = pageSize
) )
if (total > 0) state.mibTotalCount = total if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize) state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list) results.addAll(list)
} catch (_: Exception) {} } catch (_: Exception) {}
}
} }
} }
} }
@@ -280,7 +291,7 @@ class TransferHistoryFragment : Fragment() {
return return
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch

View File

@@ -154,11 +154,11 @@ class TransferReceiptFragment : Fragment() {
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch

View File

@@ -54,7 +54,7 @@ class CredentialsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
when (bankType) { when (bankType) {
"BML" -> { "BML" -> {
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector) binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc) binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
} }
"FAHIPAY" -> { "FAHIPAY" -> {
@@ -170,6 +170,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password) val passwordHash = MibLoginFlow.hashPassword(password)
val loginId = username
val flow = MibLoginFlow(CredentialStore(requireContext())) val flow = MibLoginFlow(CredentialStore(requireContext()))
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -178,11 +179,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, passwordHash, otpSeed) flow.login(username, passwordHash, otpSeed)
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveMibCredentials(username, passwordHash, otpSeed) store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
flow.lastSession?.let { s -> flow.lastSession?.let { s ->
val profile = flow.fetchPersonalProfile(s) val profile = flow.fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile( if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile( CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = profile.username,
@@ -193,11 +195,15 @@ class CredentialsFragment : Fragment() {
) )
} }
} }
AccountCache.save(requireContext(), accounts) store.saveMibProfiles(loginId, flow.lastProfiles)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.accounts = accounts // Merge with any existing MIB accounts from other logins
app.mibSession = flow.lastSession app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
app.mibProfiles = flow.lastProfiles app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
AccountCache.save(requireContext(), app.mibAccounts)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
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
startActivity(intent) startActivity(intent)
@@ -374,11 +380,13 @@ class CredentialsFragment : Fragment() {
val b = flow.fetchBalance(session) val b = flow.fetchBalance(session)
Pair(p, b) Pair(p, b)
} }
val loginTag = "fahipay_${profile.profileId}" val loginId = profile.profileId
val loginTag = "fahipay_$loginId"
val account = flow.buildAccount(profile, balance, loginTag) val account = flow.buildAccount(profile, balance, loginTag)
store.saveFahipayCredentials(idCard, password) store.saveFahipayCredentials(loginId, idCard, password)
store.saveFahipaySession(session.authId, session.sessionCookie) store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
store.saveFahipayUserProfile( store.saveFahipayUserProfile(
loginId,
CredentialStore.FahipayUserProfile( CredentialStore.FahipayUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
email = profile.email, email = profile.email,
@@ -389,11 +397,11 @@ class CredentialsFragment : Fragment() {
linkedAccounts = profile.linkedAccounts linkedAccounts = profile.linkedAccounts
) )
) )
AccountCache.saveFahipay(requireContext(), listOf(account)) AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = listOf(account) app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts + listOf(account) app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
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
startActivity(intent) startActivity(intent)

View File

@@ -40,6 +40,11 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom) view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
insets insets
} }
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
val statusBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(view.paddingLeft, statusBar.top, view.paddingRight, view.paddingBottom)
insets
}
val adapter = OnboardingPagerAdapter(this) val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
@@ -56,7 +61,7 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
} }
// Pre-select language button without triggering the listener // Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null) val savedLang = prefs.getString("language", null) ?: "en".also { selectLanguage(it) }
binding.languageToggle.clearOnButtonCheckedListeners() binding.languageToggle.clearOnButtonCheckedListeners()
when (savedLang) { when (savedLang) {
"en" -> binding.btnLangEnglish.isChecked = true "en" -> binding.btnLangEnglish.isChecked = true

View File

@@ -9,16 +9,17 @@ 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_FAHIPAY = "fahipay_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId" private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
private fun fahipayKey(loginId: String) = "fahipay_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) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
put("bank", acc.bank)
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("cifType", acc.cifType)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -68,20 +69,21 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = "BML",
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -90,7 +92,7 @@ object AccountCache {
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> = fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadBml(context, it) } loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, accounts: List<MibAccount>) { fun saveFahipay(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 {
@@ -110,36 +112,40 @@ object AccountCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_FAHIPAY, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
} }
fun loadFahipay(context: Context): List<MibAccount> { fun loadFahipay(context: Context, loginId: String): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_FAHIPAY, null) ?: return emptyList() .getString(fahipayKey(loginId), null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = "FAHIPAY",
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadFahipay(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadFahipay(context, it) }
fun clear(context: Context) { fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
} }
@@ -153,20 +159,22 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = o.optString("bank", "MIB"),
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }, profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "") profileId = o.optString("profileId", "")
) )
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -0,0 +1,15 @@
package sh.sar.basedbank.util
/**
* Standard display model for the account history header card — produced by
* per-bank parsers, consumed by AccountHistoryAdapter with no bank logic.
*/
data class AccountHistoryDisplay(
val name: String,
val number: String,
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
val availableBalance: String, // formatted "CCY amount"
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
val blockedBalance: String? // null if zero or not applicable
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser {
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
"BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account)
else -> null
}
}

View File

@@ -0,0 +1,11 @@
package sh.sar.basedbank.util
data class AccountListDisplay(
val name: String,
val number: String,
val typeLabel: String,
val balance: String,
val isCard: Boolean = false,
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
val statusLabel: String? = null // null = active; shown as status pill if set
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser {
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
"BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account)
else -> null
}
}

View File

@@ -1,28 +0,0 @@
package sh.sar.basedbank.util
object BmlDashboardParser {
/**
* Returns a display-ready product label for a BML dashboard account or card.
* Known BML product names are mapped to short friendly labels.
* Everything else is title-cased (first letter of each word capitalised).
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,22 @@
package sh.sar.basedbank.util
/**
* Standard display model for a contact row — produced by per-bank parsers,
* consumed by UI. No bank-specific logic in adapters or fragments.
*/
data class ContactDisplay(
val id: String, // internal contact ID (benefNo)
val name: String, // display nickname
val realName: String, // legal name — used for search
val accountNumber: String,
val categoryId: String, // for tab filtering
val network: TransferNetwork,
val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
val imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
val canTransfer: Boolean,
val canEdit: Boolean,
val canDelete: Boolean
)

View File

@@ -0,0 +1,18 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.bmlapi.BmlContactParser
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
import sh.sar.basedbank.util.mibapi.MibContactParser
object ContactListParser {
fun from(contact: MibBeneficiary): ContactDisplay? = when {
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
else -> null
}
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
}

View File

@@ -0,0 +1,40 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibContactsClient
/**
* Behaviour dispatcher for contact operations.
* Routes add/delete to the correct bank API based on TransferNetwork.
* UI code never inspects the network or bank type directly.
*/
object ContactManager {
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
TransferNetwork.BML -> deleteBml(contact, app)
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
}
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.id.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null)
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.id)
} catch (_: Exception) { false }
}
}

View File

@@ -21,75 +21,131 @@ class CredentialStore(context: Context) {
data class BmlCredentials(val username: String, val password: String, val otpSeed: String) data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
data class FahipayCredentials(val idCard: String, val password: String) data class FahipayCredentials(val idCard: String, val password: String)
// ── MIB login credentials ───────────────────────────────────────────────── // ── MIB login credentials (multi-login, keyed by loginId = username) ─────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") fun getMibLoginIds(): List<String> {
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card") maybeMigrateLegacyMib()
val json = prefs.getString("mib_login_ids", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
private fun addMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveMibCredentials(loginId: String, username: String, passwordHash: String, otpSeed: String) {
addMibLoginId(loginId)
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("mib_enc_username", encrypt(username, key)) .putString("mib_${loginId}_enc_password_hash", encrypt(passwordHash, key))
.putString("mib_enc_password_hash", encrypt(passwordHash, key)) .putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
.apply() .apply()
} }
fun loadMibCredentials(): MibCredentials? { fun loadMibCredentials(loginId: String): MibCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encUsername = prefs.getString("mib_enc_username", null) ?: return null val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null val encSeed = prefs.getString("mib_${loginId}_enc_otp_seed", null) ?: return null
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
return try { return try {
MibCredentials( MibCredentials(loginId, decrypt(encHash, key), decrypt(encSeed, key))
decrypt(encUsername, key),
decrypt(encHash, key),
decrypt(encSeed, key)
)
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearMibCredentials() { fun clearMibCredentials(loginId: String) {
removeMibLoginId(loginId)
prefs.edit() prefs.edit()
.remove("mib_enc_username") .remove("mib_${loginId}_enc_password_hash")
.remove("mib_enc_password_hash") .remove("mib_${loginId}_enc_otp_seed")
.remove("mib_enc_otp_seed") .remove("mib_${loginId}_enc_key1")
.remove("mib_enc_key1") .remove("mib_${loginId}_enc_key2")
.remove("mib_enc_key2") .remove("mib_${loginId}_enc_app_id")
.remove("mib_enc_app_id") .remove("mib_${loginId}_all_profiles")
.remove("mib_${loginId}_enc_profile")
.remove("mib_${loginId}_enc_full_name")
.remove("mib_${loginId}_hidden_profile_ids")
.apply() .apply()
} }
// ── MIB session keys (key1/key2) and app ID ─────────────────────────────── // ── MIB session keys (key1/key2) and app ID (per loginId) ────────────────
fun saveMibKeys(key1: String, key2: String) { fun saveMibKeys(loginId: String, key1: String, key2: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("mib_enc_key1", encrypt(key1, key)) .putString("mib_${loginId}_enc_key1", encrypt(key1, key))
.putString("mib_enc_key2", encrypt(key2, key)) .putString("mib_${loginId}_enc_key2", encrypt(key2, key))
.apply() .apply()
} }
fun loadMibKeys(): Pair<String, String>? { fun loadMibKeys(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null val encKey1 = prefs.getString("mib_${loginId}_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null val encKey2 = prefs.getString("mib_${loginId}_enc_key2", null) ?: return null
return try { return try {
Pair(decrypt(encKey1, key), decrypt(encKey2, key)) Pair(decrypt(encKey1, key), decrypt(encKey2, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun saveMibAppId(id: String) { fun saveMibAppId(loginId: String, id: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply() prefs.edit().putString("mib_${loginId}_enc_app_id", encrypt(id, key)).apply()
} }
fun loadMibAppId(): String? { fun loadMibAppId(loginId: String): String? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_app_id", null) ?: return null val enc = prefs.getString("mib_${loginId}_enc_app_id", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
/** One-time migration: if old single-login MIB data exists, move it to per-loginId storage. */
private var migrationChecked = false
private fun maybeMigrateLegacyMib() {
if (migrationChecked) return
migrationChecked = true
if (prefs.contains("mib_login_ids")) return // already migrated
val encUsername = prefs.getString("mib_enc_username", null) ?: return
val key = try { getOrCreateKey() } catch (_: Exception) { return }
val loginId = try { decrypt(encUsername, key) } catch (_: Exception) { return }
val editor = prefs.edit()
// Migrate credentials
prefs.getString("mib_enc_password_hash", null)?.let { editor.putString("mib_${loginId}_enc_password_hash", it) }
prefs.getString("mib_enc_otp_seed", null)?.let { editor.putString("mib_${loginId}_enc_otp_seed", it) }
prefs.getString("mib_enc_key1", null)?.let { editor.putString("mib_${loginId}_enc_key1", it) }
prefs.getString("mib_enc_key2", null)?.let { editor.putString("mib_${loginId}_enc_key2", it) }
prefs.getString("mib_enc_app_id", null)?.let { editor.putString("mib_${loginId}_enc_app_id", it) }
prefs.getString("mib_all_profiles", null)?.let { editor.putString("mib_${loginId}_all_profiles", it) }
prefs.getString("mib_enc_profile", null)?.let { editor.putString("mib_${loginId}_enc_profile", it) }
prefs.getString("mib_enc_full_name", null)?.let { editor.putString("mib_${loginId}_enc_full_name", it) }
prefs.getStringSet("mib_hidden_profile_ids", null)?.let { editor.putStringSet("mib_${loginId}_hidden_profile_ids", it) }
// Register the login ID and clear legacy keys
editor.putString("mib_login_ids", org.json.JSONArray(listOf(loginId)).toString())
editor.remove("mib_enc_username")
editor.remove("mib_enc_password_hash")
editor.remove("mib_enc_otp_seed")
editor.remove("mib_enc_key1")
editor.remove("mib_enc_key2")
editor.remove("mib_enc_app_id")
editor.remove("mib_all_profiles")
editor.remove("mib_enc_profile")
editor.remove("mib_enc_full_name")
editor.remove("mib_hidden_profile_ids")
editor.apply()
}
// ── BML login credentials (multi-login, keyed by loginId = username) ──────── // ── BML login credentials (multi-login, keyed by loginId = username) ────────
fun getBmlLoginIds(): List<String> { fun getBmlLoginIds(): List<String> {
@@ -171,58 +227,81 @@ class CredentialStore(context: Context) {
.apply() .apply()
} }
// ── Fahipay login credentials ───────────────────────────────────────────── // ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
fun saveFahipayCredentials(idCard: String, password: String) { fun getFahipayLoginIds(): List<String> {
maybeMigrateLegacyFahipay()
val json = prefs.getString("fahipay_login_ids", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
private fun addFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveFahipayCredentials(loginId: String, idCard: String, password: String) {
addFahipayLoginId(loginId)
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("fahipay_enc_id_card", encrypt(idCard, key)) .putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key))
.putString("fahipay_enc_password", encrypt(password, key)) .putString("fahipay_${loginId}_enc_password", encrypt(password, key))
.apply() .apply()
} }
fun loadFahipayCredentials(): FahipayCredentials? { fun loadFahipayCredentials(loginId: String): FahipayCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null
val encPw = prefs.getString("fahipay_enc_password", null) ?: return null val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null
return try { return try {
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key)) FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearFahipayCredentials() { fun clearFahipayCredentials(loginId: String) {
removeFahipayLoginId(loginId)
prefs.edit() prefs.edit()
.remove("fahipay_enc_id_card") .remove("fahipay_${loginId}_enc_id_card")
.remove("fahipay_enc_password") .remove("fahipay_${loginId}_enc_password")
.remove("fahipay_${loginId}_enc_auth_id")
.remove("fahipay_${loginId}_enc_session_cookie")
.remove("fahipay_${loginId}_enc_profile")
.apply() .apply()
} }
// ── Fahipay session (authId + __Secure-sess cookie) ─────────────────────── // ── Fahipay session (authId + __Secure-sess cookie) (per loginId) ─────────
fun saveFahipaySession(authId: String, sessionCookie: String) { fun saveFahipaySession(loginId: String, authId: String, sessionCookie: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("fahipay_enc_auth_id", encrypt(authId, key)) .putString("fahipay_${loginId}_enc_auth_id", encrypt(authId, key))
.putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key)) .putString("fahipay_${loginId}_enc_session_cookie", encrypt(sessionCookie, key))
.apply() .apply()
} }
fun loadFahipaySession(): Pair<String, String>? { fun loadFahipaySession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null val encAuth = prefs.getString("fahipay_${loginId}_enc_auth_id", null) ?: return null
val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null val encCookie = prefs.getString("fahipay_${loginId}_enc_session_cookie", null) ?: return null
return try { return try {
Pair(decrypt(encAuth, key), decrypt(encCookie, key)) Pair(decrypt(encAuth, key), decrypt(encCookie, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearFahipaySession() {
prefs.edit()
.remove("fahipay_enc_auth_id")
.remove("fahipay_enc_session_cookie")
.apply()
}
// ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─ // ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─
fun getOrCreateFahipayDeviceUuid(): String { fun getOrCreateFahipayDeviceUuid(): String {
@@ -236,7 +315,7 @@ class CredentialStore(context: Context) {
return uuid return uuid
} }
// ── Fahipay user profile ────────────────────────────────────────────────── // ── Fahipay user profile (per loginId) ────────────────────────────────────
data class FahipayUserProfile( data class FahipayUserProfile(
val fullName: String, val fullName: String,
@@ -248,7 +327,7 @@ class CredentialStore(context: Context) {
val linkedAccounts: String val linkedAccounts: String
) )
fun saveFahipayUserProfile(p: FahipayUserProfile) { fun saveFahipayUserProfile(loginId: String, p: FahipayUserProfile) {
val json = org.json.JSONObject().apply { val json = org.json.JSONObject().apply {
put("fullName", p.fullName) put("fullName", p.fullName)
put("email", p.email) put("email", p.email)
@@ -259,12 +338,12 @@ class CredentialStore(context: Context) {
put("linkedAccounts", p.linkedAccounts) put("linkedAccounts", p.linkedAccounts)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("fahipay_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("fahipay_${loginId}_enc_profile", encrypt(json, key)).apply()
} }
fun loadFahipayUserProfile(): FahipayUserProfile? { fun loadFahipayUserProfile(loginId: String): FahipayUserProfile? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("fahipay_enc_profile", null) ?: return null val enc = prefs.getString("fahipay_${loginId}_enc_profile", null) ?: return null
return try { return try {
val o = org.json.JSONObject(decrypt(enc, key)) val o = org.json.JSONObject(decrypt(enc, key))
FahipayUserProfile( FahipayUserProfile(
@@ -279,6 +358,33 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
/** One-time migration: if old single-login Fahipay data exists, move it to per-loginId storage. */
private var fahipayMigrationChecked = false
private fun maybeMigrateLegacyFahipay() {
if (fahipayMigrationChecked) return
fahipayMigrationChecked = true
if (prefs.contains("fahipay_login_ids")) return // already migrated
val encProfile = prefs.getString("fahipay_enc_profile", null) ?: return
val key = try { getOrCreateKey() } catch (_: Exception) { return }
val loginId = try {
val o = org.json.JSONObject(decrypt(encProfile, key))
o.optString("profileId").takeIf { it.isNotBlank() }
} catch (_: Exception) { null } ?: return
val editor = prefs.edit()
prefs.getString("fahipay_enc_id_card", null)?.let { editor.putString("fahipay_${loginId}_enc_id_card", it) }
prefs.getString("fahipay_enc_password", null)?.let { editor.putString("fahipay_${loginId}_enc_password", it) }
prefs.getString("fahipay_enc_auth_id", null)?.let { editor.putString("fahipay_${loginId}_enc_auth_id", it) }
prefs.getString("fahipay_enc_session_cookie", null)?.let { editor.putString("fahipay_${loginId}_enc_session_cookie", it) }
editor.putString("fahipay_${loginId}_enc_profile", encProfile)
editor.putString("fahipay_login_ids", org.json.JSONArray(listOf(loginId)).toString())
editor.remove("fahipay_enc_id_card")
editor.remove("fahipay_enc_password")
editor.remove("fahipay_enc_auth_id")
editor.remove("fahipay_enc_session_cookie")
editor.remove("fahipay_enc_profile")
editor.apply()
}
// ── Security credential (PIN / pattern hash) ────────────────────────────── // ── Security credential (PIN / pattern hash) ──────────────────────────────
/** /**
@@ -329,19 +435,55 @@ class CredentialStore(context: Context) {
val birthdate: String val birthdate: String
) )
fun saveMibFullName(name: String) { // ── MIB operating profiles (per loginId) ─────────────────────────────────
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply() fun saveMibProfiles(loginId: String, profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) {
arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("cifType", p.cifType)
put("profileType", p.profileType)
put("color", p.color)
})
}
prefs.edit().putString("mib_${loginId}_all_profiles", arr.toString()).apply()
} }
fun loadMibFullName(): String? { fun loadMibProfiles(loginId: String): List<sh.sar.basedbank.api.mib.MibProfile> {
val raw = prefs.getString("mib_${loginId}_all_profiles", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
sh.sar.basedbank.api.mib.MibProfile(
profileId = o.optString("profileId"),
customerProfileId = o.optString("profileId"),
annexId = "",
customerId = "",
name = o.optString("name"),
cifType = o.optString("cifType"),
profileType = o.optString("profileType"),
color = o.optString("color"),
customerImage = null
)
}
} catch (_: Exception) { emptyList() }
}
fun saveMibFullName(loginId: String, name: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_full_name", null) ?: return null prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(name, key)).apply()
}
fun loadMibFullName(loginId: String): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_${loginId}_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
fun saveMibUserProfile(loginId: String, p: MibUserProfile) {
fun saveMibUserProfile(p: MibUserProfile) {
val json = JSONObject().apply { val json = JSONObject().apply {
put("fullName", p.fullName) put("fullName", p.fullName)
put("username", p.username) put("username", p.username)
@@ -350,14 +492,13 @@ class CredentialStore(context: Context) {
put("enrolled", p.enrolled) put("enrolled", p.enrolled)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
// Keep the name in sync with the fast-path field prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(p.fullName, key)).apply()
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
} }
fun loadMibUserProfile(): MibUserProfile? { fun loadMibUserProfile(loginId: String): MibUserProfile? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_profile", null) ?: return null val enc = prefs.getString("mib_${loginId}_enc_profile", null) ?: return null
return try { return try {
val o = JSONObject(decrypt(enc, key)) val o = JSONObject(decrypt(enc, key))
MibUserProfile( MibUserProfile(
@@ -399,6 +540,15 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
// ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
fun getHiddenMibProfileIds(loginId: String): Set<String> =
prefs.getStringSet("mib_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
// ── Crypto primitives ───────────────────────────────────────────────────── // ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey { private fun getOrCreateKey(): SecretKey {

View File

@@ -0,0 +1,117 @@
package sh.sar.basedbank.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* Encapsulates all bank-specific pagination state and fetch logic for account history.
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
* without knowing which bank it is talking to.
*/
class HistoryFetcher(private val account: MibAccount) {
private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination
private var mibNextStart = 1
private var mibTotalCount = -1
// BML CASA pagination
private var bmlNextPage = 1
private var bmlTotalPages = -1
// BML card pagination (month-based)
private var cardMonthOffset = 0
// Fahipay pagination
private var fahipayNextStart = 0
private var fahipayTotal = -1
fun hasMore(): Boolean = when {
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
}
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
val session = app.fahipaySessionFor(account) ?: return emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
return list
}
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
val loginId = account.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId] ?: return emptyList()
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
return list
}
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
return BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
return list
}
}

View File

@@ -1,18 +0,0 @@
package sh.sar.basedbank.util
object MibAccountParser {
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
* Known MIB accountTypeName values are mapped to short friendly labels.
* Everything else is returned trimmed as-is.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -0,0 +1,10 @@
package sh.sar.basedbank.util
/** App-unified term for the transfer routing network a contact uses. */
enum class TransferNetwork {
MIB, // MIB internal (both parties on MIB)
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
SWIFT, // international SWIFT
BML, // Bank of Maldives
FAHIPAY // Fahipay wallet
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object BmlContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.BML,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}

View File

@@ -0,0 +1,69 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object BmlDashboardParser {
/**
* Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards.
*/
fun displayData(account: MibAccount): AccountListDisplay {
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}",
isCard = true,
cardBrandIcon = cardBrandIcon(account.accountTypeName),
statusLabel = if (isActive) null else account.statusDesc
)
} else {
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account)
)
}
}
/**
* Returns a display-ready product label for a BML dashboard account or card.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
fun listBalance(account: MibAccount): String =
"${account.currencyName} ${account.currentBalance}"
fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,20 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object BmlHistoryParser {
fun displayData(account: MibAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "BML",
typeLabel = BmlDashboardParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
}

View File

@@ -0,0 +1,14 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object FahipayAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = account.accountTypeName,
balance = "${account.currencyName} ${account.availableBalance}"
)
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object FahipayContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
canEdit = false,
canDelete = false
)
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object FahipayHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "FP",
typeLabel = account.accountTypeName,
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

View File

@@ -0,0 +1,26 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}"
)
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -0,0 +1,32 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object MibContactParser {
fun displayData(contact: MibBeneficiary): ContactDisplay {
val network = when (contact.benefType) {
"I" -> TransferNetwork.MIB
"S" -> TransferNetwork.SWIFT
else -> TransferNetwork.LOCAL // "L" and anything else
}
return ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = network,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M9,3h2v2H9V3zm4,0h2v2h-2V3zM9,7h2v2H9V7zm4,0h2v2h-2V7zM9,11h2v2H9v-2zm4,0h2v2h-2v-2zM9,15h2v2H9v-2zm4,0h2v2h-2v-2zM9,19h2v2H9v-2zm4,0h2v2h-2v-2z"/>
</vector>

View File

@@ -94,7 +94,7 @@
<ImageView <ImageView
android:layout_width="138dp" android:layout_width="138dp"
android:layout_height="40dp" android:layout_height="40dp"
android:src="@drawable/bml_logo_vector" android:src="@drawable/bml_logo_long"
android:contentDescription="@string/bml_name" android:contentDescription="@string/bml_name"
android:scaleType="fitStart" android:scaleType="fitStart"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView <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" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -162,33 +168,57 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Action buttons --> </LinearLayout>
</androidx.core.widget.NestedScrollView>
<!-- Quick actions fixed at bottom -->
<LinearLayout
android:id="@+id/buttonBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal"> android:orientation="horizontal">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnTransfer" android:id="@+id/btnQuickAction1"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:text="@string/transfer" /> app:iconGravity="textStart"
app:iconSize="18dp"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnPayMvQr" android:id="@+id/btnQuickAction2"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:text="@string/pay_mv_qr" /> app:iconGravity="textStart"
app:iconSize="18dp"
app:iconPadding="8dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </LinearLayout>

View File

@@ -15,7 +15,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingHorizontal="24dp" android:paddingHorizontal="24dp"
android:paddingTop="40dp" android:paddingTop="64dp"
android:paddingBottom="24dp" android:paddingBottom="24dp"
android:gravity="center_horizontal"> android:gravity="center_horizontal">

View File

@@ -45,6 +45,69 @@
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Quick actions (always active) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvQuickActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never"
android:layout_marginBottom="16dp" />
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout
android:id="@+id/sectionBottomBarShortcuts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_bottom_bar_shortcuts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvNavSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_bottom_bar_show_labels"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchShowLabels"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -37,9 +37,17 @@
android:fontFamily="monospace" android:fontFamily="monospace"
android:layout_marginTop="2dp" /> android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvAccountType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout> </LinearLayout>
<!-- Right: segmented pill (bank | type) + balance --> <!-- Right: balance + transfer button -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -47,31 +55,22 @@
android:gravity="end" android:gravity="end"
android:layout_marginStart="16dp"> android:layout_marginStart="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvPillType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<TextView <TextView
android:id="@+id/tvBalance" android:id="@+id/tvBalance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface" />
android:layout_marginTop="6dp" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout> </LinearLayout>

View File

@@ -56,7 +56,7 @@
</LinearLayout> </LinearLayout>
<!-- Status pill + balance (prepaid only) --> <!-- Transfer button + balance -->
<LinearLayout <LinearLayout
android:id="@+id/layoutCardBalance" android:id="@+id/layoutCardBalance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -85,6 +85,16 @@
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" /> android:layout_marginTop="6dp" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingVertical="12dp"
android:paddingHorizontal="8dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/ivNavIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginBottom="6dp" />
<TextView
android:id="@+id/tvNavLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>

View File

@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigation.NavigationView
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:id="@+id/navMoreView" android:id="@+id/navMoreView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content" />
app:menu="@menu/more_nav_menu" />

View File

@@ -79,6 +79,7 @@
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string> <string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
<string name="balance_mvr">ޖުމްލަ MVR</string> <string name="balance_mvr">ޖުމްލަ MVR</string>
<string name="balance_usd">ޖުމްލަ USD</string> <string name="balance_usd">ޖުމްލަ USD</string>
<string name="card_support_wip">ކާޑް ސަޕޯޓް</string> <string name="card_support_wip">ކާޑް ސަޕޯޓް</string>
@@ -86,6 +87,11 @@
<string name="pay_mv_qr">PayMV QR</string> <string name="pay_mv_qr">PayMV QR</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_bottom_bar_shortcuts">ތިރި ބާ ޝޯޓްކަޓްތައް</string>
<string name="settings_bottom_bar_select">ބަޓަން ހިޔާރު ކުރޭ</string>
<string name="settings_bottom_bar_slot_1">ތަން 1</string>
<string name="settings_bottom_bar_slot_2">ތަން 2</string>
<string name="settings_bottom_bar_slot_3">ތަން 3</string>
<string name="theme">ތީމް</string> <string name="theme">ތީމް</string>
<string name="theme_system">ސިސްޓަމް</string> <string name="theme_system">ސިސްޓަމް</string>
<string name="theme_light">ލައިޓް</string> <string name="theme_light">ލައިޓް</string>
@@ -113,6 +119,8 @@
<string name="close">ބަންދު</string> <string name="close">ބަންދު</string>
<string name="cancel">ކެންސަލް</string> <string name="cancel">ކެންސަލް</string>
<string name="settings_bottom_bar_show_labels">ބޮޓަމް ބާ ލޭބަލް އަބަދުވެސް ދެއްކުން</string>
<!-- Home --> <!-- Home -->
<string name="accounts">އެކައުންޓްތައް</string> <string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string> <string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>

View File

@@ -76,7 +76,7 @@
<!-- Navigation --> <!-- Navigation -->
<string name="nav_dashboard">Dashboard</string> <string name="nav_dashboard">Dashboard</string>
<string name="nav_add_account">Add Account</string> <string name="nav_add_account">Add Login</string>
<string name="nav_accounts">Accounts</string> <string name="nav_accounts">Accounts</string>
<string name="nav_contacts">Contacts</string> <string name="nav_contacts">Contacts</string>
<string name="nav_activities">Activities</string> <string name="nav_activities">Activities</string>
@@ -92,6 +92,7 @@
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_pending_finances">Pending Finances</string> <string name="dashboard_pending_finances">Pending Finances</string>
<string name="dashboard_quick_actions">Quick Actions</string>
<string name="balance_mvr">MVR Total</string> <string name="balance_mvr">MVR Total</string>
<string name="balance_usd">USD Total</string> <string name="balance_usd">USD Total</string>
<string name="card_support_wip">Card Support</string> <string name="card_support_wip">Card Support</string>
@@ -136,6 +137,12 @@
<string name="settings_nav_drawer">Drawer</string> <string name="settings_nav_drawer">Drawer</string>
<string name="settings_nav_bottom">Bottom Bar</string> <string name="settings_nav_bottom">Bottom Bar</string>
<string name="settings_appearance">Appearance</string> <string name="settings_appearance">Appearance</string>
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
<string name="settings_bottom_bar_select">Choose button</string>
<string name="settings_bottom_bar_slot_1">Slot 1</string>
<string name="settings_bottom_bar_slot_2">Slot 2</string>
<string name="settings_bottom_bar_slot_3">Slot 3</string>
<string name="settings_privacy_security">Privacy &amp; Security</string> <string name="settings_privacy_security">Privacy &amp; Security</string>
<string name="settings_storage">Storage</string> <string name="settings_storage">Storage</string>
<string name="settings_logins">Logins</string> <string name="settings_logins">Logins</string>
@@ -150,6 +157,7 @@
<string name="login_detail_id_card">ID Card</string> <string name="login_detail_id_card">ID Card</string>
<string name="login_detail_profiles">Profiles</string> <string name="login_detail_profiles">Profiles</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<!-- Home --> <!-- Home -->

BIN
bml_logo_long.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

85
docs/PARSERS.md Normal file
View File

@@ -0,0 +1,85 @@
# Account Display Parser Architecture
## Overview
Each bank's API returns account data in different formats and uses different field names for balances, product types, and status. To keep screens bank-agnostic, each bank has a dedicated parser that translates raw `MibAccount` model data into a standard `AccountListDisplay` object. Screens consume only `AccountListDisplay` — they never inspect `bank` or `profileType` or apply bank-specific logic.
## Bank Discriminator — `MibAccount.bank`
All dispatchers route by `account.bank`, a string set explicitly by each login flow at account creation time:
| `bank` value | Set by |
|--------------|-------------------|
| `"MIB"` | `MibLoginFlow` |
| `"BML"` | `BmlLoginFlow` |
| `"FAHIPAY"` | `FahipayLoginFlow`|
`profileType` is a bank-internal value (e.g. MIB's numeric profile ID, or BML's `"BML_PREPAID"`) and is **never** used for bank routing. Card-type checks within BML still use `profileType` (`"BML_PREPAID"` / `"BML_CREDIT"`).
`cifType` (MIB only) is the human-readable profile category name returned by the `operatingProfiles` API (e.g. `"Individual"`, `"Sole Propr"`). It is stored on `MibAccount` and surfaced in the accounts list section header and settings. It is **never hardcoded** in the app.
## Standard Output Model
```kotlin
// util/AccountListDisplay.kt
data class AccountListDisplay(
val name: String, // account or card display name
val number: String, // account/card number
val typeLabel: String, // human-friendly product type (e.g. "Savings", "Visa Platinum")
val balance: String, // formatted balance string (e.g. "MVR 1,234.56")
val isCard: Boolean, // true → use card layout; false → use account layout
val cardBrandIcon: Int, // drawable res for card brand logo (Visa / Mastercard / Amex)
val statusLabel: String? // null = active; non-null = shown as status label (e.g. "Inactive")
)
```
## Dispatcher
```kotlin
// util/AccountListParser.kt
AccountListParser.from(account: MibAccount): AccountListDisplay?
```
Routes to the correct parser based on `account.bank`. Returns `null` for unknown banks — never falls back to a specific bank.
| `account.bank` | Parser |
|----------------|-------------------------|
| `"BML"` | `BmlDashboardParser` |
| `"FAHIPAY"` | `FahipayAccountParser` |
| `"MIB"` | `MibAccountParser` |
| anything else | `null` |
## Bank Parsers
### BML — `util/bmlapi/BmlDashboardParser`
Handles both CASA accounts and prepaid/credit cards.
- **CASA balance**: uses `ledgerBalance` (working balance) — mapped to `account.currentBalance`
- **Card balance**: uses `availableBalance` (available limit from `cardBalance.AvailableLimit`)
- **Card brand**: resolved from product name (`VISA` / `MASTERCARD` / `AMEX`)
- **Status**: cards with `statusDesc != "Active"` surface `statusLabel`; active cards return `null`
### MIB — `util/mibapi/MibAccountParser`
- **Balance**: `availableBalance` from the MIB API directly
- Known product names (`SAVING ACCOUNT`, `CURRENT ACCOUNT`) mapped to short labels
- `cifType` (e.g. `"Individual"`, `"Sole Propr"`) comes from `MibProfile.cifType`, stored on `MibAccount`, displayed in section headers
### Fahipay — `util/fahipayapi/FahipayAccountParser`
- Single wallet account per user; `accountTypeName` is always `"Digital Wallet"`
- **Balance**: `availableBalance`
## Adding a New Bank
1. Create `util/<bankname>api/<Bank>AccountParser.kt` with a `displayData(account: MibAccount): AccountListDisplay` function
2. Set `bank = "<BANKNAME>"` in the new login flow when creating `MibAccount` objects
3. Add a `when` branch in `AccountListParser.from()` (and other dispatchers) for the new bank value
4. No changes needed in any screen or adapter
## Usage in Screens
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.