forked from shihaam/thijooree
patial support for BML business profile accounts
This commit is contained in:
@@ -4,6 +4,8 @@ import android.app.Application
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import kotlinx.coroutines.sync.Mutex
|
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.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
@@ -17,18 +19,30 @@ class BasedBankApp : Application() {
|
|||||||
// Held in memory after successful login; cleared on logout
|
// Held in memory after successful login; cleared on logout
|
||||||
var accounts: List<MibAccount> = emptyList()
|
var accounts: List<MibAccount> = emptyList()
|
||||||
var fullName: String = ""
|
var fullName: String = ""
|
||||||
|
|
||||||
/** Active MIB sessions keyed by loginId (= MIB username). */
|
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||||
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
||||||
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
||||||
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
||||||
var mibAccounts: List<MibAccount> = emptyList()
|
var mibAccounts: List<MibAccount> = 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<String, BmlSession> = mutableMapOf()
|
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||||
|
/** BML profiles per loginId (= BML username). */
|
||||||
|
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||||
|
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||||
|
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||||
var bmlAccounts: List<MibAccount> = emptyList()
|
var bmlAccounts: List<MibAccount> = emptyList()
|
||||||
|
|
||||||
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
var fahipayAccounts: List<MibAccount> = emptyList()
|
||||||
|
|
||||||
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||||
fun mibSessionFor(account: MibAccount): MibSession? =
|
fun mibSessionFor(account: MibAccount): MibSession? =
|
||||||
mibSessions[account.loginTag.removePrefix("mib_")]
|
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||||
@@ -53,13 +67,38 @@ class BasedBankApp : Application() {
|
|||||||
/** Returns any available MibLoginFlow. */
|
/** Returns any available MibLoginFlow. */
|
||||||
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
||||||
|
|
||||||
/** Returns the BML session for the given account (matched via loginTag). */
|
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
|
||||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
/**
|
||||||
|
* 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). */
|
/** Returns any available BML session (for non-account-specific operations). */
|
||||||
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
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}"). */
|
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
|
||||||
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
|
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
|
||||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class BmlLoginFlow {
|
|||||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
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_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||||
private val APP_VERSION = "2.1.43.345"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
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 cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -58,9 +58,27 @@ class BmlLoginFlow {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Full login: returns a BmlSession and the account list. */
|
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
private var codeVerifier: String = ""
|
||||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
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(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login")
|
Request.Builder().url("$BASE_URL/web/login")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
@@ -82,15 +100,14 @@ class BmlLoginFlow {
|
|||||||
loginResp.close()
|
loginResp.close()
|
||||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
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(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
val xsrf2 = xsrfToken() ?: xsrf
|
val xsrf2 = xsrfToken() ?: xsrf
|
||||||
|
|
||||||
// Step 4: POST OTP
|
// Step 4: POST TOTP
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val twoFaBody = JSONObject().apply {
|
val twoFaBody = JSONObject().apply {
|
||||||
put("code", otp)
|
put("code", otp)
|
||||||
@@ -104,18 +121,141 @@ class BmlLoginFlow {
|
|||||||
twoFaResp.close()
|
twoFaResp.close()
|
||||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
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)
|
// Step 5: GET /web/profile — returns list of profiles for this account
|
||||||
client.newCall(
|
val profileResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/profile")
|
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()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
|
|
||||||
// Step 6: PKCE OAuth authorize → extract auth code
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
val codeVerifier = generateCodeVerifier()
|
val body = JSONObject().apply {
|
||||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
put("code", code)
|
||||||
val deviceId = generateDeviceId()
|
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()
|
val authorizeUrl = HttpUrl.Builder()
|
||||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||||
.addPathSegments("internetbanking/oauth/authorize")
|
.addPathSegments("internetbanking/oauth/authorize")
|
||||||
@@ -135,14 +275,12 @@ class BmlLoginFlow {
|
|||||||
Request.Builder().url(authorizeUrl)
|
Request.Builder().url(authorizeUrl)
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val location = authorizeResp.header("Location")
|
||||||
authorizeResp.close()
|
authorizeResp.close()
|
||||||
|
|
||||||
val location = authorizeResp.header("Location")
|
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||||
?: throw Exception("OAuth authorize did not redirect")
|
?: throw Exception("OAuth authorize did not return auth code")
|
||||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
|
||||||
?: throw Exception("No auth code in OAuth redirect")
|
|
||||||
|
|
||||||
// Step 7: Exchange auth code for access token
|
|
||||||
val tokenBody = FormBody.Builder()
|
val tokenBody = FormBody.Builder()
|
||||||
.add("Device-ID", deviceId)
|
.add("Device-ID", deviceId)
|
||||||
.add("code", authCode)
|
.add("code", authCode)
|
||||||
@@ -161,22 +299,28 @@ class BmlLoginFlow {
|
|||||||
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
||||||
tokenResp.close()
|
tokenResp.close()
|
||||||
|
|
||||||
val tokenObj = JSONObject(tokenJson)
|
val accessToken = JSONObject(tokenJson).optString("access_token")
|
||||||
val accessToken = tokenObj.optString("access_token")
|
|
||||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||||
|
|
||||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||||
val accounts = fetchAccounts(session, "bml_$username")
|
val accounts = fetchAccounts(session, loginTag, profileName, profileId)
|
||||||
return Pair(session, accounts)
|
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 resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
val json = resp.body?.string()
|
val json = resp.body?.string()
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
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> {
|
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||||
@@ -331,9 +475,6 @@ class BmlLoginFlow {
|
|||||||
return parseContacts(json, loginId)
|
return parseContacts(json, loginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
|
||||||
*/
|
|
||||||
fun initiateTransfer(
|
fun initiateTransfer(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
debitAccount: String,
|
debitAccount: String,
|
||||||
@@ -370,9 +511,6 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
|
||||||
*/
|
|
||||||
fun confirmTransfer(
|
fun confirmTransfer(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
debitAccount: String,
|
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(
|
fun fetchAccountHistory(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
accountId: String,
|
accountId: String,
|
||||||
@@ -516,10 +628,6 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
} 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(
|
fun fetchCardHistory(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
cardId: String,
|
cardId: String,
|
||||||
@@ -546,7 +654,6 @@ class BmlLoginFlow {
|
|||||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||||
val result = mutableListOf<Transaction>()
|
val result = mutableListOf<Transaction>()
|
||||||
|
|
||||||
// Outstanding authorizations
|
|
||||||
val authDetails = payload.optJSONObject("outstanding")
|
val authDetails = payload.optJSONObject("outstanding")
|
||||||
?.optJSONArray("CardOutStdAuthDetails")
|
?.optJSONArray("CardOutStdAuthDetails")
|
||||||
if (authDetails != null) {
|
if (authDetails != null) {
|
||||||
@@ -567,7 +674,6 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settled statement entries
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
val statement = payload.optJSONArray("cardstatement")
|
||||||
if (statement != null) {
|
if (statement != null) {
|
||||||
for (i in 0 until statement.length()) {
|
for (i in 0 until statement.length()) {
|
||||||
@@ -590,14 +696,64 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun apiRequest(session: BmlSession, url: String) =
|
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||||
Request.Builder().url(url)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
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(""", "\"")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||||
@@ -612,14 +768,13 @@ class BmlLoginFlow {
|
|||||||
val product = item.optString("product")
|
val product = item.optString("product")
|
||||||
val accountNumber = item.optString("account")
|
val accountNumber = item.optString("account")
|
||||||
val status = item.optString("account_status", "Active")
|
val status = item.optString("account_status", "Active")
|
||||||
|
|
||||||
val internalId = item.optString("id", "")
|
val internalId = item.optString("id", "")
|
||||||
|
|
||||||
if (accountType == "CASA") {
|
if (accountType == "CASA") {
|
||||||
val available = item.optDouble("availableBalance", 0.0)
|
val available = item.optDouble("availableBalance", 0.0)
|
||||||
casaAccounts.add(MibAccount(
|
casaAccounts.add(MibAccount(
|
||||||
bank = "BML",
|
bank = "BML",
|
||||||
profileName = "Personal",
|
profileName = profileName,
|
||||||
profileType = "BML",
|
profileType = "BML",
|
||||||
accountNumber = accountNumber,
|
accountNumber = accountNumber,
|
||||||
accountBriefName = item.optString("alias"),
|
accountBriefName = item.optString("alias"),
|
||||||
@@ -632,18 +787,19 @@ class BmlLoginFlow {
|
|||||||
statusDesc = status,
|
statusDesc = status,
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = loginTag,
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
internalId = internalId
|
internalId = internalId
|
||||||
))
|
))
|
||||||
} else if (accountType == "Card") {
|
} else if (accountType == "Card") {
|
||||||
val isVisible = item.optBoolean("account_visible", false)
|
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 isPrepaid = item.optBoolean("prepaid_card", false)
|
||||||
val cardBalance = item.optJSONObject("cardBalance")
|
val cardBalance = item.optJSONObject("cardBalance")
|
||||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||||
prepaidCards.add(MibAccount(
|
prepaidCards.add(MibAccount(
|
||||||
bank = "BML",
|
bank = "BML",
|
||||||
profileName = "Personal",
|
profileName = profileName,
|
||||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||||
accountNumber = accountNumber,
|
accountNumber = accountNumber,
|
||||||
accountBriefName = item.optString("alias").ifBlank { product },
|
accountBriefName = item.optString("alias").ifBlank { product },
|
||||||
@@ -656,6 +812,7 @@ class BmlLoginFlow {
|
|||||||
statusDesc = status,
|
statusDesc = status,
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = loginTag,
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
internalId = internalId
|
internalId = internalId
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -723,6 +880,15 @@ class BmlLoginFlow {
|
|||||||
return result
|
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? =
|
private fun xsrfToken(): String? =
|
||||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||||
|
|
||||||
@@ -748,4 +914,26 @@ class BmlLoginFlow {
|
|||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
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
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
|
||||||
data class BmlSession(
|
data class BmlSession(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val deviceId: 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(
|
data class BmlAccountValidation(
|
||||||
val trnType: String, // IAT, QTR, DOT
|
val trnType: String, // IAT, QTR, DOT
|
||||||
val validationType: String, // BML, alias, MIB
|
val validationType: String, // BML, alias, MIB
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.AuthExpiredException
|
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.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
@@ -462,33 +464,83 @@ fun applyNavLabelVisibility() {
|
|||||||
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
||||||
loginId to async(Dispatchers.IO) {
|
loginId to async(Dispatchers.IO) {
|
||||||
val bmlFlow = BmlLoginFlow()
|
|
||||||
val loginTag = "bml_$loginId"
|
val loginTag = "bml_$loginId"
|
||||||
val savedToken = store.loadBmlSession(loginId)
|
val app = application as BasedBankApp
|
||||||
|
val savedProfiles = store.loadBmlProfiles(loginId)
|
||||||
|
val allAccounts = mutableListOf<MibAccount>()
|
||||||
|
var anyExpired = savedProfiles.isEmpty()
|
||||||
|
|
||||||
if (savedToken != null) {
|
// Try each saved profile's cached session
|
||||||
try {
|
for (profile in savedProfiles) {
|
||||||
val session = BmlSession(savedToken.first, savedToken.second)
|
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||||
val accounts = bmlFlow.fetchAccounts(session, loginTag)
|
if (saved != null) {
|
||||||
val app = application as BasedBankApp
|
try {
|
||||||
app.bmlSessions[loginId] = session
|
val session = BmlSession(saved.first, saved.second)
|
||||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||||
return@async Pair(session, accounts)
|
app.bmlSessions[profile.profileId] = session
|
||||||
} catch (_: AuthExpiredException) {
|
allAccounts += accounts
|
||||||
} catch (_: Exception) {
|
} catch (_: AuthExpiredException) { anyExpired = true
|
||||||
|
} catch (_: Exception) { anyExpired = true }
|
||||||
|
} else {
|
||||||
|
anyExpired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Also try legacy single-profile session token (pre-multi-profile installs)
|
||||||
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
|
if (savedProfiles.isEmpty()) {
|
||||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
val legacyToken = store.loadBmlSession(loginId)
|
||||||
val app = application as BasedBankApp
|
if (legacyToken != null) {
|
||||||
app.bmlSessions[loginId] = session
|
try {
|
||||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
val session = BmlSession(legacyToken.first, legacyToken.second)
|
||||||
Pair(session, accounts)
|
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
|
||||||
} catch (_: Exception) {
|
app.bmlSessions[loginId] = session
|
||||||
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
|
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<MibAccount>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,8 +591,7 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||||
val mibAccounts = mibResults.flatMap { it.second }
|
val mibAccounts = mibResults.flatMap { it.second }
|
||||||
val bmlResults = bmlJobs.map { (_, job) -> job.await() }
|
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||||
val bmlAccounts = bmlResults.flatMap { it.second }
|
|
||||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
|
||||||
val app = application as BasedBankApp
|
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<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
|
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
|
||||||
val store = CredentialStore(this@HomeActivity)
|
val store = CredentialStore(this@HomeActivity)
|
||||||
return filter { acc ->
|
return filter { acc ->
|
||||||
if (acc.bank != "MIB") return@filter true
|
when (acc.bank) {
|
||||||
val loginId = acc.loginTag.removePrefix("mib_")
|
"MIB" -> {
|
||||||
val hidden = store.getHiddenMibProfileIds(loginId)
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
hidden.isEmpty() || acc.profileId !in hidden
|
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) {
|
private fun refreshBmlContacts(app: BasedBankApp) {
|
||||||
if (app.bmlSessions.isEmpty()) return
|
if (app.bmlSessions.isEmpty()) return
|
||||||
|
val store = CredentialStore(this)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val allBmlContacts = withContext(Dispatchers.IO) {
|
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)
|
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
|
||||||
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
||||||
contacts
|
contacts
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import com.google.android.material.materialswitch.MaterialSwitch
|
import com.google.android.material.materialswitch.MaterialSwitch
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
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.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
@@ -78,24 +79,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
for (loginId in bmlLoginIds) {
|
for (loginId in bmlLoginIds) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
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) {
|
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||||
showLoginDetails(
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
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) } }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +250,126 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showBmlLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.BmlUserProfile?,
|
||||||
|
bmlProfiles: List<BmlProfile>
|
||||||
|
) {
|
||||||
|
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) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -299,9 +405,14 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun logoutBml(store: CredentialStore, loginId: String) {
|
private fun logoutBml(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
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" }
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
|
|||||||
@@ -18,14 +18,21 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
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.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.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
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.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
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() {
|
class CredentialsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -46,6 +53,15 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var fahipayFlow: FahipayLoginFlow? = null
|
private var fahipayFlow: FahipayLoginFlow? = null
|
||||||
private var fahipayAwaitingTotp = false
|
private var fahipayAwaitingTotp = false
|
||||||
|
|
||||||
|
// BML multi-profile state
|
||||||
|
private var bmlFlow: BmlLoginFlow? = null
|
||||||
|
private var bmlLoginId: String = ""
|
||||||
|
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
|
||||||
|
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
|
||||||
|
private var bmlCurrentBusinessProfile: BmlProfile? = null
|
||||||
|
private var bmlSelectedChannel: String? = null
|
||||||
|
private var bmlAwaitingBusinessOtp = false
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -151,7 +167,11 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun attemptLogin() {
|
private fun attemptLogin() {
|
||||||
when (bankType) {
|
when (bankType) {
|
||||||
"BML" -> { attemptBmlLogin(); return }
|
"BML" -> {
|
||||||
|
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
|
||||||
|
else attemptBmlLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,49 +252,206 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.progressBar.visibility = View.VISIBLE
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val loginId = username
|
bmlLoginId = username
|
||||||
val flow = BmlLoginFlow()
|
bmlAccumulatedAccounts.clear()
|
||||||
|
bmlPendingBusinessProfiles.clear()
|
||||||
|
bmlCurrentBusinessProfile = null
|
||||||
|
bmlSelectedChannel = null
|
||||||
|
bmlAwaitingBusinessOtp = false
|
||||||
|
|
||||||
|
val flow = BmlLoginFlow().also { bmlFlow = it }
|
||||||
|
val loginTag = "bml_$username"
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val (session, accounts) = withContext(Dispatchers.IO) {
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
flow.login(username, password, otpSeed)
|
flow.login(username, password, otpSeed)
|
||||||
}
|
}
|
||||||
val store = CredentialStore(requireContext())
|
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
|
||||||
store.saveBmlCredentials(loginId, username, password, otpSeed)
|
|
||||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
// Activate each profile; personal profiles are immediate, business ones need OTP
|
||||||
withContext(Dispatchers.IO) {
|
for (profile in profiles) {
|
||||||
val info = flow.fetchUserInfo(session)
|
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
|
||||||
if (info != null) store.saveBmlUserProfile(
|
when (result) {
|
||||||
loginId,
|
is BmlActivationResult.Success -> {
|
||||||
CredentialStore.BmlUserProfile(
|
bmlAccumulatedAccounts += result.accounts
|
||||||
fullName = info.fullName,
|
val store = CredentialStore(requireContext())
|
||||||
email = info.email,
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
mobile = info.mobile,
|
val app = requireActivity().application as BasedBankApp
|
||||||
customerId = info.customerId,
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
idCard = info.idCard,
|
}
|
||||||
birthdate = info.birthdate
|
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
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.bmlSessions[loginId] = session
|
app.bmlProfilesMap[bmlLoginId] = profiles
|
||||||
// Merge with any existing BML accounts from other logins
|
app.bmlLoginFlows[bmlLoginId] = flow
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
|
|
||||||
app.accounts = app.accounts + accounts
|
binding.progressBar.visibility = View.GONE
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
binding.btnLogin.isEnabled = true
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
startActivity(intent)
|
if (bmlPendingBusinessProfiles.isNotEmpty()) {
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
} else {
|
||||||
|
finishBmlLogin()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.tvError.text = e.message ?: "Login failed"
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
binding.tvError.visibility = View.VISIBLE
|
binding.tvError.visibility = View.VISIBLE
|
||||||
} finally {
|
|
||||||
binding.progressBar.visibility = View.GONE
|
binding.progressBar.visibility = View.GONE
|
||||||
binding.btnLogin.isEnabled = true
|
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<BmlOtpChannel>): 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() {
|
private fun attemptFahipayLogin() {
|
||||||
if (fahipayAwaitingTotp) {
|
if (fahipayAwaitingTotp) {
|
||||||
submitFahipayTotp()
|
submitFahipayTotp()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
|
|||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
@@ -193,24 +194,83 @@ class CredentialStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlCredentials(loginId: String) {
|
fun clearBmlCredentials(loginId: String) {
|
||||||
|
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
|
||||||
removeBmlLoginId(loginId)
|
removeBmlLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_${loginId}_enc_username")
|
.remove("bml_${loginId}_enc_username")
|
||||||
.remove("bml_${loginId}_enc_password")
|
.remove("bml_${loginId}_enc_password")
|
||||||
.remove("bml_${loginId}_enc_otp_seed")
|
.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()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BML session token (per loginId) ───────────────────────────────────────
|
// ── BML profiles (per loginId) ────────────────────────────────────────────
|
||||||
|
|
||||||
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
|
fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
|
||||||
|
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<BmlProfile> {
|
||||||
|
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<String> =
|
||||||
|
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
|
||||||
|
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()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
|
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
|
||||||
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
|
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
|
||||||
|
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<String, String>? {
|
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
||||||
@@ -220,13 +280,6 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} 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) ──
|
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
|
||||||
|
|
||||||
fun getFahipayLoginIds(): List<String> {
|
fun getFahipayLoginIds(): List<String> {
|
||||||
|
|||||||
@@ -168,6 +168,13 @@
|
|||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="save">Save</string>
|
<string name="save">Save</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="verify">Verify</string>
|
||||||
|
|
||||||
|
<!-- BML business OTP -->
|
||||||
|
<string name="bml_business_otp_title">Activate %s profile</string>
|
||||||
|
<string name="bml_business_otp_hint">%s OTP code</string>
|
||||||
|
<string name="bml_business_otp_sent">OTP sent via %s</string>
|
||||||
|
<string name="bml_business_otp_skip">Skip</string>
|
||||||
|
|
||||||
<!-- Home -->
|
<!-- Home -->
|
||||||
<string name="transfer_same_account">This is your source account</string>
|
<string name="transfer_same_account">This is your source account</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user