28 Commits
v1.0.4 ... main

Author SHA1 Message Date
00297da71e Revert "fix bug that allowed to skip password setup during inital setup"
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
This reverts commit 1602d061c1.
2026-05-22 03:07:34 +05:00
1602d061c1 fix bug that allowed to skip password setup during inital setup
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 03:01:21 +05:00
ddd64e8624 descriptive menus
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 02:03:20 +05:00
77f367844d rework back butotn 2026-05-22 01:50:12 +05:00
e2729b1d1a add support for fetching mib cards
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-22 01:40:14 +05:00
105518e147 release version 1.0.6
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m52s
2026-05-21 23:24:14 +05:00
38570615dd optmize dashboard (seperate credit section, bars for spending limits
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 23:23:45 +05:00
e82218e897 added support for BML loans
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 22:58:58 +05:00
50150b826f remove auto lock off and optimize session keepalive for mib
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-21 22:31:58 +05:00
2d705457f8 animate lock and eye icons in action bar (top)
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 01:37:37 +05:00
f03e23062b you can now hold to copy text from recipts even in full screen mode
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-21 01:04:23 +05:00
58f1b9fd6f release v1.0.5
All checks were successful
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
240d04ad74 Optmize business profile logins.. save privacy mode settings on launch
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 00:43:23 +05:00
fe507073b1 Optmize business profile logins.. save privacy mode settings on launch 2026-05-21 00:43:05 +05:00
6d48c27391 huge refactor.. might need to revert later
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-20 22:43:29 +05:00
e894f81887 remove temp logo files
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 21:59:04 +05:00
acc1278b34 card history fix
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 01:10:35 +05:00
bc678d26ad fixes for bml contact addd drop down and loading contacts
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:53:08 +05:00
bb2a80a5e3 more edging non edge fixes
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-20 00:37:02 +05:00
b107358266 toggle to enable or disable privacy mode and also privacy mode toggle
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:29:06 +05:00
02a53c8219 fix single profile multi login
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:02:36 +05:00
15a02cac1c patial support for BML business profile accounts
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 23:30:36 +05:00
35a1748055 add save and share icons 2026-05-19 22:24:44 +05:00
28682bba41 half baked PayMV QR generate support
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 21:59:05 +05:00
25484addfb rename activites to recent transfers and transfer history to transaction history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 20:16:29 +05:00
728c7d2aa3 view recipts full screen by pressing empty area, copy values by holding it
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 19:56:56 +05:00
b24949c117 add support to view previous transfer recipts 2026-05-19 19:39:20 +05:00
28e5878668 sync bottom bar when customizing bottom bar and switch checkbox to toggles in mib logins
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 14s
2026-05-19 19:23:03 +05:00
115 changed files with 5718 additions and 1496 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 5
versionName = "1.0.4" versionName = "1.0.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -73,6 +73,9 @@ dependencies {
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// ZXing core for QR code generation
implementation("com.google.zxing:core:3.5.3")
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye) // QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
implementation("androidx.camera:camera-core:1.4.2") implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2") implementation("androidx.camera:camera-camera2:1.4.2")

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

View File

@@ -4,9 +4,11 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
@@ -15,22 +17,34 @@ import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() { class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout // Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList() var accounts: List<BankAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
/** Active MIB sessions keyed by loginId (= MIB username). */ /** Active MIB sessions keyed by loginId (= MIB username). */
val mibSessions: MutableMap<String, MibSession> = mutableMapOf() val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf() val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf() val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList() var mibAccounts: List<BankAccount> = emptyList()
/** Active BML sessions keyed by loginId (= BML username). */
/**
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
* Use [bmlSessionFor] to look up the session for an account.
*/
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf() val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList() /** BML profiles per loginId (= BML username). */
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
var bmlAccounts: List<BankAccount> = emptyList()
/** Active Fahipay sessions keyed by loginId (= profileId). */ /** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf() val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList() var fahipayAccounts: List<BankAccount> = emptyList()
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */ /** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? = fun mibSessionFor(account: BankAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")] mibSessions[account.loginTag.removePrefix("mib_")]
/** Returns any available MIB session. */ /** Returns any available MIB session. */
@@ -53,15 +67,40 @@ class BasedBankApp : Application() {
/** Returns any available MibLoginFlow. */ /** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull() fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
/** Returns the BML session for the given account (matched via loginTag). */ // ─── BML helpers ──────────────────────────────────────────────────────────
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")] /**
* Returns the BML session for the given account.
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
*/
fun bmlSessionFor(account: BankAccount): BmlSession? {
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
}
/** Returns any available BML session (for non-account-specific operations). */ /** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull() fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/**
* Returns any active BML session for the given loginId.
* Tries all profiles for that login; falls back to legacy loginId key.
*/
fun anyBmlSessionFor(loginId: String): BmlSession? {
val profiles = bmlProfilesMap[loginId]
if (!profiles.isNullOrEmpty()) {
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
}
return bmlSessions[loginId]
}
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
fun bmlFlowFor(loginId: String): BmlLoginFlow =
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
// ─── Fahipay helpers ──────────────────────────────────────────────────────
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */ /** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? = fun fahipaySessionFor(account: BankAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")] fahipaySessions[account.loginTag.removePrefix("fahipay_")]
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */

View File

@@ -0,0 +1,169 @@
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 }
}
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
val code = resp.code
val json = resp.body?.string() ?: return null
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val p = root.optJSONObject("payload") ?: return null
BmlLoanDetail(
loanAmount = p.optDouble("loanAmount", 0.0),
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
repayAmount = p.optDouble("repayAmount", 0.0),
intRate = p.optDouble("intRate", 0.0),
loanStatus = p.optString("loanStatus"),
startDate = p.optString("startDate"),
endDate = p.optString("endDate"),
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
overdueAmount = p.optDouble("overdueAmount", 0.0)
)
} 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>()
val loanAccounts = 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 == "Loan") {
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
loanAccounts.add(BankAccount(
bank = "BML",
profileName = profileName,
profileType = "BML_LOAN",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(outstanding),
currentBalance = "%.2f".format(outstanding),
blockedAmount = "0.00",
mvrBalance = "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 + loanAccounts
}
}

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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() }
}
}

View File

@@ -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 }
}
}

View File

@@ -7,19 +7,15 @@ import okhttp3.FormBody
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Base64 import java.util.Base64
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AuthExpiredException : Exception("Session expired") class AuthExpiredException : Exception("Session expired")
@@ -29,9 +25,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)" private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
private val APP_VERSION = "2.1.43.345" private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0" private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -53,14 +49,27 @@ class BmlLoginFlow {
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
private val apiClient = OkHttpClient.Builder() /** PKCE params — generated once per login and reused across all profile activations. */
.connectTimeout(30, TimeUnit.SECONDS) private var codeVerifier: String = ""
.readTimeout(30, TimeUnit.SECONDS) private var codeChallenge: String = ""
.build() private var deviceId: String = ""
/** Full login: returns a BmlSession and the account list. */ /** Profiles returned by the last successful [login] call. */
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> { var lastProfiles: List<BmlProfile> = emptyList()
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies private set
// ─── Login ────────────────────────────────────────────────────────────────
/**
* Performs web authentication (login + TOTP) and returns the list of available profiles.
* Call [activateProfile] for each profile to obtain an access token + accounts.
*/
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
deviceId = generateDeviceId()
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
client.newCall( client.newCall(
Request.Builder().url("$BASE_URL/web/login") Request.Builder().url("$BASE_URL/web/login")
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
@@ -82,15 +91,14 @@ class BmlLoginFlow {
loginResp.close() loginResp.close()
if (loginResp.code != 302) throw Exception("Login failed — check your username/password") if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
// Step 3: GET 2FA page (refreshes blaze_session) // Step 3: GET 2FA page (refreshes session cookies)
client.newCall( client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa") Request.Builder().url("$BASE_URL/web/login/2fa")
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute().close() ).execute().close()
val xsrf2 = xsrfToken() ?: xsrf val xsrf2 = xsrfToken() ?: xsrf
// Step 4: POST OTP // Step 4: POST TOTP
val otp = Totp.generate(otpSeed) val otp = Totp.generate(otpSeed)
val twoFaBody = JSONObject().apply { val twoFaBody = JSONObject().apply {
put("code", otp) put("code", otp)
@@ -104,18 +112,161 @@ class BmlLoginFlow {
twoFaResp.close() twoFaResp.close()
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed") if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection) // Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
client.newCall( // 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") Request.Builder().url("$BASE_URL/web/profile")
.header("X-XSRF-TOKEN", xsrf2) .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 = 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
}
// ─── Profile activation ───────────────────────────────────────────────────
/**
* Activates a profile in the current web session and returns the result.
*
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
* - Business profiles (profile_type="business") require SMS/email OTP; returns
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
* [requestBusinessOtp] + [submitBusinessOtp].
*/
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
// 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}")
.header("User-Agent", WEB_USER_AGENT)
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
val resp = client.newCall(reqBuilder.build()).execute()
val code = resp.code
val location = resp.header("Location") ?: ""
resp.close()
return when {
code == 409 || (code == 302 && "/web/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)
}
code == 302 && "/web/profile/2fa/business" in location -> {
// Business profile: server requires SMS/email OTP
val channels = fetchBusinessOtpChannels()
BmlActivationResult.NeedsBusinessOtp(channels)
}
else -> throw Exception("Profile activation failed (HTTP $code)")
}
}
/**
* Returns available OTP channels for the business 2FA page.
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
*/
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val body = resp.body?.string() ?: ""
resp.close()
return parseBusinessOtpChannels(body)
}
/**
* Sends an OTP to [channel] for business profile activation.
* Must be called before [submitBusinessOtp].
*/
fun requestBusinessOtp(channel: String) {
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val body = JSONObject().apply {
put("code", "")
put("channel", channel)
}.toString().toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val respCode = resp.code
resp.close()
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
}
/**
* Verifies the OTP and activates the business profile.
* Returns a new [BmlSession] and accounts on success.
* @throws Exception if the OTP is invalid (retry is allowed).
*/
fun submitBusinessOtp(
channel: String,
code: String,
profile: BmlProfile,
loginTag: String
): Pair<BmlSession, List<BankAccount>> {
// Refresh XSRF token before submitting
client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute().close() ).execute().close()
// Step 6: PKCE OAuth authorize → extract auth code val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val codeVerifier = generateCodeVerifier() val body = JSONObject().apply {
val codeChallenge = generateCodeChallenge(codeVerifier) put("code", code)
val deviceId = generateDeviceId() put("channel", channel)
}.toString().toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val respCode = resp.code
val location = resp.header("Location") ?: ""
resp.close()
return when {
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
respCode == 302 ->
throw Exception("Invalid OTP — please try again")
else ->
throw Exception("Business OTP verification failed (HTTP $respCode)")
}
}
// ─── OAuth + account fetch ────────────────────────────────────────────────
/**
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
* Returns a fresh [BmlSession] and the profile's accounts.
*/
private fun doOAuthAndFetchAccounts(
loginTag: String,
profileName: String,
profileId: String
): Pair<BmlSession, List<BankAccount>> {
val authorizeUrl = HttpUrl.Builder() val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv") .scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize") .addPathSegments("internetbanking/oauth/authorize")
@@ -135,14 +286,12 @@ class BmlLoginFlow {
Request.Builder().url(authorizeUrl) Request.Builder().url(authorizeUrl)
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute() ).execute()
val location = authorizeResp.header("Location")
authorizeResp.close() authorizeResp.close()
val location = authorizeResp.header("Location") val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
?: throw Exception("OAuth authorize did not redirect") ?: throw Exception("OAuth authorize did not return auth code")
val authCode = Uri.parse(location).getQueryParameter("code")
?: throw Exception("No auth code in OAuth redirect")
// Step 7: Exchange auth code for access token
val tokenBody = FormBody.Builder() val tokenBody = FormBody.Builder()
.add("Device-ID", deviceId) .add("Device-ID", deviceId)
.add("code", authCode) .add("code", authCode)
@@ -161,568 +310,64 @@ class BmlLoginFlow {
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response") val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close() tokenResp.close()
val tokenObj = JSONObject(tokenJson) val accessToken = JSONObject(tokenJson).optString("access_token")
val accessToken = tokenObj.optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId) val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session, "bml_$username") val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts) return Pair(session, accounts)
} }
// ─── Parsing ──────────────────────────────────────────────────────────────
fun fetchAccounts(session: BmlSession, loginTag: 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)
}
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)
}
/** /**
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready). * BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
*/ */
fun initiateTransfer( private fun extractInertiaJson(html: String): String? {
session: BmlSession, val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
debitAccount: String, return match.groupValues[1]
creditAccount: String, .replace("&quot;", "\"")
amount: Double, .replace("&amp;", "&")
transferType: String, .replace("&#39;", "'")
currency: String, .replace("&lt;", "<")
bank: String? = null .replace("&gt;", ">")
): 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 }
}
} }
/** private fun parseProfiles(html: String): List<BmlProfile> {
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
*/
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 }
}
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
/**
* Fetches paginated transaction history for a BML CASA account.
* @return Pair of (transactions, totalPages)
*/
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
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 { return try {
val json = extractInertiaJson(html) ?: html
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0) val props = root.optJSONObject("props") ?: return emptyList()
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0) val profiles = props.optJSONArray("profiles") ?: return emptyList()
val totalPages = payload.optInt("totalPages", 0) (0 until profiles.length()).mapNotNull { i ->
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages) val p = profiles.getJSONObject(i)
val transactions = (0 until history.length()).map { i -> val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
val item = history.getJSONObject(i) BmlProfile(
val desc = item.optString("description").trim() profileId = p.optString("profile_id"),
val narrative1 = item.optString("narrative1") name = p.optString("name"),
val date = when (desc) { type = p.optString("type"),
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate") profileType = profileObj.optString("profile_type", "default")
"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) }
}
/**
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
* Returns combined outstanding authorizations + settled statement entries.
*/
fun fetchCardHistory(
session: BmlSession,
cardId: String,
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>()
// Outstanding authorizations
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"
))
}
}
// Settled statement entries
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() }
}
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 parseDashboard(json: String, loginTag: 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 = "Personal",
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,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
val isPrepaid = item.optBoolean("prepaid_card", false)
val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount(
bank = "BML",
profileName = "Personal",
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,
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() } } catch (_: Exception) { emptyList() }
} }
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> { private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
val root = JSONObject(json) return try {
if (!root.optBoolean("success")) return emptyList() val json = extractInertiaJson(html) ?: html
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList() val root = JSONObject(json)
val result = mutableListOf<MibBeneficiary>() val props = root.optJSONObject("props") ?: return emptyList()
for (i in 0 until payload.length()) { val channels = props.optJSONArray("channels") ?: return emptyList()
val item = payload.getJSONObject(i) (0 until channels.length()).map { i ->
val account = item.optString("account", "") val c = channels.getJSONObject(i)
if (account.isBlank()) continue BmlOtpChannel(
result.add(MibBeneficiary( channel = c.optString("channel"),
benefNo = "bml_${item.optInt("id")}", description = c.optString("description"),
benefName = item.optString("name"), masked = c.optString("masked")
benefNickName = item.optString("alias", item.optString("name")), )
benefAccount = account, }
benefType = "I", } catch (_: Exception) { emptyList() }
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
} }
private fun xsrfToken(): String? = private fun xsrfToken(): String? =
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
@@ -748,4 +393,5 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes) SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) } return bytes.joinToString("") { "%02x".format(it) }
} }
} }

View File

