From 15a02cac1ce642d16e5327bc41759210f041c1d2 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 19 May 2026 23:30:36 +0500 Subject: [PATCH] patial support for BML business profile accounts --- .../java/sh/sar/basedbank/BasedBankApp.kt | 47 ++- .../sh/sar/basedbank/api/bml/BmlLoginFlow.kt | 338 ++++++++++++++---- .../sh/sar/basedbank/api/bml/BmlModels.kt | 25 ++ .../sh/sar/basedbank/ui/home/HomeActivity.kt | 120 +++++-- .../ui/home/SettingsLoginsFragment.kt | 149 +++++++- .../basedbank/ui/login/CredentialsFragment.kt | 235 ++++++++++-- .../sh/sar/basedbank/util/CredentialStore.kt | 75 +++- app/src/main/res/values/strings.xml | 7 + 8 files changed, 829 insertions(+), 167 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index acbc409..a7e2516 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -4,6 +4,8 @@ import android.app.Application import androidx.appcompat.app.AppCompatDelegate import com.google.android.material.color.DynamicColors import kotlinx.coroutines.sync.Mutex +import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.mib.MibAccount @@ -17,18 +19,30 @@ class BasedBankApp : Application() { // Held in memory after successful login; cleared on logout var accounts: List = emptyList() var fullName: String = "" + /** Active MIB sessions keyed by loginId (= MIB username). */ val mibSessions: MutableMap = mutableMapOf() val mibProfilesMap: MutableMap> = mutableMapOf() val mibLoginFlows: MutableMap = mutableMapOf() var mibAccounts: List = emptyList() - /** Active BML sessions keyed by loginId (= BML username). */ + + /** + * Active BML sessions keyed by profileId (a globally unique GUID per BML profile). + * Use [bmlSessionFor] to look up the session for an account. + */ val bmlSessions: MutableMap = mutableMapOf() + /** BML profiles per loginId (= BML username). */ + val bmlProfilesMap: MutableMap> = mutableMapOf() + /** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */ + val bmlLoginFlows: MutableMap = mutableMapOf() var bmlAccounts: List = emptyList() + /** Active Fahipay sessions keyed by loginId (= profileId). */ val fahipaySessions: MutableMap = mutableMapOf() var fahipayAccounts: List = emptyList() + // ─── MIB helpers ────────────────────────────────────────────────────────── + /** Returns the MIB session for the given account (matched via loginTag). */ fun mibSessionFor(account: MibAccount): MibSession? = mibSessions[account.loginTag.removePrefix("mib_")] @@ -53,13 +67,38 @@ class BasedBankApp : Application() { /** Returns any available MibLoginFlow. */ fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull() - /** Returns the BML session for the given account (matched via loginTag). */ - fun bmlSessionFor(account: MibAccount): BmlSession? = - bmlSessions[account.loginTag.removePrefix("bml_")] + // ─── BML helpers ────────────────────────────────────────────────────────── + + /** + * Returns the BML session for the given account. + * Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile). + */ + fun bmlSessionFor(account: MibAccount): BmlSession? { + val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null + return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")] + } /** Returns any available BML session (for non-account-specific operations). */ fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull() + /** + * Returns any active BML session for the given loginId. + * Tries all profiles for that login; falls back to legacy loginId key. + */ + fun anyBmlSessionFor(loginId: String): BmlSession? { + val profiles = bmlProfilesMap[loginId] + if (!profiles.isNullOrEmpty()) { + return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] } + } + return bmlSessions[loginId] + } + + /** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */ + fun bmlFlowFor(loginId: String): BmlLoginFlow = + bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() } + + // ─── Fahipay helpers ────────────────────────────────────────────────────── + /** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */ fun fahipaySessionFor(account: MibAccount): FahipaySession? = fahipaySessions[account.loginTag.removePrefix("fahipay_")] 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 f4d5eab..1a3e49a 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 @@ -29,9 +29,9 @@ class BmlLoginFlow { private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" - private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)" - private val APP_VERSION = "2.1.43.345" - private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0" + private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)" + private val APP_VERSION = "2.1.44.348" + private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0" private val cookieStore = mutableMapOf>() private val cookieJar = object : CookieJar { @@ -58,9 +58,27 @@ class BmlLoginFlow { .readTimeout(30, TimeUnit.SECONDS) .build() - /** Full login: returns a BmlSession and the account list. */ - fun login(username: String, password: String, otpSeed: String): Pair> { - // Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies + /** PKCE params — generated once per login and reused across all profile activations. */ + private var codeVerifier: String = "" + private var codeChallenge: String = "" + private var deviceId: String = "" + + /** Profiles returned by the last successful [login] call. */ + var lastProfiles: List = emptyList() + private set + + // ─── Login ──────────────────────────────────────────────────────────────── + + /** + * Performs web authentication (login + TOTP) and returns the list of available profiles. + * Call [activateProfile] for each profile to obtain an access token + accounts. + */ + fun login(username: String, password: String, otpSeed: String): List { + codeVerifier = generateCodeVerifier() + codeChallenge = generateCodeChallenge(codeVerifier) + deviceId = generateDeviceId() + + // Step 1: GET login page — seeds XSRF-TOKEN + blaze_session client.newCall( Request.Builder().url("$BASE_URL/web/login") .header("User-Agent", WEB_USER_AGENT).build() @@ -82,15 +100,14 @@ class BmlLoginFlow { loginResp.close() if (loginResp.code != 302) throw Exception("Login failed — check your username/password") - // Step 3: GET 2FA page (refreshes blaze_session) + // Step 3: GET 2FA page (refreshes session cookies) client.newCall( Request.Builder().url("$BASE_URL/web/login/2fa") - .header("X-XSRF-TOKEN", xsrf) .header("User-Agent", WEB_USER_AGENT).build() ).execute().close() val xsrf2 = xsrfToken() ?: xsrf - // Step 4: POST OTP + // Step 4: POST TOTP val otp = Totp.generate(otpSeed) val twoFaBody = JSONObject().apply { put("code", otp) @@ -104,18 +121,141 @@ class BmlLoginFlow { twoFaResp.close() if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed") - // Step 5: GET /web/profile (sets blaze_identity cookie for profile selection) - client.newCall( + // Step 5: GET /web/profile — returns list of profiles for this account + val profileResp = client.newCall( Request.Builder().url("$BASE_URL/web/profile") - .header("X-XSRF-TOKEN", xsrf2) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + val profileBody = profileResp.body?.string() ?: "" + profileResp.close() + + lastProfiles = parseProfiles(profileBody) + return lastProfiles + } + + // ─── Profile activation ─────────────────────────────────────────────────── + + /** + * Activates a profile in the current web session and returns the result. + * + * - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success]. + * - Business profiles (profile_type="business") require SMS/email OTP; returns + * [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with + * [requestBusinessOtp] + [submitBusinessOtp]. + */ + fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult { + val xsrf = xsrfToken() + val reqBuilder = Request.Builder() + .url("$BASE_URL/web/profile/${profile.profileId}") + .header("User-Agent", WEB_USER_AGENT) + if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf) + + val resp = client.newCall(reqBuilder.build()).execute() + val code = resp.code + val location = resp.header("Location") ?: "" + resp.close() + + return when { + code == 409 || (code == 302 && "/web/redirect" in location) -> { + // Profile activated — blaze_identity cookie set in response headers + val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId) + BmlActivationResult.Success(session, accounts) + } + code == 302 && "/web/profile/2fa/business" in location -> { + // Business profile: server requires SMS/email OTP + val channels = fetchBusinessOtpChannels() + BmlActivationResult.NeedsBusinessOtp(channels) + } + else -> throw Exception("Profile activation failed (HTTP $code)") + } + } + + /** + * Returns available OTP channels for the business 2FA page. + * Also refreshes cookies so the subsequent POST has a valid XSRF token. + */ + private fun fetchBusinessOtpChannels(): List { + val resp = client.newCall( + Request.Builder().url("$BASE_URL/web/profile/2fa/business") + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + val body = resp.body?.string() ?: "" + resp.close() + return parseBusinessOtpChannels(body) + } + + /** + * Sends an OTP to [channel] for business profile activation. + * Must be called before [submitBusinessOtp]. + */ + fun requestBusinessOtp(channel: String) { + val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again") + val body = JSONObject().apply { + put("code", "") + put("channel", channel) + }.toString().toRequestBody("application/json".toMediaType()) + val resp = client.newCall( + Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body) + .header("X-XSRF-TOKEN", xsrf) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + val respCode = resp.code + resp.close() + if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)") + } + + /** + * Verifies the OTP and activates the business profile. + * Returns a new [BmlSession] and accounts on success. + * @throws Exception if the OTP is invalid (retry is allowed). + */ + fun submitBusinessOtp( + channel: String, + code: String, + profile: BmlProfile, + loginTag: String + ): Pair> { + // Refresh XSRF token before submitting + client.newCall( + Request.Builder().url("$BASE_URL/web/profile/2fa/business") .header("User-Agent", WEB_USER_AGENT).build() ).execute().close() - // Step 6: PKCE OAuth authorize → extract auth code - val codeVerifier = generateCodeVerifier() - val codeChallenge = generateCodeChallenge(codeVerifier) - val deviceId = generateDeviceId() + val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again") + val body = JSONObject().apply { + put("code", code) + put("channel", channel) + }.toString().toRequestBody("application/json".toMediaType()) + val resp = client.newCall( + Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body) + .header("X-XSRF-TOKEN", xsrf) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + val respCode = resp.code + val location = resp.header("Location") ?: "" + resp.close() + return when { + respCode == 409 || (respCode == 302 && "/web/redirect" in location) -> + doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId) + respCode == 302 -> + throw Exception("Invalid OTP — please try again") + else -> + throw Exception("Business OTP verification failed (HTTP $respCode)") + } + } + + // ─── OAuth + account fetch ──────────────────────────────────────────────── + + /** + * Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set). + * Returns a fresh [BmlSession] and the profile's accounts. + */ + private fun doOAuthAndFetchAccounts( + loginTag: String, + profileName: String, + profileId: String + ): Pair> { val authorizeUrl = HttpUrl.Builder() .scheme("https").host("www.bankofmaldives.com.mv") .addPathSegments("internetbanking/oauth/authorize") @@ -135,14 +275,12 @@ class BmlLoginFlow { Request.Builder().url(authorizeUrl) .header("User-Agent", WEB_USER_AGENT).build() ).execute() + val location = authorizeResp.header("Location") authorizeResp.close() - val location = authorizeResp.header("Location") - ?: throw Exception("OAuth authorize did not redirect") - val authCode = Uri.parse(location).getQueryParameter("code") - ?: throw Exception("No auth code in OAuth redirect") + val authCode = location?.let { Uri.parse(it).getQueryParameter("code") } + ?: throw Exception("OAuth authorize did not return auth code") - // Step 7: Exchange auth code for access token val tokenBody = FormBody.Builder() .add("Device-ID", deviceId) .add("code", authCode) @@ -161,22 +299,28 @@ class BmlLoginFlow { val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response") tokenResp.close() - val tokenObj = JSONObject(tokenJson) - val accessToken = tokenObj.optString("access_token") + val accessToken = JSONObject(tokenJson).optString("access_token") .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") val session = BmlSession(accessToken = accessToken, deviceId = deviceId) - val accounts = fetchAccounts(session, "bml_$username") + val accounts = fetchAccounts(session, loginTag, profileName, profileId) return Pair(session, accounts) } - fun fetchAccounts(session: BmlSession, loginTag: String): List { + // ─── API methods ───────────────────────────────────────────────────────── + + fun fetchAccounts( + session: BmlSession, + loginTag: String, + profileName: String = "Personal", + profileId: String = "" + ): List { val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute() val code = resp.code val json = resp.body?.string() resp.close() if (code == 401 || code == 419) throw AuthExpiredException() - return parseDashboard(json ?: return emptyList(), loginTag) + return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId) } fun fetchForeignLimits(session: BmlSession): List { @@ -331,9 +475,6 @@ class BmlLoginFlow { return parseContacts(json, loginId) } - /** - * Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready). - */ fun initiateTransfer( session: BmlSession, debitAccount: String, @@ -370,9 +511,6 @@ class BmlLoginFlow { } } - /** - * Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult. - */ fun confirmTransfer( session: BmlSession, debitAccount: String, @@ -440,32 +578,6 @@ class BmlLoginFlow { } } - // "12-05-2026 041675" → first 4 digits of time part as HH:mm - private fun parsePurchaseNarrative1(narrative1: String): String? { - return try { - val parts = narrative1.split(" ") - if (parts.size < 2) null - else { - val timePart = parts[1].take(4) - val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00" - val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined) - date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) } - } - } catch (_: Exception) { null } - } - - // "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss - private fun parseTransferNarrative1(narrative1: String): String? { - return try { - val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1) - date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) } - } catch (_: Exception) { null } - } - - /** - * Fetches paginated transaction history for a BML CASA account. - * @return Pair of (transactions, totalPages) - */ fun fetchAccountHistory( session: BmlSession, accountId: String, @@ -516,10 +628,6 @@ class BmlLoginFlow { } catch (_: Exception) { Pair(emptyList(), 0) } } - /** - * Fetches card statement for a BML prepaid card for the given month ("YYYYMM"). - * Returns combined outstanding authorizations + settled statement entries. - */ fun fetchCardHistory( session: BmlSession, cardId: String, @@ -546,7 +654,6 @@ class BmlLoginFlow { val payload = root.optJSONObject("payload") ?: return emptyList() val result = mutableListOf() - // Outstanding authorizations val authDetails = payload.optJSONObject("outstanding") ?.optJSONArray("CardOutStdAuthDetails") if (authDetails != null) { @@ -567,7 +674,6 @@ class BmlLoginFlow { } } - // Settled statement entries val statement = payload.optJSONArray("cardstatement") if (statement != null) { for (i in 0 until statement.length()) { @@ -590,14 +696,64 @@ class BmlLoginFlow { } catch (_: Exception) { emptyList() } } - private fun apiRequest(session: BmlSession, url: String) = - Request.Builder().url(url) - .header("Authorization", "Bearer ${session.accessToken}") - .header("User-Agent", APP_USER_AGENT) - .header("x-app-version", APP_VERSION) - .build() + // ─── Parsing ────────────────────────────────────────────────────────────── - private fun parseDashboard(json: String, loginTag: String): List { + /** + * BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON + * in the `data-page="..."` attribute of the root div. This extracts and unescapes it. + */ + private fun extractInertiaJson(html: String): String? { + val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null + return match.groupValues[1] + .replace(""", "\"") + .replace("&", "&") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + } + + private fun parseProfiles(html: String): List { + return try { + val json = extractInertiaJson(html) ?: html + val root = JSONObject(json) + val props = root.optJSONObject("props") ?: return emptyList() + val profiles = props.optJSONArray("profiles") ?: return emptyList() + (0 until profiles.length()).mapNotNull { i -> + val p = profiles.getJSONObject(i) + val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null + BmlProfile( + profileId = p.optString("profile_id"), + name = p.optString("name"), + type = p.optString("type"), + profileType = profileObj.optString("profile_type", "default") + ) + } + } catch (_: Exception) { emptyList() } + } + + private fun parseBusinessOtpChannels(html: String): List { + return try { + val json = extractInertiaJson(html) ?: html + val root = JSONObject(json) + val props = root.optJSONObject("props") ?: return emptyList() + val channels = props.optJSONArray("channels") ?: return emptyList() + (0 until channels.length()).map { i -> + val c = channels.getJSONObject(i) + BmlOtpChannel( + channel = c.optString("channel"), + description = c.optString("description"), + masked = c.optString("masked") + ) + } + } catch (_: Exception) { emptyList() } + } + + private fun parseDashboard( + json: String, + loginTag: String, + profileName: String = "Personal", + profileId: String = "" + ): List { val root = JSONObject(json) if (!root.optBoolean("success")) return emptyList() val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList() @@ -612,14 +768,13 @@ class BmlLoginFlow { val product = item.optString("product") val accountNumber = item.optString("account") val status = item.optString("account_status", "Active") - val internalId = item.optString("id", "") if (accountType == "CASA") { val available = item.optDouble("availableBalance", 0.0) casaAccounts.add(MibAccount( bank = "BML", - profileName = "Personal", + profileName = profileName, profileType = "BML", accountNumber = accountNumber, accountBriefName = item.optString("alias"), @@ -632,18 +787,19 @@ class BmlLoginFlow { statusDesc = status, profileImageHash = null, loginTag = loginTag, + profileId = profileId, internalId = internalId )) } else if (accountType == "Card") { val isVisible = item.optBoolean("account_visible", false) - if (!isVisible) continue // debit cards and other hidden cards — skip + if (!isVisible) continue val isPrepaid = item.optBoolean("prepaid_card", false) val cardBalance = item.optJSONObject("cardBalance") val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0 prepaidCards.add(MibAccount( bank = "BML", - profileName = "Personal", + profileName = profileName, profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", accountNumber = accountNumber, accountBriefName = item.optString("alias").ifBlank { product }, @@ -656,6 +812,7 @@ class BmlLoginFlow { statusDesc = status, profileImageHash = null, loginTag = loginTag, + profileId = profileId, internalId = internalId )) } @@ -723,6 +880,15 @@ class BmlLoginFlow { return result } + // ─── Helpers ────────────────────────────────────────────────────────────── + + private fun apiRequest(session: BmlSession, url: String) = + Request.Builder().url(url) + .header("Authorization", "Bearer ${session.accessToken}") + .header("User-Agent", APP_USER_AGENT) + .header("x-app-version", APP_VERSION) + .build() + private fun xsrfToken(): String? = cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value @@ -748,4 +914,26 @@ class BmlLoginFlow { SecureRandom().nextBytes(bytes) return bytes.joinToString("") { "%02x".format(it) } } + + // "12-05-2026 041675" → first 4 digits of time part as HH:mm + private fun parsePurchaseNarrative1(narrative1: String): String? { + return try { + val parts = narrative1.split(" ") + if (parts.size < 2) null + else { + val timePart = parts[1].take(4) + val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00" + val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined) + date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) } + } + } catch (_: Exception) { null } + } + + // "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss + private fun parseTransferNarrative1(narrative1: String): String? { + return try { + val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1) + date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) } + } catch (_: Exception) { null } + } } 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 72e071c..a8b9a7c 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 @@ -1,10 +1,35 @@ package sh.sar.basedbank.api.bml +import sh.sar.basedbank.api.mib.MibAccount + data class BmlSession( val accessToken: String, val deviceId: String ) +data class BmlProfile( + val profileId: String, + val name: String, + val type: String, // "Profile" (personal) or "Business" + val profileType: String // "default" or "business" +) + +data class BmlOtpChannel( + val channel: String, + val description: String, + val masked: String +) + +sealed class BmlActivationResult { + data class Success( + val session: BmlSession, + val accounts: List + ) : BmlActivationResult() + data class NeedsBusinessOtp( + val channels: List + ) : BmlActivationResult() +} + data class BmlAccountValidation( val trnType: String, // IAT, QTR, DOT val validationType: String, // BML, alias, MIB 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 165b355..ec3fd91 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 @@ -33,7 +33,9 @@ import okhttp3.RequestBody.Companion.toRequestBody import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.bml.AuthExpiredException +import sh.sar.basedbank.api.bml.BmlActivationResult import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipaySession @@ -462,33 +464,83 @@ fun applyNavLabelVisibility() { val bmlJobs = bmlLoginIds.mapNotNull { loginId -> val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null loginId to async(Dispatchers.IO) { - val bmlFlow = BmlLoginFlow() val loginTag = "bml_$loginId" - val savedToken = store.loadBmlSession(loginId) + val app = application as BasedBankApp + val savedProfiles = store.loadBmlProfiles(loginId) + val allAccounts = mutableListOf() + var anyExpired = savedProfiles.isEmpty() - if (savedToken != null) { - try { - val session = BmlSession(savedToken.first, savedToken.second) - val accounts = bmlFlow.fetchAccounts(session, loginTag) - val app = application as BasedBankApp - app.bmlSessions[loginId] = session - AccountCache.saveBml(this@HomeActivity, loginId, accounts) - return@async Pair(session, accounts) - } catch (_: AuthExpiredException) { - } catch (_: Exception) { + // 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 = BmlLoginFlow().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 } } - try { - val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed) - store.saveBmlSession(loginId, session.accessToken, session.deviceId) - val app = application as BasedBankApp - app.bmlSessions[loginId] = session - AccountCache.saveBml(this@HomeActivity, loginId, accounts) - Pair(session, accounts) - } catch (_: Exception) { - Pair(null, AccountCache.loadBml(this@HomeActivity, loginId)) + // Also try legacy single-profile session token (pre-multi-profile installs) + if (savedProfiles.isEmpty()) { + val legacyToken = store.loadBmlSession(loginId) + if (legacyToken != null) { + try { + val session = BmlSession(legacyToken.first, legacyToken.second) + val accounts = BmlLoginFlow().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()) + allAccounts += AccountCache.loadBml(this@HomeActivity, loginId) + } + } + + if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts) + allAccounts as List } } @@ -539,8 +591,7 @@ fun applyNavLabelVisibility() { val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() } val mibAccounts = mibResults.flatMap { it.second } - val bmlResults = bmlJobs.map { (_, job) -> job.await() } - val bmlAccounts = bmlResults.flatMap { it.second } + val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() } val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() } val app = application as BasedBankApp @@ -559,14 +610,23 @@ fun applyNavLabelVisibility() { } } - /** Filters MIB accounts whose profileId the user has hidden in settings. */ + /** Filters accounts whose profileId the user has hidden in settings. */ private fun List.filterVisibleAccounts(): List { val store = CredentialStore(this@HomeActivity) return filter { acc -> - if (acc.bank != "MIB") return@filter true - val loginId = acc.loginTag.removePrefix("mib_") - val hidden = store.getHiddenMibProfileIds(loginId) - hidden.isEmpty() || acc.profileId !in hidden + when (acc.bank) { + "MIB" -> { + val loginId = acc.loginTag.removePrefix("mib_") + val hidden = store.getHiddenMibProfileIds(loginId) + hidden.isEmpty() || acc.profileId !in hidden + } + "BML" -> { + val loginId = acc.loginTag.removePrefix("bml_") + val hidden = store.getHiddenBmlProfileIds(loginId) + hidden.isEmpty() || acc.profileId !in hidden + } + else -> true + } } } @@ -602,10 +662,12 @@ fun applyNavLabelVisibility() { private fun refreshBmlContacts(app: BasedBankApp) { if (app.bmlSessions.isEmpty()) return + val store = CredentialStore(this) lifecycleScope.launch { try { val allBmlContacts = withContext(Dispatchers.IO) { - app.bmlSessions.flatMap { (loginId, session) -> + store.getBmlLoginIds().flatMap { loginId -> + val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList() val contacts = BmlLoginFlow().fetchContacts(session, loginId) if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts) contacts 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 9ec66b5..3b28281 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 @@ -15,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.materialswitch.MaterialSwitch import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding @@ -78,24 +79,9 @@ class SettingsLoginsFragment : Fragment() { for (loginId in bmlLoginIds) { val profile = store.loadBmlUserProfile(loginId) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name) - val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct() + val bmlProfiles = store.loadBmlProfiles(loginId) addLoginRow(container, R.drawable.bml_logo_vector, displayName) { - showLoginDetails( - title = getString(R.string.bml_name), - details = buildString { - if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}") - if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}") - if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}") - if (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}") - if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}") - if (profileNames.isNotEmpty()) { - appendLine() - appendLine(getString(R.string.login_detail_profiles)) - profileNames.forEach { appendLine(" • $it") } - } - }.trim(), - onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } } - ) + showBmlLoginDetails(store, loginId, profile, bmlProfiles) } } @@ -264,6 +250,126 @@ class SettingsLoginsFragment : Fragment() { } } + private fun showBmlLoginDetails( + store: CredentialStore, + loginId: String, + profile: CredentialStore.BmlUserProfile?, + bmlProfiles: List + ) { + val ctx = requireContext() + val dp = ctx.resources.displayMetrics.density + val originalHidden = store.getHiddenBmlProfileIds(loginId) + val hidden = originalHidden.toMutableSet() + + val scroll = android.widget.ScrollView(ctx) + val container = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + val pad = (16 * dp).toInt() + setPadding(pad, (8 * dp).toInt(), pad, pad) + } + scroll.addView(container) + + listOfNotNull( + profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" }, + profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" }, + profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }, + profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" }, + profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $it" } + ).forEach { line -> + container.addView(TextView(ctx).apply { + text = line + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also { + it.bottomMargin = (4 * dp).toInt() + } + }) + } + + if (bmlProfiles.isNotEmpty()) { + if (profile != null) { + container.addView(View(ctx).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also { + it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt() + } + setBackgroundColor(0x1F000000) + }) + } + container.addView(TextView(ctx).apply { + text = getString(R.string.login_detail_profiles) + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also { + it.bottomMargin = (8 * dp).toInt() + } + }) + } + + val toggleRows = bmlProfiles.map { p -> + val row = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also { + it.bottomMargin = (4 * dp).toInt() + } + } + val textCol = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + textCol.addView(TextView(ctx).apply { + text = p.name + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium) + }) + if (p.type.isNotBlank()) { + textCol.addView(TextView(ctx).apply { + text = p.type + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall) + alpha = 0.6f + }) + } + val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden } + row.addView(textCol) + row.addView(toggle) + container.addView(row) + p to toggle + } + + fun updateToggleStates(saveBtn: android.widget.Button) { + val visibleCount = bmlProfiles.count { it.profileId !in hidden } + toggleRows.forEach { (_, toggle) -> + toggle.isEnabled = !(toggle.isChecked && visibleCount == 1) + } + saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1 + } + + val dialog = MaterialAlertDialogBuilder(ctx) + .setTitle(getString(R.string.bml_name)) + .setView(scroll) + .setPositiveButton(R.string.save, null) + .setNeutralButton(R.string.close, null) + .setNegativeButton(R.string.settings_logout) { _, _ -> + confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } + } + .show() + + val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE) + saveBtn.isEnabled = false + updateToggleStates(saveBtn) + + toggleRows.forEach { (p, toggle) -> + toggle.setOnCheckedChangeListener { _, checked -> + if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId) + updateToggleStates(saveBtn) + } + } + + saveBtn.setOnClickListener { + store.setHiddenBmlProfileIds(loginId, hidden) + clearAllCaches(ctx) + dialog.dismiss() + (activity as? HomeActivity)?.applyProfileVisibility() + } + } + private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) { MaterialAlertDialogBuilder(requireContext()) .setTitle(title) @@ -299,9 +405,14 @@ class SettingsLoginsFragment : Fragment() { private fun logoutBml(store: CredentialStore, loginId: String) { val ctx = requireContext() - store.clearBmlCredentials(loginId); store.clearBmlSession(loginId) val app = requireActivity().application as BasedBankApp - app.bmlSessions.remove(loginId) + // Remove all per-profile sessions for this login from the in-memory map + val profiles = app.bmlProfilesMap[loginId] ?: emptyList() + profiles.forEach { app.bmlSessions.remove(it.profileId) } + // clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally + store.clearBmlCredentials(loginId) + app.bmlProfilesMap.remove(loginId) + app.bmlLoginFlows.remove(loginId) app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } clearAllCaches(ctx) (activity as HomeActivity).relogin() 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 0977966..d1279bd 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 @@ -18,14 +18,21 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.util.Totp import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlActivationResult import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.bml.BmlOtpChannel +import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipaySession +import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.ui.home.HomeActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine class CredentialsFragment : Fragment() { @@ -46,6 +53,15 @@ class CredentialsFragment : Fragment() { private var fahipayFlow: FahipayLoginFlow? = null private var fahipayAwaitingTotp = false + // BML multi-profile state + private var bmlFlow: BmlLoginFlow? = null + private var bmlLoginId: String = "" + private var bmlAccumulatedAccounts = mutableListOf() + private var bmlPendingBusinessProfiles = ArrayDeque>>() + private var bmlCurrentBusinessProfile: BmlProfile? = null + private var bmlSelectedChannel: String? = null + private var bmlAwaitingBusinessOtp = false + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentCredentialsBinding.inflate(inflater, container, false) return binding.root @@ -151,7 +167,11 @@ class CredentialsFragment : Fragment() { private fun attemptLogin() { when (bankType) { - "BML" -> { attemptBmlLogin(); return } + "BML" -> { + if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp() + else attemptBmlLogin() + return + } "FAHIPAY" -> { attemptFahipayLogin(); return } } @@ -232,49 +252,206 @@ class CredentialsFragment : Fragment() { binding.progressBar.visibility = View.VISIBLE binding.btnLogin.isEnabled = false - val loginId = username - val flow = BmlLoginFlow() + bmlLoginId = username + bmlAccumulatedAccounts.clear() + bmlPendingBusinessProfiles.clear() + bmlCurrentBusinessProfile = null + bmlSelectedChannel = null + bmlAwaitingBusinessOtp = false + + val flow = BmlLoginFlow().also { bmlFlow = it } + val loginTag = "bml_$username" + viewLifecycleOwner.lifecycleScope.launch { try { - val (session, accounts) = withContext(Dispatchers.IO) { + val profiles = withContext(Dispatchers.IO) { flow.login(username, password, otpSeed) } - val store = CredentialStore(requireContext()) - store.saveBmlCredentials(loginId, username, password, otpSeed) - store.saveBmlSession(loginId, session.accessToken, session.deviceId) - withContext(Dispatchers.IO) { - val info = flow.fetchUserInfo(session) - if (info != null) store.saveBmlUserProfile( - loginId, - CredentialStore.BmlUserProfile( - fullName = info.fullName, - email = info.email, - mobile = info.mobile, - customerId = info.customerId, - idCard = info.idCard, - birthdate = info.birthdate - ) - ) + if (profiles.isEmpty()) throw Exception("No profiles found for this account") + + // Activate each profile; personal profiles are immediate, business ones need OTP + for (profile in profiles) { + val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) } + when (result) { + is BmlActivationResult.Success -> { + bmlAccumulatedAccounts += result.accounts + val store = CredentialStore(requireContext()) + store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId) + val app = requireActivity().application as BasedBankApp + app.bmlSessions[profile.profileId] = result.session + } + is BmlActivationResult.NeedsBusinessOtp -> { + bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels)) + } + } } - AccountCache.saveBml(requireContext(), loginId, accounts) + + // Save credentials and profile list now (before business OTP prompts) + val store = CredentialStore(requireContext()) + store.saveBmlCredentials(bmlLoginId, username, password, otpSeed) + store.saveBmlProfiles(bmlLoginId, profiles) val app = requireActivity().application as BasedBankApp - app.bmlSessions[loginId] = session - // Merge with any existing BML accounts from other logins - app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts - app.accounts = app.accounts + accounts - val intent = Intent(requireContext(), HomeActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK - startActivity(intent) + app.bmlProfilesMap[bmlLoginId] = profiles + app.bmlLoginFlows[bmlLoginId] = flow + + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + + if (bmlPendingBusinessProfiles.isNotEmpty()) { + processNextBmlBusinessProfile() + } else { + finishBmlLogin() + } } catch (e: Exception) { binding.tvError.text = e.message ?: "Login failed" binding.tvError.visibility = View.VISIBLE - } finally { binding.progressBar.visibility = View.GONE binding.btnLogin.isEnabled = true } } } + private suspend fun processNextBmlBusinessProfile() { + val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull() + ?: run { finishBmlLogin(); return } + + bmlCurrentBusinessProfile = profile + + // Show channel selection dialog + val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run { + // User skipped this profile — move on + processNextBmlBusinessProfile() + return + } + bmlSelectedChannel = selectedChannel + + // Request OTP + binding.progressBar.visibility = View.VISIBLE + binding.btnLogin.isEnabled = false + try { + withContext(Dispatchers.IO) { + bmlFlow!!.requestBusinessOtp(selectedChannel) + } + } catch (e: Exception) { + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + binding.tvError.text = e.message ?: "Failed to send OTP" + binding.tvError.visibility = View.VISIBLE + processNextBmlBusinessProfile() + return + } + + // Show OTP input — disable credential fields (same pattern as Fahipay TOTP step) + bmlAwaitingBusinessOtp = true + binding.etUsername.isEnabled = false + binding.etPassword.isEnabled = false + binding.etOtpSeed.isEnabled = false + binding.progressBar.visibility = View.GONE + binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name) + binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel) + binding.tilTotpCode.visibility = View.VISIBLE + binding.etTotpCode.text?.clear() + binding.btnLogin.text = getString(R.string.verify) + binding.btnLogin.isEnabled = true + binding.tvError.visibility = View.GONE + } + + private fun submitBmlBusinessOtp() { + val code = binding.etTotpCode.text.toString().trim() + if (code.length != 6) { + binding.tvError.text = getString(R.string.fahipay_totp_hint) + binding.tvError.visibility = View.VISIBLE + return + } + + binding.tvError.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + binding.btnLogin.isEnabled = false + + val profile = bmlCurrentBusinessProfile ?: return + val channel = bmlSelectedChannel ?: return + val loginTag = "bml_$bmlLoginId" + + viewLifecycleOwner.lifecycleScope.launch { + try { + val (session, accounts) = withContext(Dispatchers.IO) { + bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag) + } + bmlAccumulatedAccounts += accounts + val store = CredentialStore(requireContext()) + store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId) + val app = requireActivity().application as BasedBankApp + app.bmlSessions[profile.profileId] = session + + bmlAwaitingBusinessOtp = false + binding.tilTotpCode.visibility = View.GONE + binding.btnLogin.text = getString(R.string.login) + + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + + if (bmlPendingBusinessProfiles.isNotEmpty()) { + processNextBmlBusinessProfile() + } else { + finishBmlLogin() + } + } catch (e: Exception) { + binding.tvError.text = e.message ?: "OTP verification failed" + binding.tvError.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + } + } + } + + private suspend fun showBmlChannelDialog(profileName: String, channels: List): String? = + suspendCancellableCoroutine { cont -> + val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray() + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.bml_business_otp_title, profileName)) + .setItems(options) { _, which -> + if (cont.isActive) cont.resume(channels[which].channel) + } + .setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int -> + if (cont.isActive) cont.resume(null as String?) + } + .show() + dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) } + } + + private suspend fun finishBmlLogin() { + val store = CredentialStore(requireContext()) + val accounts = bmlAccumulatedAccounts.toList() + + // Fetch user profile info from any active session + val app = requireActivity().application as BasedBankApp + val anySession = app.anyBmlSessionFor(bmlLoginId) + if (anySession != null) { + withContext(Dispatchers.IO) { + val info = BmlLoginFlow().fetchUserInfo(anySession) + if (info != null) store.saveBmlUserProfile( + bmlLoginId, + CredentialStore.BmlUserProfile( + fullName = info.fullName, + email = info.email, + mobile = info.mobile, + customerId = info.customerId, + idCard = info.idCard, + birthdate = info.birthdate + ) + ) + } + } + + AccountCache.saveBml(requireContext(), bmlLoginId, accounts) + app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts + app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts + + val intent = Intent(requireContext(), HomeActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + private fun attemptFahipayLogin() { if (fahipayAwaitingTotp) { submitFahipayTotp() 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 dfea43e..397f0fb 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import org.json.JSONObject +import sh.sar.basedbank.api.bml.BmlProfile import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -193,24 +194,83 @@ class CredentialStore(context: Context) { } fun clearBmlCredentials(loginId: String) { + loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) } removeBmlLoginId(loginId) prefs.edit() .remove("bml_${loginId}_enc_username") .remove("bml_${loginId}_enc_password") .remove("bml_${loginId}_enc_otp_seed") + .remove("bml_${loginId}_profiles") + .remove("bml_${loginId}_hidden_profile_ids") + // legacy single-profile session keys + .remove("bml_${loginId}_enc_token") + .remove("bml_${loginId}_enc_device_id") .apply() } - // ── BML session token (per loginId) ─────────────────────────────────────── + // ── BML profiles (per loginId) ──────────────────────────────────────────── - fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) { + fun saveBmlProfiles(loginId: String, profiles: List) { + val arr = org.json.JSONArray() + for (p in profiles) arr.put(org.json.JSONObject().apply { + put("profileId", p.profileId) + put("name", p.name) + put("type", p.type) + put("profileType", p.profileType) + }) + prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply() + } + + fun loadBmlProfiles(loginId: String): List { + val raw = prefs.getString("bml_${loginId}_profiles", null) ?: return emptyList() + return try { + val arr = org.json.JSONArray(raw) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + BmlProfile( + profileId = o.optString("profileId"), + name = o.optString("name"), + type = o.optString("type"), + profileType = o.optString("profileType", "default") + ) + } + } catch (_: Exception) { emptyList() } + } + + fun getHiddenBmlProfileIds(loginId: String): Set = + prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet() + + fun setHiddenBmlProfileIds(loginId: String, ids: Set) = + prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply() + + // ── BML per-profile session token (keyed by profileId, a globally unique GUID) ── + + fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) { val key = getOrCreateKey() prefs.edit() - .putString("bml_${loginId}_enc_token", encrypt(accessToken, key)) - .putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key)) + .putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key)) + .putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key)) .apply() } + fun loadBmlProfileSession(profileId: String): Pair? { + val key = getOrCreateKey() + val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null + val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null + return try { + Pair(decrypt(encToken, key), decrypt(encDeviceId, key)) + } catch (_: Exception) { null } + } + + fun clearBmlProfileSession(profileId: String) { + prefs.edit() + .remove("bml_profile_${profileId}_enc_token") + .remove("bml_profile_${profileId}_enc_device_id") + .apply() + } + + // ── Legacy single-profile BML session (kept for backward compat reading) ─ + fun loadBmlSession(loginId: String): Pair? { val key = getOrCreateKey() val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null @@ -220,13 +280,6 @@ class CredentialStore(context: Context) { } catch (_: Exception) { null } } - fun clearBmlSession(loginId: String) { - prefs.edit() - .remove("bml_${loginId}_enc_token") - .remove("bml_${loginId}_enc_device_id") - .apply() - } - // ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ── fun getFahipayLoginIds(): List { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 910b080..fb89972 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -168,6 +168,13 @@ Close Save Cancel + Verify + + + Activate %s profile + %s OTP code + OTP sent via %s + Skip This is your source account