patial support for BML business profile accounts

This commit is contained in:
2026-05-19 23:30:36 +05:00
parent 35a1748055
commit 15a02cac1c
8 changed files with 829 additions and 167 deletions
@@ -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<String, MutableList<Cookie>>()
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<BmlSession, List<MibAccount>> {
// 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<BmlProfile> = 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<BmlProfile> {
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<BmlOtpChannel> {
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<BmlSession, List<MibAccount>> {
// 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<BmlSession, List<MibAccount>> {
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<MibAccount> {
// ─── API methods ─────────────────────────────────────────────────────────
fun fetchAccounts(
session: BmlSession,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<MibAccount> {
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<BmlForeignLimit> {
@@ -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<Transaction>()
// 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<MibAccount> {
/**
* 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("&quot;", "\"")
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
private fun parseProfiles(html: String): List<BmlProfile> {
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<BmlOtpChannel> {
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<MibAccount> {
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 }
}
}
@@ -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<MibAccount>
) : BmlActivationResult()
data class NeedsBusinessOtp(
val channels: List<BmlOtpChannel>
) : BmlActivationResult()
}
data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB