handle mib session expirey and keepalive
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s

This commit is contained in:
2026-05-15 10:24:51 +05:00
parent c9ec43de04
commit 1b5a417196
3 changed files with 89 additions and 6 deletions

View File

@@ -24,7 +24,12 @@ class BasedBankApp : Application() {
val mibMutex = Mutex()
val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles
}
}
}
override fun onCreate() {

View File

@@ -9,6 +9,8 @@ import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class SessionExpiredException : Exception("MIB session expired")
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
private val TAG = "MibLoginFlow"
@@ -22,6 +24,15 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
var lastProfiles: List<MibProfile> = emptyList()
private set
/** Called after automatic session recovery so callers can update their session reference. */
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
// Stored after login so the session can be silently recovered on 419
@Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null
private var inRelogin = false
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
@@ -46,6 +57,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
* Returns list of accounts from all profiles on success.
*/
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
storedUsername = username
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
val appId = getOrCreateAppId()
val key1 = prefs.getString("mib_key1_$username", null)
val key2 = prefs.getString("mib_key2_$username", null)
@@ -172,17 +186,48 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
}
private fun doRequest(session: MibSession, data: JSONObject, sfunc: String): JSONObject {
val routePath = data.optString("routePath", "?")
val response = try {
sendRequest(session, data, sfunc)
} catch (e: SessionExpiredException) {
if (inRelogin) throw e
"" // fall through to recovery below
}
// Detect expired session: HTTP 419 (empty response) or JSON with reasonCode 505
val isExpired = response.isEmpty() ||
(response.trimStart().startsWith("{") &&
JSONObject(response).optString("reasonCode") == "505")
if (isExpired && !inRelogin) {
val u = storedUsername ?: throw SessionExpiredException()
val ph = storedPasswordHash ?: throw SessionExpiredException()
val os = storedOtpSeed ?: throw SessionExpiredException()
inRelogin = true
try { login(u, ph, os) } finally { inRelogin = false }
val newSession = lastSession ?: throw SessionExpiredException()
onSessionRefreshed?.invoke(newSession, lastProfiles)
// Refresh nonce/xxid in the payload for the retry
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
data.put("appId", newSession.appId)
data.put("sodium", MibNonce.randomSodium())
data.put("xxid", newSession.xxid)
val retryResponse = sendRequest(newSession, data, sfunc)
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
}
if (response.trimStart().startsWith("{")) return JSONObject(response)
return MibCrypto.decrypt(response, session.sessionKey)
}
private fun sendRequest(session: MibSession, data: JSONObject, sfunc: String): String {
val encrypted = MibCrypto.encrypt(data, session.sessionKey)
val formBody = FormBody.Builder()
.add("xxid", session.xxid)
.add("sfunc", sfunc)
.add("data", encrypted)
.build()
val response = post(formBody)
// Server returns plain JSON (not encrypted) for error responses (e.g. expired session)
if (response.trimStart().startsWith("{")) return JSONObject(response)
return MibCrypto.decrypt(response, session.sessionKey)
return post(formBody)
}
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
@@ -276,6 +321,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
.post(body)
.build()
val response = client.newCall(request).execute()
if (response.code == 419) throw SessionExpiredException()
return response.body?.string() ?: throw IllegalStateException("Empty response body")
}

View File

@@ -16,10 +16,16 @@ import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException
@@ -141,6 +147,32 @@ class HomeActivity : AppCompatActivity() {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
// Keep MIB session 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) {}
}
}
}
}
}
private fun show(fragment: Fragment) {