optimize bml refresh flow

This commit is contained in:
2026-05-22 06:01:13 +05:00
parent 01e5c17284
commit b784085605
7 changed files with 143 additions and 65 deletions
@@ -30,6 +30,14 @@ class BmlAccountClient {
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
}
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
fun checkProfile(session: BmlSession) {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
val code = resp.code
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
}
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
val json = resp.body?.string() ?: return null
@@ -310,13 +310,47 @@ class BmlLoginFlow {
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close()
val accessToken = JSONObject(tokenJson).optString("access_token")
val tokenObj = JSONObject(tokenJson)
val accessToken = tokenObj.optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val refreshToken = tokenObj.optString("refresh_token", "")
val expiresIn = tokenObj.optLong("expires_in", 0L)
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts)
}
// ─── Token refresh ───────────────────────────────────────────────────────
/**
* Uses the saved refresh token to obtain a new access token without re-login.
* Returns a new [BmlSession] with updated tokens.
*/
fun refreshSession(session: BmlSession): BmlSession {
val body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("refresh_token", session.refreshToken)
.add("client_id", CLIENT_ID)
.add("Device-ID", session.deviceId)
.add("User-Agent", APP_USER_AGENT)
.add("x-app-version", APP_VERSION)
.build()
val resp = newBmlApiClient().newCall(
Request.Builder().url("$BASE_URL/oauth/token").post(body)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
resp.close()
val obj = JSONObject(json)
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
?: throw Exception("Token refresh failed")
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
val expiresIn = obj.optLong("expires_in", 0L)
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
}
// ─── Parsing ──────────────────────────────────────────────────────────────
/**
@@ -4,8 +4,12 @@ import sh.sar.basedbank.api.models.BankAccount
data class BmlSession(
val accessToken: String,
val deviceId: String
)
val deviceId: String,
val refreshToken: String = "",
val expiresAt: Long = 0L // Unix millis; 0 = unknown
) {
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
}
data class BmlProfile(
val profileId: String,
@@ -39,7 +39,6 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
import sh.sar.basedbank.api.bml.BmlLoanDetail
@@ -569,34 +568,63 @@ fun applyNavLabelVisibility() {
}
// One async job per BML login, all run in parallel
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
val bmlJobs = bmlLoginIds.map { loginId ->
loginId to async(Dispatchers.IO) {
val loginTag = "bml_$loginId"
val app = application as BasedBankApp
val savedProfiles = store.loadBmlProfiles(loginId)
val allAccounts = mutableListOf<BankAccount>()
var anyExpired = savedProfiles.isEmpty()
// Try each saved profile's cached session
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
if (saved != null) {
try {
val session = BmlSession(saved.first, saved.second)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
} else {
anyExpired = true
}
}
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
// Also try legacy single-profile session token (pre-multi-profile installs)
val bmlClient = BmlAccountClient()
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
if (saved == null) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
continue
}
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
suspend fun fetchWithSession(session: BmlSession) {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
}
suspend fun tryRefresh() {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
fetchWithSession(newSession)
}
try {
if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
} catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
}
}
// Legacy single-profile session (pre-multi-profile installs)
if (savedProfiles.isEmpty()) {
val legacyToken = store.loadBmlSession(loginId)
if (legacyToken != null) {
@@ -605,47 +633,11 @@ fun applyNavLabelVisibility() {
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session
allAccounts += accounts
anyExpired = false
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
}
}
if (anyExpired || allAccounts.isEmpty()) {
// Re-authenticate to refresh personal profile sessions
try {
val flow = app.bmlFlowFor(loginId)
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlProfiles(loginId, profiles)
app.bmlProfilesMap[loginId] = profiles
for (profile in profiles) {
if (profile.profileType == "business") {
// Can't activate business profiles without user OTP — use cached
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
if (allAccounts.none { it.profileId == profile.profileId })
allAccounts += cached
continue
}
try {
val result = flow.activateProfile(profile, loginTag)
if (result is BmlActivationResult.Success) {
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
app.bmlSessions[profile.profileId] = result.session
allAccounts.removeAll { it.profileId == profile.profileId }
allAccounts += result.accounts
}
} catch (_: Exception) {
if (allAccounts.none { it.profileId == profile.profileId }) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
}
}
}
} catch (_: Exception) {
if (allAccounts.isEmpty())
} catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
}
} else {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
}
}
@@ -433,6 +433,10 @@ class SettingsLoginsFragment : Fragment() {
return when (activationResult) {
is BmlActivationResult.Success -> {
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
if (activationResult.session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, activationResult.session.refreshToken)
if (activationResult.session.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, activationResult.session.expiresAt)
true
}
is BmlActivationResult.NeedsBusinessOtp ->
@@ -475,6 +479,10 @@ class SettingsLoginsFragment : Fragment() {
}
verifyProgress.dismiss()
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
if (session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, session.refreshToken)
if (session.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, session.expiresAt)
return true
} catch (e: Exception) {
verifyProgress.dismiss()
@@ -267,6 +267,10 @@ class CredentialsFragment : Fragment() {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
if (result.session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, result.session.refreshToken)
if (result.session.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, result.session.expiresAt)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
@@ -326,8 +330,16 @@ class CredentialsFragment : Fragment() {
val session = app.bmlSessions.remove(oldId)
if (session != null) {
app.bmlSessions[customerId] = session
val savedRefresh = store.loadBmlProfileRefreshToken(oldId)
val savedExpiry = store.loadBmlProfileExpiresAt(oldId)
store.clearBmlProfileSession(oldId)
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
if (session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(customerId, session.refreshToken)
else if (savedRefresh != null)
store.saveBmlProfileRefreshToken(customerId, savedRefresh)
val expiryToSave = if (session.expiresAt > 0) session.expiresAt else savedExpiry
if (expiryToSave > 0) store.saveBmlProfileExpiresAt(customerId, expiryToSave)
}
// Update stored profile list with the real ID
val updatedProfiles = profiles.map {
@@ -264,10 +264,30 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
fun saveBmlProfileExpiresAt(profileId: String, expiresAt: Long) {
prefs.edit().putLong("bml_profile_${profileId}_expires_at", expiresAt).apply()
}
fun loadBmlProfileExpiresAt(profileId: String): Long =
prefs.getLong("bml_profile_${profileId}_expires_at", 0L)
fun saveBmlProfileRefreshToken(profileId: String, refreshToken: String) {
val key = getOrCreateKey()
prefs.edit().putString("bml_profile_${profileId}_enc_refresh_token", encrypt(refreshToken, key)).apply()
}
fun loadBmlProfileRefreshToken(profileId: String): String? {
val key = getOrCreateKey()
val enc = prefs.getString("bml_profile_${profileId}_enc_refresh_token", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun clearBmlProfileSession(profileId: String) {
prefs.edit()
.remove("bml_profile_${profileId}_enc_token")
.remove("bml_profile_${profileId}_enc_device_id")
.remove("bml_profile_${profileId}_enc_refresh_token")
.remove("bml_profile_${profileId}_expires_at")
.apply()
}