10 Commits
Author SHA1 Message Date
shihaam 58f1b9fd6f release v1.0.5
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m58s
2026-05-21 00:44:12 +05:00
shihaam 240d04ad74 Optmize business profile logins.. save privacy mode settings on launch
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 00:43:23 +05:00
shihaam fe507073b1 Optmize business profile logins.. save privacy mode settings on launch 2026-05-21 00:43:05 +05:00
shihaam 6d48c27391 huge refactor.. might need to revert later
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-20 22:43:29 +05:00
shihaam e894f81887 remove temp logo files
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 21:59:04 +05:00
shihaam acc1278b34 card history fix
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 01:10:35 +05:00
shihaam bc678d26ad fixes for bml contact addd drop down and loading contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:53:08 +05:00
shihaam bb2a80a5e3 more edging non edge fixes
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-20 00:37:02 +05:00
shihaam b107358266 toggle to enable or disable privacy mode and also privacy mode toggle
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:29:06 +05:00
shihaam 02a53c8219 fix single profile multi login
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:02:36 +05:00
67 changed files with 1833 additions and 1304 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 3
versionName = "1.0.4"
versionCode = 4
versionName = "1.0.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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 = ""
@@ -121,15 +112,27 @@ class BmlLoginFlow {
twoFaResp.close()
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
// Step 5: GET /web/profile — returns list of profiles for this account
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
// single-profile accounts skip the picker and redirect straight to /web/redirect with
// blaze_identity already set in the response cookies.
val profileResp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile")
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val profileCode = profileResp.code
val profileLocation = profileResp.header("Location") ?: ""
val profileBody = profileResp.body?.string() ?: ""
profileResp.close()
lastProfiles = parseProfiles(profileBody)
lastProfiles = if (profileCode == 302) {
// Any 302 from GET /web/profile means the server auto-activated the sole profile
// and blaze_identity is already set — no profile picker shown.
// Use username as a stable temporary profileId (unique per login); it will be
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
} else {
parseProfiles(profileBody)
}
return lastProfiles
}
@@ -144,6 +147,13 @@ class BmlLoginFlow {
* [requestBusinessOtp] + [submitBusinessOtp].
*/
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
// Single-profile accounts: server already activated during login() and set blaze_identity.
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
if (profile.autoActivated) {
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
return BmlActivationResult.Success(session, accounts)
}
val xsrf = xsrfToken()
val reqBuilder = Request.Builder()
.url("$BASE_URL/web/profile/${profile.profileId}")
@@ -156,8 +166,9 @@ class BmlLoginFlow {
resp.close()
return when {
code == 409 || (code == 302 && "/web/redirect" in location) -> {
// Profile activated — blaze_identity cookie set in response headers
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
// Profile activated — blaze_identity cookie set in response headers.
// Any 302 that isn't to the business 2FA page means success.
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
BmlActivationResult.Success(session, accounts)
}
@@ -214,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")
@@ -255,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")
@@ -303,399 +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 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 ──────────────────────────────────────────────────────────────
/**
@@ -747,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
@@ -915,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,
@@ -11,7 +11,8 @@ data class BmlProfile(
val profileId: String,
val name: String,
val type: String, // "Profile" (personal) or "Business"
val profileType: String // "default" or "business"
val profileType: String, // "default" or "business"
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
)
data class BmlOtpChannel(
@@ -23,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,18 @@ 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) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyItemChanged(0) // refresh header card
// refresh all transaction rows
for (i in displayItems.indices) {
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
}
}
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
@@ -73,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 = ""
@@ -94,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
@@ -154,10 +165,10 @@ class AccountHistoryAdapter(
b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = d.bankPill
b.tvHeaderPillType.text = d.typeLabel
b.tvHeaderAvailable.text = d.availableBalance
b.tvHeaderBalance.text = d.workingBalance
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
if (d.blockedBalance != null) {
b.tvHeaderBlocked.text = d.blockedBalance
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
b.llHeaderBlocked.visibility = View.VISIBLE
} else {
b.llHeaderBlocked.visibility = View.GONE
@@ -173,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
@@ -211,17 +222,22 @@ class AccountHistoryAdapter(
b.tvDate.text = formatTime(trx.date)
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
if (hideAmounts) {
b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.setTextColor(Color.parseColor("#888888"))
} else {
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
}
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 {
@@ -282,6 +298,11 @@ class AccountHistoryAdapter(
return FULL_DATE_FMT.format(date)
}
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
fun sourceColor(source: String) = when (source) {
"MIB" -> "#FE860E"
"BML", "BML_CARD" -> "#0066A1"
@@ -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)
}
@@ -77,6 +77,8 @@ class AccountHistoryFragment : Fragment() {
adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
}
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
@@ -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,32 +16,39 @@ 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()
}
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
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)
@@ -57,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"
@@ -105,11 +112,11 @@ 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
binding.tvBalance.text = display.balance
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
@@ -121,13 +128,13 @@ 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
binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = display.balance
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
if (display.statusLabel != null) {
binding.tvCardStatus.text = display.statusLabel
binding.tvCardStatus.visibility = View.VISIBLE
@@ -146,6 +153,11 @@ class AccountsAdapter(
private const val TYPE_ACCOUNT = 1
private const val TYPE_CARD = 2
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun copyToClipboard(context: Context, accountNumber: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
@@ -44,6 +44,7 @@ class AccountsFragment : Fragment() {
}
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
override fun onResume() {
@@ -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
@@ -98,10 +99,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
}
}
val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
for ((loginId, profiles) in app.bmlProfilesMap) {
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
for (profile in profiles) {
if (app.bmlSessions.containsKey(profile.profileId)) {
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
}
}
}
return list
}
@@ -241,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)
@@ -292,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) {
@@ -418,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
}
@@ -486,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
@@ -32,6 +32,11 @@ class DashboardFragment : Fragment() {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances(viewModel.financing.value ?: emptyList())
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
}
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
@@ -63,7 +68,13 @@ 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 ••••••"
binding.tvUsdBalance.text = "USD ••••••"
return
}
val mvrTotal = accounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
@@ -76,31 +87,40 @@ class DashboardFragment : Fragment() {
}
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
val hide = viewModel.hideAmounts.value ?: false
binding.containerForeignLimits.removeAllViews()
for (entry in entries) {
for (limit in entry.limits) {
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
card.tvLimitType.text = limit.type
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
else
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.tvLimitPos.text = if (!limit.isPosEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
else
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
if (hide) {
card.tvLimitGeneral.text = "USD ••••••"
card.tvLimitMedical.text = "USD ••••••"
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
card.tvLimitEcom.text = "USD ••••••"
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
} else {
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
else
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.tvLimitPos.text = if (!limit.isPosEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
else
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
}
binding.containerForeignLimits.addView(card.root)
}
}
}
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
val total = deals.sumOf { it.outstandingAmount }
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
val hide = viewModel.hideAmounts.value ?: false
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
}
override fun onDestroyView() {
@@ -16,6 +16,14 @@ import java.util.Locale
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
@@ -52,19 +60,20 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
val ctx = binding.root.context
val currency = deal.currency
val hide = hideAmounts
binding.tvProductName.text = deal.productDesc
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
binding.tvStatus.text = deal.statusDesc
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0
binding.progressBar.progress = progress
binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate
binding.tvCompletion.text = completionText(deal, ctx)
@@ -76,14 +85,14 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate)
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) {
binding.rowOverdue.visibility = View.VISIBLE
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
} else {
binding.rowOverdue.visibility = View.GONE
}
@@ -44,6 +44,7 @@ class FinancingFragment : Fragment() {
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
override fun onResume() {
@@ -17,7 +17,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
@@ -33,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
@@ -110,6 +117,12 @@ class HomeActivity : AppCompatActivity() {
binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState()
ViewCompat.setOnApplyWindowInsetsListener(binding.navigationView) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(top = bars.top, bottom = bars.bottom)
insets
}
binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
val frag = when (item.itemId) {
@@ -181,6 +194,8 @@ class HomeActivity : AppCompatActivity() {
autoRefresh(store)
}
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
// Show dashboard on first create
if (savedInstanceState == null) {
show(DashboardFragment())
@@ -402,6 +417,11 @@ fun applyNavLabelVisibility() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu)
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
val eyeItem = menu.findItem(R.id.action_hide_amounts)
eyeItem?.isVisible = eyeEnabled
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
return true
}
@@ -410,6 +430,13 @@ fun applyNavLabelVisibility() {
lock()
return true
}
if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden
getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
invalidateOptionsMenu()
return true
}
return super.onOptionsItemSelected(item)
}
@@ -467,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
@@ -476,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
@@ -486,13 +513,15 @@ fun applyNavLabelVisibility() {
}
}
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
// Also try legacy single-profile session token (pre-multi-profile installs)
if (savedProfiles.isEmpty()) {
val legacyToken = store.loadBmlSession(loginId)
if (legacyToken != null) {
try {
val session = BmlSession(legacyToken.first, legacyToken.second)
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session
allAccounts += accounts
anyExpired = false
@@ -540,7 +569,7 @@ fun applyNavLabelVisibility() {
}
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
allAccounts as List<MibAccount>
allAccounts as List<BankAccount>
}
}
@@ -556,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)
@@ -576,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)
@@ -611,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) {
@@ -644,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 }
@@ -668,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
}
@@ -711,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()
@@ -730,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
@@ -749,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)
@@ -778,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()
@@ -786,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
@@ -805,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,17 +3,19 @@ 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())
val hideAmounts = MutableLiveData<Boolean>(false)
}
@@ -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(
@@ -16,6 +16,9 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -30,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
@@ -43,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
@@ -72,6 +75,12 @@ class PayMvQrFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
insets
}
setupDropdown()
binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.btnShare.isEnabled = false
@@ -177,7 +186,7 @@ class PayMvQrFragment : Fragment() {
private fun renderQrCard(
ctx: Context,
account: MibAccount,
account: BankAccount,
qrPayload: String,
amountStr: String?
): Bitmap {
@@ -369,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)
@@ -402,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}"
} ?: ""
@@ -11,6 +11,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp
@@ -27,11 +28,21 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.RecentsCache
import androidx.lifecycle.lifecycleScope
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
class SettingsLoginsFragment : Fragment() {
private var _binding: FragmentSettingsLoginsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
@@ -89,13 +100,15 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
showLoginDetails(
title = getString(R.string.fahipay_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
}.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
)
@@ -258,8 +271,16 @@ class SettingsLoginsFragment : Fragment() {
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenBmlProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val hidden = store.getHiddenBmlProfileIds(loginId).toMutableSet()
// Business profiles with no saved session were skipped during login — ensure they start hidden
val needsActivation = bmlProfiles
.filter { it.profileType == "business" && store.loadBmlProfileSession(it.profileId) == null }
.map { it.profileId }
.toMutableSet()
for (id in needsActivation) {
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
}
val originalHidden = hidden.toSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -269,12 +290,14 @@ class SettingsLoginsFragment : Fragment() {
}
scroll.addView(container)
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" },
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $it" }
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: ${if (hide) masked else it}" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
@@ -357,8 +380,19 @@ class SettingsLoginsFragment : Fragment() {
toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn)
if (checked && p.profileId in needsActivation) {
toggle.isChecked = false // revert — enabling requires OTP
viewLifecycleOwner.lifecycleScope.launch {
val success = activateBmlBusinessProfile(store, loginId, p)
if (success) {
needsActivation.remove(p.profileId)
toggle.isChecked = true // listener re-fires, removes from hidden
}
}
} else {
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn)
}
}
}
@@ -366,7 +400,209 @@ class SettingsLoginsFragment : Fragment() {
store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.applyProfileVisibility()
(activity as? HomeActivity)?.relogin()
}
}
private suspend fun activateBmlBusinessProfile(
store: CredentialStore,
loginId: String,
profile: BmlProfile
): Boolean {
val creds = store.loadBmlCredentials(loginId) ?: run {
showSimpleError("Credentials not found — please log out and log in again")
return false
}
val progressDialog = MaterialAlertDialogBuilder(requireContext())
.setMessage("Connecting to BML\u2026")
.setCancelable(false)
.show()
val flow = BmlLoginFlow()
val loginTag = "bml_$loginId"
val activationResult = try {
withContext(Dispatchers.IO) {
flow.login(creds.username, creds.password, creds.otpSeed)
flow.activateProfile(profile, loginTag)
}
} catch (e: Exception) {
progressDialog.dismiss()
showSimpleError(e.message ?: "Authentication failed")
return false
}
progressDialog.dismiss()
return when (activationResult) {
is BmlActivationResult.Success -> {
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
true
}
is BmlActivationResult.NeedsBusinessOtp ->
continueBmlBusinessOtpFlow(store, loginId, profile, flow, loginTag, activationResult.channels)
}
}
private suspend fun continueBmlBusinessOtpFlow(
store: CredentialStore,
loginId: String,
profile: BmlProfile,
flow: BmlLoginFlow,
loginTag: String,
channels: List<BmlOtpChannel>
): Boolean {
val selectedChannel = showBmlChannelSelectionDialog(profile.name, channels) ?: return false
val channelObj = channels.first { it.channel == selectedChannel }
val sendProgress = MaterialAlertDialogBuilder(requireContext())
.setMessage("Sending OTP\u2026")
.setCancelable(false)
.show()
try {
withContext(Dispatchers.IO) { flow.requestBusinessOtp(selectedChannel) }
} catch (e: Exception) {
sendProgress.dismiss()
showSimpleError(e.message ?: "Failed to send OTP")
return false
}
sendProgress.dismiss()
var otpError: String? = null
while (true) {
val code = showBmlOtpInputDialog(profile.name, channelObj, otpError) ?: return false
val verifyProgress = MaterialAlertDialogBuilder(requireContext())
.setMessage("Verifying\u2026")
.setCancelable(false)
.show()
try {
val (session, _) = withContext(Dispatchers.IO) {
flow.submitBusinessOtp(selectedChannel, code, profile, loginTag)
}
verifyProgress.dismiss()
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
return true
} catch (e: Exception) {
verifyProgress.dismiss()
if (e.message?.contains("Invalid OTP") == true) {
otpError = e.message
} else {
showSimpleError(e.message ?: "Verification failed")
return false
}
}
}
}
private fun showSimpleError(message: String) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(message)
.setPositiveButton(R.string.close, null)
.show()
}
private suspend fun showBmlChannelSelectionDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val vp = (8 * dp).toInt()
setPadding(0, vp, 0, vp)
}
for (channel in channels) {
val iconRes = when (channel.channel) {
"Email" -> R.drawable.ic_channel_email
"Mobile" -> R.drawable.ic_channel_sms
"WhatsApp" -> R.drawable.ic_channel_whatsapp
else -> R.drawable.ic_channel_sms
}
val iconSize = (24 * dp).toInt()
val iconView = ImageView(ctx).apply {
setImageResource(iconRes)
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = (12 * dp).toInt()
}
}
textCol.addView(TextView(ctx).apply {
text = channel.description
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
text = channel.masked
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
val hp = (24 * dp).toInt(); val vp = (12 * dp).toInt()
setPadding(hp, vp, hp, vp)
}
row.addView(iconView, LinearLayout.LayoutParams(iconSize, iconSize))
row.addView(textCol)
list.addView(row)
}
val d = MaterialAlertDialogBuilder(ctx)
.setTitle("Send verification code")
.setView(list)
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
.setCancelable(false)
.show()
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
// Wire up row clicks after dialog is created so we can dismiss it first
val rows = list.run { (0 until childCount).map { getChildAt(it) } }
rows.forEachIndexed { i, row ->
row.setOnClickListener {
d.dismiss()
if (cont.isActive) cont.resume(channels[i].channel)
}
}
}
private suspend fun showBmlOtpInputDialog(
profileName: String,
channel: BmlOtpChannel,
errorMsg: String? = null
): String? = suspendCancellableCoroutine { cont ->
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val input = android.widget.EditText(ctx).apply {
hint = "Enter OTP"
inputType = android.text.InputType.TYPE_CLASS_NUMBER
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
val msg = buildString {
append(getString(R.string.bml_business_otp_sent, channel.description))
append(" (${channel.masked})")
if (errorMsg != null) append("\n\n$errorMsg")
}
val d = MaterialAlertDialogBuilder(ctx)
.setTitle("Enter verification code")
.setMessage(msg)
.setView(input)
.setPositiveButton(R.string.verify, null)
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
.setCancelable(false)
.show()
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
d.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val code = input.text.toString().trim()
if (code.length != 6) {
input.error = "Enter 6 digits"
} else {
d.dismiss()
if (cont.isActive) cont.resume(code)
}
}
}
@@ -79,6 +79,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer()
}
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
@@ -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>()
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
@@ -60,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 = ""
@@ -107,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
@@ -134,23 +141,28 @@ 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
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
if (hideAmounts) {
b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.setTextColor(Color.parseColor("#888888"))
} else {
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
}
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,
@@ -86,6 +86,8 @@ class TransferHistoryFragment : Fragment() {
adapter = TransactionAdapter()
adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
@@ -156,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" }
@@ -170,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,
@@ -180,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,
@@ -192,7 +194,7 @@ class TransferHistoryFragment : Fragment() {
list
}
}
} catch (_: Exception) { emptyList<Transaction>() }
} catch (_: Exception) { emptyList<BankTransaction>() }
}
}.awaitAll().flatten())
@@ -201,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,21 +18,20 @@ 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
import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CredentialsFragment : Fragment() {
@@ -56,11 +55,7 @@ class CredentialsFragment : Fragment() {
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
private var bmlCurrentBusinessProfile: BmlProfile? = null
private var bmlSelectedChannel: String? = null
private var bmlAwaitingBusinessOtp = false
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
@@ -167,11 +162,7 @@ class CredentialsFragment : Fragment() {
private fun attemptLogin() {
when (bankType) {
"BML" -> {
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
else attemptBmlLogin()
return
}
"BML" -> { attemptBmlLogin(); return }
"FAHIPAY" -> { attemptFahipayLogin(); return }
}
@@ -202,7 +193,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(
@@ -254,10 +245,6 @@ class CredentialsFragment : Fragment() {
bmlLoginId = username
bmlAccumulatedAccounts.clear()
bmlPendingBusinessProfiles.clear()
bmlCurrentBusinessProfile = null
bmlSelectedChannel = null
bmlAwaitingBusinessOtp = false
val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username"
@@ -269,24 +256,22 @@ class CredentialsFragment : Fragment() {
}
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
// Activate each profile; personal profiles are immediate, business ones need OTP
var hasBusinessProfiles = false
for (profile in profiles) {
if (profile.profileType == "business") {
hasBusinessProfiles = true
continue // skip — user can enable in Settings → Logins
}
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
when (result) {
is BmlActivationResult.Success -> {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
is BmlActivationResult.NeedsBusinessOtp -> {
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
}
if (result is BmlActivationResult.Success) {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
}
// Save credentials and profile list now (before business OTP prompts)
val store = CredentialStore(requireContext())
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlProfiles(bmlLoginId, profiles)
@@ -297,11 +282,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
finishBmlLogin(hasBusinessProfiles)
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
@@ -311,115 +292,7 @@ class CredentialsFragment : Fragment() {
}
}
private suspend fun processNextBmlBusinessProfile() {
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
?: run { finishBmlLogin(); return }
bmlCurrentBusinessProfile = profile
// Show channel selection dialog
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
// User skipped this profile — move on
processNextBmlBusinessProfile()
return
}
bmlSelectedChannel = selectedChannel
// Request OTP
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
try {
withContext(Dispatchers.IO) {
bmlFlow!!.requestBusinessOtp(selectedChannel)
}
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
binding.tvError.text = e.message ?: "Failed to send OTP"
binding.tvError.visibility = View.VISIBLE
processNextBmlBusinessProfile()
return
}
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
bmlAwaitingBusinessOtp = true
binding.etUsername.isEnabled = false
binding.etPassword.isEnabled = false
binding.etOtpSeed.isEnabled = false
binding.progressBar.visibility = View.GONE
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
binding.tilTotpCode.visibility = View.VISIBLE
binding.etTotpCode.text?.clear()
binding.btnLogin.text = getString(R.string.verify)
binding.btnLogin.isEnabled = true
binding.tvError.visibility = View.GONE
}
private fun submitBmlBusinessOtp() {
val code = binding.etTotpCode.text.toString().trim()
if (code.length != 6) {
binding.tvError.text = getString(R.string.fahipay_totp_hint)
binding.tvError.visibility = View.VISIBLE
return
}
binding.tvError.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val profile = bmlCurrentBusinessProfile ?: return
val channel = bmlSelectedChannel ?: return
val loginTag = "bml_$bmlLoginId"
viewLifecycleOwner.lifecycleScope.launch {
try {
val (session, accounts) = withContext(Dispatchers.IO) {
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
}
bmlAccumulatedAccounts += accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = session
bmlAwaitingBusinessOtp = false
binding.tilTotpCode.visibility = View.GONE
binding.btnLogin.text = getString(R.string.login)
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "OTP verification failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.bml_business_otp_title, profileName))
.setItems(options) { _, which ->
if (cont.isActive) cont.resume(channels[which].channel)
}
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
if (cont.isActive) cont.resume(null as String?)
}
.show()
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
}
private suspend fun finishBmlLogin() {
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList()
@@ -428,18 +301,47 @@ class CredentialsFragment : Fragment() {
val anySession = app.anyBmlSessionFor(bmlLoginId)
if (anySession != null) {
withContext(Dispatchers.IO) {
val info = BmlLoginFlow().fetchUserInfo(anySession)
if (info != null) store.saveBmlUserProfile(
bmlLoginId,
CredentialStore.BmlUserProfile(
fullName = info.fullName,
email = info.email,
mobile = info.mobile,
customerId = info.customerId,
idCard = info.idCard,
birthdate = info.birthdate
val info = BmlAccountClient().fetchUserInfo(anySession)
if (info != null) {
store.saveBmlUserProfile(
bmlLoginId,
CredentialStore.BmlUserProfile(
fullName = info.fullName,
email = info.email,
mobile = info.mobile,
customerId = info.customerId,
idCard = info.idCard,
birthdate = info.birthdate
)
)
)
// Single-profile accounts used username as a temporary profileId.
// Replace it with the real BML customer ID so multi-login doesn't collide.
val customerId = info.customerId
if (customerId.isNotBlank()) {
val profiles = store.loadBmlProfiles(bmlLoginId)
val autoProfile = profiles.firstOrNull { it.autoActivated }
if (autoProfile != null && autoProfile.profileId != customerId) {
val oldId = autoProfile.profileId
// Re-key session in memory and storage
val session = app.bmlSessions.remove(oldId)
if (session != null) {
app.bmlSessions[customerId] = session
store.clearBmlProfileSession(oldId)
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
}
// Update stored profile list with the real ID
val updatedProfiles = profiles.map {
if (it.autoActivated) it.copy(profileId = customerId) else it
}
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
// Update accounts to use real profileId
bmlAccumulatedAccounts.replaceAll { acc ->
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
}
}
}
}
}
}
@@ -447,6 +349,9 @@ class CredentialsFragment : Fragment() {
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
}
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -553,13 +458,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)
@@ -217,6 +217,7 @@ class CredentialStore(context: Context) {
put("name", p.name)
put("type", p.type)
put("profileType", p.profileType)
put("autoActivated", p.autoActivated)
})
prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply()
}
@@ -228,10 +229,11 @@ class CredentialStore(context: Context) {
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BmlProfile(
profileId = o.optString("profileId"),
name = o.optString("name"),
type = o.optString("type"),
profileType = o.optString("profileType", "default")
profileId = o.optString("profileId"),
name = o.optString("name"),
type = o.optString("type"),
profileType = o.optString("profileType", "default"),
autoActivated = o.optBoolean("autoActivated", false)
)
}
} catch (_: Exception) { emptyList() }
@@ -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
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5V6l8,5 8,-5v2z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM9,11H7V9h2v2zM13,11h-2V9h2v2zM17,11h-2V9h2v2z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M26.576 5.363c-2.69-2.69-6.406-4.354-10.511-4.354-8.209 0-14.865 6.655-14.865 14.865 0 2.732 0.737 5.291 2.022 7.491l-0.038-0.070-2.109 7.702 7.879-2.067c2.051 1.139 4.498 1.809 7.102 1.809h0.006c8.209-0.003 14.862-6.659 14.862-14.868 0-4.103-1.662-7.817-4.349-10.507zM16.062 28.228h-0.005c-2.319 0-4.489-0.64-6.342-1.753l0.056 0.031-0.451-0.267-4.675 1.227 1.247-4.559-0.294-0.467c-1.185-1.862-1.889-4.131-1.889-6.565 0-6.822 5.531-12.353 12.353-12.353s12.353 5.531 12.353 12.353c0 6.822-5.53 12.353-12.353 12.353zM22.838 18.977c-0.371-0.186-2.197-1.083-2.537-1.208-0.341-0.124-0.589-0.185-0.837 0.187-0.246 0.371-0.958 1.207-1.175 1.455-0.216 0.249-0.434 0.279-0.805 0.094-1.15-0.466-2.138-1.087-2.997-1.852l0.010 0.009c-0.799-0.74-1.484-1.587-2.037-2.521l-0.028-0.052c-0.216-0.371-0.023-0.572 0.162-0.757 0.167-0.166 0.372-0.434 0.557-0.65 0.146-0.179 0.271-0.384 0.366-0.604l0.006-0.017c0.043-0.087 0.068-0.188 0.068-0.296 0-0.131-0.037-0.253-0.101-0.357l0.002 0.003c-0.094-0.186-0.836-2.014-1.145-2.758-0.302-0.724-0.609-0.625-0.836-0.637-0.216-0.010-0.464-0.012-0.712-0.012-0.395 0.010-0.746 0.188-0.988 0.463l-0.001 0.002c-0.802 0.761-1.3 1.834-1.3 3.023 0 0.026 0 0.053 0.001 0.079c0.131 1.467 0.681 2.784 1.527 3.857l-0.012-0.015c1.604 2.379 3.742 4.282 6.251 5.564l0.094 0.043c0.548 0.248 1.25 0.513 1.968 0.74l0.149 0.041c0.442 0.14 0.951 0.221 1.479 0.221 0.303 0 0.601-0.027 0.889-0.078l-0.031 0.004c1.069-0.223 1.956-0.868 2.497-1.749l0.009-0.017c0.165-0.366 0.261-0.793 0.261-1.242 0-0.185-0.016-0.366-0.047-0.542l0.003 0.019c-0.092-0.155-0.34-0.247-0.712-0.434z"/>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39-6,-7.5-11,-7.5zM12,17c-2.76,0-5,-2.24-5,-5s2.24,-5 5,-5 5,2.24 5,5-2.24,5-5,5zM12,9c-1.66,0-3,1.34-3,3s1.34,3 3,3 3,-1.34 3,-3-1.34,-3-3,-3z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65-0.13,1.26-0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75-1.73,-4.39-6,-7.5-11,-7.5-1.4,0-2.74,0.25-3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21-0.08,0.43-0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33-1.41,0.53-2.2,0.53-2.76,0-5,-2.24-5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66-1.34,-3-3,-3l-0.17,0.01z" />
</vector>
@@ -162,6 +162,44 @@
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowHideAmounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideAmounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
+6
View File
@@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_hide_amounts"
android:icon="@drawable/ic_visibility"
android:title="@string/action_hide_amounts"
app:showAsAction="always" />
<item
android:id="@+id/action_lock"
android:icon="@drawable/ic_lock"
+4 -3
View File
@@ -110,6 +110,8 @@
<!-- Toolbar -->
<string name="action_lock">Lock app</string>
<string name="action_hide_amounts">Hide sensitive information</string>
<string name="action_show_amounts">Show sensitive information</string>
<string name="autolock_warning_title">Locking soon</string>
<string name="autolock_warning_message">Locking in %d seconds</string>
<string name="autolock_stay">Stay unlocked</string>
@@ -137,6 +139,8 @@
<string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string>
<string name="settings_privacy">Privacy</string>
<string name="settings_hide_amounts">Hide sensitive information</string>
<string name="settings_hide_amounts_desc">Masks account balances and financial figures across the app</string>
<string name="settings_block_screenshots">Block Screenshots</string>
<string name="settings_block_screenshots_desc">Prevents the app from appearing in the recents screen and blocks screen capture</string>
<string name="settings_cache">Cache</string>
@@ -171,10 +175,7 @@
<string name="verify">Verify</string>
<!-- BML business OTP -->
<string name="bml_business_otp_title">Activate %s profile</string>
<string name="bml_business_otp_hint">%s OTP code</string>
<string name="bml_business_otp_sent">OTP sent via %s</string>
<string name="bml_business_otp_skip">Skip</string>
<!-- Home -->
<string name="transfer_same_account">This is your source account</string>
-2
View File
@@ -1,2 +0,0 @@
<svg width="60" height="60" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#FFF" d="M0 59.776h59.785V0H0z"/><path fill="#E21B23" d="M3.297 56.421h53.191V3.356H3.298z"/><path d="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z" fill="#FFF"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2021 (64-Bit) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100px" height="40px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 276 80"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#3F65AD;fill-rule:nonzero}
.fil1 {fill:#9AD141;fill-rule:nonzero}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2901355026512">
<path class="fil0" d="M272.33 28.36l-12.05 0 0 -6.16c0,-1 -0.36,-1.87 -1.08,-2.59 -0.73,-0.73 -1.61,-1.09 -2.63,-1.08 -0.95,0 -1.87,0.41 -2.52,1.11 -0.67,0.71 -1.01,1.58 -1.01,2.56l0 6.16 -4.07 0c-4.95,0 -4.66,6.64 0,6.64l4.07 0 0 27.8c0,5.47 1.19,9.74 3.53,12.67 2.41,3.01 6.48,4.53 12.11,4.53l3.65 0c1.72,0 3.16,-1.2 3.54,-2.81l0 -1.63c-0.38,-1.61 -1.82,-2.81 -3.54,-2.81l-3.65 0c-3.2,0 -5.41,-0.79 -6.56,-2.35 -1.22,-1.65 -1.84,-4.22 -1.84,-7.6l0 -27.8 12.05 0c4.74,0 4.74,-6.64 0,-6.64z"/>
<path class="fil0" d="M169.85 76.38l0 -41.07 15.31 0c0.74,0 1.47,0.08 2.18,0.27 1.63,0.46 3.04,1.32 4.16,2.55 0.82,0.91 1.49,2.02 1.99,3.29 0.5,1.27 0.76,2.7 0.76,4.24l0 30.72c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.63,-1.62 3.63,-3.62l0 -30.72c0,-2.72 -0.44,-5.17 -1.3,-7.3 -0.86,-2.14 -2.07,-3.98 -3.59,-5.47 -1.53,-1.49 -3.3,-2.63 -5.27,-3.39 -1.97,-0.76 -4.04,-1.14 -6.17,-1.14l-17.43 0c-1.54,0 -2.8,0.54 -3.75,1.61 -0.92,1.04 -1.38,2.26 -1.38,3.62l0 42.79c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.62,-1.62 3.62,-3.62z"/>
<path class="fil0" d="M225.3 28.36c15.78,0 18.49,6.01 19.17,19.96 0.27,5.45 -0.89,8.87 -5.93,9.27l-17.03 0c-1.8,0 -3.27,-1.51 -3.27,-3.37 0,-1.86 1.47,-3.37 3.27,-3.37l0.77 0 12.95 0 2.56 0c0,-9.79 0.21,-16.11 -12.49,-15.54 -3.89,0 -6.8,1.13 -8.92,3.46 -2.11,2.32 -3.18,5.53 -3.18,9.52l0.03 9.51c0.09,2.35 0.32,4.45 0.68,6.25 0.39,2 1.04,3.69 1.93,5.03 0.87,1.29 2.03,2.28 3.47,2.94 1.47,0.68 3.39,1.02 5.69,1.02l2 0 4.51 0 3.48 0c1.77,0.01 3.21,1.54 3.21,3.38 0,1.83 -1.44,3.58 -3.21,3.58l-3.48 0 -4.51 0 -2.09 0c-3.63,0 -6.69,-0.5 -9.11,-1.49 -2.48,-1.01 -4.48,-2.57 -5.96,-4.64 -1.44,-2.03 -2.47,-4.6 -3.04,-7.62 -0.57,-2.94 -0.85,-6.42 -0.85,-10.37l0 -7.59c0,-3.11 0.5,-5.94 1.49,-8.42 0.99,-2.49 2.38,-4.61 4.14,-6.3 1.75,-1.68 3.81,-2.99 6.14,-3.88 2.32,-0.88 4.87,-1.33 7.58,-1.33z"/>
<path class="fil1" d="M72.13 28.36c-1.86,0 -3.37,1.55 -3.37,3.47l0 44.7c0,1.92 1.51,3.47 3.37,3.47 1.86,0 3.36,-1.55 3.36,-3.47l0 -44.7c0,-1.92 -1.5,-3.47 -3.36,-3.47z"/>
<path class="fil1" d="M62.69 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.22,1.49 -3.22,3.32 0,1.83 1.44,3.32 3.22,3.32l12.86 0c2.22,0 3.9,0.81 5.13,2.46 1.28,1.7 1.92,3.84 1.92,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.28,-1.6 -1.68,-2.57 -0.39,-0.96 -0.59,-2.02 -0.59,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.57,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.8,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.47,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.09,4.74 -0.73,1.8 -1.11,3.76 -1.11,5.83 0,2.07 0.38,4.03 1.11,5.83 0.74,1.79 1.78,3.38 3.09,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.77,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.34,-4.13 -1,-5.99z"/>
<path class="fil1" d="M26.66 28.36l-9.83 0 0 -7.66 0 0c0,-1.08 0,-1.83 0,-1.83 0,-3.31 1.04,-6.76 3.05,-9.42 1.11,-1.47 2.8,-2.4 4.63,-2.77 1.27,-0.25 5.79,-0.34 7.59,-0.35l0 0c1.75,0 3.16,-1.42 3.16,-3.17 0,-1.75 -1.41,-3.16 -3.16,-3.16 -3.39,0.02 -9.41,0.24 -12.15,1.58 -3.98,1.94 -7.03,5.42 -8.56,9.56 -0.85,2.28 -1.27,4.73 -1.27,7.34l0 9.88 -6.84 0c-1.75,0 -3.17,1.42 -3.17,3.16 0,1.75 1.42,3.48 3.17,3.48l0 0 6.84 0 0 41.64c0,1.86 1.5,3.36 3.36,3.36 1.85,0 3.35,-1.5 3.35,-3.36l0 -41.64 9.83 0 0 0.01c1.75,0 3.17,-1.74 3.17,-3.48 0,-1.75 -1.42,-3.17 -3.17,-3.17z"/>
<path class="fil1" d="M156.53 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.21,1.49 -3.21,3.32 0,1.83 1.43,3.32 3.21,3.32l12.86 0c2.22,0 3.9,0.81 5.14,2.46 1.27,1.7 1.91,3.84 1.91,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.27,-1.6 -1.67,-2.57 -0.4,-0.96 -0.6,-2.02 -0.6,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.58,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.81,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.46,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.08,4.74 -0.74,1.8 -1.12,3.76 -1.12,5.83 0,2.07 0.38,4.03 1.12,5.83 0.73,1.79 1.77,3.38 3.08,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.78,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.33,-4.13 -1,-5.99z"/>
<path class="fil1" d="M112.72 54.91c-2.71,-2.17 -6.46,-3.62 -11.16,-4.29l-3.62 -0.49c-3.92,-0.51 -6.78,-1.31 -8.51,-2.38 -1.58,-0.98 -2.35,-2.65 -2.35,-5.09 0,-2.76 0.9,-4.65 2.73,-5.77 1.95,-1.19 4.4,-1.89 7.34,-1.89l15.72 0.01 0 0c1.78,0 3.22,-1.48 3.22,-3.32 0,-1.83 -1.44,-3.32 -3.22,-3.32l-15.72 -0.01c-2.21,0 -4.33,0.35 -6.36,0.85 -2.06,0.52 -3.88,1.36 -5.42,2.5 -1.56,1.15 -2.83,2.67 -3.78,4.51 -0.94,1.85 -1.43,4.09 -1.43,6.64 0,4.73 1.51,8.17 4.47,10.23 2.82,1.97 6.79,3.23 11.78,3.77l4.07 0.49c3.13,0.43 5.55,1.33 7.2,2.66 1.58,1.28 2.34,2.91 2.34,5 0,3.16 -0.98,5.27 -2.99,6.45 -2.15,1.26 -5.11,1.9 -8.81,1.9l-14.83 0 0 0c-1.78,0 -3.23,1.48 -3.23,3.32 0,1.83 1.45,3.32 3.23,3.32l14.83 0c2.71,0 5.21,-0.24 7.44,-0.7 2.29,-0.48 4.29,-1.32 5.93,-2.49 1.66,-1.19 2.98,-2.77 3.93,-4.69 0.94,-1.91 1.42,-4.33 1.42,-7.2 0,-4.39 -1.42,-7.75 -4.22,-10.01z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB