persist mib sessions on disk instead of refreshing token and fix contact picker render issue #37
Auto Tag on Version Change / check-version (push) Failing after 15m8s

This commit is contained in:
2026-06-10 00:17:43 +05:00
parent 26dcb20f7f
commit 4b1c2419ec
5 changed files with 117 additions and 7 deletions
@@ -19,7 +19,7 @@ class MibContactsClient {
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
"mbnonce=${MibNonce.generate(session.nonceGenerator)}; time-tracker=597"
private fun Request.Builder.withSessionHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
@@ -52,6 +52,18 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
// ─── Public entry point ───────────────────────────────────────────────────
/**
* Restores a previously persisted session without any network call. Sets the session and
* stored credentials so that doRequest() can do an automatic re-login on 419 if needed.
*/
fun restoreSession(session: MibSession, username: String, passwordHash: String, otpSeed: String) {
loginId = username
storedUsername = username
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
lastSession = session
}
/**
* Full login flow. Automatically handles first-time device registration
* vs. subsequent logins using stored key1/key2.
@@ -245,10 +257,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
"" // fall through to recovery below
}
// Detect expired session: HTTP 419 (empty response) or JSON with reasonCode 505
// Detect expired session: HTTP 419 (empty response), reasonCode 505, or digestFail
// (digestFail / reasonCode 501 means the nonce is invalid for this session — happens
// when a persisted session is loaded after the server has already expired it)
val isExpired = response.isEmpty() ||
(response.trimStart().startsWith("{") &&
JSONObject(response).optString("reasonCode") == "505")
(response.trimStart().startsWith("{") && run {
val obj = JSONObject(response)
obj.optString("reasonCode") == "505" || obj.optBoolean("digestFail", false)
})
if (isExpired && inRelogin.compareAndSet(false, true)) {
try {
@@ -151,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.triggerRefresh()
(activity as? HomeActivity)?.loadAllContacts()
}
private fun attachMediator(pages: List<TabDef>) {
@@ -734,13 +734,75 @@ fun applyNavLabelVisibility() {
val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val app = application as BasedBankApp
// Prefer in-memory session; fall back to persisted session on restart.
var existingSession = app.mibSessions[loginId]
var existingFlow = app.mibLoginFlows[loginId]
val cachedProfiles = app.mibProfilesMap[loginId]?.takeIf { it.isNotEmpty() }
?: store.loadMibProfiles(loginId)
if (existingSession == null) {
val persisted = store.loadMibSession(loginId)
if (persisted != null && cachedProfiles.isNotEmpty()) {
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
flow.restoreSession(persisted, creds.username, creds.passwordHash, creds.otpSeed)
existingSession = persisted
existingFlow = flow
app.mibSessions[loginId] = persisted
app.mibLoginFlows[loginId] = flow
app.mibProfilesMap[loginId] = cachedProfiles
}
}
// Try the existing session first — avoids a full re-login on every refresh.
// doRequest() inside fetchAllProfiles() handles 419 (session expired) by
// automatically re-logging in and retrying, so this is safe to call directly.
if (existingSession != null && existingFlow != null && cachedProfiles.isNotEmpty()) {
existingFlow.onSessionRefreshed = { newSession, newProfiles ->
app.mibSessions[loginId] = newSession
app.mibProfilesMap[loginId] = newProfiles
store.saveMibSession(loginId, newSession)
store.saveMibProfiles(loginId, newProfiles)
}
try {
val visibleProfiles = cachedProfiles.filterVisibleProfiles(loginId)
val accounts = existingFlow.fetchAllProfiles(existingSession, visibleProfiles, "mib_${creds.username}")
val freshSession = existingFlow.lastSession ?: existingSession
app.mibSessions[loginId] = freshSession
store.saveMibSession(loginId, freshSession)
if (existingFlow.lastProfiles.isNotEmpty()) {
app.mibProfilesMap[loginId] = existingFlow.lastProfiles
store.saveMibProfiles(loginId, existingFlow.lastProfiles)
}
AccountCache.save(this@HomeActivity, accounts)
return@async accounts
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
return@async AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
return@async AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
} catch (_: Exception) {
// Session completely unrecoverable — fall through to full re-login
}
}
// Full re-login: first start with no persisted session, or unrecoverable above
try {
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
flow.onSessionRefreshed = { newSession, newProfiles ->
app.mibSessions[loginId] = newSession
app.mibProfilesMap[loginId] = newProfiles
store.saveMibSession(loginId, newSession)
store.saveMibProfiles(loginId, newProfiles)
}
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
val app = application as BasedBankApp
app.mibSessions[loginId] = flow.lastSession!!
val newSession = flow.lastSession!!
app.mibSessions[loginId] = newSession
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibSession(loginId, newSession)
store.saveMibProfiles(loginId, flow.lastProfiles)
accounts
} catch (e: java.io.IOException) {
@@ -79,9 +79,41 @@ class CredentialStore(context: Context) {
.remove("mib_${loginId}_enc_profile")
.remove("mib_${loginId}_enc_full_name")
.remove("mib_${loginId}_hidden_profile_ids")
.remove("mib_${loginId}_enc_session")
.apply()
}
// ── MIB active session (xxid + sessionKey + nonceGenerator) ─────────────────
fun saveMibSession(loginId: String, session: sh.sar.basedbank.api.mib.MibSession) {
val json = JSONObject().apply {
put("xxid", session.xxid)
put("sessionKey", session.sessionKey)
put("nonceGenerator", session.nonceGenerator)
put("appId", session.appId)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("mib_${loginId}_enc_session", encrypt(json, key)).apply()
}
fun loadMibSession(loginId: String): sh.sar.basedbank.api.mib.MibSession? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_${loginId}_enc_session", null) ?: return null
return try {
val o = JSONObject(decrypt(enc, key))
sh.sar.basedbank.api.mib.MibSession(
appId = o.getString("appId"),
xxid = o.getString("xxid"),
nonceGenerator = o.getString("nonceGenerator"),
sessionKey = o.getString("sessionKey")
)
} catch (_: Exception) { null }
}
fun clearMibSession(loginId: String) {
prefs.edit().remove("mib_${loginId}_enc_session").apply()
}
// ── MIB session keys (key1/key2) and app ID (per loginId) ────────────────
fun saveMibKeys(loginId: String, key1: String, key2: String) {