@@ -1,10 +1,36 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import sh.sar.basedbank.api.models.BankAccount
data class BmlSession( data class BmlSession(
val accessToken: String, val accessToken: String,
val deviceId: String val deviceId: String
) )
data class BmlProfile(
val profileId: String,
val name: String,
val type: String, // "Profile" (personal) or "Business"
val profileType: String, // "default" or "business"
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
)
data class BmlOtpChannel(
val channel: String,
val description: String,
val masked: String
)
sealed class BmlActivationResult {
data class Success(
val session: BmlSession,
val accounts: List<BankAccount>
) : BmlActivationResult()
data class NeedsBusinessOtp(
val channels: List<BmlOtpChannel>
) : BmlActivationResult()
}
data class BmlAccountValidation( data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB val validationType: String, // BML, alias, MIB
@@ -24,6 +50,18 @@ data class BmlTransferResult(
val errorMessage: String = "" val errorMessage: String = ""
) )
data class BmlLoanDetail(
val loanAmount: Double,
val outstandingAmt: Double, // negative as returned by API
val repayAmount: Double,
val intRate: Double,
val loanStatus: String,
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
val endDate: String,
val noOfRepayOverdue: Int,
val overdueAmount: Double
)
data class BmlForeignLimit( data class BmlForeignLimit(
val type: String, val type: String,
val used: Double, val used: Double,

View File

@@ -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") }
}
}
}

View File

@@ -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 }
}
}

View File

@@ -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
)
}

View File

@@ -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
}
}

View File

@@ -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) }
}
}

View File

@@ -10,10 +10,6 @@ import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.Buffer import okio.Buffer
import org.json.JSONObject 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.security.SecureRandom
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -21,8 +17,6 @@ class FahipayLoginFlow {
private val BASE_URL = "https://fahipay.mv" 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_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 cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -144,165 +138,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response") ?: 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( private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true", "device[available]" to "true",
"device[platform]" to "Android", "device[platform]" to "Android",

View File

@@ -1,5 +1,7 @@
package sh.sar.basedbank.api.fahipay package sh.sar.basedbank.api.fahipay
import sh.sar.basedbank.api.models.BankContact
data class FahipaySession( data class FahipaySession(
val authId: String, val authId: String,
val sessionCookie: String val sessionCookie: String
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
data class FahipayContactGroup( data class FahipayContactGroup(
val categoryId: String, val categoryId: String,
val label: String, val label: String,
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary> val contacts: List<BankContact>
) )

View File

@@ -0,0 +1,62 @@
package sh.sar.basedbank.api.mib
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
val body = FormBody.Builder()
.add("name", "")
.add("start", "1")
.add("end", "50")
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body)
.header("Cookie", cookieHeader(session))
.header("User-Agent", "Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//debitCards?dashurl=1")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return emptyList()
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() }
if (!json.optBoolean("success")) return emptyList()
val data = json.optJSONArray("data") ?: return emptyList()
(0 until data.length()).map { i ->
val item = data.getJSONObject(i)
MibCard(
cardId = item.optString("cardId"),
maskedCardNumber = item.optString("maskedCardNumber"),
cardStatus = item.optString("cardStatus"),
cardType = item.optString("cardType"),
cardTypeDesc = item.optString("cardTypeDesc"),
customerId = item.optString("customerId"),
phoneNumber = item.optString("phoneNumber"),
cardHolderName = item.optString("cardHolderName"),
loginTag = loginTag
)
}
}
}
}

View File

@@ -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. */ /** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
fun fetchProfileImage(session: MibSession, imageHash: String): String? { fun fetchProfileImage(session: MibSession, imageHash: String): String? {
val payload = baseData(session, "P41").apply { val payload = baseData(session, "P41").apply {

View File

@@ -1,5 +1,16 @@
package sh.sar.basedbank.api.mib 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( data class MibSession(
val appId: String, val appId: String,
val xxid: String, val xxid: String,
@@ -19,25 +30,6 @@ data class MibProfile(
val customerImage: String? 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( data class MibTransferResult(
val success: Boolean, val success: Boolean,
@@ -46,27 +38,6 @@ data class MibTransferResult(
val errorMessage: String = "" 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( data class MibIpsAccountInfo(
val accountName: String, val accountName: String,
@@ -74,18 +45,17 @@ data class MibIpsAccountInfo(
val bankId: String val bankId: String
) )
data class Transaction(
val id: String, data class MibCard(
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML val cardId: String,
val description: String, val maskedCardNumber: String,
val amount: Double, // negative = debit, positive = credit val cardStatus: String,
val currency: String, val cardType: String,
val counterpartyName: String?, val cardTypeDesc: String,
val reference: String?, val customerId: String,
val accountNumber: String, val phoneNumber: String,
val accountDisplayName: String, val cardHolderName: String,
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY" val loginTag: String
val iconUrl: String? = null // merchant icon URL (Fahipay only)
) )
data class MibFinanceDeal( data class MibFinanceDeal(

View File

@@ -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 }
}
}

View File

@@ -68,6 +68,7 @@ class MibTransferClient {
.withWvHeaders(session) .withWvHeaders(session)
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: "" val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null } val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) { if (json == null || !json.optBoolean("success")) {

View File

@@ -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)
)

View File

@@ -9,8 +9,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.util.AccountHistoryDisplay import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
@@ -21,13 +21,13 @@ import java.util.Date
import java.util.Locale import java.util.Locale
class AccountHistoryAdapter( class AccountHistoryAdapter(
private val account: MibAccount, private val account: BankAccount,
private val display: AccountHistoryDisplay private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
data class DateHeader(val label: String) : 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>() private val displayItems = mutableListOf<Item>()
@@ -36,7 +36,18 @@ class AccountHistoryAdapter(
private val iconUrlCache = mutableMapOf<String, Bitmap>() private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: 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) { fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
@@ -73,7 +84,7 @@ class AccountHistoryAdapter(
* Display the given (already sorted + filtered) list with date group headers. * Display the given (already sorted + filtered) list with date group headers.
* Silently resets the loading footer so notifyDataSetChanged covers everything. * Silently resets the loading footer so notifyDataSetChanged covers everything.
*/ */
fun setTransactions(transactions: List<Transaction>) { fun setTransactions(transactions: List<BankTransaction>) {
_showLoadingFooter = false _showLoadingFooter = false
displayItems.clear() displayItems.clear()
lastInsertedDateKey = "" lastInsertedDateKey = ""
@@ -94,7 +105,7 @@ class AccountHistoryAdapter(
* Appends [newTransactions] (assumed to be older than all existing items) using incremental * Appends [newTransactions] (assumed to be older than all existing items) using incremental
* notifications, so the RecyclerView doesn't reset scroll position. * notifications, so the RecyclerView doesn't reset scroll position.
*/ */
fun appendTransactions(newTransactions: List<Transaction>) { fun appendTransactions(newTransactions: List<BankTransaction>) {
if (newTransactions.isEmpty()) return if (newTransactions.isEmpty()) return
if (_showLoadingFooter) { if (_showLoadingFooter) {
val pos = itemCount - 1 val pos = itemCount - 1
@@ -154,10 +165,10 @@ class AccountHistoryAdapter(
b.tvHeaderAccountNumber.text = d.number b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = d.bankPill b.tvHeaderPillBank.text = d.bankPill
b.tvHeaderPillType.text = d.typeLabel b.tvHeaderPillType.text = d.typeLabel
b.tvHeaderAvailable.text = d.availableBalance b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
b.tvHeaderBalance.text = d.workingBalance b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
if (d.blockedBalance != null) { 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 b.llHeaderBlocked.visibility = View.VISIBLE
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
@@ -173,7 +184,7 @@ class AccountHistoryAdapter(
inner class TransactionVH(private val b: ItemTransactionBinding) : inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(trx: Transaction) { fun bind(trx: BankTransaction) {
val isCredit = trx.amount >= 0 val isCredit = trx.amount >= 0
val color = sourceColor(trx.source) val color = sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description val name = trx.counterpartyName ?: trx.description
@@ -211,17 +222,22 @@ class AccountHistoryAdapter(
b.tvDate.text = formatTime(trx.date) b.tvDate.text = formatTime(trx.date)
val sign = if (isCredit) "+" else "-" if (hideAmounts) {
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount)) b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.text = "$sign ${trx.currency} $absAmt" b.tvAmount.setTextColor(Color.parseColor("#888888"))
b.tvAmount.setTextColor( } else {
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043") 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) } b.root.setOnClickListener { showDetail(trx) }
} }
private fun showDetail(trx: Transaction) { private fun showDetail(trx: BankTransaction) {
val ctx = b.root.context val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString { val details = buildString {
@@ -282,6 +298,11 @@ class AccountHistoryAdapter(
return FULL_DATE_FMT.format(date) return FULL_DATE_FMT.format(date)
} }
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
fun sourceColor(source: String) = when (source) { fun sourceColor(source: String) = when (source) {
"MIB" -> "#FE860E" "MIB" -> "#FE860E"
"BML", "BML_CARD" -> "#0066A1" "BML", "BML_CARD" -> "#0066A1"

View File

@@ -22,9 +22,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient 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.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser import sh.sar.basedbank.util.AccountHistoryParser
@@ -39,10 +39,10 @@ class AccountHistoryFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountHistoryAdapter private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount private lateinit var account: BankAccount
private lateinit var fetcher: HistoryFetcher private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<BankTransaction>()
private var searchQuery = "" private var searchQuery = ""
private var firstPageDone = false private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>() private val pendingImageNames = mutableSetOf<String>()
@@ -53,7 +53,7 @@ class AccountHistoryFragment : Fragment() {
companion object { companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number" private const val ARG_ACCOUNT_NUMBER = "account_number"
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply { fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, account.accountNumber) putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
} }
@@ -77,6 +77,8 @@ class AccountHistoryFragment : Fragment() {
adapter.onTransferClick = { acc -> adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(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.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
@@ -116,6 +118,14 @@ class AccountHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPage() loadNextPage()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
} }
override fun onResume() { override fun onResume() {
@@ -133,6 +143,17 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun resetAndReload() {
allTransactions.clear()
pendingImageNames.clear()
pendingIconUrls.clear()
firstPageDone = false
fetcher = HistoryFetcher(account)
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
loadNextPage()
}
private fun loadNextPage() { private fun loadNextPage() {
if (isLoading || !fetcher.hasMore()) return if (isLoading || !fetcher.hasMore()) return
isLoading = true isLoading = true
@@ -151,6 +172,7 @@ class AccountHistoryFragment : Fragment() {
if (!firstPageDone) { if (!firstPageDone) {
firstPageDone = true firstPageDone = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
} }
if (transactions.isNotEmpty()) { if (transactions.isNotEmpty()) {

View File

@@ -8,7 +8,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView 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.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
@@ -16,32 +16,39 @@ import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<BankAccount>,
private val onAccountClick: (MibAccount) -> Unit = {} private val onAccountClick: (BankAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null var onTransferClick: ((BankAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item() data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item() data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
} }
private val items: MutableList<Item> = buildItems(accounts).toMutableList() private val items: MutableList<Item> = buildItems(accounts).toMutableList()
fun updateAccounts(accounts: List<MibAccount>) { fun updateAccounts(accounts: List<BankAccount>) {
items.clear() items.clear()
items.addAll(buildItems(accounts)) items.addAll(buildItems(accounts))
notifyDataSetChanged() 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 displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val nonCards = displayed.filter { !it.second.isCard } val nonCards = displayed.filter { !it.second.isCard }
val cards = 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) { for ((acc, display) in nonCards) {
val title = sectionTitle(acc) val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc to display) 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) { val bankName = when (account.bank) {
"BML" -> "Bank of Maldives" "BML" -> "Bank of Maldives"
"FAHIPAY" -> "Fahipay" "FAHIPAY" -> "Fahipay"
@@ -105,11 +112,11 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) : private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount, display: AccountListDisplay) { fun bind(account: BankAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel 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.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
@@ -121,13 +128,13 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) : private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount, display: AccountListDisplay) { fun bind(account: BankAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(display.cardBrandIcon) binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = display.name binding.tvCardName.text = display.name
binding.tvCardNumber.text = display.number binding.tvCardNumber.text = display.number
binding.tvCardProduct.text = display.typeLabel binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE 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) { if (display.statusLabel != null) {
binding.tvCardStatus.text = display.statusLabel binding.tvCardStatus.text = display.statusLabel
binding.tvCardStatus.visibility = View.VISIBLE binding.tvCardStatus.visibility = View.VISIBLE
@@ -146,6 +153,11 @@ class AccountsAdapter(
private const val TYPE_ACCOUNT = 1 private const val TYPE_ACCOUNT = 1
private const val TYPE_CARD = 2 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) { private fun copyToClipboard(context: Context, accountNumber: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber)) cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))

View File

@@ -44,6 +44,12 @@ class AccountsFragment : Fragment() {
} }
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) } viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
} }
override fun onResume() { override fun onResume() {

View File

@@ -0,0 +1,108 @@
package sh.sar.basedbank.ui.home
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
import sh.sar.basedbank.util.ReceiptStore
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ActivitiesAdapter(
private val onItemClick: (ReceiptStore.Entry) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
}
private val displayItems = mutableListOf<Item>()
fun setEntries(entries: List<ReceiptStore.Entry>) {
displayItems.clear()
var lastDateKey = ""
for (entry in entries) {
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
if (dateKey != lastDateKey) {
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
lastDateKey = dateKey
}
displayItems.add(Item.ReceiptItem(entry))
}
notifyDataSetChanged()
}
override fun getItemCount() = displayItems.size
override fun getItemViewType(position: Int) =
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_DATE_HEADER)
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
else
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
}
}
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(label: String) { b.tvDateHeader.text = label }
}
inner class ReceiptVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(entry: ReceiptStore.Entry) {
val d = entry.data
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
b.fvAvatar.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
}
b.tvInitial.visibility = android.view.View.VISIBLE
b.tvInitial.text = initial
b.tvCounterparty.text = d.toLabel
b.tvCounterparty.visibility = android.view.View.VISIBLE
b.tvDescription.text = buildString {
append(d.fromLabel)
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
}
b.tvDate.text = formatTime(entry.savedAt)
b.tvAmount.text = "- ${d.currency} ${d.amount}"
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
b.root.setOnClickListener { onItemClick(entry) }
}
}
private fun formatDateHeader(millis: Long): String {
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
return sdf.format(Date(millis))
}
private fun formatTime(millis: Long): String {
val sdf = SimpleDateFormat("HH:mm", Locale.US)
return sdf.format(Date(millis))
}
companion object {
private const val TYPE_DATE_HEADER = 0
private const val TYPE_RECEIPT = 1
}
}

View File

@@ -0,0 +1,95 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
import sh.sar.basedbank.util.ReceiptStore
class ActivitiesFragment : Fragment() {
private var _binding: FragmentActivitiesBinding? = null
private val binding get() = _binding!!
private lateinit var adapter: ActivitiesAdapter
private val allEntries = mutableListOf<ReceiptStore.Entry>()
private var searchQuery = ""
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ActivitiesAdapter { entry ->
(activity as? HomeActivity)?.showWithBackStack(
TransferReceiptFragment.newInstance(entry.data, null)
)
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext()
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay()
}
})
loadEntries()
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_activities)
// Reload in case a new receipt was added while we were away
loadEntries()
}
private fun loadEntries() {
allEntries.clear()
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
filterAndDisplay()
}
private fun filterAndDisplay() {
val filtered = if (searchQuery.isBlank()) allEntries
else allEntries.filter { entry ->
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
}
adapter.setEntries(filtered)
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -30,8 +30,9 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory 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.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibTransferClient import sh.sar.basedbank.api.mib.MibTransferClient
@@ -64,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedImageBase64: String = "" private var selectedImageBase64: String = ""
private var selectedCategoryId: String = "0" 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? -> private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
@@ -98,10 +99,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) { for ((loginId, profiles) in app.bmlProfilesMap) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: "" for (profile in profiles) {
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName)) if (app.bmlSessions.containsKey(profile.profileId)) {
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
}
}
} }
return list return list
} }
@@ -241,14 +245,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun lookupForBml(input: String): BmlAccountValidation? { private fun lookupForBml(input: String): BmlAccountValidation? {
val loginId = selectedDest?.bmlLoginId ?: return null val loginId = selectedDest?.bmlLoginId ?: return null
val bmlSess = app.bmlSessions[loginId] ?: return null val bmlSess = app.bmlSessions[loginId] ?: return null
val bmlFlow = BmlLoginFlow()
// 1) Try BML validate // 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 if (validated != null) return validated
// 2) Try BML MIB verify // 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 if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML) // 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 // MIB lookup failed (e.g. BML USD account) — fall back to BML validate
val bmlSess = app.anyBmlSession() ?: return null 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) { private fun showLookupResult(validation: BmlAccountValidation, input: String) {
@@ -418,15 +420,14 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val loginId = selectedDest?.bmlLoginId ?: return false val loginId = selectedDest?.bmlLoginId ?: return false
val bmlSess = app.bmlSessions[loginId] ?: return false val bmlSess = app.bmlSessions[loginId] ?: return false
val lookup = bmlLookup ?: return false val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow()
val account = lookup.account val account = lookup.account
return when { return when {
account.matches(Regex("^7\\d{12}$")) -> account.matches(Regex("^7\\d{12}$")) ->
// BML account → IAT // BML account → IAT
bmlFlow.saveContact(bmlSess, "IAT", account, alias) BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
account.matches(Regex("^9\\d{16}$")) -> account.matches(Regex("^9\\d{16}$")) ->
// MIB internal → DOT; swift is BML's internal UUID for MIB bank // 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) currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
else -> false else -> false
} }
@@ -486,7 +487,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (dest.isBml) { if (dest.isBml) {
val loginId = dest.bmlLoginId ?: return@launch val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: 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 existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged) viewModel.contacts.postValue(merged)

View File

@@ -0,0 +1,114 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
class CardSettingsFragment : Fragment() {
private var _binding: FragmentCardSettingsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardSettingsAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefreshCards()
}
viewModel.mibCards.observe(viewLifecycleOwner) { cards ->
if (cards == null) return@observe
adapter.update(cards)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE
}
if (viewModel.mibCards.value == null) {
binding.loadingView.visibility = View.VISIBLE
(activity as? HomeActivity)?.triggerRefreshCards()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_card_settings)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardSettingsAdapter(
private var cards: List<MibCard>,
private val context: Context
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
fun update(newCards: List<MibCard>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
private val btnBlock: View = view.findViewById(R.id.btnBlock)
fun bind(card: MibCard) {
tvCardOwner.text = card.cardHolderName
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber)
tvCardType.text = card.cardTypeDesc
PayWithCardFragment.loadCardImage(ivCardImage, PayWithCardFragment.cardImageAsset(card))
val wip = View.OnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
btnChangePin.setOnClickListener(wip)
btnFreeze.setOnClickListener(wip)
btnBlock.setOnClickListener(wip)
}
}
}
}

View File

@@ -23,7 +23,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay import sh.sar.basedbank.util.ContactDisplay
@@ -48,6 +48,7 @@ class ContactsFragment : Fragment() {
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
private var contactsRefreshing = false
private data class TabPage(val categoryId: String?, val label: String) private data class TabPage(val categoryId: String?, val label: String)
@@ -134,6 +135,11 @@ class ContactsFragment : Fragment() {
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.setOnRefreshListener {
contactsRefreshing = true
(activity as? HomeActivity)?.loadAllContacts()
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
rebuildPager(cats) rebuildPager(cats)
} }
@@ -143,6 +149,10 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateContacts(allContacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
if (contactsRefreshing) {
contactsRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
} }
} }
@@ -153,7 +163,7 @@ class ContactsFragment : Fragment() {
}.also { it.attach() } }.also { it.attach() }
} }
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) { private fun rebuildPager(cats: List<BankContactCategory>) {
val pages = buildList { val pages = buildList {
add(TabPage(null, getString(R.string.contacts_tab_all))) add(TabPage(null, getString(R.string.contacts_tab_all)))
cats.forEach { add(TabPage(it.id, it.categoryName)) } cats.forEach { add(TabPage(it.id, it.categoryName)) }

View File

@@ -12,8 +12,9 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlForeignLimit 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.api.mib.MibFinanceDeal
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -30,8 +31,19 @@ class DashboardFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) } viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) } viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
@@ -63,44 +75,134 @@ class DashboardFragment : Fragment() {
} }
} }
private fun updateBalances(accounts: List<MibAccount>) { private fun updateBalances(accounts: List<BankAccount>) {
val mvrTotal = accounts val hide = viewModel.hideAmounts.value ?: false
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" }
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
if (hide) {
binding.tvMvrBalance.text = "MVR ••••••"
binding.tvUsdBalance.text = "USD ••••••"
if (creditAccounts.isNotEmpty()) {
binding.rowCreditCards.visibility = View.VISIBLE
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
binding.tvMvrCredit.text = "MVR ••••••"
binding.tvUsdCredit.text = "USD ••••••"
} else {
binding.rowCreditCards.visibility = View.GONE
}
return
}
val mvrTotal = nonCreditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) } .filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 } .sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdTotal = accounts val usdTotal = nonCreditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) } .filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 } .sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal) binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal) binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
val mvrCredit = creditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdCredit = creditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
if (creditAccounts.isNotEmpty()) {
binding.rowCreditCards.visibility = View.VISIBLE
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
} else {
binding.rowCreditCards.visibility = View.GONE
}
} }
private val expandedLimits = mutableSetOf<Int>()
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) { private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
val hide = viewModel.hideAmounts.value ?: false
binding.containerForeignLimits.removeAllViews() binding.containerForeignLimits.removeAllViews()
var cardIndex = 0
for (entry in entries) { for (entry in entries) {
for (limit in entry.limits) { for (limit in entry.limits) {
val idx = cardIndex++
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false) val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" } bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
card.tvLimitType.text = limit.type card.root.setOnClickListener {
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap) if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining) updateForeignLimits(entries)
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) binding.containerForeignLimits.addView(card.root)
} }
} }
} }
private fun updatePendingFinances(deals: List<MibFinanceDeal>) { private fun bindLimitCard(
val total = deals.sumOf { it.outstandingAmount } card: ItemForeignLimitBinding,
binding.tvPendingFinances.text = "MVR %,.2f".format(total) userName: String,
limit: BmlForeignLimit,
hide: Boolean,
expanded: Boolean
) {
card.tvLimitUserName.text = userName.ifBlank { "BML" }
card.tvLimitType.text = limit.type
// ECOM (always visible)
card.tvLimitEcom.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
// General (always visible)
card.tvLimitGeneral.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
// Expanded section
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
card.dividerLimitDetails.visibility = detailsVisible
card.detailsGroup.visibility = detailsVisible
if (expanded) {
// ATM
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
card.tvLimitAtm.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
// POS
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
card.tvLimitPos.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
// Medical
card.tvLimitMedical.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
}
}
private fun updatePendingFinances() {
val hide = viewModel.hideAmounts.value ?: false
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
val total = mibTotal + bmlTotal
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@@ -5,92 +5,139 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.mib.MibFinancingClient import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
import sh.sar.basedbank.databinding.ItemFinanceDealBinding import sh.sar.basedbank.databinding.ItemFinanceDealBinding
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
class FinancingAdapter(private var deals: List<MibFinanceDeal>) : class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() { RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class Mib(val deal: MibFinanceDeal) : Item()
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
}
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
private var hideAmounts: Boolean = false
private val expandedPositions = mutableSetOf<Int>() private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply { private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2 minimumFractionDigits = 2
maximumFractionDigits = 2 maximumFractionDigits = 2
} }
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US) private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun updateDeals(newDeals: List<MibFinanceDeal>) { fun setHideAmounts(hide: Boolean) {
deals = newDeals if (hideAmounts == hide) return
expandedPositions.clear() hideAmounts = hide
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false) expandedPositions.clear()
return ViewHolder(binding) items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { // Legacy compatibility — used on initial empty construction
holder.bind(deals[position], position in expandedPositions) fun updateDeals(newDeals: List<MibFinanceDeal>) {
holder.binding.root.setOnClickListener { expandedPositions.clear()
val bmlItems = items.filterIsInstance<Item.Bml>()
items = newDeals.map { Item.Mib(it) } + bmlItems
notifyDataSetChanged()
}
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
expandedPositions.clear()
val mibItems = items.filterIsInstance<Item.Mib>()
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
}
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.Mib -> TYPE_MIB
is Item.Bml -> TYPE_BML
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val expanded = position in expandedPositions
when (val item = items[position]) {
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
}
holder.itemView.setOnClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos) if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
notifyItemChanged(pos) notifyItemChanged(pos)
} }
} }
override fun getItemCount() = deals.size override fun getItemCount() = items.size
inner class ViewHolder(val binding: ItemFinanceDealBinding) : // ── MIB ViewHolder ────────────────────────────────────────────────────────
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(deal: MibFinanceDeal, expanded: Boolean) { fun bind(deal: MibFinanceDeal, expanded: Boolean) {
val ctx = binding.root.context val ctx = binding.root.context
val currency = deal.currency val currency = deal.currency
val hide = hideAmounts
binding.tvProductName.text = deal.productDesc binding.tvProductName.text = deal.productDesc
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo) binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
binding.tvStatus.text = deal.statusDesc binding.tvStatus.text = deal.statusDesc
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}" binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}" binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}" binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0) val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100) ((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0 else 0
binding.progressBar.progress = progress binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate binding.tvCompletion.text = mibCompletionText(deal, ctx)
binding.tvCompletion.text = completionText(deal, ctx)
// Expanded details
val detailsVisible = if (expanded) View.VISIBLE else View.GONE val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.dividerDetails.visibility = detailsVisible binding.dividerDetails.visibility = detailsVisible
binding.detailsGroup.visibility = detailsVisible binding.detailsGroup.visibility = detailsVisible
if (expanded) { if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate) binding.tvDealDate.text = formatMibDate(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.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate) binding.tvLastPaidDate.text = formatMibDate(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) { if (deal.overdueAmount > 0) {
binding.rowOverdue.visibility = View.VISIBLE 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 { } else {
binding.rowOverdue.visibility = View.GONE binding.rowOverdue.visibility = View.GONE
} }
} }
} }
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String { private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done) if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
val remaining = MibFinancingClient.remainingMonths(deal) val remaining = MibFinancingClient.remainingMonths(deal)
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done) if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
@@ -100,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
return ctx.getString(R.string.financing_completion_fmt, month) return ctx.getString(R.string.financing_completion_fmt, month)
} }
private fun formatDate(raw: String): String { private fun formatMibDate(raw: String): String {
return try { return try {
outputDateFmt.format(inputDateFmt.parse(raw)!!) outputDateFmt.format(mibDateFmt.parse(raw)!!)
} catch (_: Exception) { } catch (_: Exception) { raw.take(10) }
raw.take(10)
}
} }
} }
// ── BML ViewHolder ────────────────────────────────────────────────────────
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
val ctx = binding.root.context
val currency = account.currencyName
val hide = hideAmounts
binding.tvLoanProduct.text = account.accountTypeName
.trim().lowercase().split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
binding.tvLoanAccount.text = account.accountNumber
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
val loanAmt = detail?.loanAmount ?: 0.0
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
binding.loanProgressBar.progress = if (hide) 0 else progress
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.loanDividerDetails.visibility = detailsVisible
binding.loanDetailsGroup.visibility = detailsVisible
if (expanded && detail != null) {
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
if (detail.overdueAmount > 0) {
binding.loanRowOverdue.visibility = View.VISIBLE
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
} else {
binding.loanRowOverdue.visibility = View.GONE
}
}
}
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
if (detail == null) return ""
val outstanding = abs(detail.outstandingAmt)
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
return ctx.getString(R.string.financing_completion_done)
val remaining = ceil(outstanding / detail.repayAmount).toInt()
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, remaining)
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatIsoDate(raw: String): String {
return try {
outputDateFmt.format(isoDateFmt.parse(raw)!!)
} catch (_: Exception) { raw.take(10) }
}
}
companion object {
private const val TYPE_MIB = 0
private const val TYPE_BML = 1
}
} }

