huge refactor.. might need to revert later

This commit is contained in:
2026-05-20 22:43:29 +05:00
parent e894f81887
commit 6d48c27391
49 changed files with 1224 additions and 1072 deletions
@@ -8,7 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
@@ -17,14 +17,14 @@ import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList()
var accounts: List<BankAccount> = emptyList()
var fullName: String = ""
/** Active MIB sessions keyed by loginId (= MIB username). */
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList()
var mibAccounts: List<BankAccount> = emptyList()
/**
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
@@ -35,16 +35,16 @@ class BasedBankApp : Application() {
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<BankAccount> = emptyList()
/** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList()
var fahipayAccounts: List<BankAccount> = emptyList()
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? =
fun mibSessionFor(account: BankAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")]
/** Returns any available MIB session. */
@@ -73,7 +73,7 @@ class BasedBankApp : Application() {
* 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? {
fun bmlSessionFor(account: BankAccount): BmlSession? {
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
}
@@ -100,7 +100,7 @@ class BasedBankApp : Application() {
// ─── Fahipay helpers ──────────────────────────────────────────────────────
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
@@ -0,0 +1,124 @@
package sh.sar.basedbank.api.bml
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount
data class BmlUserInfo(
val fullName: String,
val email: String,
val mobile: String,
val customerId: String,
val idCard: String,
val birthdate: String
)
class BmlAccountClient {
private val client = newBmlApiClient()
fun fetchAccounts(
session: BmlSession,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<BankAccount> {
val resp = client.newCall(bmlApiRequest(session, "$BML_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, profileName, profileId)
}
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
BmlUserInfo(
fullName = user.optString("fullname").trim(),
email = user.optString("email").trim(),
mobile = user.optString("mobile_phone").trim(),
customerId = user.optString("customer_number").trim(),
idCard = user.optString("idcard").trim(),
birthdate = user.optString("birthdate").trim()
)
} catch (_: Exception) { null }
}
private fun parseDashboard(
json: String,
loginTag: String,
profileName: String,
profileId: String
): List<BankAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
val casaAccounts = mutableListOf<BankAccount>()
val prepaidCards = mutableListOf<BankAccount>()
for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i)
val currency = item.optString("currency", "MVR")
val accountType = item.optString("account_type", "CASA")
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(BankAccount(
bank = "BML",
profileName = profileName,
profileType = "BML",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
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(BankAccount(
bank = "BML",
profileName = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(current),
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
}
}
return casaAccounts + prepaidCards
}
}
@@ -0,0 +1,21 @@
package sh.sar.basedbank.api.bml
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
internal const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
internal const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
@@ -0,0 +1,95 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankContact
class BmlContactsClient {
private val client = newBmlApiClient()
fun fetchContacts(session: BmlSession, loginId: String): List<BankContact> {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseContacts(json, loginId)
}
fun saveContact(
session: BmlSession,
contactType: String,
account: String,
alias: String,
currency: String? = null,
name: String? = null,
swift: String? = null
): Boolean {
val bodyObj = JSONObject().apply {
put("contact_type", contactType)
put("account", account)
put("alias", alias)
if (currency != null) put("currency", currency)
if (name != null) put("name", name)
if (swift != null) put("swift", swift)
}
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts")
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
}
fun deleteContact(session: BmlSession, contactId: String): Boolean {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts/$contactId")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
).execute()
val bodyStr = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
private fun parseContacts(json: String, loginId: String): List<BankContact> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
val result = mutableListOf<BankContact>()
for (i in 0 until payload.length()) {
val item = payload.getJSONObject(i)
val account = item.optString("account", "")
if (account.isBlank()) continue
result.add(BankContact(
benefNo = "bml_${item.optInt("id")}",
benefName = item.optString("name"),
benefNickName = item.optString("alias", item.optString("name")),
benefAccount = account,
benefType = "I",
bankColor = "#0066A1",
benefBankName = "Bank of Maldives",
bankCode = "",
benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null,
benefCategoryId = "BML",
profileId = loginId
))
}
return result
}
}
@@ -0,0 +1,64 @@
package sh.sar.basedbank.api.bml
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class BmlForeignLimitsClient {
// Foreign limits use a different host than the main BML API
private val BASE_URL = "https://app.bankofmaldives.com.mv/api/v2"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/foreign-limits")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseForeignLimits(json ?: return emptyList())
}
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONArray("payload") ?: return emptyList()
(0 until payload.length()).map { i ->
val item = payload.getJSONObject(i)
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
val atm = usage.optJSONObject("ATM") ?: JSONObject()
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
val pos = usage.optJSONObject("POS") ?: JSONObject()
BmlForeignLimit(
type = item.optString("type", "Debit"),
used = item.optDouble("used", 0.0),
totalLimit = item.optDouble("totalLimit", 0.0),
generalCap = item.optDouble("generalCap", 0.0),
generalRemaining = item.optDouble("generalRemaining", 0.0),
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
isPosEnabled = item.optBoolean("isPosEnabled", false),
atmRemaining = atm.optDouble("remaining", 0.0),
atmLimit = atm.optDouble("limit", 0.0),
ecomRemaining = ecom.optDouble("remaining", 0.0),
ecomLimit = ecom.optDouble("limit", 0.0),
posRemaining = pos.optDouble("remaining", 0.0),
posLimit = pos.optDouble("limit", 0.0)
)
}
} catch (_: Exception) { emptyList() }
}
}
@@ -0,0 +1,174 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat
import java.util.Locale
class BmlHistoryClient {
private val client = newBmlApiClient()
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String,
page: Int
): Pair<List<BankTransaction>, Int> {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/account/$accountId/history/$page")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
val totalPages = payload.optInt("totalPages", 0)
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
val transactions = (0 until history.length()).map { i ->
val item = history.getJSONObject(i)
val desc = item.optString("description").trim()
val narrative1 = item.optString("narrative1")
val date = when (desc) {
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
else -> item.optString("bookingDate")
}
BankTransaction(
id = item.optString("id"),
date = date,
description = desc,
amount = item.optDouble("amount", 0.0),
currency = item.optString("currency"),
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
reference = item.optString("reference").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
Pair(transactions, totalPages)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
fun fetchCardHistory(
session: BmlSession,
cardId: String,
accountDisplayName: String,
accountNumber: String,
month: String
): List<BankTransaction> {
val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/card/statement").post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<BankTransaction>()
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(BankTransaction(
id = "auth_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val unbilled = payload.optJSONObject("unbilled")
?.optJSONArray("CardUnbillTxnDetails")
if (unbilled != null) {
for (i in 0 until unbilled.length()) {
val item = unbilled.getJSONObject(i)
result.add(BankTransaction(
id = "unbilled_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(BankTransaction(
id = "stmt_${item.optString("TranRef", i.toString())}",
date = item.optString("TransDate", item.optString("TranDate", "")),
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
amount = -item.optDouble("TranAmount", 0.0),
currency = item.optString("TranCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
result
} catch (_: Exception) { emptyList() }
}
// "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 }
}
}
@@ -7,19 +7,15 @@ import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.Totp
import java.security.MessageDigest
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Base64
import java.util.Locale
import java.util.concurrent.TimeUnit
class AuthExpiredException : Exception("Session expired")
@@ -53,11 +49,6 @@ class BmlLoginFlow {
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val apiClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** PKCE params — generated once per login and reused across all profile activations. */
private var codeVerifier: String = ""
private var codeChallenge: String = ""
@@ -234,7 +225,7 @@ class BmlLoginFlow {
code: String,
profile: BmlProfile,
loginTag: String
): Pair<BmlSession, List<MibAccount>> {
): Pair<BmlSession, List<BankAccount>> {
// Refresh XSRF token before submitting
client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
@@ -275,7 +266,7 @@ class BmlLoginFlow {
loginTag: String,
profileName: String,
profileId: String
): Pair<BmlSession, List<MibAccount>> {
): Pair<BmlSession, List<BankAccount>> {
val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize")
@@ -323,419 +314,9 @@ class BmlLoginFlow {
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session, loginTag, profileName, profileId)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts)
}
// ─── 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, profileName, profileId)
}
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
val resp = apiClient.newCall(
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseForeignLimits(json ?: return emptyList())
}
data class BmlUserInfo(
val fullName: String,
val email: String,
val mobile: String,
val customerId: String,
val idCard: String,
val birthdate: String
)
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
BmlUserInfo(
fullName = user.optString("fullname").trim(),
email = user.optString("email").trim(),
mobile = user.optString("mobile_phone").trim(),
customerId = user.optString("customer_number").trim(),
idCard = user.optString("idcard").trim(),
birthdate = user.optString("birthdate").trim()
)
} catch (_: Exception) { null }
}
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val payload = root.optJSONObject("payload") ?: return null
val trnType = payload.optString("trnType", "")
val validationType = payload.optString("validationType", "")
if (validationType == "alias") {
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = cdtrAcct.optString("Acct"),
originalInput = input,
name = payload.optString("contact_name").trim(),
alias = null,
currency = payload.optString("currency", "MVR"),
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
)
} else {
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = payload.optString("account"),
originalInput = input,
name = payload.optString("name"),
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
currency = payload.optString("currency", "MVR")
)
}
} catch (_: Exception) { null }
}
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
account = root.optString("account"),
originalInput = account,
name = root.optString("name"),
alias = null,
currency = "MVR",
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
)
} catch (_: Exception) { null }
}
fun saveContact(
session: BmlSession,
contactType: String,
account: String,
alias: String,
currency: String? = null,
name: String? = null,
swift: String? = null
): Boolean {
val bodyObj = JSONObject().apply {
put("contact_type", contactType)
put("account", account)
put("alias", alias)
if (currency != null) put("currency", currency)
if (name != null) put("name", name)
if (swift != null) put("swift", swift)
}
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/contacts")
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
}
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseContacts(json, loginId)
}
fun initiateTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
bank: String? = null
): Boolean {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
if (bank != null) put("bank", bank)
}
val body = jo.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/transfer")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try {
val json = JSONObject(bodyStr)
json.optBoolean("success") && json.optInt("code") == 22
} catch (_: Exception) { false }
}
}
fun confirmTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
otp: String,
remarks: String = "",
bank: String? = null
): BmlTransferResult {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
put("otp", otp)
if (remarks.isNotBlank()) put("remarks", remarks)
if (bank != null) put("bank", bank)
}
val body = jo.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/transfer")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return@use BmlTransferResult(false, errorMessage = "No response")
try {
val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) {
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
} else {
val payload = json.optJSONObject("payload")
BmlTransferResult(
success = true,
reference = payload?.optString("reference") ?: "",
timestamp = payload?.optString("timestamp") ?: "",
message = json.optString("message")
)
}
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
}
}
fun deleteContact(session: BmlSession, contactId: String): Boolean {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/contacts/$contactId")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
}
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String,
page: Int
): Pair<List<Transaction>, Int> {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
val totalPages = payload.optInt("totalPages", 0)
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
val transactions = (0 until history.length()).map { i ->
val item = history.getJSONObject(i)
val desc = item.optString("description").trim()
val narrative1 = item.optString("narrative1")
val date = when (desc) {
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
else -> item.optString("bookingDate")
}
Transaction(
id = item.optString("id"),
date = date,
description = desc,
amount = item.optDouble("amount", 0.0),
currency = item.optString("currency"),
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
reference = item.optString("reference").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
Pair(transactions, totalPages)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
fun fetchCardHistory(
session: BmlSession,
cardId: String,
accountDisplayName: String,
accountNumber: String,
month: String
): List<Transaction> {
val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType())
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>()
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(Transaction(
id = "auth_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val unbilled = payload.optJSONObject("unbilled")
?.optJSONArray("CardUnbillTxnDetails")
if (unbilled != null) {
for (i in 0 until unbilled.length()) {
val item = unbilled.getJSONObject(i)
result.add(Transaction(
id = "unbilled_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(Transaction(
id = "stmt_${item.optString("TranRef", i.toString())}",
date = item.optString("TransDate", item.optString("TranDate", "")),
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
amount = -item.optDouble("TranAmount", 0.0),
currency = item.optString("TranCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
result
} catch (_: Exception) { emptyList() }
}
// ─── Parsing ──────────────────────────────────────────────────────────────
/**
@@ -787,148 +368,6 @@ class BmlLoginFlow {
}
} 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()
val casaAccounts = mutableListOf<MibAccount>()
val prepaidCards = mutableListOf<MibAccount>()
for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i)
val currency = item.optString("currency", "MVR")
val accountType = item.optString("account_type", "CASA")
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 = profileName,
profileType = "BML",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
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 = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(current),
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
}
}
return casaAccounts + prepaidCards
}
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONArray("payload") ?: return emptyList()
(0 until payload.length()).map { i ->
val item = payload.getJSONObject(i)
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
val atm = usage.optJSONObject("ATM") ?: JSONObject()
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
val pos = usage.optJSONObject("POS") ?: JSONObject()
BmlForeignLimit(
type = item.optString("type", "Debit"),
used = item.optDouble("used", 0.0),
totalLimit = item.optDouble("totalLimit", 0.0),
generalCap = item.optDouble("generalCap", 0.0),
generalRemaining = item.optDouble("generalRemaining", 0.0),
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
isPosEnabled = item.optBoolean("isPosEnabled", false),
atmRemaining = atm.optDouble("remaining", 0.0),
atmLimit = atm.optDouble("limit", 0.0),
ecomRemaining = ecom.optDouble("remaining", 0.0),
ecomLimit = ecom.optDouble("limit", 0.0),
posRemaining = pos.optDouble("remaining", 0.0),
posLimit = pos.optDouble("limit", 0.0)
)
}
} catch (_: Exception) { emptyList() }
}
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
val result = mutableListOf<MibBeneficiary>()
for (i in 0 until payload.length()) {
val item = payload.getJSONObject(i)
val account = item.optString("account", "")
if (account.isBlank()) continue
result.add(MibBeneficiary(
benefNo = "bml_${item.optInt("id")}",
benefName = item.optString("name"),
benefNickName = item.optString("alias", item.optString("name")),
benefAccount = account,
benefType = "I",
bankColor = "#0066A1",
benefBankName = "Bank of Maldives",
bankCode = "",
benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null,
benefCategoryId = "BML",
profileId = loginId
))
}
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
@@ -955,25 +394,4 @@ class BmlLoginFlow {
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,6 +1,6 @@
package sh.sar.basedbank.api.bml
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
data class BmlSession(
val accessToken: String,
@@ -24,7 +24,7 @@ data class BmlOtpChannel(
sealed class BmlActivationResult {
data class Success(
val session: BmlSession,
val accounts: List<MibAccount>
val accounts: List<BankAccount>
) : BmlActivationResult()
data class NeedsBusinessOtp(
val channels: List<BmlOtpChannel>
@@ -0,0 +1,98 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class BmlTransferClient {
private val client = newBmlApiClient()
/** Step 1: initiate the transfer (triggers OTP). Returns true if the server accepted it. */
fun initiateTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
bank: String? = null
): Boolean {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
if (bank != null) put("bank", bank)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/transfer")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try {
val json = JSONObject(bodyStr)
json.optBoolean("success") && json.optInt("code") == 22
} catch (_: Exception) { false }
}
}
/** Step 2: confirm with OTP. Returns a [BmlTransferResult] with success/reference/error. */
fun confirmTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
otp: String,
remarks: String = "",
bank: String? = null
): BmlTransferResult {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
put("otp", otp)
if (remarks.isNotBlank()) put("remarks", remarks)
if (bank != null) put("bank", bank)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/transfer")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return@use BmlTransferResult(false, errorMessage = "No response")
try {
val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) {
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
} else {
val payload = json.optJSONObject("payload")
BmlTransferResult(
success = true,
reference = payload?.optString("reference") ?: "",
timestamp = payload?.optString("timestamp") ?: "",
message = json.optString("message")
)
}
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
}
}
}
@@ -0,0 +1,79 @@
package sh.sar.basedbank.api.bml
import okhttp3.Request
import org.json.JSONObject
class BmlValidateClient {
private val client = newBmlApiClient()
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/validate/account/$input")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val payload = root.optJSONObject("payload") ?: return null
val trnType = payload.optString("trnType", "")
val validationType = payload.optString("validationType", "")
if (validationType == "alias") {
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = cdtrAcct.optString("Acct"),
originalInput = input,
name = payload.optString("contact_name").trim(),
alias = null,
currency = payload.optString("currency", "MVR"),
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
)
} else {
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = payload.optString("account"),
originalInput = input,
name = payload.optString("name"),
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
currency = payload.optString("currency", "MVR")
)
}
} catch (_: Exception) { null }
}
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/favara/account-verification/$account/MIB")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
account = root.optString("account"),
originalInput = account,
name = root.optString("name"),
alias = null,
currency = "MVR",
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
)
} catch (_: Exception) { null }
}
}
@@ -0,0 +1,76 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount
import java.util.concurrent.TimeUnit
class FahipayAccountClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private fun Request.Builder.auth(session: FahipaySession): Request.Builder = this
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("content-type", "multipart/form-data")
.header("User-Agent", UA)
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
.auth(session).build()
).execute()
val json = resp.body?.string() ?: throw Exception("Empty profile response")
resp.close()
val obj = JSONObject(json)
val props = obj.optJSONObject("props") ?: JSONObject()
return FahipayUserProfile(
fullName = obj.optString("fullname").trim(),
email = obj.optString("email").trim(),
mobile = obj.optString("mobile").trim(),
nid = obj.optString("nid").trim(),
profileId = obj.optString("profileID").trim(),
walletAccount = props.optString("acc", ""),
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
)
}
fun fetchBalance(session: FahipaySession): Double {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
.auth(session).build()
).execute()
val json = resp.body?.string() ?: return 0.0
resp.close()
return try {
val obj = JSONObject(json)
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
} catch (_: Exception) { 0.0 }
}
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): BankAccount =
BankAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY",
accountNumber = profile.walletAccount,
accountBriefName = "Fahipay Wallet",
currencyName = "MVR",
accountTypeName = "Digital Wallet",
availableBalance = "%.2f".format(balance),
currentBalance = "%.2f".format(balance),
blockedAmount = "0.00",
mvrBalance = "%.2f".format(balance),
statusDesc = "Active",
profileImageHash = null,
loginTag = loginTag,
internalId = profile.profileId
)
}
@@ -0,0 +1,69 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.util.AccountInputParser
import java.util.concurrent.TimeUnit
class FahipayContactsClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
val endpoints = listOf(
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
)
val result = mutableListOf<FahipayContactGroup>()
for ((catId, label, page) in endpoints) {
try {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("User-Agent", UA)
.build()
).execute()
val json = resp.body?.string() ?: continue
resp.close()
val obj = JSONObject(json)
val groupObj = obj.optJSONObject(page) ?: continue
val contacts = mutableListOf<BankContact>()
for (key in groupObj.keys()) {
val entry = groupObj.getJSONObject(key)
val number = entry.optString("number")
val name = entry.optString("name").trim().ifBlank { number }
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
contacts.add(BankContact(
benefNo = "fp_${page}_$number",
benefName = "",
benefNickName = name,
benefAccount = number,
benefType = "FAHIPAY",
bankColor = "#FF6B00",
benefBankName = label,
bankCode = "",
benefStatus = "",
transferCyDesc = "",
customerImgHash = null,
benefCategoryId = catId,
profileId = ""
))
}
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
} catch (_: Exception) {}
}
return result
}
}
@@ -0,0 +1,60 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankTransaction
import java.util.concurrent.TimeUnit
class FahipayHistoryClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val PAGE_SIZE = 15
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchHistory(
session: FahipaySession,
accountDisplayName: String,
accountNumber: String,
start: Int
): Pair<List<BankTransaction>, Int> {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("content-type", "multipart/form-data")
.header("User-Agent", UA)
.build()
).execute()
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
return try {
val obj = JSONObject(json)
val total = obj.optInt("total", 0)
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
val list = (0 until entries.length()).map { i ->
val e = entries.getJSONObject(i)
BankTransaction(
id = e.optString("transaction"),
date = e.optString("date"),
description = e.optString("name").trim(),
amount = e.optDouble("amount", 0.0),
currency = "MVR",
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
reference = e.optString("transaction").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "FAHIPAY",
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
)
}
Pair(list, total)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
}
@@ -10,10 +10,6 @@ import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.AccountInputParser
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
@@ -21,8 +17,6 @@ class FahipayLoginFlow {
private val BASE_URL = "https://fahipay.mv"
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val UA_OKHTTP = "okhttp/4.12.0"
private val PAGE_SIZE = 15
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
@@ -144,165 +138,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response")
}
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
.header("authid", session.authId)
.header("content-type", "multipart/form-data")
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: throw Exception("Empty profile response")
resp.close()
val obj = JSONObject(json)
val props = obj.optJSONObject("props") ?: JSONObject()
return FahipayUserProfile(
fullName = obj.optString("fullname").trim(),
email = obj.optString("email").trim(),
mobile = obj.optString("mobile").trim(),
nid = obj.optString("nid").trim(),
profileId = obj.optString("profileID").trim(),
walletAccount = props.optString("acc", ""),
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
)
}
fun fetchBalance(session: FahipaySession): Double {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
.header("authid", session.authId)
.header("content-type", "multipart/form-data")
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: return 0.0
resp.close()
return try {
val obj = JSONObject(json)
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
} catch (_: Exception) { 0.0 }
}
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY",
accountNumber = profile.walletAccount,
accountBriefName = "Fahipay Wallet",
currencyName = "MVR",
accountTypeName = "Digital Wallet",
availableBalance = "%.2f".format(balance),
currentBalance = "%.2f".format(balance),
blockedAmount = "0.00",
mvrBalance = "%.2f".format(balance),
statusDesc = "Active",
profileImageHash = null,
loginTag = loginTag,
internalId = profile.profileId
)
/**
* Fetches paginated activity history.
* @param start offset (0-based)
* @return Pair of (transactions, total count)
*/
fun fetchHistory(
session: FahipaySession,
accountDisplayName: String,
accountNumber: String,
start: Int
): Pair<List<Transaction>, Int> {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
.header("authid", session.authId)
.header("content-type", "multipart/form-data")
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
return try {
val obj = JSONObject(json)
val total = obj.optInt("total", 0)
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
val list = (0 until entries.length()).map { i ->
val e = entries.getJSONObject(i)
Transaction(
id = e.optString("transaction"),
date = e.optString("date"),
description = e.optString("name").trim(),
amount = e.optDouble("amount", 0.0),
currency = "MVR",
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
reference = e.optString("transaction").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "FAHIPAY",
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
)
}
Pair(list, total)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
/**
* Fetches Fahipay saved favourites for the 4 service groups.
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
* Groups with no valid entries are omitted.
*/
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
val endpoints = listOf(
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
)
val result = mutableListOf<FahipayContactGroup>()
for ((catId, label, page) in endpoints) {
try {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
.header("authid", session.authId)
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: continue
resp.close()
val obj = JSONObject(json)
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
val groupObj = obj.optJSONObject(page) ?: continue
val contacts = mutableListOf<MibBeneficiary>()
for (key in groupObj.keys()) {
val entry = groupObj.getJSONObject(key)
val number = entry.optString("number")
val name = entry.optString("name").trim().ifBlank { number }
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
contacts.add(MibBeneficiary(
benefNo = "fp_${page}_$number",
benefName = "",
benefNickName = name,
benefAccount = number,
benefType = "FAHIPAY",
bankColor = "#FF6B00",
benefBankName = label,
bankCode = "",
benefStatus = "",
transferCyDesc = "",
customerImgHash = null,
benefCategoryId = catId,
profileId = ""
))
}
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
} catch (_: Exception) {}
}
return result
}
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
@@ -1,5 +1,7 @@
package sh.sar.basedbank.api.fahipay
import sh.sar.basedbank.api.models.BankContact
data class FahipaySession(
val authId: String,
val sessionCookie: String
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
data class FahipayContactGroup(
val categoryId: String,
val label: String,
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
val contacts: List<BankContact>
)
@@ -356,44 +356,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
}
}
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
)
/** Fetches the customer's profile info from the Faisanet personal profile page. */
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.get()
.header("Cookie", cookieHeader)
.build()
return try {
val resp = client.newCall(request).execute()
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
)
} catch (_: Exception) { null }
}
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
val payload = baseData(session, "P41").apply {
@@ -1,5 +1,16 @@
package sh.sar.basedbank.api.mib
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.models.BankTransaction
// Kept for source compatibility within the mib package
typealias MibAccount = BankAccount
typealias MibBeneficiary = BankContact
typealias Transaction = BankTransaction
typealias MibBeneficiaryCategory = BankContactCategory
data class MibSession(
val appId: String,
val xxid: String,
@@ -19,25 +30,6 @@ data class MibProfile(
val customerImage: String?
)
data class MibAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String,
val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,
val accountTypeName: String,
val availableBalance: String,
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String,
val profileImageHash: String?,
val loginTag: String = "",
val profileId: String = "", // MIB profile ID; empty for BML accounts
val internalId: String = "" // BML internal UUID; empty for MIB accounts
)
data class MibTransferResult(
val success: Boolean,
@@ -46,27 +38,6 @@ data class MibTransferResult(
val errorMessage: String = ""
)
data class MibBeneficiaryCategory(
val id: String,
val categoryName: String,
val numBenef: Int
)
data class MibBeneficiary(
val benefNo: String,
val benefName: String,
val benefNickName: String,
val benefAccount: String,
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
val bankColor: String,
val benefBankName: String,
val bankCode: String,
val benefStatus: String,
val transferCyDesc: String,
val customerImgHash: String?,
val benefCategoryId: String, // "0" = uncategorized
val profileId: String = "" // MIB profile ID; empty for BML contacts
)
data class MibIpsAccountInfo(
val accountName: String,
@@ -74,19 +45,6 @@ data class MibIpsAccountInfo(
val bankId: String
)
data class Transaction(
val id: String,
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
val description: String,
val amount: Double, // negative = debit, positive = credit
val currency: String,
val counterpartyName: String?,
val reference: String?,
val accountNumber: String,
val accountDisplayName: String,
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
val iconUrl: String? = null // merchant icon URL (Fahipay only)
)
data class MibFinanceDeal(
val dealNo: String,
@@ -0,0 +1,50 @@
package sh.sar.basedbank.api.mib
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
)
class MibProfileClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.get()
.header("Cookie", cookieHeader)
.build()
return try {
val resp = client.newCall(request).execute()
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
)
} catch (_: Exception) { null }
}
}
@@ -0,0 +1,72 @@
package sh.sar.basedbank.api.models
/**
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
* The [bank] field identifies which bank owns this account.
*/
data class BankAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String,
val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,
val accountTypeName: String,
val availableBalance: String,
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String,
val profileImageHash: String?,
val loginTag: String = "",
val profileId: String = "", // profile ID used by the bank; empty if not applicable
val internalId: String = "" // bank-internal UUID or ID; empty if not applicable
)
/**
* Unified contact/beneficiary model used across all banks.
* Each bank may interpret fields differently; see per-bank notes below.
*/
data class BankContact(
val benefNo: String,
val benefName: String,
val benefNickName: String,
val benefAccount: String,
val benefType: String, // MIB: L=Local, I=Internal, S=Swift; BML: "I"; Fahipay: "FAHIPAY"
val bankColor: String,
val benefBankName: String,
val bankCode: String,
val benefStatus: String,
val transferCyDesc: String,
val customerImgHash: String?,
val benefCategoryId: String, // MIB: numeric category ID or "0"; BML: "BML"; Fahipay: "FAHIPAY"
val profileId: String = "" // owning profile ID; empty where not applicable
)
/**
* Contact category (group) used across MIB and Fahipay.
*/
data class BankContactCategory(
val id: String,
val categoryName: String,
val numBenef: Int
)
/**
* Unified transaction model used across all banks.
* [source] identifies the originating bank/account type.
*/
data class BankTransaction(
val id: String,
val date: String, // "YYYY-MM-DD HH:mm:ss" (MIB/BML normalised) or ISO8601
val description: String,
val amount: Double, // negative = debit, positive = credit
val currency: String,
val counterpartyName: String?,
val reference: String?,
val accountNumber: String,
val accountDisplayName: String,
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
val iconUrl: String? = null // merchant icon URL (Fahipay only)
)
@@ -9,8 +9,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
@@ -21,13 +21,13 @@ import java.util.Date
import java.util.Locale
class AccountHistoryAdapter(
private val account: MibAccount,
private val account: BankAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class Trx(val transaction: Transaction) : Item()
data class Trx(val transaction: BankTransaction) : Item()
}
private val displayItems = mutableListOf<Item>()
@@ -36,7 +36,7 @@ class AccountHistoryAdapter(
private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((MibAccount) -> Unit)? = null
var onTransferClick: ((BankAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
@@ -84,7 +84,7 @@ class AccountHistoryAdapter(
* Display the given (already sorted + filtered) list with date group headers.
* Silently resets the loading footer so notifyDataSetChanged covers everything.
*/
fun setTransactions(transactions: List<Transaction>) {
fun setTransactions(transactions: List<BankTransaction>) {
_showLoadingFooter = false
displayItems.clear()
lastInsertedDateKey = ""
@@ -105,7 +105,7 @@ class AccountHistoryAdapter(
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
* notifications, so the RecyclerView doesn't reset scroll position.
*/
fun appendTransactions(newTransactions: List<Transaction>) {
fun appendTransactions(newTransactions: List<BankTransaction>) {
if (newTransactions.isEmpty()) return
if (_showLoadingFooter) {
val pos = itemCount - 1
@@ -184,7 +184,7 @@ class AccountHistoryAdapter(
inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(trx: Transaction) {
fun bind(trx: BankTransaction) {
val isCredit = trx.amount >= 0
val color = sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description
@@ -237,7 +237,7 @@ class AccountHistoryAdapter(
b.root.setOnClickListener { showDetail(trx) }
}
private fun showDetail(trx: Transaction) {
private fun showDetail(trx: BankTransaction) {
val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString {
@@ -22,9 +22,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
@@ -39,10 +39,10 @@ class AccountHistoryFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount
private lateinit var account: BankAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>()
private val allTransactions = mutableListOf<BankTransaction>()
private var searchQuery = ""
private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>()
@@ -53,7 +53,7 @@ class AccountHistoryFragment : Fragment() {
companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number"
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
}
@@ -8,7 +8,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
@@ -16,22 +16,22 @@ import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter(
accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {}
accounts: List<BankAccount>,
private val onAccountClick: (BankAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null
var onTransferClick: ((BankAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
private sealed class Item {
data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
}
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
fun updateAccounts(accounts: List<MibAccount>) {
fun updateAccounts(accounts: List<BankAccount>) {
items.clear()
items.addAll(buildItems(accounts))
notifyDataSetChanged()
@@ -43,12 +43,12 @@ class AccountsAdapter(
notifyDataSetChanged()
}
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val nonCards = displayed.filter { !it.second.isCard }
val cards = displayed.filter { it.second.isCard }
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
for ((acc, display) in nonCards) {
val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
@@ -64,7 +64,7 @@ class AccountsAdapter(
}
}
private fun sectionTitle(account: MibAccount): String {
private fun sectionTitle(account: BankAccount): String {
val bankName = when (account.bank) {
"BML" -> "Bank of Maldives"
"FAHIPAY" -> "Fahipay"
@@ -112,7 +112,7 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount, display: AccountListDisplay) {
fun bind(account: BankAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel
@@ -128,7 +128,7 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount, display: AccountListDisplay) {
fun bind(account: BankAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = display.name
binding.tvCardNumber.text = display.number
@@ -30,8 +30,9 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibTransferClient
@@ -64,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedImageBase64: String = ""
private var selectedCategoryId: String = "0"
private var categories: List<MibBeneficiaryCategory> = emptyList()
private var categories: List<BankContactCategory> = emptyList()
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult
@@ -244,14 +245,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun lookupForBml(input: String): BmlAccountValidation? {
val loginId = selectedDest?.bmlLoginId ?: return null
val bmlSess = app.bmlSessions[loginId] ?: return null
val bmlFlow = BmlLoginFlow()
// 1) Try BML validate
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
if (validated != null) return validated
// 2) Try BML MIB verify
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
@@ -295,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
val bmlSess = app.anyBmlSession() ?: return null
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
}
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
@@ -421,15 +420,14 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val loginId = selectedDest?.bmlLoginId ?: return false
val bmlSess = app.bmlSessions[loginId] ?: return false
val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow()
val account = lookup.account
return when {
account.matches(Regex("^7\\d{12}$")) ->
// BML account → IAT
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
account.matches(Regex("^9\\d{16}$")) ->
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
else -> false
}
@@ -489,7 +487,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (dest.isBml) {
val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged)
@@ -23,7 +23,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
@@ -153,7 +153,7 @@ class ContactsFragment : Fragment() {
}.also { it.attach() }
}
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) {
private fun rebuildPager(cats: List<BankContactCategory>) {
val pages = buildList {
add(TabPage(null, getString(R.string.contacts_tab_all)))
cats.forEach { add(TabPage(it.id, it.categoryName)) }
@@ -12,7 +12,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -68,7 +68,7 @@ class DashboardFragment : Fragment() {
}
}
private fun updateBalances(accounts: List<MibAccount>) {
private fun updateBalances(accounts: List<BankAccount>) {
val hide = viewModel.hideAmounts.value ?: false
if (hide) {
binding.tvMvrBalance.text = "MVR ••••••"
@@ -36,18 +36,22 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayContactsClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile
@@ -490,7 +494,7 @@ fun applyNavLabelVisibility() {
val loginTag = "bml_$loginId"
val app = application as BasedBankApp
val savedProfiles = store.loadBmlProfiles(loginId)
val allAccounts = mutableListOf<MibAccount>()
val allAccounts = mutableListOf<BankAccount>()
var anyExpired = savedProfiles.isEmpty()
// Try each saved profile's cached session
@@ -499,7 +503,7 @@ fun applyNavLabelVisibility() {
if (saved != null) {
try {
val session = BmlSession(saved.first, saved.second)
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag, profile.name, profile.profileId)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
} catch (_: AuthExpiredException) { anyExpired = true
@@ -517,7 +521,7 @@ fun applyNavLabelVisibility() {
if (legacyToken != null) {
try {
val session = BmlSession(legacyToken.first, legacyToken.second)
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session
allAccounts += accounts
anyExpired = false
@@ -565,7 +569,7 @@ fun applyNavLabelVisibility() {
}
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
allAccounts as List<MibAccount>
allAccounts as List<BankAccount>
}
}
@@ -581,10 +585,9 @@ fun applyNavLabelVisibility() {
if (savedSession != null) {
try {
val session = FahipaySession(savedSession.first, savedSession.second)
fahipayFlow.setSessionCookie(session.sessionCookie)
val balance = fahipayFlow.fetchBalance(session)
val profile = fahipayFlow.fetchProfile(session)
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val balance = FahipayAccountClient().fetchBalance(session)
val profile = FahipayAccountClient().fetchProfile(session)
val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp
app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
@@ -601,9 +604,9 @@ fun applyNavLabelVisibility() {
val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
val session = FahipaySession(authId, cookieValue)
store.saveFahipaySession(loginId, authId, cookieValue)
val profile = fahipayFlow.fetchProfile(session)
val balance = fahipayFlow.fetchBalance(session)
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val profile = FahipayAccountClient().fetchProfile(session)
val balance = FahipayAccountClient().fetchBalance(session)
val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp
app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
@@ -636,7 +639,7 @@ fun applyNavLabelVisibility() {
}
/** Filters accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
val store = CredentialStore(this@HomeActivity)
return filter { acc ->
when (acc.bank) {
@@ -669,11 +672,10 @@ fun applyNavLabelVisibility() {
}
private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow()
lifecycleScope.launch {
try {
val (userName, limits) = withContext(Dispatchers.IO) {
Pair(bmlFlow.fetchUserInfo(session)?.fullName ?: "", bmlFlow.fetchForeignLimits(session))
Pair(BmlAccountClient().fetchUserInfo(session)?.fullName ?: "", BmlForeignLimitsClient().fetchForeignLimits(session))
}
val existing = viewModel.bmlLimits.value?.toMutableList() ?: mutableListOf()
val idx = existing.indexOfFirst { it.userName == userName }
@@ -693,7 +695,7 @@ fun applyNavLabelVisibility() {
val allBmlContacts = withContext(Dispatchers.IO) {
store.getBmlLoginIds().flatMap { loginId ->
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
val contacts = BmlContactsClient().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts
}
@@ -736,13 +738,11 @@ fun applyNavLabelVisibility() {
lifecycleScope.launch {
try {
val groups = withContext(Dispatchers.IO) {
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
flow.fetchContacts(session)
FahipayContactsClient().fetchContacts(session)
}
if (groups.isEmpty()) return@launch
val contacts = groups.flatMap { it.contacts }
val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) }
val categories = groups.map { BankContactCategory(it.categoryId, it.label, it.contacts.size) }
ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
@@ -755,11 +755,11 @@ fun applyNavLabelVisibility() {
}
private fun mergeContacts(
mib: List<MibBeneficiary>,
bml: List<MibBeneficiary>
): List<MibBeneficiary> {
mib: List<BankContact>,
bml: List<BankContact>
): List<BankContact> {
val seen = mutableSetOf<String>()
val result = mutableListOf<MibBeneficiary>()
val result = mutableListOf<BankContact>()
for (c in mib) if (seen.add(c.benefNo)) result.add(c)
for (c in bml) if (seen.add(c.benefNo)) result.add(c)
return result
@@ -774,8 +774,8 @@ fun applyNavLabelVisibility() {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
val seenCategories = mutableSetOf<String>()
val contacts = mutableListOf<MibBeneficiary>()
val categories = mutableListOf<MibBeneficiaryCategory>()
val contacts = mutableListOf<BankContact>()
val categories = mutableListOf<BankContactCategory>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
@@ -803,7 +803,7 @@ fun applyNavLabelVisibility() {
}
}
fun refreshBalances(src: MibAccount) {
fun refreshBalances(src: BankAccount) {
val app = application as BasedBankApp
lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList()
@@ -811,12 +811,10 @@ fun applyNavLabelVisibility() {
val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySessionFor(src) ?: return@withContext null
try {
val flow = FahipayLoginFlow()
flow.setSessionCookie(sess.sessionCookie)
val balance = flow.fetchBalance(sess)
val profile = flow.fetchProfile(sess)
val balance = FahipayAccountClient().fetchBalance(sess)
val profile = FahipayAccountClient().fetchProfile(sess)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
val loginId = src.loginTag.removePrefix("fahipay_")
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
@@ -830,7 +828,7 @@ fun applyNavLabelVisibility() {
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null
try {
val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
app.bmlAccounts = otherBml + accounts
@@ -3,16 +3,16 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<MibAccount>>(emptyList())
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList())
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList())
val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
@@ -14,7 +14,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.FragmentOtpBinding
import sh.sar.basedbank.databinding.ItemOtpCardBinding
@@ -94,7 +95,7 @@ class OtpFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) {
try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null }
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
}
if (profile != null) {
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
@@ -114,7 +115,7 @@ class OtpFragment : Fragment() {
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
val session = app.bmlSessions[loginId] ?: continue
val info = withContext(Dispatchers.IO) {
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
try { BmlAccountClient().fetchUserInfo(session) } catch (_: Exception) { null }
}
if (info != null) {
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
@@ -33,7 +33,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.PaymvQrParser
@@ -46,7 +46,7 @@ class PayMvQrFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null
private var selectedAccount: BankAccount? = null
private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null
@@ -186,7 +186,7 @@ class PayMvQrFragment : Fragment() {
private fun renderQrCard(
ctx: Context,
account: MibAccount,
account: BankAccount,
qrPayload: String,
amountStr: String?
): Bitmap {
@@ -378,10 +378,10 @@ class PayMvQrFragment : Fragment() {
private inner class QrAccountAdapter(
private val context: Context,
private val accounts: List<MibAccount>
private val accounts: List<BankAccount>
) : BaseAdapter(), Filterable {
fun getAccount(position: Int): MibAccount? = accounts.getOrNull(position)
fun getAccount(position: Int): BankAccount? = accounts.getOrNull(position)
override fun getCount() = accounts.size
override fun getItem(position: Int) = accounts.getOrNull(position)
@@ -411,7 +411,7 @@ class PayMvQrFragment : Fragment() {
FilterResults().apply { values = accounts; count = accounts.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) =
(r as? MibAccount)?.let {
(r as? BankAccount)?.let {
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
"$prefix${it.accountBriefName}"
} ?: ""
@@ -9,17 +9,17 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transaction History — date-grouped, shows account name in secondary line. */
/** Adapter for BankTransaction History — date-grouped, shows account name in secondary line. */
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class Trx(val transaction: Transaction) : Item()
data class Trx(val transaction: BankTransaction) : Item()
}
private val displayItems = mutableListOf<Item>()
@@ -67,7 +67,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
/** Replace the full sorted transaction list and rebuild date groups. */
fun setTransactions(transactions: List<Transaction>) {
fun setTransactions(transactions: List<BankTransaction>) {
_showLoadingFooter = false
displayItems.clear()
var lastDateKey = ""
@@ -114,7 +114,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(trx: Transaction) {
fun bind(trx: BankTransaction) {
val isCredit = trx.amount >= 0
val color = AccountHistoryAdapter.sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description
@@ -141,7 +141,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
b.tvDescription.text = trx.description
// Show account name in secondary line for Transaction History
// Show account name in secondary line for BankTransaction History
b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE
@@ -162,7 +162,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
b.root.setOnClickListener { showDetail(trx) }
}
private fun showDetail(trx: Transaction) {
private fun showDetail(trx: BankTransaction) {
val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString {
@@ -36,12 +36,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlTransferClient
import sh.sar.basedbank.api.bml.BmlTransferResult
import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.bml.BmlTransferResult
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException
@@ -65,11 +66,11 @@ class TransferFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null
private var selectedAccount: BankAccount? = null
private val session get() = selectedAccount
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyMibSession()
private fun bmlSessionFor(account: MibAccount?) =
private fun bmlSessionFor(account: BankAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession()
@@ -105,7 +106,7 @@ class TransferFragment : Fragment() {
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
}
@@ -239,7 +240,7 @@ class TransferFragment : Fragment() {
}
}
private fun showFromCard(account: MibAccount) {
private fun showFromCard(account: BankAccount) {
val colorHex = when (account.bank) {
"BML" -> "#0066A1"
"FAHIPAY" -> "#15BEA7"
@@ -284,7 +285,7 @@ class TransferFragment : Fragment() {
}
}
private fun updateAmountPrefix(account: MibAccount) {
private fun updateAmountPrefix(account: BankAccount) {
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
}
@@ -369,10 +370,9 @@ class TransferFragment : Fragment() {
val info = withContext(Dispatchers.IO) {
if (isBmlSource && bmlSess != null) {
val inputType = AccountInputParser.detect(accountNumber)
val bmlFlow = BmlLoginFlow()
val bmlResult = try {
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) bmlFlow.verifyMibAccount(bmlSess, accountNumber)
else bmlFlow.validateAccount(bmlSess, accountNumber)
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) BmlValidateClient().verifyMibAccount(bmlSess, accountNumber)
else BmlValidateClient().validateAccount(bmlSess, accountNumber)
} catch (_: Exception) { null }
if (bmlResult != null) {
val bankId = when (bmlResult.trnType) {
@@ -393,7 +393,7 @@ class TransferFragment : Fragment() {
catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else {
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) {
val bankId = when (bmlResult.trnType) {
"IAT" -> "MALBMVMV"
@@ -731,7 +731,7 @@ class TransferFragment : Fragment() {
}
private fun doMibTransfer(
src: MibAccount,
src: BankAccount,
destAccount: String,
destName: String,
destDisplay: String,
@@ -790,7 +790,7 @@ class TransferFragment : Fragment() {
mibReferenceNo = result.trxId,
mibTransactionDate = result.date
)
Triple(true, "Transaction ID: ${result.trxId}\n${result.date}", receipt)
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
@@ -800,7 +800,7 @@ class TransferFragment : Fragment() {
}
private fun doBmlTransfer(
src: MibAccount,
src: BankAccount,
destAccount: String,
destDisplay: String,
amount: Double,
@@ -809,8 +809,8 @@ class TransferFragment : Fragment() {
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<MibAccount>,
allContacts: List<MibBeneficiary>
allAccounts: List<BankAccount>,
allContacts: List<BankContact>
): Triple<Boolean, String, TransferReceiptData?> {
val loginId = src.loginTag.removePrefix("bml_")
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
@@ -845,10 +845,9 @@ class TransferFragment : Fragment() {
}
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
val bmlFlow = BmlLoginFlow()
// Step 1: initiate
val initiated = try {
bmlFlow.initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
@@ -858,7 +857,7 @@ class TransferFragment : Fragment() {
?.let { Totp.generate(it) } ?: otp
return try {
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val receipt = TransferReceiptData(
bank = "BML",
@@ -1000,12 +999,12 @@ class TransferFragment : Fragment() {
_binding = null
}
private fun MibAccount.toDisplayString() = "$accountBriefName · $accountNumber"
private fun BankAccount.toDisplayString() = "$accountBriefName · $accountNumber"
// items: String = section header, MibAccount = selectable row
// items: String = section header, BankAccount = selectable row
private inner class AccountDropdownAdapter(
private val context: Context,
accounts: List<MibAccount>
accounts: List<BankAccount>
) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList {
@@ -1018,7 +1017,7 @@ class TransferFragment : Fragment() {
}
}
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getCount() = items.size
@@ -1043,7 +1042,7 @@ class TransferFragment : Fragment() {
b.tvHeader.text = item
b.root
} else {
val acc = item as MibAccount
val acc = item as BankAccount
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding
} else {
@@ -25,12 +25,12 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
import sh.sar.basedbank.util.ContactImageCache
@@ -48,12 +48,12 @@ class TransferHistoryFragment : Fragment() {
private lateinit var adapter: TransactionAdapter
// All merged transactions sorted by date desc
private val allTransactions = mutableListOf<Transaction>()
private val allTransactions = mutableListOf<BankTransaction>()
private var searchQuery = ""
// Per-account pagination state
private data class AccountState(
val account: MibAccount,
val account: BankAccount,
var mibNextStart: Int = 1,
var mibTotalCount: Int = -1,
var bmlNextPage: Int = 1,
@@ -158,7 +158,7 @@ class TransferHistoryFragment : Fragment() {
lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>()
val results = mutableListOf<BankTransaction>()
// BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.bank == "BML" }
@@ -172,7 +172,7 @@ class TransferHistoryFragment : Fragment() {
cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
state.cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
BmlHistoryClient().fetchCardHistory(
session = session,
cardId = state.account.internalId,
accountDisplayName = state.account.accountBriefName,
@@ -182,7 +182,7 @@ class TransferHistoryFragment : Fragment() {
}
else -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
session = session,
accountId = state.account.internalId,
accountDisplayName = state.account.accountBriefName,
@@ -194,7 +194,7 @@ class TransferHistoryFragment : Fragment() {
list
}
}
} catch (_: Exception) { emptyList<Transaction>() }
} catch (_: Exception) { emptyList<BankTransaction>() }
}
}.awaitAll().flatten())
@@ -203,9 +203,7 @@ class TransferHistoryFragment : Fragment() {
for (state in fahipayStates) {
val session = app.fahipaySessionFor(state.account) ?: continue
try {
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
val (list, total) = FahipayHistoryClient().fetchHistory(
session = session,
accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber,
@@ -18,14 +18,17 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
@@ -56,7 +59,7 @@ class CredentialsFragment : Fragment() {
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
private var bmlCurrentBusinessProfile: BmlProfile? = null
private var bmlSelectedChannel: String? = null
@@ -202,7 +205,7 @@ class CredentialsFragment : Fragment() {
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) {
flow.lastSession?.let { s ->
val profile = flow.fetchPersonalProfile(s)
val profile = MibProfileClient().fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile(
@@ -428,7 +431,7 @@ class CredentialsFragment : Fragment() {
val anySession = app.anyBmlSessionFor(bmlLoginId)
if (anySession != null) {
withContext(Dispatchers.IO) {
val info = BmlLoginFlow().fetchUserInfo(anySession)
val info = BmlAccountClient().fetchUserInfo(anySession)
if (info != null) {
store.saveBmlUserProfile(
bmlLoginId,
@@ -582,13 +585,13 @@ class CredentialsFragment : Fragment() {
store: CredentialStore
) {
val (profile, balance) = withContext(Dispatchers.IO) {
val p = flow.fetchProfile(session)
val b = flow.fetchBalance(session)
val p = FahipayAccountClient().fetchProfile(session)
val b = FahipayAccountClient().fetchBalance(session)
Pair(p, b)
}
val loginId = profile.profileId
val loginTag = "fahipay_$loginId"
val account = flow.buildAccount(profile, balance, loginTag)
val account = FahipayAccountClient().buildAccount(profile, balance, loginTag)
store.saveFahipayCredentials(loginId, idCard, password)
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
store.saveFahipayUserProfile(
@@ -3,7 +3,7 @@ package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
object AccountCache {
@@ -12,7 +12,7 @@ object AccountCache {
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
fun save(context: Context, accounts: List<MibAccount>) {
fun save(context: Context, accounts: List<BankAccount>) {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
@@ -38,7 +38,7 @@ object AccountCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
}
fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
fun saveBml(context: Context, loginId: String, accounts: List<BankAccount>) {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
@@ -61,14 +61,14 @@ object AccountCache {
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(context: Context, loginId: String): List<MibAccount> {
fun loadBml(context: Context, loginId: String): List<BankAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
BankAccount(
bank = "BML",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
@@ -89,10 +89,10 @@ object AccountCache {
} catch (_: Exception) { emptyList() }
}
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
fun loadBml(context: Context, loginIds: List<String>): List<BankAccount> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, loginId: String, accounts: List<MibAccount>) {
fun saveFahipay(context: Context, loginId: String, accounts: List<BankAccount>) {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
@@ -115,14 +115,14 @@ object AccountCache {
.edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadFahipay(context: Context, loginId: String): List<MibAccount> {
fun loadFahipay(context: Context, loginId: String): List<BankAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(fahipayKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
BankAccount(
bank = "FAHIPAY",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
@@ -143,14 +143,14 @@ object AccountCache {
} catch (_: Exception) { emptyList() }
}
fun loadFahipay(context: Context, loginIds: List<String>): List<MibAccount> =
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
loginIds.flatMap { loadFahipay(context, it) }
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
fun load(context: Context): List<MibAccount> {
fun load(context: Context): List<BankAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()
return try {
@@ -158,7 +158,7 @@ object AccountCache {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
BankAccount(
bank = o.optString("bank", "MIB"),
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
@@ -1,13 +1,13 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser {
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
fun from(account: BankAccount): AccountHistoryDisplay? = when (account.bank) {
"BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account)
@@ -1,13 +1,13 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser {
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
fun from(account: BankAccount): AccountListDisplay? = when (account.bank) {
"BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account)
@@ -1,18 +1,18 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.util.bmlapi.BmlContactParser
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
import sh.sar.basedbank.util.mibapi.MibContactParser
object ContactListParser {
fun from(contact: MibBeneficiary): ContactDisplay? = when {
fun from(contact: BankContact): ContactDisplay? = when {
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
else -> null
}
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
fun fromList(contacts: List<BankContact>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
}
@@ -1,7 +1,7 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.mib.MibContactsClient
/**
@@ -21,7 +21,7 @@ object ContactManager {
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.id.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
return try { BmlContactsClient().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
@@ -3,8 +3,8 @@ package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
object ContactsCache {
@@ -14,8 +14,8 @@ object ContactsCache {
fun save(
context: Context,
contacts: List<MibBeneficiary>,
categories: List<MibBeneficiaryCategory>
contacts: List<BankContact>,
categories: List<BankContactCategory>
) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
@@ -55,7 +55,7 @@ object ContactsCache {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
fun loadContacts(context: Context): List<MibBeneficiary> {
fun loadContacts(context: Context): List<BankContact> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CONTACTS, null) ?: return emptyList()
return try {
@@ -63,7 +63,7 @@ object ContactsCache {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiary(
BankContact(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName"),
@@ -86,7 +86,7 @@ object ContactsCache {
private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
fun saveBml(context: Context, loginId: String, contacts: List<BankContact>) {
val arr = JSONArray()
for (c in contacts) {
arr.put(JSONObject().apply {
@@ -108,14 +108,14 @@ object ContactsCache {
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
fun loadBml(context: Context, loginId: String): List<BankContact> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiary(
BankContact(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName"),
@@ -134,10 +134,10 @@ object ContactsCache {
} catch (_: Exception) { emptyList() }
}
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
fun loadBml(context: Context, loginIds: List<String>): List<BankContact> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
fun saveFahipay(context: Context, contacts: List<BankContact>, categories: List<BankContactCategory>) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
val arr = JSONArray()
for (c in contacts) arr.put(JSONObject().apply {
@@ -159,14 +159,14 @@ object ContactsCache {
prefs.apply()
}
fun loadFahipay(context: Context): List<MibBeneficiary> {
fun loadFahipay(context: Context): List<BankContact> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("fahipay_contacts", null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiary(
BankContact(
benefNo = o.optString("benefNo"),
benefName = "",
benefNickName = o.optString("benefNickName"),
@@ -185,19 +185,19 @@ object ContactsCache {
} catch (_: Exception) { emptyList() }
}
fun loadFahipayCategories(context: Context): List<MibBeneficiaryCategory> {
fun loadFahipayCategories(context: Context): List<BankContactCategory> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("fahipay_categories", null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
BankContactCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
}
} catch (_: Exception) { emptyList() }
}
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
fun loadCategories(context: Context): List<BankContactCategory> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList()
return try {
@@ -205,7 +205,7 @@ object ContactsCache {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
BankContactCategory(
id = o.optString("id"),
categoryName = o.optString("categoryName"),
numBenef = o.optInt("numBenef", 0)
@@ -4,11 +4,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@@ -18,7 +18,7 @@ import java.util.Locale
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
* without knowing which bank it is talking to.
*/
class HistoryFetcher(private val account: MibAccount) {
class HistoryFetcher(private val account: BankAccount) {
private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
@@ -46,18 +46,16 @@ class HistoryFetcher(private val account: MibAccount) {
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
}
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
val session = app.fahipaySessionFor(account) ?: return emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
val (list, total) = FahipayHistoryClient().fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
@@ -68,7 +66,7 @@ class HistoryFetcher(private val account: MibAccount) {
return list
}
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<BankTransaction> {
val loginId = account.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId] ?: return emptyList()
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
@@ -86,13 +84,13 @@ class HistoryFetcher(private val account: MibAccount) {
return list
}
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
return BmlLoginFlow().fetchCardHistory(
return BmlHistoryClient().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
@@ -101,9 +99,9 @@ class HistoryFetcher(private val account: MibAccount) {
)
}
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
@@ -1,12 +1,12 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object BmlContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
fun displayData(contact: BankContact) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
@@ -1,7 +1,7 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountListDisplay
object BmlDashboardParser {
@@ -10,7 +10,7 @@ object BmlDashboardParser {
* Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards.
*/
fun displayData(account: MibAccount): AccountListDisplay {
fun displayData(account: BankAccount): AccountListDisplay {
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
@@ -51,7 +51,7 @@ object BmlDashboardParser {
}
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
fun listBalance(account: MibAccount): String =
fun listBalance(account: BankAccount): String =
"${account.currencyName} ${account.currentBalance}"
fun cardBrandIcon(productName: String): Int = when {
@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object BmlHistoryParser {
fun displayData(account: MibAccount): AccountHistoryDisplay {
fun displayData(account: BankAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay(
name = account.accountBriefName,
@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountListDisplay
object FahipayAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
fun displayData(account: BankAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = account.accountTypeName,
@@ -1,12 +1,12 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object FahipayContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
fun displayData(contact: BankContact) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object FahipayHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
fun displayData(account: BankAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "FP",
@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
fun displayData(account: BankAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
@@ -1,12 +1,12 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object MibContactParser {
fun displayData(contact: MibBeneficiary): ContactDisplay {
fun displayData(contact: BankContact): ContactDisplay {
val network = when (contact.benefType) {
"I" -> TransferNetwork.MIB
"S" -> TransferNetwork.SWIFT
@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
fun displayData(account: BankAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill