From b78408560579483b0c8057c121e876cb858e1729 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 22 May 2026 06:01:13 +0500 Subject: [PATCH] optimize bml refresh flow --- .../sar/basedbank/api/bml/BmlAccountClient.kt | 8 ++ .../sh/sar/basedbank/api/bml/BmlLoginFlow.kt | 38 +++++- .../sh/sar/basedbank/api/bml/BmlModels.kt | 8 +- .../sh/sar/basedbank/ui/home/HomeActivity.kt | 114 ++++++++---------- .../ui/home/SettingsLoginsFragment.kt | 8 ++ .../basedbank/ui/login/CredentialsFragment.kt | 12 ++ .../sh/sar/basedbank/util/CredentialStore.kt | 20 +++ 7 files changed, 143 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt index 1e1c3e0..0ae411c 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index fa21694..764c5d6 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -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 ────────────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt index da25541..4d6b3ee 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt @@ -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, 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 78fe42a..f59414b 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 @@ -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() - 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) } } 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 fa16ed5..7d98eb7 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 @@ -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() 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 452b657..4e3cd5a 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 @@ -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 { 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 efd0362..3f19102 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -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() }