forked from thijooree/android
huge refactor.. might need to revert later
This commit is contained in:
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user