diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index bbfe78e..59e3766 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -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() { 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 e5a5fc5..eb867e2 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 @@ -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 = emptyList() private set + /** Called after automatic session recovery so callers can update their session reference. */ + var onSessionRefreshed: ((MibSession, List) -> 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 { + 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") } 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 c759c8a..6ec4800 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 @@ -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) {