diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt index d685eb1..dc0f9a0 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt @@ -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)) 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 4979253..429c392 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 @@ -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 { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index 225a965..f738b42 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -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) { 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 f21729d..409689c 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 @@ -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) { 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 095c07e..85417c8 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -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) {