View File

@@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding import sh.sar.basedbank.databinding.FragmentFinancingBinding
class FinancingFragment : Fragment() { class FinancingFragment : Fragment() {
@@ -18,6 +21,10 @@ class FinancingFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: FinancingAdapter private lateinit var adapter: FinancingAdapter
private var financingRefreshing = false
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFinancingBinding.inflate(inflater, container, false) _binding = FragmentFinancingBinding.inflate(inflater, container, false)
@@ -38,11 +45,38 @@ class FinancingFragment : Fragment() {
insets insets
} }
binding.swipeRefresh.setOnRefreshListener {
financingRefreshing = true
(activity as? HomeActivity)?.triggerRefreshFinancing()
}
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
viewModel.financing.observe(viewLifecycleOwner) { deals -> viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals) latestMibDeals = deals
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE rebuildAdapter()
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE }
binding.loadingView.visibility = View.GONE viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
latestBmlLoanDetails = details
rebuildAdapter()
}
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
private fun rebuildAdapter() {
val accounts = viewModel.accounts.value ?: emptyList()
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans)
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
if (financingRefreshing) {
financingRefreshing = false
binding.swipeRefresh.isRefreshing = false
} }
} }

View File

@@ -14,10 +14,15 @@ import android.widget.Toast
import sh.sar.basedbank.ui.home.NavCustomization import sh.sar.basedbank.ui.home.NavCustomization
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.core.view.GravityCompat
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -33,17 +38,25 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayContactsClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding import sh.sar.basedbank.databinding.ActivityHomeBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.mib.MibBeneficiary import sh.sar.basedbank.api.models.BankContact
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.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
@@ -61,6 +74,10 @@ class HomeActivity : AppCompatActivity() {
private lateinit var toggle: ActionBarDrawerToggle private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false private var suppressBottomNavCallback = false
private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper())
private val resetBackPress = Runnable { backPressedOnce = false }
private val autolockHandler = Handler(Looper.getMainLooper()) private val autolockHandler = Handler(Looper.getMainLooper())
private var warningDialog: AlertDialog? = null private var warningDialog: AlertDialog? = null
private var countdownTimer: CountDownTimer? = null private var countdownTimer: CountDownTimer? = null
@@ -108,6 +125,12 @@ class HomeActivity : AppCompatActivity() {
binding.drawerLayout.addDrawerListener(toggle) binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState() 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 -> binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true if (suppressBottomNavCallback) return@setOnItemSelectedListener true
val frag = when (item.itemId) { val frag = when (item.itemId) {
@@ -115,11 +138,15 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment() R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment() R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> PayWithCardFragment()
R.id.nav_card_settings -> CardSettingsFragment()
else -> null else -> null
} }
if (frag != null) show(frag) if (frag != null) show(frag)
@@ -153,6 +180,8 @@ class HomeActivity : AppCompatActivity() {
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
@@ -161,6 +190,7 @@ class HomeActivity : AppCompatActivity() {
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId)) refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
} }
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshBmlLoanDetails()
} else { } else {
// Came from lock screen — show caches immediately, refresh everything in background // Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this) val store = CredentialStore(this)
@@ -171,18 +201,58 @@ class HomeActivity : AppCompatActivity() {
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
autoRefresh(store) autoRefresh(store)
} }
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
// Show dashboard on first create // Show dashboard on first create
if (savedInstanceState == null) { if (savedInstanceState == null) {
show(DashboardFragment()) show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard) binding.navigationView.setCheckedItem(R.id.nav_dashboard)
} }
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Close drawer if open (drawer-nav mode)
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawers()
return
}
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
return
}
// In bottom nav mode, pressing back navigates up the hierarchy
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment())
return
}
binding.bottomNavigation.selectedItemId = R.id.nav_dashboard
return
}
// At top level — require double-tap to exit
if (backPressedOnce) {
backPressHandler.removeCallbacks(resetBackPress)
finish()
} else {
backPressedOnce = true
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
backPressHandler.postDelayed(resetBackPress, 2000)
}
}
})
// Keep all MIB sessions alive every 25 seconds while the app is in the foreground // Keep all MIB sessions alive every 25 seconds while the app is in the foreground
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -248,9 +318,22 @@ class HomeActivity : AppCompatActivity() {
} }
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more) menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more)
.setIcon(R.drawable.ic_nav_more) .setIcon(R.drawable.ic_nav_more)
// Restore selection to current destination after menu rebuild
val currentId = binding.navigationView.checkedItem?.itemId
if (currentId != null) {
val bottomNavIds = (0 until menu.size()).map { menu.getItem(it).itemId }.toSet()
val selectId = if (currentId in bottomNavIds) currentId
else if (R.id.nav_more in bottomNavIds) R.id.nav_more
else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
} }
fun applyNavLabelVisibility() { fun applyNavLabelVisibility() {
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true) val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
binding.bottomNavigation.labelVisibilityMode = binding.bottomNavigation.labelVisibilityMode =
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
@@ -259,14 +342,18 @@ class HomeActivity : AppCompatActivity() {
fun navigateTo(itemId: Int, fragment: Fragment? = null) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment() R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> PayWithCardFragment()
R.id.nav_card_settings -> CardSettingsFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return } else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
} }
show(dest) show(dest)
@@ -290,6 +377,19 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun triggerRefresh() {
autoRefresh(CredentialStore(this))
}
fun triggerRefreshFinancing() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlLoanDetails()
}
fun setRefreshing(visible: Boolean) { fun setRefreshing(visible: Boolean) {
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
} }
@@ -303,11 +403,12 @@ class HomeActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Returning from LockActivity — skip the elapsed check and reset state. // Returning from LockActivity — refresh sessions since they may have expired.
if (isLocked) { if (isLocked) {
isLocked = false isLocked = false
pauseTime = 0L pauseTime = 0L
resetAutolockTimer() resetAutolockTimer()
autoRefresh(CredentialStore(this))
return return
} }
// If we were away long enough to have hit the autolock timeout (e.g. while // If we were away long enough to have hit the autolock timeout (e.g. while
@@ -320,6 +421,9 @@ class HomeActivity : AppCompatActivity() {
lock() lock()
return return
} }
if (elapsed > 45_000L) {
autoRefresh(CredentialStore(this))
}
} }
resetAutolockTimer() resetAutolockTimer()
} }
@@ -383,12 +487,34 @@ class HomeActivity : AppCompatActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu) 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 return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) { if (item.itemId == R.id.action_lock) {
lock() val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
if (avd != null) {
item.icon = avd
avd.start()
Handler(Looper.getMainLooper()).postDelayed({ lock() }, 200)
} else {
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()
val avd = getDrawable(if (newHidden) R.drawable.avd_hide_amounts else R.drawable.avd_show_amounts)
as? android.graphics.drawable.AnimatedVectorDrawable
item.icon = avd
avd?.start()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
@@ -445,33 +571,85 @@ class HomeActivity : AppCompatActivity() {
val bmlJobs = bmlLoginIds.mapNotNull { loginId -> val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) { loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow()
val loginTag = "bml_$loginId" val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId) val app = application as BasedBankApp
val savedProfiles = store.loadBmlProfiles(loginId)
val allAccounts = mutableListOf<BankAccount>()
var anyExpired = savedProfiles.isEmpty()
if (savedToken != null) { // Try each saved profile's cached session
try { for (profile in savedProfiles) {
val session = BmlSession(savedToken.first, savedToken.second) val saved = store.loadBmlProfileSession(profile.profileId)
val accounts = bmlFlow.fetchAccounts(session, loginTag) if (saved != null) {
val app = application as BasedBankApp try {
app.bmlSessions[loginId] = session val session = BmlSession(saved.first, saved.second)
AccountCache.saveBml(this@HomeActivity, loginId, accounts) val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
return@async Pair(session, accounts) app.bmlSessions[profile.profileId] = session
} catch (_: AuthExpiredException) { allAccounts += accounts
} catch (_: Exception) { } catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
} else {
anyExpired = true
} }
} }
try { if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId) // Also try legacy single-profile session token (pre-multi-profile installs)
val app = application as BasedBankApp if (savedProfiles.isEmpty()) {
app.bmlSessions[loginId] = session val legacyToken = store.loadBmlSession(loginId)
AccountCache.saveBml(this@HomeActivity, loginId, accounts) if (legacyToken != null) {
Pair(session, accounts) try {
} catch (_: Exception) { val session = BmlSession(legacyToken.first, legacyToken.second)
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId)) val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session
allAccounts += accounts
anyExpired = false
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
}
} }
if (anyExpired || allAccounts.isEmpty()) {
// Re-authenticate to refresh personal profile sessions
try {
val flow = app.bmlFlowFor(loginId)
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlProfiles(loginId, profiles)
app.bmlProfilesMap[loginId] = profiles
for (profile in profiles) {
if (profile.profileType == "business") {
// Can't activate business profiles without user OTP — use cached
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
if (allAccounts.none { it.profileId == profile.profileId })
allAccounts += cached
continue
}
try {
val result = flow.activateProfile(profile, loginTag)
if (result is BmlActivationResult.Success) {
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
app.bmlSessions[profile.profileId] = result.session
allAccounts.removeAll { it.profileId == profile.profileId }
allAccounts += result.accounts
}
} catch (_: Exception) {
if (allAccounts.none { it.profileId == profile.profileId }) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
}
}
}
} catch (_: Exception) {
if (allAccounts.isEmpty())
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
}
}
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
allAccounts as List<BankAccount>
} }
} }
@@ -487,10 +665,9 @@ class HomeActivity : AppCompatActivity() {
if (savedSession != null) { if (savedSession != null) {
try { try {
val session = FahipaySession(savedSession.first, savedSession.second) val session = FahipaySession(savedSession.first, savedSession.second)
fahipayFlow.setSessionCookie(session.sessionCookie) val balance = FahipayAccountClient().fetchBalance(session)
val balance = fahipayFlow.fetchBalance(session) val profile = FahipayAccountClient().fetchProfile(session)
val profile = fahipayFlow.fetchProfile(session) val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp val app = application as BasedBankApp
app.fahipaySessions[loginId] = session app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
@@ -507,9 +684,9 @@ class HomeActivity : AppCompatActivity() {
val cookieValue = fahipayFlow.getSessionCookieValue() ?: "" val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
val session = FahipaySession(authId, cookieValue) val session = FahipaySession(authId, cookieValue)
store.saveFahipaySession(loginId, authId, cookieValue) store.saveFahipaySession(loginId, authId, cookieValue)
val profile = fahipayFlow.fetchProfile(session) val profile = FahipayAccountClient().fetchProfile(session)
val balance = fahipayFlow.fetchBalance(session) val balance = FahipayAccountClient().fetchBalance(session)
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val accounts = listOf(FahipayAccountClient().buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp val app = application as BasedBankApp
app.fahipaySessions[loginId] = session app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
@@ -522,8 +699,7 @@ class HomeActivity : AppCompatActivity() {
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() } val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
val mibAccounts = mibResults.flatMap { it.second } val mibAccounts = mibResults.flatMap { it.second }
val bmlResults = bmlJobs.map { (_, job) -> job.await() } val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() } val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
val app = application as BasedBankApp val app = application as BasedBankApp
@@ -539,17 +715,27 @@ class HomeActivity : AppCompatActivity() {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId)) refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
} }
refreshBmlLoanDetails()
} }
} }
/** Filters MIB accounts whose profileId the user has hidden in settings. */ /** Filters accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> { private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
val store = CredentialStore(this@HomeActivity) val store = CredentialStore(this@HomeActivity)
return filter { acc -> return filter { acc ->
if (acc.bank != "MIB") return@filter true when (acc.bank) {
val loginId = acc.loginTag.removePrefix("mib_") "MIB" -> {
val hidden = store.getHiddenMibProfileIds(loginId) val loginId = acc.loginTag.removePrefix("mib_")
hidden.isEmpty() || acc.profileId !in hidden val hidden = store.getHiddenMibProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
"BML" -> {
val loginId = acc.loginTag.removePrefix("bml_")
val hidden = store.getHiddenBmlProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
else -> true
}
} }
} }
@@ -567,11 +753,10 @@ class HomeActivity : AppCompatActivity() {
} }
private fun refreshBmlLimits(session: BmlSession) { private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val (userName, limits) = withContext(Dispatchers.IO) { 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 existing = viewModel.bmlLimits.value?.toMutableList() ?: mutableListOf()
val idx = existing.indexOfFirst { it.userName == userName } val idx = existing.indexOfFirst { it.userName == userName }
@@ -585,11 +770,13 @@ class HomeActivity : AppCompatActivity() {
private fun refreshBmlContacts(app: BasedBankApp) { private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return if (app.bmlSessions.isEmpty()) return
val store = CredentialStore(this)
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val allBmlContacts = withContext(Dispatchers.IO) { val allBmlContacts = withContext(Dispatchers.IO) {
app.bmlSessions.flatMap { (loginId, session) -> store.getBmlLoginIds().flatMap { loginId ->
val contacts = BmlLoginFlow().fetchContacts(session, loginId) val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
val contacts = BmlContactsClient().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts) if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts contacts
} }
@@ -632,13 +819,11 @@ class HomeActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val groups = withContext(Dispatchers.IO) { val groups = withContext(Dispatchers.IO) {
val flow = FahipayLoginFlow() FahipayContactsClient().fetchContacts(session)
flow.setSessionCookie(session.sessionCookie)
flow.fetchContacts(session)
} }
if (groups.isEmpty()) return@launch if (groups.isEmpty()) return@launch
val contacts = groups.flatMap { it.contacts } 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) ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
val mibContacts = ContactsCache.loadContacts(this@HomeActivity) val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds() val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
@@ -651,11 +836,11 @@ class HomeActivity : AppCompatActivity() {
} }
private fun mergeContacts( private fun mergeContacts(
mib: List<MibBeneficiary>, mib: List<BankContact>,
bml: List<MibBeneficiary> bml: List<BankContact>
): List<MibBeneficiary> { ): List<BankContact> {
val seen = mutableSetOf<String>() 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 mib) if (seen.add(c.benefNo)) result.add(c)
for (c in bml) if (seen.add(c.benefNo)) result.add(c) for (c in bml) if (seen.add(c.benefNo)) result.add(c)
return result return result
@@ -670,8 +855,8 @@ class HomeActivity : AppCompatActivity() {
val (allContacts, allCategories) = withContext(Dispatchers.IO) { val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>() val seenContacts = mutableSetOf<String>()
val seenCategories = mutableSetOf<String>() val seenCategories = mutableSetOf<String>()
val contacts = mutableListOf<MibBeneficiary>() val contacts = mutableListOf<BankContact>()
val categories = mutableListOf<MibBeneficiaryCategory>() val categories = mutableListOf<BankContactCategory>()
for (profile in profiles) { for (profile in profiles) {
try { try {
flow.switchProfile(session, profile) flow.switchProfile(session, profile)
@@ -699,7 +884,7 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun refreshBalances(src: MibAccount) { fun refreshBalances(src: BankAccount) {
val app = application as BasedBankApp val app = application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
@@ -707,12 +892,10 @@ class HomeActivity : AppCompatActivity() {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySessionFor(src) ?: return@withContext null val sess = app.fahipaySessionFor(src) ?: return@withContext null
try { try {
val flow = FahipayLoginFlow() val balance = FahipayAccountClient().fetchBalance(sess)
flow.setSessionCookie(sess.sessionCookie) val profile = FahipayAccountClient().fetchProfile(sess)
val balance = flow.fetchBalance(sess)
val profile = flow.fetchProfile(sess)
val loginTag = "fahipay_${profile.profileId}" 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_") val loginId = src.loginTag.removePrefix("fahipay_")
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
@@ -726,7 +909,7 @@ class HomeActivity : AppCompatActivity() {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
try { try {
val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag) val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
AccountCache.saveBml(this@HomeActivity, loginId, accounts) AccountCache.saveBml(this@HomeActivity, loginId, accounts)
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag } val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
app.bmlAccounts = otherBml + accounts app.bmlAccounts = otherBml + accounts
@@ -770,6 +953,69 @@ class HomeActivity : AppCompatActivity() {
} }
} }
private fun refreshBmlLoanDetails() {
val app = application as BasedBankApp
val loanAccounts = app.bmlAccounts.filter { it.profileType == "BML_LOAN" }
if (loanAccounts.isEmpty()) return
lifecycleScope.launch {
try {
val details = withContext(Dispatchers.IO) {
val map = mutableMapOf<String, BmlLoanDetail>()
for (acc in loanAccounts) {
val session = app.bmlSessionFor(acc) ?: continue
try {
val detail = BmlAccountClient().fetchLoanDetail(session, acc.internalId)
if (detail != null) map[acc.internalId] = detail
} catch (_: Exception) { /* keep existing */ }
}
map
}
if (details.isNotEmpty()) {
val merged = (viewModel.bmlLoanDetails.value ?: emptyMap()) + details
FinancingCache.saveBmlLoans(this@HomeActivity, merged)
viewModel.bmlLoanDetails.postValue(merged)
}
} catch (_: Exception) { /* keep cached data */ }
}
}
fun triggerRefreshCards() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
}
}
private fun refreshMibCards(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val client = MibCardsClient()
lifecycleScope.launch {
try {
val cards = withContext(Dispatchers.IO) {
val result = mutableListOf<sh.sar.basedbank.api.mib.MibCard>()
val seen = mutableSetOf<String>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (card in client.fetchCards(session, "mib_$loginId")) {
if (seen.add(card.cardId)) result += card
}
} catch (_: Exception) { }
}
result
}
if (cards.isNotEmpty()) {
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
existing.removeAll { it.loginTag == "mib_$loginId" }
existing += cards
viewModel.mibCards.postValue(existing)
}
} catch (_: Exception) { }
}
}
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) { private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId) val flow = (application as BasedBankApp).mibFlowFor(loginId)

