Merge branch 'feat/hide-accounts'
Auto Tag on Version Change / check-version (push) Failing after 3s

This commit is contained in:
2026-09-25 04:21:08 +05:00
3 changed files with 382 additions and 63 deletions
@@ -244,9 +244,9 @@ class HomeActivity : AppCompatActivity() {
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
if (merged.isNotEmpty()) viewModel.accounts.value = merged
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
@@ -774,47 +774,13 @@ fun applyNavLabelVisibility() {
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
val bmlClient = BmlAccountClient()
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
if (saved == null) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
continue
}
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
suspend fun fetchWithSession(session: BmlSession) {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
}
suspend fun tryRefresh() {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
fetchWithSession(newSession)
}
if (profile.profileId in hiddenProfiles) continue
try {
if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
@@ -985,15 +951,17 @@ fun applyNavLabelVisibility() {
refreshBmlLoanDetails()
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
}
/** Filters accounts whose profileId the user has hidden in settings. */
/** Filters accounts whose profileId or account number the user has hidden in settings. */
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
val store = CredentialStore(this@HomeActivity)
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
return filter { acc ->
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
when (acc.bank) {
"MIB" -> {
val loginId = acc.loginTag.removePrefix("mib_")
@@ -1022,10 +990,140 @@ fun applyNavLabelVisibility() {
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()
/** Drops MIB cards belonging to profiles the user has hidden. */
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
val store = CredentialStore(this@HomeActivity)
return filter { card ->
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
}
}
/**
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
*/
fun applyVisibility() {
val app = application as BasedBankApp
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
viewModel.mibCards.value?.let { cards ->
val visible = cards.filterVisibleCards()
viewModel.mibCards.value = visible
CardsCache.save(this, visible)
}
}
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
if (profileIds.isEmpty()) return
val app = application as BasedBankApp
val loginTag = "mib_$loginId"
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val fresh = withContext(Dispatchers.IO) {
val session = app.mibSessions[loginId]
if (session != null && enabled.isNotEmpty()) {
try {
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
if (accounts.isNotEmpty()) {
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
}
} catch (_: Exception) { }
}
// Session expired — log this one login in again (it now includes the unhidden profiles)
val store = CredentialStore(this@HomeActivity)
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)
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
} catch (_: Exception) { null }
}
binding.refreshIndicator.visibility = View.GONE
if (fresh == null) return@launch
app.mibAccounts = fresh
AccountCache.save(this@HomeActivity, fresh)
applyVisibility()
val session = app.mibSessions[loginId] ?: return@launch
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
// Financing deals carry no profile id, so the login's visible profiles are fetched together
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
if (profileIds.isEmpty()) return
val app = application as BasedBankApp
val store = CredentialStore(this)
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
if (enabled.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val fetched = withContext(Dispatchers.IO) {
enabled.mapNotNull { profile ->
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
}
}
binding.refreshIndicator.visibility = View.GONE
if (fetched.isEmpty()) return@launch
val fetchedIds = fetched.map { it.first }.toSet()
val loginTag = "bml_$loginId"
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
fetched.flatMap { it.second }
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
applyVisibility()
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
refreshBmlLoanDetails()
}
}
/**
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
* Returns null when the profile has no saved session; throws on network/server errors.
*/
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
val app = application as BasedBankApp
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
val bmlClient = BmlAccountClient()
fun fetchWithSession(session: BmlSession): List<BankAccount> {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
return accounts
}
fun tryRefresh(): List<BankAccount> {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
return fetchWithSession(newSession)
}
return if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
}
private fun refreshBmlLimits(session: BmlSession) {
@@ -1259,7 +1357,7 @@ fun applyNavLabelVisibility() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
@@ -1284,7 +1382,8 @@ fun applyNavLabelVisibility() {
}
if (cards.isNotEmpty()) {
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
existing.removeAll { it.loginTag == "mib_$loginId" }
val fetchedIds = profiles.map { it.profileId }.toSet()
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
existing += cards
viewModel.mibCards.postValue(existing)
CardsCache.save(this@HomeActivity, existing)
@@ -33,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity
@@ -530,6 +531,119 @@ class SettingsLoginsFragment : Fragment() {
}
}
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
private fun addAccountRow(
ctx: Context,
container: LinearLayout,
dp: Float,
acc: BankAccount,
hiddenAccounts: MutableSet<String>,
indent: Boolean
): Pair<BankAccount, MaterialSwitch> {
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()
if (indent) it.marginStart = (28 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
textCol.addView(TextView(ctx).apply {
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
setTextAppearance(nameAppearance)
})
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
else acc.accountTypeName.trim()
textCol.addView(TextView(ctx).apply {
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
.joinToString(" · ")
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val toggle = MaterialSwitch(ctx).apply {
isChecked = acc.accountNumber !in hiddenAccounts
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = (4 * dp).toInt()
}
}
row.addView(textCol)
row.addView(toggle)
container.addView(row)
return acc to toggle
}
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
private fun addNestedAccountRows(
ctx: Context,
container: LinearLayout,
dp: Float,
accounts: List<BankAccount>,
hiddenAccounts: MutableSet<String>
): List<Pair<BankAccount, MaterialSwitch>> =
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
private fun addAccountsSection(
ctx: Context,
container: LinearLayout,
dp: Float,
accounts: List<BankAccount>,
hiddenAccounts: MutableSet<String>,
showDivider: Boolean
): List<Pair<BankAccount, MaterialSwitch>> {
if (accounts.isEmpty()) return emptyList()
if (showDivider) {
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.accounts)
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()
}
})
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
}
/**
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
* the account listeners ignore changes made while a toggle is disabled.
*/
private fun syncAccountToggles(
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
hiddenAccounts: Set<String>,
isParentHidden: (BankAccount) -> Boolean
) {
accountRows.forEach { (acc, toggle) ->
if (isParentHidden(acc)) {
toggle.isEnabled = false
toggle.isChecked = false
} else {
toggle.isChecked = acc.accountNumber !in hiddenAccounts
toggle.isEnabled = true
}
}
}
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
}
private fun showMibLoginDetails(
store: CredentialStore,
loginId: String,
@@ -540,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -582,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
// Each profile's own accounts nest directly beneath it (tree view), since one profile
// can have multiple accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
@@ -623,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
row.addView(pencil)
row.addView(toggle)
container.addView(row)
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
p to toggle
}
// Accounts that don't belong to any known profile still need to be reachable.
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
accountRows += addAccountsSection(
ctx, container, dp, unassignedAccounts, hiddenAccounts,
showDivider = mibProfiles.isNotEmpty() || profile != null
)
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = mibProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (p, toggle) ->
// Disable the sole remaining visible toggle so it can't be turned off
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
@@ -656,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
}
}
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(loginId, hidden)
clearAllCaches(ctx)
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
// Hiding is applied offline; only profiles that were just unhidden get fetched
val home = activity as? HomeActivity
home?.applyVisibility()
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
}
}
@@ -682,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
}
val originalHidden = hidden.toSet()
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -727,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Each profile's own accounts nest directly beneath it (tree view), since one profile
// can have multiple accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = bmlProfiles.map { p ->
val avatarIv = makeCircleAvatarView(ctx, 36)
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
@@ -774,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
row.addView(pencil)
row.addView(toggle)
container.addView(row)
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
p to toggle
}
// Accounts that don't belong to any known profile still need to be reachable.
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
accountRows += addAccountsSection(
ctx, container, dp, unassignedAccounts, hiddenAccounts,
showDivider = bmlProfiles.isNotEmpty() || profile != null
)
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
@@ -817,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
}
}
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx)
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
// Hiding is applied offline; only profiles that were just unhidden get fetched
val home = activity as? HomeActivity
home?.applyVisibility()
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
}
}
@@ -1044,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
val dp = ctx.resources.displayMetrics.density
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -1094,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
})
}
MaterialAlertDialogBuilder(ctx)
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.fahipay_name))
.setView(scroll)
.setPositiveButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
.apply {
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
setNeutralButton(R.string.close, null)
setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
}
}
.show()
if (loginAccounts.isNotEmpty()) {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
}
}
saveBtn.setOnClickListener {
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.applyVisibility()
}
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
@@ -1184,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
val originalHidden = hidden.toSet()
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
// Each toggle covers the set of pocket account numbers that belong to that profile.
@@ -1239,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
})
}
// Each group's own pockets nest directly beneath it (tree view), since a group
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = profileRows.map { row ->
val v = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
@@ -1261,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
v.addView(label)
v.addView(toggle)
container.addView(v)
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
row to toggle
}
@@ -1269,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
@@ -1296,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
}
}
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
// All pockets come back in one login response, so hiding/unhiding never needs a request
store.setHiddenMfaisaPocketIds(loginId, hidden)
clearAllCaches(ctx)
persistHiddenAccounts(store, pockets, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
(activity as? HomeActivity)?.applyVisibility()
}
}
}
@@ -820,6 +820,15 @@ class CredentialStore(context: Context) {
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
// ── Per-account visibility (account numbers are globally unique) ─────────
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
fun getHiddenAccountNumbers(): Set<String> =
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {