From 8c40322ff0ee37fcc4daa4f5c47f6f878511dc9e Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 19 May 2026 16:13:36 +0500 Subject: [PATCH] fahipay serialized multi-login added --- .../java/sh/sar/basedbank/BasedBankApp.kt | 47 ++- .../sh/sar/basedbank/api/mib/MibLoginFlow.kt | 12 +- .../ui/home/AccountHistoryFragment.kt | 2 +- .../ui/home/AddContactSheetFragment.kt | 24 +- .../ui/home/ContactPickerSheetFragment.kt | 4 +- .../sar/basedbank/ui/home/ContactsFragment.kt | 2 +- .../sh/sar/basedbank/ui/home/HomeActivity.kt | 229 ++++++++------- .../sh/sar/basedbank/ui/home/OtpFragment.kt | 18 +- .../ui/home/SettingsLoginsFragment.kt | 44 +-- .../sar/basedbank/ui/home/TransferFragment.kt | 16 +- .../ui/home/TransferHistoryFragment.kt | 44 +-- .../ui/home/TransferReceiptFragment.kt | 4 +- .../basedbank/ui/login/CredentialsFragment.kt | 33 ++- .../sh/sar/basedbank/util/AccountCache.kt | 14 +- .../sh/sar/basedbank/util/ContactManager.kt | 9 +- .../sh/sar/basedbank/util/CredentialStore.kt | 274 ++++++++++++------ .../sh/sar/basedbank/util/HistoryFetcher.kt | 10 +- app/src/main/res/values/strings.xml | 2 +- bml_logo_long.png | Bin 0 -> 11385 bytes 19 files changed, 486 insertions(+), 302 deletions(-) create mode 100644 bml_logo_long.png diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index acbcfb6..acbc409 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -17,14 +17,42 @@ class BasedBankApp : Application() { // Held in memory after successful login; cleared on logout var accounts: List = emptyList() var fullName: String = "" - var mibSession: MibSession? = null - var mibProfiles: List = emptyList() + /** Active MIB sessions keyed by loginId (= MIB username). */ + val mibSessions: MutableMap = mutableMapOf() + val mibProfilesMap: MutableMap> = mutableMapOf() + val mibLoginFlows: MutableMap = mutableMapOf() + var mibAccounts: List = emptyList() /** Active BML sessions keyed by loginId (= BML username). */ val bmlSessions: MutableMap = mutableMapOf() var bmlAccounts: List = emptyList() - var fahipaySession: FahipaySession? = null + /** Active Fahipay sessions keyed by loginId (= profileId). */ + val fahipaySessions: MutableMap = mutableMapOf() var fahipayAccounts: List = 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 = 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). */ fun bmlSessionFor(account: MibAccount): BmlSession? = bmlSessions[account.loginTag.removePrefix("bml_")] @@ -32,18 +60,13 @@ class BasedBankApp : Application() { /** Returns any available BML session (for non-account-specific operations). */ 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. */ val mibMutex = Mutex() - val mibLoginFlow by lazy { - MibLoginFlow(CredentialStore(this)).also { flow -> - flow.onSessionRefreshed = { session, profiles -> - mibSession = session - mibProfiles = profiles - } - } - } - override fun onCreate() { super.onCreate() DynamicColors.applyToActivitiesIfAvailable(this) diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index 0d94302..202fa0b 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) { var onSessionRefreshed: ((MibSession, List) -> Unit)? = null // 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 storedPasswordHash: 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. */ fun login(username: String, passwordHash: String, otpSeed: String): List { + loginId = username storedUsername = username storedPasswordHash = passwordHash storedOtpSeed = otpSeed val appId = getOrCreateAppId() - val keys = credentialStore.loadMibKeys() + val keys = credentialStore.loadMibKeys(loginId) return if (keys != null) { 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 key1 = keyData.getString("key1") val key2 = keyData.getString("key2") - credentialStore.saveMibKeys(key1, key2) + credentialStore.saveMibKeys(loginId, key1, key2) return regularLogin(username, passwordHash, appId, key1, key2) } @@ -139,7 +141,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) { lastSession = session2 lastProfiles = profiles // keep ALL profiles so settings can show them all - val hidden = credentialStore.getHiddenMibProfileIds() + 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 @@ -433,11 +435,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) { private fun generateOtp(seed: String): String = Totp.generate(seed) private fun getOrCreateAppId(): String { - var id = credentialStore.loadMibAppId() + var id = credentialStore.loadMibAppId(loginId) if (id == null) { val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("") - credentialStore.saveMibAppId(id) + credentialStore.saveMibAppId(loginId, id) } return id } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt index e832c87..2dcc068 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt @@ -176,7 +176,7 @@ class AccountHistoryFragment : Fragment() { return } val app = requireActivity().application as BasedBankApp - val sess = app.mibSession ?: return + val sess = app.anyMibSession() ?: return viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { try { val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt index d983ae4..3713f25 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt @@ -50,6 +50,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { val label: String, val isBml: Boolean, val mibProfile: MibProfile? = null, + val mibLoginId: String? = null, val bmlLoginId: String? = null, val subtitle: String = "" ) @@ -91,8 +92,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { private fun buildDestinations(): List { val list = mutableListOf() - for (profile in app.mibProfiles) { - list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, subtitle = profile.cifType)) + for ((loginId, profiles) in app.mibProfilesMap) { + for (profile in profiles) { + list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType)) + } } val store = CredentialStore(requireContext()) for ((loginId, _) in app.bmlSessions) { @@ -249,7 +252,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { if (mibVerified != null) return mibVerified // 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 { val info = MibTransferClient().lookup(mibSess, input) BmlAccountValidation( @@ -266,11 +269,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { } 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 mibResult = try { - app.mibLoginFlow.switchProfile(mibSess, profile) + app.mibFlowFor(loginId).switchProfile(mibSess, profile) val info = MibTransferClient().lookup(mibSess, input) BmlAccountValidation( trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", @@ -429,8 +433,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { } private fun saveToMib(alias: String): Boolean { - val mibSess = app.mibSession ?: 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 account = mibLookupAccount ?: return false val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR" @@ -442,7 +447,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { val name = bmlLookup?.name ?: "" return try { - app.mibLoginFlow.switchProfile(mibSess, profile) + app.mibFlowFor(loginId).switchProfile(mibSess, profile) MibContactsClient().createContact( session = mibSess, benefType = benefType, @@ -488,8 +493,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() { if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh) } else { val profile = dest.mibProfile ?: return@launch - val mibSess = app.mibSession ?: return@launch - app.mibLoginFlow.switchProfile(mibSess, profile) + val mibLoginId = dest.mibLoginId ?: return@launch + val mibSess = app.mibSessions[mibLoginId] ?: return@launch + app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile) val fresh = MibContactsClient().fetchContacts(mibSess) .map { it.copy(profileId = profile.profileId) } val existing = viewModel.contacts.value ?: emptyList() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index 031c28a..41be3ba 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -36,7 +36,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { private val sharedImageCache = mutableMapOf() private val profileImageHashes = mutableSetOf() 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 mediator: TabLayoutMediator? = null @@ -291,7 +291,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { lifecycleScope.launch(Dispatchers.IO) { try { val base64 = if (hash in profileImageHashes) { - app.mibLoginFlow.fetchProfileImage(sess, hash) + app.anyMibFlow()?.fetchProfileImage(sess, hash) } else { MibContactsClient().fetchProfileImageBase64(sess, hash) } ?: return@launch diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index 3bfe121..ec95350 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -40,7 +40,7 @@ class ContactsFragment : Fragment() { private val pendingHashes = mutableSetOf() private val sharedImageCache = mutableMapOf() private val app get() = requireActivity().application as BasedBankApp - private val session get() = app.mibSession + private val session get() = app.anyMibSession() private var allContacts: List = emptyList() private var currentSearch: String = "" diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 324223a..aef216e 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -128,31 +128,36 @@ class HomeActivity : AppCompatActivity() { // Load data 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 - val mibAccounts = app.accounts.filter { it.bank == "MIB" } - val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts + val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts viewModel.accounts.value = merged.filterVisibleAccounts() - if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts) + if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts) if (app.bmlAccounts.isNotEmpty()) { val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") } byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) } } - if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts) + if (app.fahipayAccounts.isNotEmpty()) { + val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") } + byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) } + } val cachedFinancing = FinancingCache.load(this) if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing val cachedLimits = ForeignLimitsCache.load(this) if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits - refreshFinancing(app.mibSession, app.mibProfiles.filterVisibleProfiles()) + 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) } else { // Came from lock screen — show caches immediately, refresh everything in background val store = CredentialStore(this) val cachedMib = AccountCache.load(this) val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds()) - val cachedFahipay = AccountCache.loadFahipay(this) + val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds()) val merged = cachedMib + cachedBml + cachedFahipay if (merged.isNotEmpty()) viewModel.accounts.value = merged val cachedFinancing = FinancingCache.load(this) @@ -160,7 +165,7 @@ class HomeActivity : AppCompatActivity() { val cachedLimits = ForeignLimitsCache.load(this) if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits - autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) + autoRefresh(store) } // Show dashboard on first create @@ -169,27 +174,29 @@ class HomeActivity : AppCompatActivity() { 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 { repeatOnLifecycle(Lifecycle.State.STARTED) { while (true) { delay(25_000) - val session = (application as BasedBankApp).mibSession ?: continue - withContext(Dispatchers.IO) { - try { - val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + - "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" - val request = Request.Builder() - .url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive") - .post(ByteArray(0).toRequestBody()) - .header("Cookie", cookieHeader) - .header("User-Agent", "okhttp/4.11.0") - .header("Accept", "application/json, text/plain, */*") - .header("Accept-Encoding", "gzip") - .header("Connection", "Keep-Alive") - .build() - OkHttpClient().newCall(request).execute().close() - } catch (_: Exception) {} + val sessions = (application as BasedBankApp).mibSessions.values.toList() + for (session in sessions) { + withContext(Dispatchers.IO) { + try { + val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + + "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" + val request = Request.Builder() + .url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive") + .post(ByteArray(0).toRequestBody()) + .header("Cookie", cookieHeader) + .header("User-Agent", "okhttp/4.11.0") + .header("Accept", "application/json, text/plain, */*") + .header("Accept-Encoding", "gzip") + .header("Connection", "Keep-Alive") + .build() + OkHttpClient().newCall(request).execute().close() + } catch (_: Exception) {} + } } } } @@ -353,51 +360,47 @@ class HomeActivity : AppCompatActivity() { fun relogin() { val store = CredentialStore(this) - val hasMib = store.hasMibCredentials() + val mibLoginIds = store.getMibLoginIds() val bmlLoginIds = store.getBmlLoginIds() - val hasFahipay = store.hasFahipayCredentials() - if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) { + val fahipayLoginIds = store.getFahipayLoginIds() + if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) { startActivity(Intent(this, LoginActivity::class.java)) finish() 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() viewModel.accounts.value = current.filter { acc -> - if (!hasMib && acc.bank == "MIB") return@filter false - if (acc.bank == "BML") { - val loginId = acc.loginTag.removePrefix("bml_") - return@filter loginId in bmlLoginIds - } - if (!hasFahipay && acc.bank == "FAHIPAY") return@filter false + if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds + if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds + if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds true } - autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) + autoRefresh(store) } - private fun autoRefresh( - mibCreds: CredentialStore.MibCredentials?, - fahipayCreds: CredentialStore.FahipayCredentials?, - store: CredentialStore - ) { + private fun autoRefresh(store: CredentialStore) { + val mibLoginIds = store.getMibLoginIds() 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 lifecycleScope.launch { - val mibJob = mibCreds?.let { - async(Dispatchers.IO) { + // One async job per MIB login, all run in parallel + val mibJobs = mibLoginIds.mapNotNull { loginId -> + val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null + loginId to async(Dispatchers.IO) { try { 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 - app.accounts = accounts - app.mibSession = flow.lastSession - app.mibProfiles = flow.lastProfiles - AccountCache.save(this@HomeActivity, accounts) - CredentialStore(this@HomeActivity).saveMibProfiles(flow.lastProfiles) + app.mibSessions[loginId] = flow.lastSession!! + app.mibProfilesMap[loginId] = flow.lastProfiles + app.mibLoginFlows[loginId] = flow + store.saveMibProfiles(loginId, flow.lastProfiles) accounts - } catch (_: Exception) { AccountCache.load(this@HomeActivity) } + } catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } } } } @@ -435,78 +438,87 @@ class HomeActivity : AppCompatActivity() { } } - val fahipayJob = fahipayCreds?.let { creds -> - async(Dispatchers.IO) { + // One async job per Fahipay login, all run in parallel + val fahipayJobs = fahipayLoginIds.mapNotNull { loginId -> + val creds = store.loadFahipayCredentials(loginId) ?: return@mapNotNull null + loginId to async(Dispatchers.IO) { val fahipayFlow = FahipayLoginFlow() val deviceUuid = store.getOrCreateFahipayDeviceUuid() + val loginTag = "fahipay_$loginId" - val savedSession = store.loadFahipaySession() + val savedSession = store.loadFahipaySession(loginId) if (savedSession != null) { try { val session = FahipaySession(savedSession.first, savedSession.second) fahipayFlow.setSessionCookie(session.sessionCookie) val balance = fahipayFlow.fetchBalance(session) val profile = fahipayFlow.fetchProfile(session) - val loginTag = "fahipay_${profile.profileId}" val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val app = application as BasedBankApp - app.fahipaySession = session - app.fahipayAccounts = accounts - AccountCache.saveFahipay(this@HomeActivity, accounts) - return@async Pair(session, accounts) - } catch (_: Exception) { - } + app.fahipaySessions[loginId] = session + AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) + return@async accounts + } catch (_: Exception) { } } try { val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid) 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 session = FahipaySession(authId, cookieValue) - store.saveFahipaySession(authId, cookieValue) + store.saveFahipaySession(loginId, authId, cookieValue) val profile = fahipayFlow.fetchProfile(session) val balance = fahipayFlow.fetchBalance(session) - val loginTag = "fahipay_${profile.profileId}" val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val app = application as BasedBankApp - app.fahipaySession = session - app.fahipayAccounts = accounts - AccountCache.saveFahipay(this@HomeActivity, accounts) - Pair(session, accounts) + app.fahipaySessions[loginId] = session + AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) + accounts } 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 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 + app.mibAccounts = mibAccounts + AccountCache.save(this@HomeActivity, mibAccounts) app.bmlAccounts = bmlAccounts + app.fahipayAccounts = fahipayAccounts viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts()) binding.refreshIndicator.visibility = View.GONE for ((_, session) in app.bmlSessions) refreshBmlLimits(session) - refreshFinancing(app.mibSession, app.mibProfiles.filterVisibleProfiles()) + 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.filterVisibleAccounts(): List { - val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds() - if (hidden.isEmpty()) return this - return filter { it.bank != "MIB" || it.profileId !in hidden } + 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 — prevents API requests for hidden profiles. */ - private fun List.filterVisibleProfiles(): List { - val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds() + /** Filters MIB profiles the user has hidden for a given loginId. */ + private fun List.filterVisibleProfiles(loginId: String): List { + val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds(loginId) if (hidden.isEmpty()) return this return filter { it.profileId !in hidden } } @@ -571,9 +583,12 @@ class HomeActivity : AppCompatActivity() { if (cats.isNotEmpty()) viewModel.contactCategories.value = cats } // Refresh all banks in background - refreshContacts(app.mibSession, app.mibProfiles.filterVisibleProfiles()) + for ((loginId, session) in app.mibSessions) { + val profiles = app.mibProfilesMap[loginId] ?: emptyList() + refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId)) + } refreshBmlContacts(app) - if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!) + for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session) } private fun refreshFahipayContacts(session: FahipaySession) { @@ -609,9 +624,9 @@ class HomeActivity : AppCompatActivity() { return result } - private fun refreshContacts(session: MibSession?, profiles: List) { - if (session == null || profiles.isEmpty()) return - val flow = MibLoginFlow(CredentialStore(this)) + private fun refreshContacts(loginId: String, session: MibSession, profiles: List) { + if (profiles.isEmpty()) return + val flow = (application as BasedBankApp).mibFlowFor(loginId) val contactsClient = MibContactsClient() lifecycleScope.launch { try { @@ -636,8 +651,8 @@ class HomeActivity : AppCompatActivity() { } if (allContacts.isNotEmpty()) { ContactsCache.save(this@HomeActivity, allContacts, allCategories) - val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds() - val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds) + val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity) + val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds()) val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity) val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity) viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts)) @@ -653,7 +668,7 @@ class HomeActivity : AppCompatActivity() { val current = viewModel.accounts.value ?: emptyList() if (src.bank == "FAHIPAY") { val fresh = withContext(Dispatchers.IO) { - val sess = app.fahipaySession ?: return@withContext null + val sess = app.fahipaySessionFor(src) ?: return@withContext null try { val flow = FahipayLoginFlow() flow.setSessionCookie(sess.sessionCookie) @@ -661,8 +676,9 @@ class HomeActivity : AppCompatActivity() { val profile = flow.fetchProfile(sess) val loginTag = "fahipay_${profile.profileId}" val accounts = listOf(flow.buildAccount(profile, balance, loginTag)) - AccountCache.saveFahipay(this@HomeActivity, accounts) - app.fahipayAccounts = accounts + val loginId = src.loginTag.removePrefix("fahipay_") + AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) + app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts accounts } catch (_: Exception) { null } } ?: return@launch @@ -683,40 +699,43 @@ class HomeActivity : AppCompatActivity() { val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag } viewModel.accounts.postValue(otherAccounts + fresh) } else { + val loginId = src.loginTag.removePrefix("mib_") val fresh = withContext(Dispatchers.IO) { val store = CredentialStore(this@HomeActivity) - val hidden = store.getHiddenMibProfileIds() - val allVisible = app.mibProfiles.filter { hidden.isEmpty() || it.profileId !in hidden } - // Try P47 for all visible profiles (fast path, works for multi-profile) - val sess = app.mibSession + val hidden = store.getHiddenMibProfileIds(loginId) + val profiles = app.mibProfilesMap[loginId] ?: emptyList() + val allVisible = profiles.filter { hidden.isEmpty() || it.profileId !in hidden } + val sess = app.mibSessions[loginId] if (sess != null && allVisible.isNotEmpty()) { try { - val accounts = MibLoginFlow(store).fetchAllProfiles(sess, allVisible, src.loginTag) + val accounts = app.mibFlowFor(loginId).fetchAllProfiles(sess, allVisible, src.loginTag) if (accounts.isNotEmpty()) return@withContext accounts } catch (_: Exception) { } } - // P47 returned nothing — re-login to get fresh balances (handles single-profile A41 path) - val creds = store.loadMibCredentials() ?: return@withContext null + val creds = store.loadMibCredentials(loginId) ?: return@withContext null try { val flow = MibLoginFlow(store) val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed) - app.mibSession = flow.lastSession - app.mibProfiles = flow.lastProfiles - store.saveMibProfiles(flow.lastProfiles) + 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 - // Replace all MIB accounts with fresh data - val others = current.filter { it.bank != "MIB" } - AccountCache.save(this@HomeActivity, fresh) + // Replace accounts for this MIB login + val others = current.filter { it.loginTag != src.loginTag } + val newMibAccounts = app.mibAccounts.filter { it.loginTag != src.loginTag } + fresh + app.mibAccounts = newMibAccounts + AccountCache.save(this@HomeActivity, newMibAccounts) viewModel.accounts.postValue(others + fresh) } } } - private fun refreshFinancing(session: MibSession?, profiles: List) { - if (session == null || profiles.isEmpty()) return - val flow = MibLoginFlow(CredentialStore(this)) + private fun refreshFinancing(loginId: String, session: MibSession, profiles: List) { + if (profiles.isEmpty()) return + val flow = (application as BasedBankApp).mibFlowFor(loginId) val client = MibFinancingClient() lifecycleScope.launch { try { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt index d7e4103..b3fef88 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt @@ -71,8 +71,9 @@ class OtpFragment : Fragment() { val app = requireActivity().application as BasedBankApp val entries = mutableListOf() - store.loadMibCredentials()?.let { creds -> - val name = store.loadMibFullName() + for (loginId in store.getMibLoginIds()) { + val creds = store.loadMibCredentials(loginId) ?: continue + val name = store.loadMibFullName(loginId) entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) } for (loginId in store.getBmlLoginIds()) { @@ -88,20 +89,23 @@ class OtpFragment : Fragment() { // Fetch real names in background if not yet cached, then refresh labels viewLifecycleOwner.lifecycleScope.launch { var changed = false - if (store.loadMibFullName() == null) { - app.mibSession?.let { session -> + for (loginId in store.getMibLoginIds()) { + if (store.loadMibFullName(loginId) == null) { + val session = app.mibSessions[loginId] ?: continue + val flow = app.mibFlowFor(loginId) val profile = withContext(Dispatchers.IO) { - try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null } + try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null } } if (profile != null) { - store.saveMibUserProfile(CredentialStore.MibUserProfile( + store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile( fullName = profile.fullName, username = profile.username, email = profile.email, mobile = profile.mobile, 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 } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt index 121cd0b..f55147f 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt @@ -60,18 +60,18 @@ class SettingsLoginsFragment : Fragment() { val container = binding.loginsContainer container.removeAllViews() - val hasMib = store.hasMibCredentials() + val mibLoginIds = store.getMibLoginIds() 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) { - val profile = store.loadMibUserProfile() + for (loginId in mibLoginIds) { + val profile = store.loadMibUserProfile(loginId) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name) - val mibProfiles = store.loadMibProfiles() + val mibProfiles = store.loadMibProfiles(loginId) addLoginRow(container, R.drawable.mib_logo, displayName) { - showMibLoginDetails(store, profile, mibProfiles) + showMibLoginDetails(store, loginId, profile, mibProfiles) } } @@ -99,8 +99,8 @@ class SettingsLoginsFragment : Fragment() { } } - if (hasFahipay) { - val profile = store.loadFahipayUserProfile() + for (loginId in fahipayLoginIds) { + val profile = store.loadFahipayUserProfile(loginId) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) addLoginRow(container, R.drawable.fahipay_logo, displayName) { showLoginDetails( @@ -111,7 +111,7 @@ class SettingsLoginsFragment : Fragment() { 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}") }.trim(), - onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store) } } + onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } } ) } } @@ -145,12 +145,13 @@ class SettingsLoginsFragment : Fragment() { private fun showMibLoginDetails( store: CredentialStore, + loginId: String, profile: CredentialStore.MibUserProfile?, mibProfiles: List ) { val ctx = requireContext() val dp = ctx.resources.displayMetrics.density - val originalHidden = store.getHiddenMibProfileIds() + val originalHidden = store.getHiddenMibProfileIds(loginId) val hidden = originalHidden.toMutableSet() val scroll = android.widget.ScrollView(ctx) @@ -231,7 +232,7 @@ class SettingsLoginsFragment : Fragment() { .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) } + confirmLogout(getString(R.string.mib_name)) { logoutMib(store, loginId) } } .show() @@ -247,7 +248,7 @@ class SettingsLoginsFragment : Fragment() { } saveBtn.setOnClickListener { - store.setHiddenMibProfileIds(hidden) + store.setHiddenMibProfileIds(loginId, hidden) clearAllCaches(ctx) dialog.dismiss() (activity as? HomeActivity)?.relogin() @@ -272,12 +273,16 @@ class SettingsLoginsFragment : Fragment() { .show() } - private fun logoutMib(store: CredentialStore) { + private fun logoutMib(store: CredentialStore, loginId: String) { val ctx = requireContext() - store.clearMibCredentials() + store.clearMibCredentials(loginId) ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply() 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) (activity as HomeActivity).relogin() buildLoginsSection() @@ -294,11 +299,12 @@ class SettingsLoginsFragment : Fragment() { buildLoginsSection() } - private fun logoutFahipay(store: CredentialStore) { + private fun logoutFahipay(store: CredentialStore, loginId: String) { val ctx = requireContext() - store.clearFahipayCredentials(); store.clearFahipaySession() + store.clearFahipayCredentials(loginId) 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) (activity as HomeActivity).relogin() buildLoginsSection() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 368971a..f0cf6d4 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -64,7 +64,9 @@ class TransferFragment : Fragment() { private val viewModel: HomeViewModel by activityViewModels() 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?) = account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) } ?: (requireActivity().application as BasedBankApp).anyBmlSession() @@ -244,7 +246,7 @@ class TransferFragment : Fragment() { val app = requireActivity().application as BasedBankApp viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { 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 bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch withContext(Dispatchers.Main) { @@ -706,12 +708,14 @@ class TransferFragment : Fragment() { ): Triple { val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val app = requireActivity().application as BasedBankApp + val loginId = src.loginTag.removePrefix("mib_") // Switch to the profile that owns the source account if (src.profileId.isNotBlank()) { - val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } - if (profile != null) app.mibLoginFlow.switchProfile(sess, profile) + val profiles = app.mibProfilesMap[loginId] ?: emptyList() + 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) } ?: return Triple(false, "OTP unavailable", null) val currencyCode = if (src.currencyName == "USD") "840" else "462" @@ -878,7 +882,7 @@ class TransferFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { try { val base64 = if (isProfile) { - app.mibLoginFlow.fetchProfileImage(sess, hash) + app.anyMibFlow()?.fetchProfileImage(sess, hash) } else { MibContactsClient().fetchProfileImageBase64(sess, hash) } ?: return@launch diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt index 1384b24..0b8e13f 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt @@ -142,7 +142,6 @@ class TransferHistoryFragment : Fragment() { } val app = requireActivity().application as BasedBankApp - val mibSession = app.mibSession lifecycleScope.launch { val newTransactions = withContext(Dispatchers.IO) { @@ -189,7 +188,7 @@ class TransferHistoryFragment : Fragment() { // Fahipay accounts val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" } for (state in fahipayStates) { - val session = app.fahipaySession ?: continue + val session = app.fahipaySessionFor(state.account) ?: continue try { val flow = FahipayLoginFlow() flow.setSessionCookie(session.sessionCookie) @@ -207,24 +206,27 @@ class TransferHistoryFragment : Fragment() { // MIB accounts: serialized per profile, protected by mutex to prevent session race val mibStates = activeStates.filter { it.account.bank == "MIB" } - for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { - val session = mibSession ?: break - app.mibMutex.withLock { - val profile = app.mibProfiles.firstOrNull { it.profileId == profileId } - if (profile != null) app.mibLoginFlow.switchProfile(session, profile) - for (state in states) { - try { - val (list, total) = MibHistoryClient().fetchHistory( - session = session, - accountNo = state.account.accountNumber, - accountDisplayName = state.account.accountBriefName, - start = state.mibNextStart, - pageSize = pageSize - ) - if (total > 0) state.mibTotalCount = total - state.mibNextStart += list.size.coerceAtLeast(pageSize) - results.addAll(list) - } catch (_: Exception) {} + for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) { + val session = app.mibSessions[loginId] ?: continue + for ((profileId, states) in loginStates.groupBy { it.account.profileId }) { + app.mibMutex.withLock { + val profiles = app.mibProfilesMap[loginId] ?: emptyList() + val profile = profiles.firstOrNull { it.profileId == profileId } + if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile) + for (state in states) { + try { + val (list, total) = MibHistoryClient().fetchHistory( + session = session, + accountNo = state.account.accountNumber, + accountDisplayName = state.account.accountBriefName, + start = state.mibNextStart, + pageSize = pageSize + ) + if (total > 0) state.mibTotalCount = total + state.mibNextStart += list.size.coerceAtLeast(pageSize) + results.addAll(list) + } catch (_: Exception) {} + } } } } @@ -278,7 +280,7 @@ class TransferHistoryFragment : Fragment() { return } val app = requireActivity().application as BasedBankApp - val sess = app.mibSession ?: return + val sess = app.anyMibSession() ?: return viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { try { val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt index cee2e23..3616651 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt @@ -154,11 +154,11 @@ class TransferReceiptFragment : Fragment() { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { val app = requireActivity().application as BasedBankApp - val sess = app.mibSession ?: return + val sess = app.anyMibSession() ?: return viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { try { val base64 = if (isProfile) { - app.mibLoginFlow.fetchProfileImage(sess, hash) + app.anyMibFlow()?.fetchProfileImage(sess, hash) } else { MibContactsClient().fetchProfileImageBase64(sess, hash) } ?: return@launch diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index b27ba07..f0c8161 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -170,6 +170,7 @@ class CredentialsFragment : Fragment() { binding.btnLogin.isEnabled = false val passwordHash = MibLoginFlow.hashPassword(password) + val loginId = username val flow = MibLoginFlow(CredentialStore(requireContext())) viewLifecycleOwner.lifecycleScope.launch { @@ -178,11 +179,12 @@ class CredentialsFragment : Fragment() { flow.login(username, passwordHash, otpSeed) } val store = CredentialStore(requireContext()) - store.saveMibCredentials(username, passwordHash, otpSeed) + store.saveMibCredentials(loginId, username, passwordHash, otpSeed) withContext(Dispatchers.IO) { flow.lastSession?.let { s -> val profile = flow.fetchPersonalProfile(s) if (profile != null) store.saveMibUserProfile( + loginId, CredentialStore.MibUserProfile( fullName = profile.fullName, username = profile.username, @@ -193,12 +195,15 @@ class CredentialsFragment : Fragment() { ) } } - AccountCache.save(requireContext(), accounts) - CredentialStore(requireContext()).saveMibProfiles(flow.lastProfiles) + store.saveMibProfiles(loginId, flow.lastProfiles) val app = requireActivity().application as BasedBankApp - app.accounts = accounts - app.mibSession = flow.lastSession - app.mibProfiles = flow.lastProfiles + // Merge with any existing MIB accounts from other logins + app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts + 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) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK startActivity(intent) @@ -375,11 +380,13 @@ class CredentialsFragment : Fragment() { val b = flow.fetchBalance(session) Pair(p, b) } - val loginTag = "fahipay_${profile.profileId}" + val loginId = profile.profileId + val loginTag = "fahipay_$loginId" val account = flow.buildAccount(profile, balance, loginTag) - store.saveFahipayCredentials(idCard, password) - store.saveFahipaySession(session.authId, session.sessionCookie) + store.saveFahipayCredentials(loginId, idCard, password) + store.saveFahipaySession(loginId, session.authId, session.sessionCookie) store.saveFahipayUserProfile( + loginId, CredentialStore.FahipayUserProfile( fullName = profile.fullName, email = profile.email, @@ -390,11 +397,11 @@ class CredentialsFragment : Fragment() { linkedAccounts = profile.linkedAccounts ) ) - AccountCache.saveFahipay(requireContext(), listOf(account)) + AccountCache.saveFahipay(requireContext(), loginId, listOf(account)) val app = requireActivity().application as BasedBankApp - app.fahipaySession = session - app.fahipayAccounts = listOf(account) - app.accounts = app.accounts + listOf(account) + app.fahipaySessions[loginId] = session + app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account) + app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account) val intent = Intent(requireContext(), HomeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK startActivity(intent) diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index 3ab454d..789eb24 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -9,9 +9,8 @@ object AccountCache { private const val PREFS = "account_cache" private const val KEY_MIB = "mib_accounts" - private const val KEY_FAHIPAY = "fahipay_accounts" - private fun bmlKey(loginId: String) = "bml_accounts_$loginId" + private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId" fun save(context: Context, accounts: List) { val arr = JSONArray() @@ -93,7 +92,7 @@ object AccountCache { fun loadBml(context: Context, loginIds: List): List = loginIds.flatMap { loadBml(context, it) } - fun saveFahipay(context: Context, accounts: List) { + fun saveFahipay(context: Context, loginId: String, accounts: List) { val arr = JSONArray() for (acc in accounts) { arr.put(JSONObject().apply { @@ -113,12 +112,12 @@ object AccountCache { }) } 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 { + fun loadFahipay(context: Context, loginId: String): List { val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getString(KEY_FAHIPAY, null) ?: return emptyList() + .getString(fahipayKey(loginId), null) ?: return emptyList() return try { val arr = JSONArray(CacheEncryption.decrypt(raw)) (0 until arr.length()).map { i -> @@ -144,6 +143,9 @@ object AccountCache { } catch (_: Exception) { emptyList() } } + fun loadFahipay(context: Context, loginIds: List): List = + loginIds.flatMap { loadFahipay(context, it) } + fun clear(context: Context) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() } diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt b/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt index 6bdd5a2..0392069 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactManager.kt @@ -25,11 +25,14 @@ object ContactManager { } private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean { - val sess = app.mibSession ?: return false + val sess = app.anyMibSession() ?: 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) + 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 } diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index db74c15..dfea43e 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -21,75 +21,131 @@ class CredentialStore(context: Context) { data class BmlCredentials(val username: String, val password: String, val otpSeed: 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 hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card") + fun getMibLoginIds(): List { + 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() prefs.edit() - .putString("mib_enc_username", encrypt(username, key)) - .putString("mib_enc_password_hash", encrypt(passwordHash, key)) - .putString("mib_enc_otp_seed", encrypt(otpSeed, key)) + .putString("mib_${loginId}_enc_password_hash", encrypt(passwordHash, key)) + .putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, key)) .apply() } - fun loadMibCredentials(): MibCredentials? { + fun loadMibCredentials(loginId: String): MibCredentials? { val key = getOrCreateKey() - val encUsername = prefs.getString("mib_enc_username", null) ?: return null - val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null - val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null + val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null + val encSeed = prefs.getString("mib_${loginId}_enc_otp_seed", null) ?: return null return try { - MibCredentials( - decrypt(encUsername, key), - decrypt(encHash, key), - decrypt(encSeed, key) - ) + MibCredentials(loginId, decrypt(encHash, key), decrypt(encSeed, key)) } catch (_: Exception) { null } } - fun clearMibCredentials() { + fun clearMibCredentials(loginId: String) { + removeMibLoginId(loginId) prefs.edit() - .remove("mib_enc_username") - .remove("mib_enc_password_hash") - .remove("mib_enc_otp_seed") - .remove("mib_enc_key1") - .remove("mib_enc_key2") - .remove("mib_enc_app_id") + .remove("mib_${loginId}_enc_password_hash") + .remove("mib_${loginId}_enc_otp_seed") + .remove("mib_${loginId}_enc_key1") + .remove("mib_${loginId}_enc_key2") + .remove("mib_${loginId}_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() } - // ── 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() prefs.edit() - .putString("mib_enc_key1", encrypt(key1, key)) - .putString("mib_enc_key2", encrypt(key2, key)) + .putString("mib_${loginId}_enc_key1", encrypt(key1, key)) + .putString("mib_${loginId}_enc_key2", encrypt(key2, key)) .apply() } - fun loadMibKeys(): Pair? { + fun loadMibKeys(loginId: String): Pair? { val key = getOrCreateKey() - val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null - val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null + val encKey1 = prefs.getString("mib_${loginId}_enc_key1", null) ?: return null + val encKey2 = prefs.getString("mib_${loginId}_enc_key2", null) ?: return null return try { Pair(decrypt(encKey1, key), decrypt(encKey2, key)) } catch (_: Exception) { null } } - fun saveMibAppId(id: String) { + fun saveMibAppId(loginId: String, id: String) { 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 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 } } + /** 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) ──────── fun getBmlLoginIds(): List { @@ -171,58 +227,81 @@ class CredentialStore(context: Context) { .apply() } - // ── Fahipay login credentials ───────────────────────────────────────────── + // ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ── - fun saveFahipayCredentials(idCard: String, password: String) { + fun getFahipayLoginIds(): List { + 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() prefs.edit() - .putString("fahipay_enc_id_card", encrypt(idCard, key)) - .putString("fahipay_enc_password", encrypt(password, key)) + .putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key)) + .putString("fahipay_${loginId}_enc_password", encrypt(password, key)) .apply() } - fun loadFahipayCredentials(): FahipayCredentials? { + fun loadFahipayCredentials(loginId: String): FahipayCredentials? { val key = getOrCreateKey() - val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null - val encPw = prefs.getString("fahipay_enc_password", null) ?: return null + val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null + val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null return try { FahipayCredentials(decrypt(encId, key), decrypt(encPw, key)) } catch (_: Exception) { null } } - fun clearFahipayCredentials() { + fun clearFahipayCredentials(loginId: String) { + removeFahipayLoginId(loginId) prefs.edit() - .remove("fahipay_enc_id_card") - .remove("fahipay_enc_password") + .remove("fahipay_${loginId}_enc_id_card") + .remove("fahipay_${loginId}_enc_password") + .remove("fahipay_${loginId}_enc_auth_id") + .remove("fahipay_${loginId}_enc_session_cookie") + .remove("fahipay_${loginId}_enc_profile") .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() prefs.edit() - .putString("fahipay_enc_auth_id", encrypt(authId, key)) - .putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key)) + .putString("fahipay_${loginId}_enc_auth_id", encrypt(authId, key)) + .putString("fahipay_${loginId}_enc_session_cookie", encrypt(sessionCookie, key)) .apply() } - fun loadFahipaySession(): Pair? { + fun loadFahipaySession(loginId: String): Pair? { val key = getOrCreateKey() - val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null - val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null + val encAuth = prefs.getString("fahipay_${loginId}_enc_auth_id", null) ?: return null + val encCookie = prefs.getString("fahipay_${loginId}_enc_session_cookie", null) ?: return null return try { Pair(decrypt(encAuth, key), decrypt(encCookie, key)) } 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) ─ fun getOrCreateFahipayDeviceUuid(): String { @@ -236,7 +315,7 @@ class CredentialStore(context: Context) { return uuid } - // ── Fahipay user profile ────────────────────────────────────────────────── + // ── Fahipay user profile (per loginId) ──────────────────────────────────── data class FahipayUserProfile( val fullName: String, @@ -248,7 +327,7 @@ class CredentialStore(context: Context) { val linkedAccounts: String ) - fun saveFahipayUserProfile(p: FahipayUserProfile) { + fun saveFahipayUserProfile(loginId: String, p: FahipayUserProfile) { val json = org.json.JSONObject().apply { put("fullName", p.fullName) put("email", p.email) @@ -259,12 +338,12 @@ class CredentialStore(context: Context) { put("linkedAccounts", p.linkedAccounts) }.toString() 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 enc = prefs.getString("fahipay_enc_profile", null) ?: return null + val enc = prefs.getString("fahipay_${loginId}_enc_profile", null) ?: return null return try { val o = org.json.JSONObject(decrypt(enc, key)) FahipayUserProfile( @@ -279,6 +358,33 @@ class CredentialStore(context: Context) { } 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) ────────────────────────────── /** @@ -329,9 +435,9 @@ class CredentialStore(context: Context) { val birthdate: String ) - // ── MIB operating profiles (all profiles, regardless of visibility) ─────── + // ── MIB operating profiles (per loginId) ───────────────────────────────── - fun saveMibProfiles(profiles: List) { + fun saveMibProfiles(loginId: String, profiles: List) { val arr = org.json.JSONArray() for (p in profiles) { arr.put(org.json.JSONObject().apply { @@ -342,11 +448,11 @@ class CredentialStore(context: Context) { put("color", p.color) }) } - prefs.edit().putString("mib_all_profiles", arr.toString()).apply() + prefs.edit().putString("mib_${loginId}_all_profiles", arr.toString()).apply() } - fun loadMibProfiles(): List { - val raw = prefs.getString("mib_all_profiles", null) ?: return emptyList() + fun loadMibProfiles(loginId: String): List { + 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 -> @@ -366,19 +472,18 @@ class CredentialStore(context: Context) { } catch (_: Exception) { emptyList() } } - fun saveMibFullName(name: String) { + fun saveMibFullName(loginId: String, name: String) { val key = getOrCreateKey() - prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply() + prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(name, key)).apply() } - fun loadMibFullName(): String? { + fun loadMibFullName(loginId: String): String? { val key = getOrCreateKey() - val enc = prefs.getString("mib_enc_full_name", null) ?: return null + val enc = prefs.getString("mib_${loginId}_enc_full_name", null) ?: return null return try { decrypt(enc, key) } catch (_: Exception) { null } } - - fun saveMibUserProfile(p: MibUserProfile) { + fun saveMibUserProfile(loginId: String, p: MibUserProfile) { val json = JSONObject().apply { put("fullName", p.fullName) put("username", p.username) @@ -387,14 +492,13 @@ class CredentialStore(context: Context) { put("enrolled", p.enrolled) }.toString() val key = getOrCreateKey() - prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply() - // Keep the name in sync with the fast-path field - prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply() + prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply() + prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(p.fullName, key)).apply() } - fun loadMibUserProfile(): MibUserProfile? { + fun loadMibUserProfile(loginId: String): MibUserProfile? { 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 { val o = JSONObject(decrypt(enc, key)) MibUserProfile( @@ -436,14 +540,14 @@ class CredentialStore(context: Context) { } catch (_: Exception) { null } } - // ── MIB profile visibility ──────────────────────────────────────────────── + // ── MIB profile visibility (per loginId) ───────────────────────────────── - /** Returns the set of MIB profile IDs the user has chosen to hide from the app. */ - fun getHiddenMibProfileIds(): Set = - prefs.getStringSet("mib_hidden_profile_ids", emptySet()) ?: emptySet() + /** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */ + fun getHiddenMibProfileIds(loginId: String): Set = + prefs.getStringSet("mib_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet() - fun setHiddenMibProfileIds(ids: Set) = - prefs.edit().putStringSet("mib_hidden_profile_ids", ids).apply() + fun setHiddenMibProfileIds(loginId: String, ids: Set) = + prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply() // ── Crypto primitives ───────────────────────────────────────────────────── diff --git a/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt b/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt index 82fd128..4a94748 100644 --- a/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt +++ b/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt @@ -54,7 +54,7 @@ class HistoryFetcher(private val account: MibAccount) { } private fun fetchFahipay(app: BasedBankApp): List { - val session = app.fahipaySession ?: return emptyList() + val session = app.fahipaySessionFor(account) ?: return emptyList() val flow = FahipayLoginFlow() flow.setSessionCookie(session.sessionCookie) val (list, total) = flow.fetchHistory( @@ -69,9 +69,11 @@ class HistoryFetcher(private val account: MibAccount) { } private fun fetchMib(app: BasedBankApp, pageSize: Int): List { - val session = app.mibSession ?: return emptyList() - val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId } - if (profile != null) app.mibLoginFlow.switchProfile(session, profile) + 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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0c3038..b8d6c61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,7 +76,7 @@ Dashboard - Add Account + Add Login Accounts Contacts Activities diff --git a/bml_logo_long.png b/bml_logo_long.png new file mode 100644 index 0000000000000000000000000000000000000000..83baccaa0822804918f89c3c344cf51dcfb298da GIT binary patch literal 11385 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 AZ~y=R literal 0 HcmV?d00001