View File

@@ -3,17 +3,25 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibBeneficiary import sh.sar.basedbank.api.models.BankAccount
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.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<MibAccount>>(emptyList()) val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList()) val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList()) /** BML loan details keyed by account internalId. */
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList()) val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>) data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList()) val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
val mibCards = MutableLiveData<List<MibCard>?>(null)
val hideAmounts = MutableLiveData<Boolean>(false)
} }

View File

@@ -25,6 +25,7 @@ class MoreFragment : Fragment() {
val row = inflater.inflate(R.layout.item_more_nav, list, false) val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes) row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes) row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.findViewById<TextView>(R.id.tvDescription).setText(item.descriptionRes)
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) } row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
list.addView(row) list.addView(row)
} }

View File

@@ -10,21 +10,23 @@ object NavCustomization {
data class NavItemDef( data class NavItemDef(
val id: Int, val id: Int,
@DrawableRes val iconRes: Int, @DrawableRes val iconRes: Int,
@StringRes val titleRes: Int @StringRes val titleRes: Int,
@StringRes val descriptionRes: Int
) )
/** All items that can occupy either a bottom nav slot or the "More" screen. */ /** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf( val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts), NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts), NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer), NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr), NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities), NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history), NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances), NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings), NavItemDef(R.id.nav_pay_with_card, R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp), NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings), NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
) )
fun getSlots(prefs: SharedPreferences): List<Int> = listOf( fun getSlots(prefs: SharedPreferences): List<Int> = listOf(

View File

@@ -14,7 +14,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp 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.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.FragmentOtpBinding import sh.sar.basedbank.databinding.FragmentOtpBinding
import sh.sar.basedbank.databinding.ItemOtpCardBinding import sh.sar.basedbank.databinding.ItemOtpCardBinding
@@ -94,7 +95,7 @@ class OtpFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId) val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) { val profile = withContext(Dispatchers.IO) {
try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null } try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
} }
if (profile != null) { if (profile != null) {
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile( store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
@@ -114,7 +115,7 @@ class OtpFragment : Fragment() {
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) { if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
val session = app.bmlSessions[loginId] ?: continue val session = app.bmlSessions[loginId] ?: continue
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null } try { BmlAccountClient().fetchUserInfo(session) } catch (_: Exception) { null }
} }
if (info != null) { if (info != null) {
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile( store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(

View File

@@ -0,0 +1,420 @@
package sh.sar.basedbank.ui.home
import android.content.ContentValues
import android.content.Context
import android.graphics.*
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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
import androidx.lifecycle.lifecycleScope
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
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
import java.io.File
import java.io.FileOutputStream
class PayMvQrFragment : Fragment() {
private var _binding: FragmentPayMvQrBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null
private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
val activity = requireActivity() as HomeActivity
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose
))
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
return binding.root
}
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
binding.btnSave.isEnabled = false
binding.btnShare.setOnClickListener { shareQr() }
binding.btnSave.setOnClickListener { saveQr() }
binding.btnScanQr.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
}
val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter)
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
selectedAccount = picked
scheduleGenerate()
}
}
}
private fun scheduleGenerate() {
generateJob?.cancel()
generateJob = viewLifecycleOwner.lifecycleScope.launch {
delay(300)
generateQr()
}
}
private suspend fun generateQr() {
val account = selectedAccount ?: return
val acquirer = when (account.bank) {
"BML" -> "MALBMVMV"
"MIB" -> "MADVMVMV"
"FAHIPAY" -> "FAHIMVMV"
else -> "MADVMVMV"
}
val amountFormatted = binding.etAmount.text?.toString()?.trim()
?.replace(",", "")
?.toDoubleOrNull()
?.takeIf { it > 0 }
?.let { "%.2f".format(it) }
val ctx = requireContext()
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
renderQrCard(ctx, account, payload, amountFormatted)
}
if (_binding == null) return
generatedBitmap = bmp
binding.tvQrPlaceholder.visibility = View.GONE
binding.ivQrCard.setImageBitmap(bmp)
binding.ivQrCard.visibility = View.VISIBLE
binding.btnShare.isEnabled = true
binding.btnSave.isEnabled = true
}
// ── EMV MPQR payload ──────────────────────────────────────────────────────
private fun buildQrPayload(
accountNumber: String,
accountName: String,
acquirer: String,
amountStr: String?
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
return tag + (if (len < 10) "0$len" else "$len") + value
}
val format = tlv("00", "01")
val poi = tlv("01", "11")
val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer)
val sub03 = tlv("03", accountNumber)
val sub10 = tlv("10", "IPAY")
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
val currency = tlv("53", "462")
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
val country = tlv("58", "MV")
val name = tlv("59", accountName.take(25))
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
return prefix + crc16(prefix)
}
private fun crc16(data: String): String {
var crc = 0xFFFF
for (c in data) {
crc = crc xor ((c.code and 0xFF) shl 8)
repeat(8) {
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
else (crc shl 1) and 0xFFFF
}
}
return crc.toString(16).uppercase().padStart(4, '0')
}
// ── QR card rendering ────────────────────────────────────────────────────
private fun renderQrCard(
ctx: Context,
account: BankAccount,
qrPayload: String,
amountStr: String?
): Bitmap {
val W = 900
val H = 1080
val outerCorner = 48f
val boxBlue = Color.parseColor("#2272B7")
val footerBlue = Color.parseColor("#1A5799")
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Clip to outer rounded card shape
val outerPath = Path()
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
canvas.clipPath(outerPath)
canvas.drawColor(Color.WHITE)
// --- Bank logo top-left ---
val logoRes = when (account.bank) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long
}
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
val nW = d.intrinsicWidth.coerceAtLeast(1)
val nH = d.intrinsicHeight.coerceAtLeast(1)
val maxW = 180f; val maxH = 76f
val scale = minOf(maxW / nW, maxH / nH)
val lW = (nW * scale).toInt()
val lH = (nH * scale).toInt()
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
d.setBounds(24, lTop, 24 + lW, lTop + lH)
d.draw(canvas)
}
// --- "PayMV QR" top-right ---
paint.color = Color.parseColor("#1A1A2E")
paint.textSize = 36f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.RIGHT
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
// --- Blue rounded box ---
paint.color = boxBlue
paint.textAlign = Paint.Align.LEFT
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
// Account name (white, bold, uppercase, auto-scaled to fit)
paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
val nameText = account.accountBriefName.uppercase()
paint.textSize = 36f
val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) {
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
}
val nameBaseline = boxT + 68f
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
// Optional amount below name
val qrTopY: Float
if (!amountStr.isNullOrBlank()) {
paint.textSize = 28f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
val amtBaseline = nameBaseline + 42f
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
qrTopY = amtBaseline + 20f
} else {
qrTopY = nameBaseline + 26f
}
// QR code — white modules on the same blue as the box background
val availH = boxB - qrTopY - 24f
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
val qrLeft = ((W - qrPx) / 2).toFloat()
try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) {
for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
}
}
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ }
// --- Dark blue footer ---
paint.color = footerBlue
paint.textAlign = Paint.Align.LEFT
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
paint.color = Color.WHITE
paint.textSize = 32f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
return bm
}
// ── Share / Save ─────────────────────────────────────────────────────────
private fun shareQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
lifecycleScope.launch {
val uri = withContext(Dispatchers.IO) {
try {
val dir = File(requireContext().cacheDir, "qr")
dir.mkdirs()
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val file = File(dir, "${safeName}_paymv_qr.png")
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
file
)
} catch (_: Exception) { null }
}
if (uri == null || _binding == null) return@launch
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
}
}
private fun saveQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) {
try {
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val filename = "${safeName}_PayMV_QR.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
val uri = requireContext().contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
) ?: return@withContext false
requireContext().contentResolver.openOutputStream(uri)?.use {
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
}
} else {
@Suppress("DEPRECATION")
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
dir.mkdirs()
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
true
} catch (_: Exception) { false }
}
if (_binding == null) return@launch
Toast.makeText(
requireContext(),
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
Toast.LENGTH_SHORT
).show()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.pay_mv_qr)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
// ── Account dropdown adapter ──────────────────────────────────────────────
private inner class QrAccountAdapter(
private val context: Context,
private val accounts: List<BankAccount>
) : BaseAdapter(), Filterable {
fun getAccount(position: Int): BankAccount? = accounts.getOrNull(position)
override fun getCount() = accounts.size
override fun getItem(position: Int) = accounts.getOrNull(position)
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
getDropDownView(position, convertView, parent)
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val acc = accounts[position]
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding
} else {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
}
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = acc.accountNumber
b.tvDropdownBalance.text = ""
b.root.alpha = 1f
return b.root
}
override fun getFilter() = object : Filter() {
override fun performFiltering(c: CharSequence?) =
FilterResults().apply { values = accounts; count = accounts.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) =
(r as? BankAccount)?.let {
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
"$prefix${it.accountBriefName}"
} ?: ""
}
}
}

View File

@@ -0,0 +1,141 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
class PayWithCardFragment : Fragment() {
private var _binding: FragmentPayWithCardBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardWalletAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefreshCards()
}
viewModel.mibCards.observe(viewLifecycleOwner) { cards ->
if (cards == null) return@observe
adapter.update(cards)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE
}
if (viewModel.mibCards.value == null) {
binding.loadingView.visibility = View.VISIBLE
(activity as? HomeActivity)?.triggerRefreshCards()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardWalletAdapter(
private var cards: List<MibCard>,
private val context: Context
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
fun update(newCards: List<MibCard>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(card: MibCard) {
tvCardOwner.text = card.cardHolderName
tvCardNumber.text = formatMasked(card.maskedCardNumber)
tvCardType.text = card.cardTypeDesc
loadCardImage(ivCardImage, cardImageAsset(card))
btnPayQr.setOnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
btnPayNfc.setOnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
}
}
}
companion object {
fun cardImageAsset(card: MibCard): String {
val desc = card.cardTypeDesc.lowercase()
return when {
"corporate" in desc || "business" in desc -> "cards/mib/visa_business.jpg"
"platinum" in desc -> "cards/mib/cards-platinum-bg-w-logo-1.jpg"
else -> "cards/mib/cards-blue-bg-w-logo-1.jpg"
}
}
fun loadCardImage(imageView: ImageView, assetPath: String) {
try {
val bitmap = imageView.context.assets.open(assetPath).use {
BitmapFactory.decodeStream(it)
}
imageView.setImageBitmap(bitmap)
} catch (_: Exception) {
imageView.setImageResource(android.R.color.darker_gray)
}
}
fun formatMasked(masked: String): String {
if (masked.length < 4) return masked
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"
}
}
}

View File

@@ -17,14 +17,15 @@ class SettingsFragment : Fragment() {
private data class SettingsItem( private data class SettingsItem(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
@StringRes val title: Int, @StringRes val title: Int,
@StringRes val description: Int,
val dest: () -> Fragment val dest: () -> Fragment
) )
private val items = listOf( private val items = listOf(
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() }, SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance) { SettingsAppearanceFragment() }, SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security) { SettingsSecurityFragment() }, SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() }, SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
) )
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
@@ -37,6 +38,7 @@ class SettingsFragment : Fragment() {
val row = inflater.inflate(R.layout.item_more_nav, list, false) val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon) row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title) row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
row.findViewById<TextView>(R.id.tvDescription).setText(item.description)
row.setOnClickListener { row.setOnClickListener {
(requireActivity() as HomeActivity).showWithBackStack(item.dest()) (requireActivity() as HomeActivity).showWithBackStack(item.dest())
} }

View File

@@ -7,14 +7,16 @@ import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
@@ -26,11 +28,21 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.RecentsCache 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() { class SettingsLoginsFragment : Fragment() {
private var _binding: FragmentSettingsLoginsBinding? = null private var _binding: FragmentSettingsLoginsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false) _binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
@@ -78,24 +90,9 @@ class SettingsLoginsFragment : Fragment() {
for (loginId in bmlLoginIds) { for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile(loginId) val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct() val bmlProfiles = store.loadBmlProfiles(loginId)
addLoginRow(container, R.drawable.bml_logo_vector, displayName) { addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
showLoginDetails( showBmlLoginDetails(store, loginId, profile, bmlProfiles)
title = getString(R.string.bml_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}")
if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}")
if (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
)
} }
} }
@@ -103,13 +100,15 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId) val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) { addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
showLoginDetails( showLoginDetails(
title = getString(R.string.fahipay_name), title = getString(R.string.fahipay_name),
details = buildString { details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}") 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?.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)}: ${profile!!.mobile}") 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)}: ${profile!!.nid}") if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
}.trim(), }.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } } onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
) )
@@ -195,8 +194,8 @@ class SettingsLoginsFragment : Fragment() {
}) })
} }
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button // Build toggle rows — wired up after dialog.show() so we can reference the Save button
val checkboxRows = mibProfiles.map { p -> val toggleRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply { val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
@@ -219,11 +218,20 @@ class SettingsLoginsFragment : Fragment() {
alpha = 0.6f alpha = 0.6f
}) })
} }
val cb = CheckBox(ctx).apply { isChecked = p.profileId !in hidden } val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol) row.addView(textCol)
row.addView(cb) row.addView(toggle)
container.addView(row) container.addView(row)
p to cb p to toggle
}
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = mibProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (p, toggle) ->
// Disable the sole remaining visible toggle so it can't be turned off
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
} }
val dialog = MaterialAlertDialogBuilder(ctx) val dialog = MaterialAlertDialogBuilder(ctx)
@@ -238,12 +246,12 @@ class SettingsLoginsFragment : Fragment() {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE) val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false saveBtn.isEnabled = false
updateToggleStates(saveBtn)
checkboxRows.forEach { (p, cb) -> toggleRows.forEach { (p, toggle) ->
cb.setOnCheckedChangeListener { _, checked -> toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId) if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden } updateToggleStates(saveBtn)
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
} }
} }
@@ -255,6 +263,349 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
private fun showBmlLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.BmlUserProfile?,
bmlProfiles: List<BmlProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val 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 {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
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)}: ${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
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
})
}
if (bmlProfiles.isNotEmpty()) {
if (profile != null) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.login_detail_profiles)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
}
val toggleRows = bmlProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
text = p.name
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
})
if (p.type.isNotBlank()) {
textCol.addView(TextView(ctx).apply {
text = p.type
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
}
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol)
row.addView(toggle)
container.addView(row)
p to toggle
}
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.bml_name))
.setView(scroll)
.setPositiveButton(R.string.save, null)
.setNeutralButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) }
}
.show()
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
updateToggleStates(saveBtn)
toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked && 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)
}
}
}
saveBtn.setOnClickListener {
store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(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)
}
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
@@ -290,9 +641,14 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutBml(store: CredentialStore, loginId: String) { private fun logoutBml(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSessions.remove(loginId) // Remove all per-profile sessions for this login from the in-memory map
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
profiles.forEach { app.bmlSessions.remove(it.profileId) }
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
store.clearBmlCredentials(loginId)
app.bmlProfilesMap.remove(loginId)
app.bmlLoginFlows.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()

View File

@@ -60,7 +60,6 @@ class SettingsSecurityFragment : Fragment() {
// Auto-lock // Auto-lock
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) { binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
0L -> R.id.btnAutolockOff
30_000L -> R.id.btnAutolock30s 30_000L -> R.id.btnAutolock30s
180_000L -> R.id.btnAutolock3m 180_000L -> R.id.btnAutolock3m
300_000L -> R.id.btnAutolock5m 300_000L -> R.id.btnAutolock5m
@@ -69,7 +68,6 @@ class SettingsSecurityFragment : Fragment() {
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
val timeout = when (checkedId) { val timeout = when (checkedId) {
R.id.btnAutolockOff -> 0L
R.id.btnAutolock30s -> 30_000L R.id.btnAutolock30s -> 30_000L
R.id.btnAutolock3m -> 180_000L R.id.btnAutolock3m -> 180_000L
R.id.btnAutolock5m -> 300_000L R.id.btnAutolock5m -> 300_000L
@@ -79,6 +77,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer() (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 // Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true) val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots binding.switchBlockScreenshots.isChecked = blockScreenshots

View File

@@ -9,17 +9,17 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transfer 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>() { class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
data class DateHeader(val label: String) : 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>() private val displayItems = mutableListOf<Item>()
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val iconUrlCache = mutableMapOf<String, Bitmap>() private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: 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) { fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
@@ -60,7 +67,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
/** Replace the full sorted transaction list and rebuild date groups. */ /** Replace the full sorted transaction list and rebuild date groups. */
fun setTransactions(transactions: List<Transaction>) { fun setTransactions(transactions: List<BankTransaction>) {
_showLoadingFooter = false _showLoadingFooter = false
displayItems.clear() displayItems.clear()
var lastDateKey = "" var lastDateKey = ""
@@ -107,7 +114,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
inner class TransactionVH(private val b: ItemTransactionBinding) : inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(trx: Transaction) { fun bind(trx: BankTransaction) {
val isCredit = trx.amount >= 0 val isCredit = trx.amount >= 0
val color = AccountHistoryAdapter.sourceColor(trx.source) val color = AccountHistoryAdapter.sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description val name = trx.counterpartyName ?: trx.description
@@ -134,23 +141,28 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
b.tvDescription.text = trx.description b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History // Show account name in secondary line for BankTransaction History
b.tvCounterparty.text = trx.accountDisplayName b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE b.tvCounterparty.visibility = View.VISIBLE
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date) b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
val sign = if (isCredit) "+" else "-" if (hideAmounts) {
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount)) b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.text = "$sign ${trx.currency} $absAmt" b.tvAmount.setTextColor(Color.parseColor("#888888"))
b.tvAmount.setTextColor( } else {
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043") 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) } b.root.setOnClickListener { showDetail(trx) }
} }
private fun showDetail(trx: Transaction) { private fun showDetail(trx: BankTransaction) {
val ctx = b.root.context val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString { val details = buildString {

View File

@@ -36,12 +36,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.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.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.bml.BmlTransferResult import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException import sh.sar.basedbank.api.mib.MibLookupException
@@ -56,6 +57,7 @@ import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
class TransferFragment : Fragment() { class TransferFragment : Fragment() {
@@ -64,11 +66,11 @@ class TransferFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null private var selectedAccount: BankAccount? = null
private val session get() = selectedAccount private val session get() = selectedAccount
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) } ?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyMibSession() ?: (requireActivity().application as BasedBankApp).anyMibSession()
private fun bmlSessionFor(account: MibAccount?) = private fun bmlSessionFor(account: BankAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) } account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession() ?: (requireActivity().application as BasedBankApp).anyBmlSession()
@@ -101,8 +103,10 @@ class TransferFragment : Fragment() {
private const val ARG_COLOR = "contact_color" private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash" private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_FROM_ACCOUNT = "from_account" private const val ARG_FROM_ACCOUNT = "from_account"
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) } arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
} }
@@ -121,6 +125,22 @@ class TransferFragment : Fragment() {
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash) if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
} }
} }
fun newInstanceFromQr(
accountNumber: String,
displayName: String,
amount: String?,
remarks: String?
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B")
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
}
}
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -155,7 +175,7 @@ class TransferFragment : Fragment() {
binding.etAmount.addTextChangedListener { updateTransferButton() } binding.etAmount.addTextChangedListener { updateTransferButton() }
// Pre-select contact if navigated from contacts page // Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly( prefillToDirectly(
accountNumber = account, accountNumber = account,
@@ -165,6 +185,8 @@ class TransferFragment : Fragment() {
imageHash = arguments?.getString(ARG_IMAGE_HASH) imageHash = arguments?.getString(ARG_IMAGE_HASH)
) )
} }
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
} }
private fun startLookupLoading() { private fun startLookupLoading() {
@@ -218,7 +240,7 @@ class TransferFragment : Fragment() {
} }
} }
private fun showFromCard(account: MibAccount) { private fun showFromCard(account: BankAccount) {
val colorHex = when (account.bank) { val colorHex = when (account.bank) {
"BML" -> "#0066A1" "BML" -> "#0066A1"
"FAHIPAY" -> "#15BEA7" "FAHIPAY" -> "#15BEA7"
@@ -263,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 " binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
} }
@@ -348,10 +370,9 @@ class TransferFragment : Fragment() {
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
if (isBmlSource && bmlSess != null) { if (isBmlSource && bmlSess != null) {
val inputType = AccountInputParser.detect(accountNumber) val inputType = AccountInputParser.detect(accountNumber)
val bmlFlow = BmlLoginFlow()
val bmlResult = try { val bmlResult = try {
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) bmlFlow.verifyMibAccount(bmlSess, accountNumber) if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) BmlValidateClient().verifyMibAccount(bmlSess, accountNumber)
else bmlFlow.validateAccount(bmlSess, accountNumber) else BmlValidateClient().validateAccount(bmlSess, accountNumber)
} catch (_: Exception) { null } } catch (_: Exception) { null }
if (bmlResult != null) { if (bmlResult != null) {
val bankId = when (bmlResult.trnType) { val bankId = when (bmlResult.trnType) {
@@ -372,7 +393,7 @@ class TransferFragment : Fragment() {
catch (e: MibLookupException) { errorMsg = e.message; null } catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else { } else {
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null } val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) { if (bmlResult != null) {
val bankId = when (bmlResult.trnType) { val bankId = when (bmlResult.trnType) {
"IAT" -> "MALBMVMV" "IAT" -> "MALBMVMV"
@@ -628,9 +649,10 @@ class TransferFragment : Fragment() {
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) { if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.refreshBalances(src) activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) { } else if (!ok) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
@@ -709,7 +731,7 @@ class TransferFragment : Fragment() {
} }
private fun doMibTransfer( private fun doMibTransfer(
src: MibAccount, src: BankAccount,
destAccount: String, destAccount: String,
destName: String, destName: String,
destDisplay: String, destDisplay: String,
@@ -755,7 +777,7 @@ class TransferFragment : Fragment() {
) )
if (result.success) { if (result.success) {
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
isMib = true, bank = "MIB",
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0), amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency, currency = currency,
fromLabel = src.accountBriefName, fromLabel = src.accountBriefName,
@@ -768,7 +790,7 @@ class TransferFragment : Fragment() {
mibReferenceNo = result.trxId, mibReferenceNo = result.trxId,
mibTransactionDate = result.date mibTransactionDate = result.date
) )
Triple(true, "Transaction ID: ${result.trxId}\n${result.date}", receipt) Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else { } else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null) Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
} }
@@ -778,7 +800,7 @@ class TransferFragment : Fragment() {
} }
private fun doBmlTransfer( private fun doBmlTransfer(
src: MibAccount, src: BankAccount,
destAccount: String, destAccount: String,
destDisplay: String, destDisplay: String,
amount: Double, amount: Double,
@@ -787,8 +809,8 @@ class TransferFragment : Fragment() {
isSrcCard: Boolean, isSrcCard: Boolean,
isDestMib: Boolean, isDestMib: Boolean,
currency: String, currency: String,
allAccounts: List<MibAccount>, allAccounts: List<BankAccount>,
allContacts: List<MibBeneficiary> allContacts: List<BankContact>
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val loginId = src.loginTag.removePrefix("bml_") val loginId = src.loginTag.removePrefix("bml_")
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
@@ -823,10 +845,9 @@ class TransferFragment : Fragment() {
} }
val toBank = bank ?: if (isDestMib) "MIB" else "BML" val toBank = bank ?: if (isDestMib) "MIB" else "BML"
val bmlFlow = BmlLoginFlow()
// Step 1: initiate // Step 1: initiate
val initiated = try { 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) } } catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null) if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
@@ -836,10 +857,10 @@ class TransferFragment : Fragment() {
?.let { Totp.generate(it) } ?: otp ?.let { Totp.generate(it) } ?: otp
return try { 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) { if (result.success) {
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
isMib = false, bank = "BML",
amount = "%.2f".format(amount), amount = "%.2f".format(amount),
currency = currency, currency = currency,
fromLabel = src.accountBriefName, fromLabel = src.accountBriefName,
@@ -978,12 +999,12 @@ class TransferFragment : Fragment() {
_binding = null _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 inner class AccountDropdownAdapter(
private val context: Context, private val context: Context,
accounts: List<MibAccount> accounts: List<BankAccount>
) : BaseAdapter(), Filterable { ) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList { private val items: List<Any> = buildList {
@@ -996,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) } ?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getCount() = items.size override fun getCount() = items.size
@@ -1021,7 +1042,7 @@ class TransferFragment : Fragment() {
b.tvHeader.text = item b.tvHeader.text = item
b.root b.root
} else { } else {
val acc = item as MibAccount val acc = item as BankAccount
val b = if (convertView?.tag is ItemAccountDropdownBinding) { val b = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding convertView.tag as ItemAccountDropdownBinding
} else { } else {

View File

@@ -25,12 +25,12 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
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.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient 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.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
@@ -48,12 +48,12 @@ class TransferHistoryFragment : Fragment() {
private lateinit var adapter: TransactionAdapter private lateinit var adapter: TransactionAdapter
// All merged transactions sorted by date desc // All merged transactions sorted by date desc
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<BankTransaction>()
private var searchQuery = "" private var searchQuery = ""
// Per-account pagination state // Per-account pagination state
private data class AccountState( private data class AccountState(
val account: MibAccount, val account: BankAccount,
var mibNextStart: Int = 1, var mibNextStart: Int = 1,
var mibTotalCount: Int = -1, var mibTotalCount: Int = -1,
var bmlNextPage: Int = 1, var bmlNextPage: Int = 1,
@@ -86,6 +86,8 @@ class TransferHistoryFragment : Fragment() {
adapter = TransactionAdapter() adapter = TransactionAdapter()
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } 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.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
@@ -136,6 +138,14 @@ class TransferHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPages() loadNextPages()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
} }
override fun onResume() { override fun onResume() {
@@ -143,6 +153,19 @@ class TransferHistoryFragment : Fragment() {
requireActivity().title = getString(R.string.nav_transfer_history) requireActivity().title = getString(R.string.nav_transfer_history)
} }
private fun resetAndReload() {
allTransactions.clear()
pendingImageNames.clear()
pendingIconUrls.clear()
firstBatchDone = false
val accounts = accountStates.map { it.account }
accountStates.clear()
accounts.forEach { accountStates.add(AccountState(it)) }
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
loadNextPages()
}
private fun loadNextPages() { private fun loadNextPages() {
val activeStates = accountStates.filter { it.hasMore() } val activeStates = accountStates.filter { it.hasMore() }
if (isLoading || activeStates.isEmpty()) return if (isLoading || activeStates.isEmpty()) return
@@ -156,7 +179,7 @@ class TransferHistoryFragment : Fragment() {
lifecycleScope.launch { lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>() val results = mutableListOf<BankTransaction>()
// BML accounts: fetch in parallel // BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.bank == "BML" } val bmlStates = activeStates.filter { it.account.bank == "BML" }
@@ -170,7 +193,7 @@ class TransferHistoryFragment : Fragment() {
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
state.cardMonthOffset++ state.cardMonthOffset++
BmlLoginFlow().fetchCardHistory( BmlHistoryClient().fetchCardHistory(
session = session, session = session,
cardId = state.account.internalId, cardId = state.account.internalId,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
@@ -180,7 +203,7 @@ class TransferHistoryFragment : Fragment() {
} }
else -> { else -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
session = session, session = session,
accountId = state.account.internalId, accountId = state.account.internalId,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
@@ -192,7 +215,7 @@ class TransferHistoryFragment : Fragment() {
list list
} }
} }
} catch (_: Exception) { emptyList<Transaction>() } } catch (_: Exception) { emptyList<BankTransaction>() }
} }
}.awaitAll().flatten()) }.awaitAll().flatten())
@@ -201,9 +224,7 @@ class TransferHistoryFragment : Fragment() {
for (state in fahipayStates) { for (state in fahipayStates) {
val session = app.fahipaySessionFor(state.account) ?: continue val session = app.fahipaySessionFor(state.account) ?: continue
try { try {
val flow = FahipayLoginFlow() val (list, total) = FahipayHistoryClient().fetchHistory(
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session, session = session,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber, accountNumber = state.account.accountNumber,
@@ -250,6 +271,7 @@ class TransferHistoryFragment : Fragment() {
if (!firstBatchDone) { if (!firstBatchDone) {
firstBatchDone = true firstBatchDone = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
} }
if (newTransactions.isNotEmpty()) { if (newTransactions.isNotEmpty()) {

View File

@@ -1,7 +1,7 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
data class TransferReceiptData( data class TransferReceiptData(
val isMib: Boolean, val bank: String, // "MIB", "BML", etc.
val amount: String, val amount: String,
val currency: String, val currency: String,
val fromLabel: String, val fromLabel: String,

View File

@@ -1,6 +1,10 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues import android.content.ContentValues
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
private val receiptCard get() = _receiptCard!! private val receiptCard get() = _receiptCard!!
companion object { companion object {
private const val ARG_IS_MIB = "is_mib" private const val ARG_BANK = "bank"
private const val ARG_AMOUNT = "amount" private const val ARG_AMOUNT = "amount"
private const val ARG_CURRENCY = "currency" private const val ARG_CURRENCY = "currency"
private const val ARG_FROM_LABEL = "from_label" private const val ARG_FROM_LABEL = "from_label"
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply { fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
pendingToAvatarBitmap = toAvatarBitmap pendingToAvatarBitmap = toAvatarBitmap
arguments = Bundle().apply { arguments = Bundle().apply {
putBoolean(ARG_IS_MIB, data.isMib) putString(ARG_BANK, data.bank)
putString(ARG_AMOUNT, data.amount) putString(ARG_AMOUNT, data.amount)
putString(ARG_CURRENCY, data.currency) putString(ARG_CURRENCY, data.currency)
putString(ARG_FROM_LABEL, data.fromLabel) putString(ARG_FROM_LABEL, data.fromLabel)
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
return if (isMib) { return if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false) val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding) bindMib(binding)
_receiptCard = binding.receiptCard _receiptCard = binding.receiptCard
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() }
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener { view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
} }
@@ -150,6 +156,12 @@ class TransferReceiptFragment : Fragment() {
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "") binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "") binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "") binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
copyOnLongClick(
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
)
} }
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
binding.remarksDivider.visibility = View.VISIBLE binding.remarksDivider.visibility = View.VISIBLE
binding.remarksRow.visibility = View.VISIBLE binding.remarksRow.visibility = View.VISIBLE
} }
copyOnLongClick(
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
binding.tvAmountCurrency, binding.tvRemarks
)
} }
// ── Share / Save ────────────────────────────────────────────────────────── // ── Share / Save ──────────────────────────────────────────────────────────
@@ -310,6 +329,66 @@ class TransferReceiptFragment : Fragment() {
return bm return bm
} }
private fun showFullScreenReceipt() {
val ctx = requireContext()
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val scrollView = android.widget.ScrollView(ctx).apply {
setBackgroundColor(Color.BLACK)
}
val cardView = if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
bindMib(binding)
binding.receiptCard
} else {
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
bindBml(binding)
binding.receiptCard
}
(cardView.parent as? ViewGroup)?.removeView(cardView)
cardView.setOnClickListener { dialog.dismiss() }
scrollView.addView(cardView)
scrollView.setOnTouchListener { _, _ -> dialog.dismiss(); true }
dialog.setContentView(scrollView)
val actWin = requireActivity().window
val prevColor = actWin.statusBarColor
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
actWin.statusBarColor = Color.BLACK
insetsCtrl.isAppearanceLightStatusBars = false
dialog.setOnDismissListener {
actWin.statusBarColor = prevColor
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
insetsCtrl.isAppearanceLightStatusBars = isLight
}
dialog.show()
dialog.window?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
androidx.core.view.WindowInsetsControllerCompat(win, scrollView).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
private fun copyOnLongClick(vararg views: android.widget.TextView) {
for (tv in views) {
tv.setOnLongClickListener {
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
if (text.isBlank()) return@setOnLongClickListener false
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
true
}
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = "Receipt" requireActivity().title = "Receipt"

View File

@@ -18,14 +18,20 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow 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.AccountCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class CredentialsFragment : Fragment() { class CredentialsFragment : Fragment() {
@@ -46,6 +52,11 @@ class CredentialsFragment : Fragment() {
private var fahipayFlow: FahipayLoginFlow? = null private var fahipayFlow: FahipayLoginFlow? = null
private var fahipayAwaitingTotp = false private var fahipayAwaitingTotp = false
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false) _binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -182,7 +193,7 @@ class CredentialsFragment : Fragment() {
store.saveMibCredentials(loginId, username, passwordHash, otpSeed) store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
flow.lastSession?.let { s -> flow.lastSession?.let { s ->
val profile = flow.fetchPersonalProfile(s) val profile = MibProfileClient().fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile( if (profile != null) store.saveMibUserProfile(
loginId, loginId,
CredentialStore.MibUserProfile( CredentialStore.MibUserProfile(
@@ -232,20 +243,68 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val loginId = username bmlLoginId = username
val flow = BmlLoginFlow() bmlAccumulatedAccounts.clear()
val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username"
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
try { try {
val (session, accounts) = withContext(Dispatchers.IO) { val profiles = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed) flow.login(username, password, otpSeed)
} }
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
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) }
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
}
}
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlCredentials(loginId, username, password, otpSeed) store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId) store.saveBmlProfiles(bmlLoginId, profiles)
withContext(Dispatchers.IO) { val app = requireActivity().application as BasedBankApp
val info = flow.fetchUserInfo(session) app.bmlProfilesMap[bmlLoginId] = profiles
if (info != null) store.saveBmlUserProfile( app.bmlLoginFlows[bmlLoginId] = flow
loginId,
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
finishBmlLogin(hasBusinessProfiles)
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList()
// Fetch user profile info from any active session
val app = requireActivity().application as BasedBankApp
val anySession = app.anyBmlSessionFor(bmlLoginId)
if (anySession != null) {
withContext(Dispatchers.IO) {
val info = BmlAccountClient().fetchUserInfo(anySession)
if (info != null) {
store.saveBmlUserProfile(
bmlLoginId,
CredentialStore.BmlUserProfile( CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
@@ -255,24 +314,47 @@ class CredentialsFragment : Fragment() {
birthdate = info.birthdate 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
}
}
}
} }
AccountCache.saveBml(requireContext(), loginId, accounts)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[loginId] = session
// Merge with any existing BML accounts from other logins
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
app.accounts = app.accounts + accounts
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
} finally {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
} }
} }
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
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)
} }
private fun attemptFahipayLogin() { private fun attemptFahipayLogin() {
@@ -376,13 +458,13 @@ class CredentialsFragment : Fragment() {
store: CredentialStore store: CredentialStore
) { ) {
val (profile, balance) = withContext(Dispatchers.IO) { val (profile, balance) = withContext(Dispatchers.IO) {
val p = flow.fetchProfile(session) val p = FahipayAccountClient().fetchProfile(session)
val b = flow.fetchBalance(session) val b = FahipayAccountClient().fetchBalance(session)
Pair(p, b) Pair(p, b)
} }
val loginId = profile.profileId val loginId = profile.profileId
val loginTag = "fahipay_$loginId" val loginTag = "fahipay_$loginId"
val account = flow.buildAccount(profile, balance, loginTag) val account = FahipayAccountClient().buildAccount(profile, balance, loginTag)
store.saveFahipayCredentials(loginId, idCard, password) store.saveFahipayCredentials(loginId, idCard, password)
store.saveFahipaySession(loginId, session.authId, session.sessionCookie) store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
store.saveFahipayUserProfile( store.saveFahipayUserProfile(

View File

@@ -3,7 +3,7 @@ package sh.sar.basedbank.util
import android.content.Context import android.content.Context
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
object AccountCache { object AccountCache {
@@ -12,7 +12,7 @@ object AccountCache {
private fun bmlKey(loginId: String) = "bml_accounts_$loginId" private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
private fun fahipayKey(loginId: String) = "fahipay_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() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -38,7 +38,7 @@ object AccountCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply() .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() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -61,14 +61,14 @@ object AccountCache {
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply() .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) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList() .getString(bmlKey(loginId), null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( BankAccount(
bank = "BML", bank = "BML",
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),
@@ -89,10 +89,10 @@ object AccountCache {
} catch (_: Exception) { emptyList() } } 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) } 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() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -115,14 +115,14 @@ object AccountCache {
.edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply() .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) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(fahipayKey(loginId), null) ?: return emptyList() .getString(fahipayKey(loginId), null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( BankAccount(
bank = "FAHIPAY", bank = "FAHIPAY",
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),
@@ -143,14 +143,14 @@ object AccountCache {
} catch (_: Exception) { emptyList() } } 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) } loginIds.flatMap { loadFahipay(context, it) }
fun clear(context: Context) { fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() 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) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList() .getString(KEY_MIB, null) ?: return emptyList()
return try { return try {
@@ -158,7 +158,7 @@ object AccountCache {
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( BankAccount(
bank = o.optString("bank", "MIB"), bank = o.optString("bank", "MIB"),
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),

View File

@@ -1,13 +1,13 @@
package sh.sar.basedbank.util 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.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser { object AccountHistoryParser {
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) { fun from(account: BankAccount): AccountHistoryDisplay? = when (account.bank) {
"BML" -> BmlHistoryParser.displayData(account) "BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account) "FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account) "MIB" -> MibHistoryParser.displayData(account)

View File

@@ -1,13 +1,13 @@
package sh.sar.basedbank.util 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.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser { object AccountListParser {
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) { fun from(account: BankAccount): AccountListDisplay? = when (account.bank) {
"BML" -> BmlDashboardParser.displayData(account) "BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account) "FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account) "MIB" -> MibAccountParser.displayData(account)

View File

@@ -1,18 +1,18 @@
package sh.sar.basedbank.util 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.bmlapi.BmlContactParser
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
import sh.sar.basedbank.util.mibapi.MibContactParser import sh.sar.basedbank.util.mibapi.MibContactParser
object ContactListParser { object ContactListParser {
fun from(contact: MibBeneficiary): ContactDisplay? = when { fun from(contact: BankContact): ContactDisplay? = when {
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact) contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact) contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact) contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
else -> null else -> null
} }
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) } fun fromList(contacts: List<BankContact>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
} }

View File

@@ -1,7 +1,7 @@
package sh.sar.basedbank.util package sh.sar.basedbank.util
import sh.sar.basedbank.BasedBankApp 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 import sh.sar.basedbank.api.mib.MibContactsClient
/** /**
@@ -21,7 +21,7 @@ object ContactManager {
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean { private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.id.removePrefix("bml_") 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 { private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {

View File

@@ -3,8 +3,8 @@ package sh.sar.basedbank.util
import android.content.Context import android.content.Context
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibBeneficiary import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.models.BankContactCategory
object ContactsCache { object ContactsCache {
@@ -14,8 +14,8 @@ object ContactsCache {
fun save( fun save(
context: Context, context: Context,
contacts: List<MibBeneficiary>, contacts: List<BankContact>,
categories: List<MibBeneficiaryCategory> categories: List<BankContactCategory>
) { ) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
@@ -55,7 +55,7 @@ object ContactsCache {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() 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) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CONTACTS, null) ?: return emptyList() .getString(KEY_CONTACTS, null) ?: return emptyList()
return try { return try {
@@ -63,7 +63,7 @@ object ContactsCache {
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibBeneficiary( BankContact(
benefNo = o.optString("benefNo"), benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"), benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName"), benefNickName = o.optString("benefNickName"),
@@ -86,7 +86,7 @@ object ContactsCache {
private fun bmlKey(loginId: String) = "bml_contacts_$loginId" 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() val arr = JSONArray()
for (c in contacts) { for (c in contacts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -108,14 +108,14 @@ object ContactsCache {
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply() .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) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList() .getString(bmlKey(loginId), null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibBeneficiary( BankContact(
benefNo = o.optString("benefNo"), benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"), benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName"), benefNickName = o.optString("benefNickName"),
@@ -134,10 +134,10 @@ object ContactsCache {
} catch (_: Exception) { emptyList() } } 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) } 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 prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
val arr = JSONArray() val arr = JSONArray()
for (c in contacts) arr.put(JSONObject().apply { for (c in contacts) arr.put(JSONObject().apply {
@@ -159,14 +159,14 @@ object ContactsCache {
prefs.apply() prefs.apply()
} }
fun loadFahipay(context: Context): List<MibBeneficiary> { fun loadFahipay(context: Context): List<BankContact> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("fahipay_contacts", null) ?: return emptyList() .getString("fahipay_contacts", null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibBeneficiary( BankContact(
benefNo = o.optString("benefNo"), benefNo = o.optString("benefNo"),
benefName = "", benefName = "",
benefNickName = o.optString("benefNickName"), benefNickName = o.optString("benefNickName"),
@@ -185,19 +185,19 @@ object ContactsCache {
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadFahipayCategories(context: Context): List<MibBeneficiaryCategory> { fun loadFahipayCategories(context: Context): List<BankContactCategory> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("fahipay_categories", null) ?: return emptyList() .getString("fahipay_categories", null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(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() } } catch (_: Exception) { emptyList() }
} }
fun loadCategories(context: Context): List<MibBeneficiaryCategory> { fun loadCategories(context: Context): List<BankContactCategory> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList() .getString(KEY_CATEGORIES, null) ?: return emptyList()
return try { return try {
@@ -205,7 +205,7 @@ object ContactsCache {
val arr = JSONArray(json) val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibBeneficiaryCategory( BankContactCategory(
id = o.optString("id"), id = o.optString("id"),
categoryName = o.optString("categoryName"), categoryName = o.optString("categoryName"),
numBenef = o.optInt("numBenef", 0) numBenef = o.optInt("numBenef", 0)

View File

@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlProfile
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
@@ -193,24 +194,85 @@ class CredentialStore(context: Context) {
} }
fun clearBmlCredentials(loginId: String) { fun clearBmlCredentials(loginId: String) {
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
removeBmlLoginId(loginId) removeBmlLoginId(loginId)
prefs.edit() prefs.edit()
.remove("bml_${loginId}_enc_username") .remove("bml_${loginId}_enc_username")
.remove("bml_${loginId}_enc_password") .remove("bml_${loginId}_enc_password")
.remove("bml_${loginId}_enc_otp_seed") .remove("bml_${loginId}_enc_otp_seed")
.remove("bml_${loginId}_profiles")
.remove("bml_${loginId}_hidden_profile_ids")
// legacy single-profile session keys
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.apply() .apply()
} }
// ── BML session token (per loginId) ─────────────────────────────────────── // ── BML profiles (per loginId) ────────────────────────────────────────────
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) { fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("type", p.type)
put("profileType", p.profileType)
put("autoActivated", p.autoActivated)
})
prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply()
}
fun loadBmlProfiles(loginId: String): List<BmlProfile> {
val raw = prefs.getString("bml_${loginId}_profiles", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BmlProfile(
profileId = o.optString("profileId"),
name = o.optString("name"),
type = o.optString("type"),
profileType = o.optString("profileType", "default"),
autoActivated = o.optBoolean("autoActivated", false)
)
}
} catch (_: Exception) { emptyList() }
}
fun getHiddenBmlProfileIds(loginId: String): Set<String> =
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply()
// ── BML per-profile session token (keyed by profileId, a globally unique GUID) ──
fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key)) .putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key)) .putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
.apply() .apply()
} }
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
return try {
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
} catch (_: Exception) { null }
}
fun clearBmlProfileSession(profileId: String) {
prefs.edit()
.remove("bml_profile_${profileId}_enc_token")
.remove("bml_profile_${profileId}_enc_device_id")
.apply()
}
// ── Legacy single-profile BML session (kept for backward compat reading) ─
fun loadBmlSession(loginId: String): Pair<String, String>? { fun loadBmlSession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
@@ -220,13 +282,6 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlSession(loginId: String) {
prefs.edit()
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.apply()
}
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ── // ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
fun getFahipayLoginIds(): List<String> { fun getFahipayLoginIds(): List<String> {

View File

@@ -3,12 +3,14 @@ package sh.sar.basedbank.util
import android.content.Context import android.content.Context
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
object FinancingCache { object FinancingCache {
private const val PREFS = "financing_cache" private const val PREFS = "financing_cache"
private const val KEY_MIB = "mib_financing" private const val KEY_MIB = "mib_financing"
private const val KEY_BML_LOANS = "bml_loans"
fun save(context: Context, deals: List<MibFinanceDeal>) { fun save(context: Context, deals: List<MibFinanceDeal>) {
val arr = JSONArray() val arr = JSONArray()
@@ -34,6 +36,52 @@ object FinancingCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
} }
fun saveBmlLoans(context: Context, loans: Map<String, BmlLoanDetail>) {
val arr = JSONArray()
for ((internalId, d) in loans) {
arr.put(JSONObject().apply {
put("internalId", internalId)
put("loanAmount", d.loanAmount)
put("outstandingAmt", d.outstandingAmt)
put("repayAmount", d.repayAmount)
put("intRate", d.intRate)
put("loanStatus", d.loanStatus)
put("startDate", d.startDate)
put("endDate", d.endDate)
put("noOfRepayOverdue", d.noOfRepayOverdue)
put("overdueAmount", d.overdueAmount)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML_LOANS, CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBmlLoans(context: Context): Map<String, BmlLoanDetail> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML_LOANS, null) ?: return emptyMap()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
buildMap {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val id = o.optString("internalId")
if (id.isNotBlank()) put(id, BmlLoanDetail(
loanAmount = o.optDouble("loanAmount", 0.0),
outstandingAmt = o.optDouble("outstandingAmt", 0.0),
repayAmount = o.optDouble("repayAmount", 0.0),
intRate = o.optDouble("intRate", 0.0),
loanStatus = o.optString("loanStatus"),
startDate = o.optString("startDate"),
endDate = o.optString("endDate"),
noOfRepayOverdue = o.optInt("noOfRepayOverdue", 0),
overdueAmount = o.optDouble("overdueAmount", 0.0)
))
}
}
} catch (_: Exception) { emptyMap() }
}
fun clear(context: Context) { fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
} }

View File

@@ -4,11 +4,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibHistoryClient 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.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
@@ -18,10 +18,11 @@ import java.util.Locale
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage] * The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
* without knowing which bank it is talking to. * 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 isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isBmlLoan get() = account.profileType == "BML_LOAN"
private val isFahipay get() = account.bank == "FAHIPAY" private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination // MIB pagination
@@ -40,24 +41,24 @@ class HistoryFetcher(private val account: MibAccount) {
private var fahipayTotal = -1 private var fahipayTotal = -1
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
isBmlLoan -> false
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3 isBmlCard -> cardMonthOffset < 3
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages 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 {
isBmlLoan -> emptyList()
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) } isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } } isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) } isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(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 session = app.fahipaySessionFor(account) ?: return emptyList()
val flow = FahipayLoginFlow() val (list, total) = FahipayHistoryClient().fetchHistory(
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session, session = session,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber, accountNumber = account.accountNumber,
@@ -68,7 +69,7 @@ class HistoryFetcher(private val account: MibAccount) {
return list 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 loginId = account.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId] ?: return emptyList() val session = app.mibSessions[loginId] ?: return emptyList()
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
@@ -86,13 +87,13 @@ class HistoryFetcher(private val account: MibAccount) {
return list return list
} }
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> { private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
val session = app.bmlSessionFor(account) ?: return emptyList() val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset) cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++ cardMonthOffset++
return BmlLoginFlow().fetchCardHistory( return BmlHistoryClient().fetchCardHistory(
session = session, session = session,
cardId = account.internalId, cardId = account.internalId,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
@@ -101,9 +102,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 session = app.bmlSessionFor(account) ?: return emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
session = session, session = session,
accountId = account.internalId, accountId = account.internalId,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,

View File

@@ -0,0 +1,83 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.ui.home.TransferReceiptData
import java.io.File
/** Persistent (non-cache) store for completed transfer receipts shown in Recent Transfers. */
object ReceiptStore {
private const val FILE_NAME = "activities.json"
data class Entry(val data: TransferReceiptData, val savedAt: Long)
fun save(context: Context, receipt: TransferReceiptData) {
val existing = loadAll(context).toMutableList()
existing.add(0, Entry(receipt, System.currentTimeMillis()))
writeAll(context, existing)
}
fun loadAll(context: Context): List<Entry> {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
Entry(
data = TransferReceiptData(
bank = o.optString("bank", "MIB"),
amount = o.optString("amount"),
currency = o.optString("currency"),
fromLabel = o.optString("fromLabel"),
fromColorHex = o.optString("fromColorHex"),
fromProfileImageHash = o.optString("fromProfileImageHash").takeIf { it.isNotBlank() },
toLabel = o.optString("toLabel"),
toAccount = o.optString("toAccount"),
toBank = o.optString("toBank"),
remarks = o.optString("remarks"),
mibReferenceNo = o.optString("mibReferenceNo"),
mibTransactionDate = o.optString("mibTransactionDate"),
bmlFromName = o.optString("bmlFromName"),
bmlReference = o.optString("bmlReference"),
bmlTimestamp = o.optString("bmlTimestamp"),
bmlMessage = o.optString("bmlMessage")
),
savedAt = o.optLong("savedAt", 0L)
)
}
} catch (_: Exception) { emptyList() }
}
fun clearAll(context: Context) {
File(context.filesDir, FILE_NAME).delete()
}
private fun writeAll(context: Context, items: List<Entry>) {
try {
val arr = JSONArray()
for ((d, ts) in items) arr.put(JSONObject().apply {
put("bank", d.bank)
put("amount", d.amount)
put("currency", d.currency)
put("fromLabel", d.fromLabel)
put("fromColorHex", d.fromColorHex)
put("fromProfileImageHash", d.fromProfileImageHash ?: "")
put("toLabel", d.toLabel)
put("toAccount", d.toAccount)
put("toBank", d.toBank)
put("remarks", d.remarks)
put("mibReferenceNo", d.mibReferenceNo)
put("mibTransactionDate", d.mibTransactionDate)
put("bmlFromName", d.bmlFromName)
put("bmlReference", d.bmlReference)
put("bmlTimestamp", d.bmlTimestamp)
put("bmlMessage", d.bmlMessage)
put("savedAt", ts)
})
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
} catch (_: Exception) {}
}
}

View File

@@ -1,12 +1,12 @@
package sh.sar.basedbank.util.bmlapi 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.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork import sh.sar.basedbank.util.TransferNetwork
object BmlContactParser { object BmlContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay( fun displayData(contact: BankContact) = ContactDisplay(
id = contact.benefNo, id = contact.benefNo,
name = contact.benefNickName, name = contact.benefNickName,
realName = contact.benefName, realName = contact.benefName,

View File

@@ -1,7 +1,7 @@
package sh.sar.basedbank.util.bmlapi package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R 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 import sh.sar.basedbank.util.AccountListDisplay
object BmlDashboardParser { object BmlDashboardParser {
@@ -10,7 +10,8 @@ object BmlDashboardParser {
* Returns all display fields for an account/card row in the accounts list. * Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards. * Handles both BML CASA accounts and BML prepaid/credit cards.
*/ */
fun displayData(account: MibAccount): AccountListDisplay { fun displayData(account: BankAccount): AccountListDisplay? {
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) { return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) val isActive = account.statusDesc.equals("Active", ignoreCase = true)
@@ -51,7 +52,7 @@ object BmlDashboardParser {
} }
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */ /** 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}" "${account.currencyName} ${account.currentBalance}"
fun cardBrandIcon(productName: String): Int = when { fun cardBrandIcon(productName: String): Int = when {

View File

@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.bmlapi 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 import sh.sar.basedbank.util.AccountHistoryDisplay
object BmlHistoryParser { object BmlHistoryParser {
fun displayData(account: MibAccount): AccountHistoryDisplay { fun displayData(account: BankAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0 val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay( return AccountHistoryDisplay(
name = account.accountBriefName, name = account.accountBriefName,

View File

@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.fahipayapi 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 import sh.sar.basedbank.util.AccountListDisplay
object FahipayAccountParser { object FahipayAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay( fun displayData(account: BankAccount) = AccountListDisplay(
name = account.accountBriefName, name = account.accountBriefName,
number = account.accountNumber, number = account.accountNumber,
typeLabel = account.accountTypeName, typeLabel = account.accountTypeName,

View File

@@ -1,12 +1,12 @@
package sh.sar.basedbank.util.fahipayapi 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.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork import sh.sar.basedbank.util.TransferNetwork
object FahipayContactParser { object FahipayContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay( fun displayData(contact: BankContact) = ContactDisplay(
id = contact.benefNo, id = contact.benefNo,
name = contact.benefNickName, name = contact.benefNickName,
realName = contact.benefName, realName = contact.benefName,

View File

@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.fahipayapi 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 import sh.sar.basedbank.util.AccountHistoryDisplay
object FahipayHistoryParser { object FahipayHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay( fun displayData(account: BankAccount) = AccountHistoryDisplay(
name = account.accountBriefName, name = account.accountBriefName,
number = account.accountNumber, number = account.accountNumber,
bankPill = "FP", bankPill = "FP",

View File

@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.mibapi 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 import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser { object MibAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay( fun displayData(account: BankAccount) = AccountListDisplay(
name = account.accountBriefName, name = account.accountBriefName,
number = account.accountNumber, number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName), typeLabel = productLabel(account.accountTypeName),

View File

@@ -1,12 +1,12 @@
package sh.sar.basedbank.util.mibapi 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.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork import sh.sar.basedbank.util.TransferNetwork
object MibContactParser { object MibContactParser {
fun displayData(contact: MibBeneficiary): ContactDisplay { fun displayData(contact: BankContact): ContactDisplay {
val network = when (contact.benefType) { val network = when (contact.benefType) {
"I" -> TransferNetwork.MIB "I" -> TransferNetwork.MIB
"S" -> TransferNetwork.SWIFT "S" -> TransferNetwork.SWIFT

View File

@@ -1,11 +1,11 @@
package sh.sar.basedbank.util.mibapi 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 import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser { object MibHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay( fun displayData(account: BankAccount) = AccountHistoryDisplay(
name = account.accountBriefName, name = account.accountBriefName,
number = account.accountNumber, number = account.accountNumber,
bankPill = null, // MIB has no bank pill bankPill = null, // MIB has no bank pill

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
android:tint="?attr/colorControlNormal">
<path
android:name="strike_through"
android:pathData="M3.27,4.27 L19.74,20.74"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"
android:trimPathEnd="0"/>
<group>
<clip-path
android:name="eye_mask"
android:pathData="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
<path
android:name="eye"
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"/>
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="320"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:propertyName="pathData"
android:valueFrom="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueTo="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueType="pathType"/>
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="320"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:propertyName="trimPathEnd"
android:valueFrom="0"
android:valueTo="1"/>
</aapt:attr>
</target>
</animated-vector>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
android:tint="?attr/colorControlNormal">
<!--
Shackle drawn first (behind body) so it appears to slot into the body.
Starts translateY=-4 (open/raised), animates to 0 (locked).
-->
<group
android:name="shackle"
android:translateY="-4">
<path
android:fillColor="@android:color/transparent"
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeWidth="2.2"/>
</group>
<!--
Body on top — covers the shackle legs once they slide inside.
Even-odd fill cuts out the keyhole.
-->
<path
android:fillColor="@android:color/white"
android:fillType="evenOdd"
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
</vector>
</aapt:attr>
<target android:name="shackle">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="320"
android:interpolator="@android:interpolator/overshoot"
android:propertyName="translateY"
android:valueFrom="-4"
android:valueTo="0"/>
</aapt:attr>
</target>
</animated-vector>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
android:tint="?attr/colorControlNormal">
<path
android:name="strike_through"
android:pathData="M3.27,4.27 L19.74,20.74"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"/>
<group>
<clip-path
android:name="eye_mask"
android:pathData="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
<path
android:name="eye"
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"/>
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="200"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="pathData"
android:valueFrom="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueTo="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueType="pathType"/>
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="200"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"/>
</aapt:attr>
</target>
</animated-vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#00000000"
android:endColor="#CC000000"
android:angle="270"/>
</shape>

View File

@@ -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/colorOnSurface"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM4,12c0,-4.42 3.58,-8 8,-8 1.85,0 3.55,0.63 4.9,1.68L5.68,16.9C4.63,15.55 4,13.85 4,12zM12,20c-1.85,0 -3.55,-0.63 -4.9,-1.68L18.32,7.1C19.37,8.45 20,10.15 20,12c0,4.42 -3.58,8 -8,8z"/>
</vector>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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/colorOnSurface"
android:pathData="M22,11h-4.17l3.24,-3.24 -1.41,-1.42L15,11h-2V9l4.66,-4.66 -1.42,-1.41L13,6.17V2h-2v4.17L7.76,2.93 6.34,4.34 11,9v2H9L4.34,6.34 2.93,7.76 6.17,11H2v2h4.17l-3.24,3.24 1.41,1.42L9,13h2v2l-4.66,4.66 1.42,1.41L11,17.83V22h2v-4.17l3.24,3.24 1.42,-1.41L13,15v-2h2l4.66,4.66 1.41,-1.42L17.83,13H22z"/>
</vector>

View File

@@ -5,7 +5,19 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24" android:viewportHeight="24"
android:tint="?attr/colorControlNormal"> android:tint="?attr/colorControlNormal">
<!-- Shackle (behind body) -->
<path
android:fillColor="@android:color/transparent"
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeWidth="2.2"/>
<!-- Body + keyhole cutout -->
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M18,8h-1V6c0-2.76-2.24-5-5-5S7,3.24 7,6v2H6c-1.1,0-2,0.9-2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1-0.9,-2-2,-2zm-6,9c-1.1,0-2,-0.9-2,-2s0.9,-2 2,-2 2,0.9 2,2-0.9,2-2,2zm3.1,-9H8.9V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z" /> android:fillType="evenOdd"
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
</vector> </vector>

View File

@@ -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/colorOnSurface"
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4l0,16c0,1.1 0.9,2 2,2l16,0c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2zM13,18l-2,0 0,-1c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5l-2,0c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3l0,-2 2,3zM19,12l-2,0c0,-2.76 -2.24,-5 -5,-5l0,-2C15.87,5 19,8.13 19,12z"/>
</vector>

View File

@@ -0,0 +1,13 @@
<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="@android:color/transparent"
android:strokeColor="?attr/colorPrimary"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12,3v13M7,11l5,5 5,-5M5,21h14" />
</vector>

View File

@@ -0,0 +1,13 @@
<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="@android:color/transparent"
android:strokeColor="?attr/colorPrimary"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81c1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3s-3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65c0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
</vector>

View File

@@ -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>

View File

@@ -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>

View File

@@ -53,7 +53,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/bottom_nav_menu" />
</LinearLayout> </LinearLayout>

View File

@@ -31,28 +31,35 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView <FrameLayout
android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:paddingBottom="16dp"
android:clipToPadding="false" />
<TextView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/emptyView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center" android:paddingBottom="16dp"
android:text="No transactions found" android:clipToPadding="false" />
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout> <TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>

View File

@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recyclerView" android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingHorizontal="16dp" android:background="?attr/colorSurface">
android:paddingTop="8dp"
android:paddingBottom="16dp" <androidx.recyclerview.widget.RecyclerView
android:clipToPadding="false" android:id="@+id/recyclerView"
android:background="?attr/colorSurface" /> android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="4dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No recent transfers"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:clipToPadding="false"/>
<LinearLayout
android:id="@+id/loadingView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/cards_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"/>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -41,34 +41,41 @@
app:tabMode="scrollable" app:tabMode="scrollable"
app:tabGravity="start" /> app:tabGravity="start" />
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<androidx.viewpager2.widget.ViewPager2 <FrameLayout
android:id="@+id/viewPager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent">
<ProgressBar <androidx.viewpager2.widget.ViewPager2
android:id="@+id/loadingView" android:id="@+id/viewPager"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent" />
android:layout_gravity="center"
android:visibility="visible" />
<TextView <ProgressBar
android:id="@+id/emptyView" android:id="@+id/loadingView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:text="@string/contacts_empty" android:visibility="visible" />
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout> <TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/contacts_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>

View File

@@ -7,11 +7,16 @@
android:orientation="vertical" android:orientation="vertical"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -92,6 +97,85 @@
</LinearLayout> </LinearLayout>
<!-- Available Credit row (hidden when no credit cards) -->
<LinearLayout
android:id="@+id/rowCreditCards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardMvrCredit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/balance_mvr_credit"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvMvrCredit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardUsdCredit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/balance_usd_credit"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvUsdCredit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Pending Finances card --> <!-- Pending Finances card -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -172,6 +256,8 @@
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Quick actions fixed at bottom --> <!-- Quick actions fixed at bottom -->
<LinearLayout <LinearLayout
android:id="@+id/buttonBar" android:id="@+id/buttonBar"

View File

@@ -1,41 +1,48 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<androidx.recyclerview.widget.RecyclerView <FrameLayout
android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/loadingView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center" android:paddingHorizontal="16dp"
android:orientation="vertical" android:paddingTop="8dp"
android:visibility="gone"> android:paddingBottom="16dp"
android:clipToPadding="false" />
<ProgressBar <LinearLayout
android:layout_width="wrap_content" android:id="@+id/loadingView"
android:layout_height="wrap_content" /> android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
</LinearLayout> <ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView </LinearLayout>
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/financing_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout> <TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/financing_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:background="?attr/colorSurface">
<!-- QR card fills all available space above the inputs -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/tvQrPlaceholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Select an account"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant" />
<ImageView
android:id="@+id/ivQrCard"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:scaleType="fitCenter"
android:contentDescription="@string/pay_mv_qr" />
</FrameLayout>
<!-- Account dropdown -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAccount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:hint="@string/paymvqr_select_account">
<AutoCompleteTextView
android:id="@+id/actvAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:focusable="false"
android:focusableInTouchMode="false" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Amount (optional) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/paymvqr_amount_hint"
app:helperText="@string/paymvqr_amount_helper"
app:prefixText="MVR ">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
<LinearLayout
android:id="@+id/layoutActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShare"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:enabled="false"
android:text="@string/paymvqr_share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:enabled="false"
android:text="@string/paymvqr_save_image"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanQr"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/transfer_scan_qr"
app:icon="@drawable/ic_qr_scan" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:clipToPadding="false"/>
<LinearLayout
android:id="@+id/loadingView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/cards_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"/>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
@@ -225,7 +226,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:text="Share" /> android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnSave" android:id="@+id/btnSave"
@@ -234,7 +236,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:text="Save" /> android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnDone" android:id="@+id/btnDone"

View File

@@ -253,7 +253,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:text="Share" /> android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnSave" android:id="@+id/btnSave"
@@ -262,7 +263,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:text="Save" /> android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnDone" android:id="@+id/btnDone"

View File

@@ -112,14 +112,6 @@
app:singleSelection="true" app:singleSelection="true"
app:selectionRequired="true"> app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolockOff"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_off" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock30s" android:id="@+id/btnAutolock30s"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
@@ -162,6 +154,44 @@
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" /> 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 <LinearLayout
android:id="@+id/rowBlockScreenshots" android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent" android:layout_width="match_parent"

Some files were not shown because too many files have changed in this diff Show More