19 Commits
v1.0.3 ... main

Author SHA1 Message Date
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
b1e73533f6 version 1.0.4
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m45s
2026-05-19 18:26:19 +05:00
3a5b9459a9 customize quick actions and bottom bar
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 18:21:36 +05:00
9c9729e268 UI/edgetoedge non edge screen fixes
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 17:24:01 +05:00
399cfbf108 transfer button enables after you fill required feilds
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 16:50:49 +05:00
19f4d01015 add long bml logo
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 16:39:44 +05:00
8c40322ff0 fahipay serialized multi-login added
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 16:13:36 +05:00
782e2e7674 refactor codebase to be more module for later adding new banks.. add support for single profile mib accounts.. add suport for disabling mib profiles in settings
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-19 14:48:04 +05:00
87 changed files with 4240 additions and 951 deletions

View File

@@ -3,11 +3,11 @@
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 2
versionName = "1.0.3"
versionCode = 3
versionName = "1.0.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -73,6 +73,9 @@ dependencies {
// Coroutines
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)
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")

View File

@@ -4,6 +4,8 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors
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.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
@@ -17,33 +19,93 @@ class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList()
var fullName: String = ""
var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList()
/** Active BML sessions keyed by loginId (= BML username). */
/** Active MIB sessions keyed by loginId (= MIB username). */
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList()
/**
* 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()
/** BML profiles per loginId (= BML username). */
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null
/** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")]
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")]
/** Returns any available MIB session. */
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
/** Returns all MIB profiles across all logins. */
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
fun mibFlowFor(loginId: String): MibLoginFlow =
mibLoginFlows.getOrPut(loginId) {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSessions[loginId] = session
mibProfilesMap[loginId] = profiles
}
}
}
/** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
// ─── BML helpers ──────────────────────────────────────────────────────────
/**
* Returns the BML session for the given account.
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
*/
fun bmlSessionFor(account: MibAccount): BmlSession? {
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
}
/** Returns any available BML session (for non-account-specific operations). */
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}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex()
val mibLoginFlow by lazy {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles
}
}
}
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)

View File

@@ -29,9 +29,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
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_VERSION = "2.1.43.345"
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
private val APP_VERSION = "2.1.44.348"
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 cookieJar = object : CookieJar {
@@ -58,9 +58,27 @@ class BmlLoginFlow {
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** Full login: returns a BmlSession and the account list. */
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
/** PKCE params — generated once per login and reused across all profile activations. */
private var codeVerifier: String = ""
private var codeChallenge: String = ""
private var deviceId: String = ""
/** Profiles returned by the last successful [login] call. */
var lastProfiles: List<BmlProfile> = emptyList()
private set
// ─── Login ────────────────────────────────────────────────────────────────
/**
* Performs web authentication (login + TOTP) and returns the list of available profiles.
* Call [activateProfile] for each profile to obtain an access token + accounts.
*/
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
deviceId = generateDeviceId()
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
client.newCall(
Request.Builder().url("$BASE_URL/web/login")
.header("User-Agent", WEB_USER_AGENT).build()
@@ -82,15 +100,14 @@ class BmlLoginFlow {
loginResp.close()
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(
Request.Builder().url("$BASE_URL/web/login/2fa")
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute().close()
val xsrf2 = xsrfToken() ?: xsrf
// Step 4: POST OTP
// Step 4: POST TOTP
val otp = Totp.generate(otpSeed)
val twoFaBody = JSONObject().apply {
put("code", otp)
@@ -104,18 +121,161 @@ class BmlLoginFlow {
twoFaResp.close()
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)
client.newCall(
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
// single-profile accounts skip the picker and redirect straight to /web/redirect with
// blaze_identity already set in the response cookies.
val profileResp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile")
.header("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<MibAccount>> {
// Refresh XSRF token before submitting
client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
.header("User-Agent", WEB_USER_AGENT).build()
).execute().close()
// Step 6: PKCE OAuth authorize → extract auth code
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
val deviceId = generateDeviceId()
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val body = JSONObject().apply {
put("code", 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
val location = resp.header("Location") ?: ""
resp.close()
return when {
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
respCode == 302 ->
throw Exception("Invalid OTP — please try again")
else ->
throw Exception("Business OTP verification failed (HTTP $respCode)")
}
}
// ─── OAuth + account fetch ────────────────────────────────────────────────
/**
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
* Returns a fresh [BmlSession] and the profile's accounts.
*/
private fun doOAuthAndFetchAccounts(
loginTag: String,
profileName: String,
profileId: String
): Pair<BmlSession, List<MibAccount>> {
val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize")
@@ -135,14 +295,12 @@ class BmlLoginFlow {
Request.Builder().url(authorizeUrl)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val location = authorizeResp.header("Location")
authorizeResp.close()
val location = authorizeResp.header("Location")
?: throw Exception("OAuth authorize did not redirect")
val authCode = Uri.parse(location).getQueryParameter("code")
?: throw Exception("No auth code in OAuth redirect")
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
?: throw Exception("OAuth authorize did not return auth code")
// Step 7: Exchange auth code for access token
val tokenBody = FormBody.Builder()
.add("Device-ID", deviceId)
.add("code", authCode)
@@ -161,22 +319,28 @@ class BmlLoginFlow {
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close()
val tokenObj = JSONObject(tokenJson)
val accessToken = tokenObj.optString("access_token")
val accessToken = JSONObject(tokenJson).optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session, "bml_$username")
val accounts = fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts)
}
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
// ─── API methods ─────────────────────────────────────────────────────────
fun fetchAccounts(
session: BmlSession,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseDashboard(json ?: return emptyList(), loginTag)
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
}
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
@@ -331,9 +495,6 @@ class BmlLoginFlow {
return parseContacts(json, loginId)
}
/**
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
*/
fun initiateTransfer(
session: BmlSession,
debitAccount: String,
@@ -370,9 +531,6 @@ class BmlLoginFlow {
}
}
/**
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
*/
fun confirmTransfer(
session: BmlSession,
debitAccount: String,
@@ -440,32 +598,6 @@ class BmlLoginFlow {
}
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
/**
* Fetches paginated transaction history for a BML CASA account.
* @return Pair of (transactions, totalPages)
*/
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
@@ -516,10 +648,6 @@ class BmlLoginFlow {
} 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,
@@ -546,7 +674,6 @@ class BmlLoginFlow {
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>()
// Outstanding authorizations
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
@@ -567,7 +694,26 @@ class BmlLoginFlow {
}
}
// Settled statement entries
val unbilled = payload.optJSONObject("unbilled")
?.optJSONArray("CardUnbillTxnDetails")
if (unbilled != null) {
for (i in 0 until unbilled.length()) {
val item = unbilled.getJSONObject(i)
result.add(Transaction(
id = "unbilled_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
@@ -590,14 +736,64 @@ class BmlLoginFlow {
} 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()
// ─── Parsing ──────────────────────────────────────────────────────────────
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
/**
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
*/
private fun extractInertiaJson(html: String): String? {
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
return match.groupValues[1]
.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
private fun parseProfiles(html: String): List<BmlProfile> {
return try {
val json = extractInertiaJson(html) ?: html
val root = JSONObject(json)
val props = root.optJSONObject("props") ?: return emptyList()
val profiles = props.optJSONArray("profiles") ?: return emptyList()
(0 until profiles.length()).mapNotNull { i ->
val p = profiles.getJSONObject(i)
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
BmlProfile(
profileId = p.optString("profile_id"),
name = p.optString("name"),
type = p.optString("type"),
profileType = profileObj.optString("profile_type", "default")
)
}
} catch (_: Exception) { emptyList() }
}
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
return try {
val json = extractInertiaJson(html) ?: html
val root = JSONObject(json)
val props = root.optJSONObject("props") ?: return emptyList()
val channels = props.optJSONArray("channels") ?: return emptyList()
(0 until channels.length()).map { i ->
val c = channels.getJSONObject(i)
BmlOtpChannel(
channel = c.optString("channel"),
description = c.optString("description"),
masked = c.optString("masked")
)
}
} catch (_: Exception) { emptyList() }
}
private fun parseDashboard(
json: String,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<MibAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
@@ -612,13 +808,13 @@ class BmlLoginFlow {
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(
profileName = "Personal",
bank = "BML",
profileName = profileName,
profileType = "BML",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
@@ -631,17 +827,19 @@ class BmlLoginFlow {
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
if (!isVisible) continue
val isPrepaid = item.optBoolean("prepaid_card", false)
val 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(
profileName = "Personal",
bank = "BML",
profileName = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
@@ -654,6 +852,7 @@ class BmlLoginFlow {
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
}
@@ -721,6 +920,15 @@ class BmlLoginFlow {
return result
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
private fun xsrfToken(): String? =
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
@@ -746,4 +954,26 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
}

View File

@@ -1,10 +1,36 @@
package sh.sar.basedbank.api.bml
import sh.sar.basedbank.api.mib.MibAccount
data class BmlSession(
val accessToken: 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<MibAccount>
) : BmlActivationResult()
data class NeedsBusinessOtp(
val channels: List<BmlOtpChannel>
) : BmlActivationResult()
}
data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB

View File

@@ -186,6 +186,7 @@ class FahipayLoginFlow {
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY",
accountNumber = profile.walletAccount,

View File

@@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
// Stored after login so the session can be silently recovered on 419
@Volatile private var loginId: String = ""
@Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null
@@ -58,11 +59,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
* Returns list of accounts from all profiles on success.
*/
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
loginId = username
storedUsername = username
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
val appId = getOrCreateAppId()
val keys = credentialStore.loadMibKeys()
val keys = credentialStore.loadMibKeys(loginId)
return if (keys != null) {
regularLogin(username, passwordHash, appId, keys.first, keys.second)
@@ -106,7 +108,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2")
credentialStore.saveMibKeys(key1, key2)
credentialStore.saveMibKeys(loginId, key1, key2)
return regularLogin(username, passwordHash, appId, key1, key2)
}
@@ -136,10 +138,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
}
val profiles = parseProfiles(loginResp)
lastSession = session2
lastProfiles = profiles
return fetchAllProfiles(session2, profiles, "mib_$username")
lastProfiles = profiles // keep ALL profiles so settings can show them all
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
// When the server already selected the profile and returned balances in A41
// (single-profile case: profileSelected=true), use those accounts directly
// without making an extra P47 call (which the server ignores or rejects).
if (loginResp.optBoolean("profileSelected", false)) {
val a41Balances = loginResp.optJSONArray("accountBalance")
if (a41Balances != null && a41Balances.length() > 0) {
val selectedId = loginResp.optString("selectedProfileId")
val profile = profiles.firstOrNull { it.profileId == selectedId }
?: profiles.firstOrNull()
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
val allAccounts = mutableListOf<MibAccount>()
for (i in 0 until a41Balances.length()) {
val a = a41Balances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
loginTag = "mib_$username",
profileId = profile.profileId
)
)
}
return allAccounts
}
}
}
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
}
// ─── Helpers ─────────────────────────────────────────────────────────────
@@ -271,8 +315,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val a = accountBalances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
@@ -389,11 +435,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String {
var id = credentialStore.loadMibAppId()
var id = credentialStore.loadMibAppId(loginId)
if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
credentialStore.saveMibAppId(id)
credentialStore.saveMibAppId(loginId, id)
}
return id
}

View File

@@ -20,8 +20,10 @@ data class MibProfile(
)
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,

View File

@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
@@ -20,7 +21,8 @@ import java.util.Date
import java.util.Locale
class AccountHistoryAdapter(
private val account: MibAccount
private val account: MibAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
@@ -35,6 +37,17 @@ class AccountHistoryAdapter(
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((MibAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyItemChanged(0) // refresh header card
// refresh all transaction rows
for (i in displayItems.indices) {
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
}
}
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
@@ -138,7 +151,7 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind(account)
is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit
@@ -147,37 +160,20 @@ class AccountHistoryAdapter(
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(acc: MibAccount) {
b.tvHeaderAccountName.text = acc.accountBriefName
b.tvHeaderAccountNumber.text = acc.accountNumber
b.tvHeaderPillBank.text = when {
acc.profileType.startsWith("BML") -> "BML"
acc.profileType == "FAHIPAY" -> "FP"
else -> null
}
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
if (blocked > 0.0) {
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
fun bind(d: AccountHistoryDisplay) {
b.tvHeaderAccountName.text = d.name
b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = d.bankPill
b.tvHeaderPillType.text = d.typeLabel
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
if (d.blockedBalance != null) {
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
b.llHeaderBlocked.visibility = View.VISIBLE
} else {
b.llHeaderBlocked.visibility = View.GONE
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
}
private fun friendlyType(raw: String): String {
val u = raw.trim().uppercase()
return when {
u.contains("SAVING") -> "Savings"
u.contains("CURRENT") -> "Current"
u.contains("WADIAH") -> "Islamic"
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
u.contains("PREPAID") -> "Prepaid"
else -> raw.trim().take(12)
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
}
}
@@ -226,12 +222,17 @@ class AccountHistoryAdapter(
b.tvDate.text = formatTime(trx.date)
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
if (hideAmounts) {
b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.setTextColor(Color.parseColor("#888888"))
} else {
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
}
b.root.setOnClickListener { showDetail(trx) }
}
@@ -297,6 +298,11 @@ class AccountHistoryAdapter(
return FULL_DATE_FMT.format(date)
}
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
fun sourceColor(source: String) = when (source) {
"MIB" -> "#FE860E"
"BML", "BML_CARD" -> "#0066A1"

View File

@@ -10,6 +10,8 @@ import android.util.Base64
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.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
@@ -17,23 +19,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() {
@@ -43,21 +40,13 @@ class AccountHistoryFragment : Fragment() {
private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = ""
private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>()
// Pagination state
private var mibNextStart = 1
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
private var bmlNextPage = 1
private var bmlTotalPages = -1
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
private var fahipayNextStart = 0
private var fahipayTotal = -1
private var isLoading = false
private val pageSize = 10
@@ -79,15 +68,29 @@ class AccountHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
fetcher = HistoryFetcher(account)
adapter = AccountHistoryAdapter(account)
val historyDisplay = AccountHistoryParser.from(account) ?: return
adapter = AccountHistoryAdapter(account, historyDisplay)
adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
}
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.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.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return
@@ -104,11 +107,10 @@ class AccountHistoryFragment : Fragment() {
override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay()
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
}
})
// Load cache immediately, then fetch fresh data in background
val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
@@ -133,19 +135,8 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
}
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when {
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
private fun loadNextPage() {
if (isLoading || !hasMore()) return
if (isLoading || !fetcher.hasMore()) return
isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) {
@@ -155,68 +146,7 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
when {
isFahipay() -> {
val session = app.fahipaySession ?: return@withContext emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
list
}
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
list
}
}
isBmlCard() -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
else -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
list
}
}
}
val transactions = fetcher.fetchNextPage(app, pageSize)
isLoading = false
@@ -233,7 +163,6 @@ class AccountHistoryFragment : Fragment() {
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
if (searchQuery.isBlank()) {
// Append incrementally to preserve scroll position
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
adapter.appendTransactions(sorted)
binding.emptyView.visibility = View.GONE
@@ -243,7 +172,7 @@ class AccountHistoryFragment : Fragment() {
} else {
adapter.showLoadingFooter = false
}
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
} else {
adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
@@ -261,7 +190,7 @@ class AccountHistoryFragment : Fragment() {
return
}
val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -288,8 +217,7 @@ class AccountHistoryFragment : Fragment() {
val response = client.newCall(Request.Builder().url(url).build()).execute()
val bytes = response.body?.bytes() ?: return@launch
response.close()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: return@launch
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
MerchantIconCache.save(requireContext(), url, bitmap)
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
} catch (_: Exception) {

View File

@@ -8,23 +8,25 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser
import sh.sar.basedbank.util.MibAccountParser
import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter(
accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
private sealed class Item {
data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item()
data class Card(val account: MibAccount) : Item()
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
}
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
@@ -35,39 +37,45 @@ class AccountsAdapter(
notifyDataSetChanged()
}
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
// Group non-prepaid accounts by their derived section title, preserving order
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
for (acc in nonPrepaid) {
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val nonCards = displayed.filter { !it.second.isCard }
val cards = displayed.filter { it.second.isCard }
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
for ((acc, display) in nonCards) {
val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
}
for ((title, group) in groups) {
add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) }
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
}
if (prepaid.isNotEmpty()) {
if (cards.isNotEmpty()) {
add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) }
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
}
}
private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
val bankName = when (account.bank) {
"BML" -> "Bank of Maldives"
"FAHIPAY" -> "Fahipay"
"MIB" -> "Maldives Islamic Bank"
else -> account.bank
}
val bank = when {
account.profileType.startsWith("BML") -> "Bank of Maldives"
account.profileType == "FAHIPAY" -> "Fahipay"
else -> "Maldives Islamic Bank"
val profileLabel = when (account.bank) {
"MIB" -> account.cifType.ifBlank { account.profileName }
else -> account.profileName
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
}
override fun getItemViewType(position: Int) = when (items[position]) {
@@ -79,17 +87,17 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
is Item.Card -> (holder as CardViewHolder).bind(item.account)
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
}
}
@@ -104,18 +112,15 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) {
binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber
val label = if (account.profileType.startsWith("BML"))
BmlDashboardParser.productLabel(account.accountTypeName)
else
MibAccountParser.productLabel(account.accountTypeName)
binding.tvPillType.text = label
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
fun bind(account: MibAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber)
copyToClipboard(it.context, display.number)
true
}
}
@@ -123,23 +128,22 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) {
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName))
binding.tvCardName.text = account.accountBriefName
binding.tvCardNumber.text = account.accountNumber
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName)
fun bind(account: MibAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = display.name
binding.tvCardNumber.text = display.number
binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
if (isActive) {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} else {
binding.tvCardStatus.text = account.statusDesc
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
if (display.statusLabel != null) {
binding.tvCardStatus.text = display.statusLabel
binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f
} else {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
}
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
}
}
@@ -149,18 +153,15 @@ class AccountsAdapter(
private const val TYPE_ACCOUNT = 1
private const val TYPE_CARD = 2
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun copyToClipboard(context: Context, accountNumber: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
}
private fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
}
}

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
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.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
@@ -26,10 +28,23 @@ class AccountsFragment : Fragment() {
adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
}
adapter.onTransferClick = { account ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
}
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", android.content.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
}
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
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

@@ -50,6 +50,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val label: String,
val isBml: Boolean,
val mibProfile: MibProfile? = null,
val mibLoginId: String? = null,
val bmlLoginId: String? = null,
val subtitle: String = ""
)
@@ -91,14 +92,19 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
for ((loginId, profiles) in app.mibProfilesMap) {
for (profile in profiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
}
}
val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
for ((loginId, profiles) in app.bmlProfilesMap) {
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
for (profile in profiles) {
if (app.bmlSessions.containsKey(profile.profileId)) {
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
}
}
}
return list
}
@@ -249,7 +255,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.mibSession ?: return null
val mibSess = app.anyMibSession() ?: return null
return try {
val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation(
@@ -266,11 +272,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
}
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
val mibSess = app.mibSession ?: return null
val loginId = dest.mibLoginId ?: return null
val mibSess = app.mibSessions[loginId] ?: return null
val profile = dest.mibProfile ?: return null
val mibResult = try {
app.mibLoginFlow.switchProfile(mibSess, profile)
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
@@ -429,8 +436,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
}
private fun saveToMib(alias: String): Boolean {
val mibSess = app.mibSession ?: return false
val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false
val account = mibLookupAccount ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
@@ -442,7 +450,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val name = bmlLookup?.name ?: ""
return try {
app.mibLoginFlow.switchProfile(mibSess, profile)
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact(
session = mibSess,
benefType = benefType,
@@ -488,8 +496,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else {
val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch
app.mibLoginFlow.switchProfile(mibSess, profile)
val mibLoginId = dest.mibLoginId ?: return@launch
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList()

View File

@@ -36,7 +36,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
private val session get() = app.anyMibSession()
private var fromAccountNumber: String = ""
private var mediator: TabLayoutMediator? = null
@@ -291,7 +291,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (hash in profileImageHashes) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch

View File

@@ -12,23 +12,23 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit
private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList()
private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<ContactDisplay> = emptyList()
private var activeCategoryId: String? = null
private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) {
fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts
applyFilter()
}
@@ -36,7 +36,7 @@ class ContactsAdapter(
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index)
if (contact.imageHash == hash) notifyItemChanged(index)
}
}
@@ -48,11 +48,11 @@ class ContactsAdapter(
private fun applyFilter() {
displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery)
contact.name.contains(searchQuery, ignoreCase = true) ||
contact.realName.contains(searchQuery, ignoreCase = true) ||
contact.accountNumber.contains(searchQuery)
matchesCategory && matchesSearch
}
notifyDataSetChanged()
@@ -76,7 +76,7 @@ class ContactsAdapter(
binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
val account = displayed[pos].benefAccount
val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
@@ -88,7 +88,7 @@ class ContactsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash ->
val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
@@ -99,21 +99,24 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY"
binding.tvContactName.text = contact.benefNickName
binding.tvContactAccount.text = contact.benefAccount
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}"
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
fun bind(contact: ContactDisplay, photo: Bitmap?) {
binding.tvContactName.text = contact.name
binding.tvContactAccount.text = contact.accountNumber
binding.tvRealName.text = contact.detail ?: ""
binding.tvRealName.visibility =
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility =
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo)
} else {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
makeInitialsBitmap(contact.name, contact.bankColor)
)
}
}

View File

@@ -8,7 +8,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() {
@@ -38,9 +42,9 @@ class ContactsFragment : Fragment() {
private val pendingHashes = mutableSetOf<String>()
private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
private val session get() = app.anyMibSession()
private var allContacts: List<MibBeneficiary> = emptyList()
private var allContacts: List<ContactDisplay> = emptyList()
private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter
@@ -53,9 +57,9 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter(
imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) }
).also { a ->
a.setFilter(page.categoryId, currentSearch)
@@ -63,7 +67,7 @@ class ContactsFragment : Fragment() {
}
}
fun updateContacts(contacts: List<MibBeneficiary>) =
fun updateContacts(contacts: List<ContactDisplay>) =
contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) =
@@ -86,7 +90,7 @@ class ContactsFragment : Fragment() {
)
clipToPadding = false
val p4 = (4 * density).toInt()
val p80 = (80 * density).toInt()
val p80 = (65 * density).toInt()
setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType]
}
@@ -113,6 +117,17 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateSearch(currentSearch)
}
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
lp.bottomMargin = fabMarginBase + extraBottom
v.layoutParams = lp
insets
}
binding.fabAddContact.setOnClickListener {
AddContactSheetFragment().show(childFragmentManager, "add_contact")
}
@@ -124,8 +139,8 @@ class ContactsFragment : Fragment() {
}
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
allContacts = contacts
pagerAdapter.updateContacts(contacts)
allContacts = ContactListParser.fromList(contacts)
pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
@@ -150,31 +165,29 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
}
private fun openTransfer(contact: MibBeneficiary) {
private fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
accountNumber = contact.accountNumber,
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
}
private fun confirmDelete(contact: MibBeneficiary) {
AlertDialog.Builder(requireContext())
private fun confirmDelete(contact: ContactDisplay) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
.setMessage(getString(R.string.contact_delete_message, contact.name))
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null)
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun deleteContact(contact: MibBeneficiary) {
private fun deleteContact(contact: ContactDisplay) {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact)
@@ -184,27 +197,10 @@ class ContactsFragment : Fragment() {
}
}
private fun deleteBml(contact: MibBeneficiary): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: MibBeneficiary): Boolean {
val sess = session ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.benefNo)
} catch (_: Exception) { false }
}
private fun removeFromViewModel(contact: MibBeneficiary) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
private fun removeFromViewModel(contact: ContactDisplay) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") {
if (contact.network == TransferNetwork.BML) {
updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId }
.forEach { (loginId, contacts) ->
@@ -221,7 +217,6 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return
// Check disk cache first — if hash matches we already have the image
val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) {
view?.post { pagerAdapter.updateImage(hash, cached) }

View File

@@ -1,10 +1,13 @@
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.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R
@@ -29,21 +32,49 @@ class DashboardFragment : Fragment() {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances(viewModel.financing.value ?: emptyList())
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
}
binding.btnTransfer.setOnClickListener {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer)
}
binding.btnPayMvQr.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.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
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_dashboard)
refreshQuickActions()
}
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
if (def != null) {
btn.setText(def.titleRes)
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
}
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
}
}
private fun updateBalances(accounts: List<MibAccount>) {
val hide = viewModel.hideAmounts.value ?: false
if (hide) {
binding.tvMvrBalance.text = "MVR ••••••"
binding.tvUsdBalance.text = "USD ••••••"
return
}
val mvrTotal = accounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
@@ -56,31 +87,40 @@ class DashboardFragment : Fragment() {
}
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
val hide = viewModel.hideAmounts.value ?: false
binding.containerForeignLimits.removeAllViews()
for (entry in entries) {
for (limit in entry.limits) {
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
card.tvLimitType.text = limit.type
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
else
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.tvLimitPos.text = if (!limit.isPosEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
else
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
if (hide) {
card.tvLimitGeneral.text = "USD ••••••"
card.tvLimitMedical.text = "USD ••••••"
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
card.tvLimitEcom.text = "USD ••••••"
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
} else {
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
else
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.tvLimitPos.text = if (!limit.isPosEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
else
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
}
binding.containerForeignLimits.addView(card.root)
}
}
}
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
val total = deals.sumOf { it.outstandingAmount }
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
val hide = viewModel.hideAmounts.value ?: false
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
}
override fun onDestroyView() {

View File

@@ -16,6 +16,14 @@ import java.util.Locale
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
@@ -52,19 +60,20 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
val ctx = binding.root.context
val currency = deal.currency
val hide = hideAmounts
binding.tvProductName.text = deal.productDesc
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
binding.tvStatus.text = deal.statusDesc
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0
binding.progressBar.progress = progress
binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate
binding.tvCompletion.text = completionText(deal, ctx)
@@ -76,14 +85,14 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate)
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) {
binding.rowOverdue.visibility = View.VISIBLE
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
} else {
binding.rowOverdue.visibility = View.GONE
}

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
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.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
@@ -27,12 +29,22 @@ class FinancingFragment : Fragment() {
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", android.content.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
}
viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals)
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
override fun onResume() {

View File

@@ -9,13 +9,18 @@ import android.os.Looper
import android.view.Menu
import android.view.MenuItem
import android.view.View
import com.google.android.material.navigation.NavigationBarView
import android.widget.Toast
import sh.sar.basedbank.ui.home.NavCustomization
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
@@ -31,7 +36,9 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
@@ -106,16 +113,31 @@ class HomeActivity : AppCompatActivity() {
binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState()
ViewCompat.setOnApplyWindowInsetsListener(binding.navigationView) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(top = bars.top, bottom = bars.bottom)
insets
}
binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
when (item.itemId) {
R.id.nav_dashboard -> { show(DashboardFragment()); true }
R.id.nav_accounts -> { show(AccountsFragment()); true }
R.id.nav_contacts -> { show(ContactsFragment()); true }
R.id.nav_transfer -> { show(TransferFragment()); true }
R.id.nav_more -> { show(MoreFragment()); true }
else -> false
val frag = when (item.itemId) {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
else -> null
}
if (frag != null) show(frag)
else Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
true
}
applyNavMode()
@@ -128,31 +150,36 @@ class HomeActivity : AppCompatActivity() {
// Load data
val app = application as BasedBankApp
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background
val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" }
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged.filterVisibleAccounts()
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
}
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts)
if (app.fahipayAccounts.isNotEmpty()) {
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
}
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles)
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else {
// Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this)
val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this)
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this)
@@ -160,36 +187,41 @@ class HomeActivity : AppCompatActivity() {
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
autoRefresh(store)
}
// hideAmounts is always false on launch; eye feature just needs to be enabled
viewModel.hideAmounts.value = false
// Show dashboard on first create
if (savedInstanceState == null) {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
// Keep MIB session 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 {
repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
delay(25_000)
val session = (application as BasedBankApp).mibSession ?: continue
withContext(Dispatchers.IO) {
try {
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/aProfile/keepAlive")
.post(ByteArray(0).toRequestBody())
.header("Cookie", cookieHeader)
.header("User-Agent", "okhttp/4.11.0")
.header("Accept", "application/json, text/plain, */*")
.header("Accept-Encoding", "gzip")
.header("Connection", "Keep-Alive")
.build()
OkHttpClient().newCall(request).execute().close()
} catch (_: Exception) {}
val sessions = (application as BasedBankApp).mibSessions.values.toList()
for (session in sessions) {
withContext(Dispatchers.IO) {
try {
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/aProfile/keepAlive")
.post(ByteArray(0).toRequestBody())
.header("Cookie", cookieHeader)
.header("User-Agent", "okhttp/4.11.0")
.header("Accept", "application/json, text/plain, */*")
.header("Accept-Encoding", "gzip")
.header("Connection", "Keep-Alive")
.build()
OkHttpClient().newCall(request).execute().close()
} catch (_: Exception) {}
}
}
}
}
@@ -203,12 +235,15 @@ class HomeActivity : AppCompatActivity() {
}
fun applyNavMode() {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
} else {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true
@@ -217,25 +252,65 @@ class HomeActivity : AppCompatActivity() {
}
}
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
val slots = NavCustomization.getSlots(prefs)
val menu = binding.bottomNavigation.menu
menu.clear()
menu.add(Menu.NONE, R.id.nav_dashboard, 0, R.string.nav_dashboard)
.setIcon(R.drawable.ic_nav_dashboard)
slots.forEachIndexed { i, id ->
val item = NavCustomization.ALL_SWAPPABLE.find { it.id == id } ?: return@forEachIndexed
menu.add(Menu.NONE, item.id, i + 1, item.titleRes).setIcon(item.iconRes)
}
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.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() {
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
binding.bottomNavigation.labelVisibilityMode =
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
else NavigationBarView.LABEL_VISIBILITY_AUTO
}
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment()
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
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_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
}
show(dest)
binding.navigationView.setCheckedItem(itemId)
val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more)
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = itemId
suppressBottomNavCallback = false
if (binding.bottomNavigation.visibility == View.VISIBLE) {
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
val selectId = if (itemId in bottomNavIds) itemId 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
}
}
}
@@ -339,6 +414,11 @@ class HomeActivity : AppCompatActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu)
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
val eyeItem = menu.findItem(R.id.action_hide_amounts)
eyeItem?.isVisible = eyeEnabled
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
return true
}
@@ -347,56 +427,59 @@ class HomeActivity : AppCompatActivity() {
lock()
return true
}
if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden
invalidateOptionsMenu()
return true
}
return super.onOptionsItemSelected(item)
}
fun relogin() {
val store = CredentialStore(this)
val hasMib = store.hasMibCredentials()
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials()
if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) {
val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
return
}
// Immediately drop accounts for logged-out banks from the displayed list
// Immediately drop accounts for logged-out logins from the displayed list
val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false
if (acc.profileType.startsWith("BML")) {
val loginId = acc.loginTag.removePrefix("bml_")
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
true
}
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
autoRefresh(store)
}
private fun autoRefresh(
mibCreds: CredentialStore.MibCredentials?,
fahipayCreds: CredentialStore.FahipayCredentials?,
store: CredentialStore
) {
private fun autoRefresh(store: CredentialStore) {
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return
val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val mibJob = mibCreds?.let {
async(Dispatchers.IO) {
// One async job per MIB login, all run in parallel
val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
try {
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
val app = application as BasedBankApp
app.accounts = accounts
app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles
AccountCache.save(this@HomeActivity, accounts)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity) }
} catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
}
}
@@ -404,98 +487,187 @@ class HomeActivity : AppCompatActivity() {
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow()
val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId)
val app = application as BasedBankApp
val savedProfiles = store.loadBmlProfiles(loginId)
val allAccounts = mutableListOf<MibAccount>()
var anyExpired = savedProfiles.isEmpty()
if (savedToken != null) {
try {
val session = BmlSession(savedToken.first, savedToken.second)
val accounts = bmlFlow.fetchAccounts(session, loginTag)
val app = application as BasedBankApp
app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
return@async Pair(session, accounts)
} catch (_: AuthExpiredException) {
} catch (_: Exception) {
// Try each saved profile's cached session
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
if (saved != null) {
try {
val session = BmlSession(saved.first, saved.second)
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
} else {
anyExpired = true
}
}
try {
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
val app = application as BasedBankApp
app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
Pair(session, accounts)
} catch (_: Exception) {
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
// Also try legacy single-profile session token (pre-multi-profile installs)
if (savedProfiles.isEmpty()) {
val legacyToken = store.loadBmlSession(loginId)
if (legacyToken != null) {
try {
val session = BmlSession(legacyToken.first, legacyToken.second)
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
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<MibAccount>
}
}
val fahipayJob = fahipayCreds?.let { creds ->
async(Dispatchers.IO) {
// One async job per Fahipay login, all run in parallel
val fahipayJobs = fahipayLoginIds.mapNotNull { loginId ->
val creds = store.loadFahipayCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val fahipayFlow = FahipayLoginFlow()
val deviceUuid = store.getOrCreateFahipayDeviceUuid()
val loginTag = "fahipay_$loginId"
val savedSession = store.loadFahipaySession()
val savedSession = store.loadFahipaySession(loginId)
if (savedSession != null) {
try {
val session = FahipaySession(savedSession.first, savedSession.second)
fahipayFlow.setSessionCookie(session.sessionCookie)
val balance = fahipayFlow.fetchBalance(session)
val profile = fahipayFlow.fetchProfile(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp
app.fahipaySession = session
app.fahipayAccounts = accounts
AccountCache.saveFahipay(this@HomeActivity, accounts)
return@async Pair(session, accounts)
} catch (_: Exception) {
}
app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
return@async accounts
} catch (_: Exception) { }
}
try {
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
if (step.twoFactorRequired) {
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
}
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
val authId = step.authId ?: return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
val session = FahipaySession(authId, cookieValue)
store.saveFahipaySession(authId, cookieValue)
store.saveFahipaySession(loginId, authId, cookieValue)
val profile = fahipayFlow.fetchProfile(session)
val balance = fahipayFlow.fetchBalance(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp
app.fahipaySession = session
app.fahipayAccounts = accounts
AccountCache.saveFahipay(this@HomeActivity, accounts)
Pair(session, accounts)
app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
accounts
} catch (_: Exception) {
Pair(null, AccountCache.loadFahipay(this@HomeActivity))
AccountCache.loadFahipay(this@HomeActivity, loginId)
}
}
}
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity)
val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity))
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
val mibAccounts = mibResults.flatMap { it.second }
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
val app = application as BasedBankApp
app.mibAccounts = mibAccounts
AccountCache.save(this@HomeActivity, mibAccounts)
app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
app.fahipayAccounts = fahipayAccounts
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshFinancing(app.mibSession, app.mibProfiles)
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
}
/** Filters accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
val store = CredentialStore(this@HomeActivity)
return filter { acc ->
when (acc.bank) {
"MIB" -> {
val loginId = acc.loginTag.removePrefix("mib_")
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
}
}
}
/** Filters MIB profiles the user has hidden for a given loginId. */
private fun List<MibProfile>.filterVisibleProfiles(loginId: String): List<MibProfile> {
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds(loginId)
if (hidden.isEmpty()) return this
return filter { it.profileId !in hidden }
}
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
fun applyProfileVisibility() {
val current = viewModel.accounts.value ?: return
viewModel.accounts.value = current.filterVisibleAccounts()
}
private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow()
lifecycleScope.launch {
@@ -515,10 +687,12 @@ class HomeActivity : AppCompatActivity() {
private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return
val store = CredentialStore(this)
lifecycleScope.launch {
try {
val allBmlContacts = withContext(Dispatchers.IO) {
app.bmlSessions.flatMap { (loginId, session) ->
store.getBmlLoginIds().flatMap { loginId ->
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts
@@ -550,9 +724,12 @@ class HomeActivity : AppCompatActivity() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
}
// Refresh all banks in background
refreshContacts(app.mibSession, app.mibProfiles)
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlContacts(app)
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!)
for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
}
private fun refreshFahipayContacts(session: FahipaySession) {
@@ -588,9 +765,9 @@ class HomeActivity : AppCompatActivity() {
return result
}
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val flow = MibLoginFlow(CredentialStore(this))
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient()
lifecycleScope.launch {
try {
@@ -615,8 +792,8 @@ class HomeActivity : AppCompatActivity() {
}
if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds())
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
@@ -630,9 +807,9 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp
lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList()
if (src.profileType == "FAHIPAY") {
if (src.bank == "FAHIPAY") {
val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySession ?: return@withContext null
val sess = app.fahipaySessionFor(src) ?: return@withContext null
try {
val flow = FahipayLoginFlow()
flow.setSessionCookie(sess.sessionCookie)
@@ -640,14 +817,15 @@ class HomeActivity : AppCompatActivity() {
val profile = flow.fetchProfile(sess)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
AccountCache.saveFahipay(this@HomeActivity, accounts)
app.fahipayAccounts = accounts
val loginId = src.loginTag.removePrefix("fahipay_")
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
accounts
} catch (_: Exception) { null }
} ?: return@launch
val others = current.filter { it.profileType != "FAHIPAY" }
val others = current.filter { it.bank != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) {
} else if (src.bank == "BML") {
val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null
@@ -659,27 +837,46 @@ class HomeActivity : AppCompatActivity() {
accounts
} catch (_: Exception) { null }
} ?: return@launch
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
viewModel.accounts.postValue(otherAccounts + fresh)
} else {
val loginId = src.loginTag.removePrefix("mib_")
val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
catch (_: Exception) { null }
val store = CredentialStore(this@HomeActivity)
val hidden = store.getHiddenMibProfileIds(loginId)
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val allVisible = profiles.filter { hidden.isEmpty() || it.profileId !in hidden }
val sess = app.mibSessions[loginId]
if (sess != null && allVisible.isNotEmpty()) {
try {
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(sess, allVisible, src.loginTag)
if (accounts.isNotEmpty()) return@withContext accounts
} catch (_: Exception) { }
}
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
try {
val flow = MibLoginFlow(store)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
accounts.takeIf { it.isNotEmpty() }
} catch (_: Exception) { null }
} ?: return@launch
// Replace accounts from this profile only, keep everything else
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") }
val merged = others + fresh
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") })
viewModel.accounts.postValue(merged)
// Replace accounts for this MIB login
val others = current.filter { it.loginTag != src.loginTag }
val newMibAccounts = app.mibAccounts.filter { it.loginTag != src.loginTag } + fresh
app.mibAccounts = newMibAccounts
AccountCache.save(this@HomeActivity, newMibAccounts)
viewModel.accounts.postValue(others + fresh)
}
}
}
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val flow = MibLoginFlow(CredentialStore(this))
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val client = MibFinancingClient()
lifecycleScope.launch {
try {

View File

@@ -16,4 +16,6 @@ class HomeViewModel : ViewModel() {
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
val hideAmounts = MutableLiveData<Boolean>(false)
}

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -7,35 +8,23 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
class MoreFragment : Fragment() {
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
private val items = listOf(
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItem(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val list = view.findViewById<LinearLayout>(R.id.moreList)
val inflater = LayoutInflater.from(requireContext())
for (item in items) {
val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
list.addView(row)
}

View File

@@ -0,0 +1,61 @@
package sh.sar.basedbank.ui.home
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import sh.sar.basedbank.R
object NavCustomization {
data class NavItemDef(
val id: Int,
@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int
)
/** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts),
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
)
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putInt("bottom_nav_slot_1", slots[0])
.putInt("bottom_nav_slot_2", slots[1])
.putInt("bottom_nav_slot_3", slots[2])
.apply()
}
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("quick_action_1", R.id.nav_transfer),
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
)
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
prefs.edit()
.putInt("quick_action_1", ids[0])
.putInt("quick_action_2", ids[1])
.apply()
}
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots }
}
}

View File

@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@@ -16,7 +18,14 @@ class NavMoreSheetFragment : BottomSheetDialogFragment() {
inflater.inflate(R.layout.sheet_nav_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item ->
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
navView.menu.clear()
items.forEachIndexed { i, item ->
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
}
navView.setNavigationItemSelectedListener { item ->
dismiss()
onNavigate?.invoke(item.itemId)
true

View File

@@ -71,8 +71,9 @@ class OtpFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>()
store.loadMibCredentials()?.let { creds ->
val name = store.loadMibFullName()
for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
}
for (loginId in store.getBmlLoginIds()) {
@@ -88,20 +89,23 @@ class OtpFragment : Fragment() {
// Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch {
var changed = false
if (store.loadMibFullName() == null) {
app.mibSession?.let { session ->
for (loginId in store.getMibLoginIds()) {
if (store.loadMibFullName(loginId) == null) {
val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) {
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null }
try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null }
}
if (profile != null) {
store.saveMibUserProfile(CredentialStore.MibUserProfile(
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName,
username = profile.username,
email = profile.email,
mobile = profile.mobile,
enrolled = profile.enrolled
))
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed }
val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
}
}

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.mib.MibAccount
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: MibAccount? = 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: MibAccount,
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<MibAccount>
) : BaseAdapter(), Filterable {
fun getAccount(position: Int): MibAccount? = 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? MibAccount)?.let {
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
"$prefix${it.accountBriefName}"
} ?: ""
}
}
}

View File

@@ -1,29 +1,45 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import java.util.Collections
class SettingsAppearanceFragment : Fragment() {
private var _binding: FragmentSettingsAppearanceBinding? = null
private val binding get() = _binding!!
private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false)
@@ -32,8 +48,35 @@ class SettingsAppearanceFragment : Fragment() {
if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
(activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility()
}
// Quick actions
quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) {
NavCustomization.saveQuickActions(prefs, quickActions)
}
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
// Bottom bar shortcuts
slots.clear()
slots.addAll(NavCustomization.getSlots(prefs))
slotAdapter = NavItemAdapter(slots) {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
}
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
(activity as? HomeActivity)?.applyNavLabelVisibility()
}
updateShortcutsVisibility()
// Theme
val saved = prefs.getString("theme", "system")
binding.themeToggle.check(when (saved) {
@@ -63,6 +106,95 @@ class SettingsAppearanceFragment : Fragment() {
}
}
private fun setupNavItemRecyclerView(
rv: RecyclerView,
adapter: NavItemAdapter,
items: MutableList<Int>
) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0
) {
override fun onMove(
rv: RecyclerView,
from: RecyclerView.ViewHolder,
to: RecyclerView.ViewHolder
): Boolean {
val fromPos = from.adapterPosition
val toPos = to.adapterPosition
Collections.swap(items, fromPos, toPos)
adapter.notifyItemMoved(fromPos, toPos)
adapter.onSave()
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
}).attachToRecyclerView(rv)
}
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
val rows = available.map { item ->
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
listLayout.addView(row)
}
}
val scroll = ScrollView(ctx).apply { addView(listLayout) }
var dialog: androidx.appcompat.app.AlertDialog? = null
dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.settings_bottom_bar_select)
.setView(scroll)
.setNegativeButton(R.string.cancel, null)
.show()
available.forEachIndexed { i, item ->
rows[i].setOnClickListener {
items[slotIndex] = item.id
adapter.onSave()
adapter.notifyItemChanged(slotIndex)
dialog?.dismiss()
}
}
}
private inner class NavItemAdapter(
val items: MutableList<Int>,
val onSave: () -> Unit
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
else RecyclerView.LayoutParams.WRAP_CONTENT
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
return VH(view)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
holder.ivNavIcon.setImageResource(def.iconRes)
holder.tvNavLabel.setText(def.titleRes)
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.settings_appearance)

View File

@@ -12,8 +12,11 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp
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.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity
@@ -58,60 +61,32 @@ class SettingsLoginsFragment : Fragment() {
val container = binding.loginsContainer
container.removeAllViews()
val hasMib = store.hasMibCredentials()
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials()
val fahipayLoginIds = store.getFahipayLoginIds()
binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
if (hasMib) {
val profile = store.loadMibUserProfile()
for (loginId in mibLoginIds) {
val profile = store.loadMibUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val profileNames = AccountCache.load(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct()
val mibProfiles = store.loadMibProfiles(loginId)
addLoginRow(container, R.drawable.mib_logo, displayName) {
showLoginDetails(
title = getString(R.string.mib_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 (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
)
showMibLoginDetails(store, loginId, profile, mibProfiles)
}
}
for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile(loginId)
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) {
showLoginDetails(
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) } }
)
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
}
}
if (hasFahipay) {
val profile = store.loadFahipayUserProfile()
for (loginId in fahipayLoginIds) {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
showLoginDetails(
@@ -122,7 +97,7 @@ class SettingsLoginsFragment : Fragment() {
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
}.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store) } }
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
)
}
}
@@ -154,6 +129,247 @@ class SettingsLoginsFragment : Fragment() {
container.addView(row)
}
private fun showMibLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.MibUserProfile?,
mibProfiles: List<MibProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
// Account info lines
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
).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 (mibProfiles.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()
}
})
}
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
val toggleRows = mibProfiles.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.cifType.isNotBlank()) {
textCol.addView(TextView(ctx).apply {
text = p.cifType
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 = 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)
.setTitle(getString(R.string.mib_name))
.setView(scroll)
.setPositiveButton(R.string.save, null) // null — set manually after show()
.setNeutralButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.mib_name)) { logoutMib(store, loginId) }
}
.show()
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
updateToggleStates(saveBtn)
toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
}
}
private fun showBmlLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.BmlUserProfile?,
bmlProfiles: List<BmlProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenBmlProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" },
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $it" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
})
}
if (bmlProfiles.isNotEmpty()) {
if (profile != null) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.login_detail_profiles)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
}
val toggleRows = bmlProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
text = p.name
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
})
if (p.type.isNotBlank()) {
textCol.addView(TextView(ctx).apply {
text = p.type
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
}
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol)
row.addView(toggle)
container.addView(row)
p to toggle
}
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.bml_name))
.setView(scroll)
.setPositiveButton(R.string.save, null)
.setNeutralButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) }
}
.show()
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
updateToggleStates(saveBtn)
toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.applyProfileVisibility()
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
@@ -172,12 +388,16 @@ class SettingsLoginsFragment : Fragment() {
.show()
}
private fun logoutMib(store: CredentialStore) {
private fun logoutMib(store: CredentialStore, loginId: String) {
val ctx = requireContext()
store.clearMibCredentials()
store.clearMibCredentials(loginId)
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
val app = requireActivity().application as BasedBankApp
app.accounts = emptyList(); app.mibSession = null; app.mibProfiles = emptyList()
app.mibSessions.remove(loginId)
app.mibProfilesMap.remove(loginId)
app.mibLoginFlows.remove(loginId)
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
@@ -185,20 +405,26 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutBml(store: CredentialStore, loginId: String) {
val ctx = requireContext()
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
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" }
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
}
private fun logoutFahipay(store: CredentialStore) {
private fun logoutFahipay(store: CredentialStore, loginId: String) {
val ctx = requireContext()
store.clearFahipayCredentials(); store.clearFahipaySession()
store.clearFahipayCredentials(loginId)
val app = requireActivity().application as BasedBankApp
app.fahipaySession = null; app.fahipayAccounts = emptyList()
app.fahipaySessions.remove(loginId)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != "fahipay_$loginId" }
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()

View File

@@ -79,6 +79,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer()
}
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots

View File

@@ -14,7 +14,7 @@ import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
/** Adapter for Transaction History — date-grouped, shows account name in secondary line. */
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
@@ -134,18 +141,23 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History
// Show account name in secondary line for Transaction History
b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
if (hideAmounts) {
b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.setTextColor(Color.parseColor("#888888"))
} else {
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
}
b.root.setOnClickListener { showDetail(trx) }
}

View File

@@ -19,6 +19,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
@@ -49,11 +50,13 @@ import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.Totp
class TransferFragment : Fragment() {
@@ -63,7 +66,9 @@ class TransferFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val session get() = selectedAccount
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyMibSession()
private fun bmlSessionFor(account: MibAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession()
@@ -97,6 +102,8 @@ class TransferFragment : Fragment() {
private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash"
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 {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
@@ -117,6 +124,22 @@ class TransferFragment : Fragment() {
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 {
@@ -146,9 +169,12 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
binding.btnTransfer.isEnabled = false
binding.btnTransfer.setOnClickListener { initiateTransfer() }
// Pre-select contact if navigated from contacts page
binding.etAmount.addTextChangedListener { updateTransferButton() }
// Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly(
accountNumber = account,
@@ -158,6 +184,8 @@ class TransferFragment : Fragment() {
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() {
@@ -183,6 +211,7 @@ class TransferFragment : Fragment() {
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
updateTransferButton()
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
@@ -194,6 +223,7 @@ class TransferFragment : Fragment() {
selectedAccount = picked
updateAmountPrefix(picked)
showFromCard(picked)
updateTransferButton()
}
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
@@ -203,22 +233,22 @@ class TransferFragment : Fragment() {
selectedAccount = match
updateAmountPrefix(match)
showFromCard(match)
updateTransferButton()
}
}
}
}
private fun showFromCard(account: MibAccount) {
val isBml = account.profileType.startsWith("BML")
val colorHex = when {
isBml -> "#0066A1"
account.profileType == "FAHIPAY" -> "#15BEA7"
else -> "#FE860E"
val colorHex = when (account.bank) {
"BML" -> "#0066A1"
"FAHIPAY" -> "#15BEA7"
else -> "#FE860E"
}
val bankLabel = when {
isBml -> "BML"
account.profileType == "FAHIPAY" -> "FP"
else -> null
val bankLabel = when (account.bank) {
"BML" -> "BML"
"FAHIPAY" -> "FP"
else -> null
}
val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card"
@@ -234,7 +264,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE
if (!isBml && account.profileImageHash != null) {
if (account.bank != "BML" && account.profileImageHash != null) {
loadFromPhoto(account.profileImageHash)
}
}
@@ -244,7 +274,7 @@ class TransferFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = app.mibLoginFlow.fetchProfileImage(sess, hash) ?: return@launch
val base64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) {
@@ -271,6 +301,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null
updateTransferButton()
}
binding.etTo.addTextChangedListener {
@@ -282,6 +313,7 @@ class TransferFragment : Fragment() {
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
updateTransferButton()
}
}
}
@@ -312,7 +344,7 @@ class TransferFragment : Fragment() {
}
// Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "FAHIPAY") {
if (selectedAccount?.bank == "FAHIPAY") {
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupFahipayTarget(accountNumber)
} else {
@@ -328,7 +360,7 @@ class TransferFragment : Fragment() {
return
}
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
val isBmlSource = selectedAccount?.bank == "BML"
startLookupLoading()
@@ -394,6 +426,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info)
when {
@@ -520,6 +553,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
if (contact != null) {
@@ -567,7 +601,7 @@ class TransferFragment : Fragment() {
binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML")
val isSrcBml = src.bank == "BML"
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" }
@@ -578,10 +612,10 @@ class TransferFragment : Fragment() {
if (isSrcBml && isDestMib && currency == "USD") {
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
if (!hasBmlContact) {
AlertDialog.Builder(requireContext())
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer_bml_contact_required_title)
.setMessage(R.string.transfer_bml_contact_required_msg)
.setPositiveButton("OK", null)
.setPositiveButton(R.string.close, null)
.show()
return
}
@@ -615,6 +649,7 @@ class TransferFragment : Fragment() {
binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
clearForm()
val activity = requireActivity() as HomeActivity
activity.refreshBalances(src)
@@ -625,7 +660,7 @@ class TransferFragment : Fragment() {
}
}
val dialogBuilder = AlertDialog.Builder(requireContext())
val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null)
@@ -706,12 +741,14 @@ class TransferFragment : Fragment() {
): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("mib_")
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed
val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
@@ -740,7 +777,7 @@ class TransferFragment : Fragment() {
)
if (result.success) {
val receipt = TransferReceiptData(
isMib = true,
bank = "MIB",
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency,
fromLabel = src.accountBriefName,
@@ -824,7 +861,7 @@ class TransferFragment : Fragment() {
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val receipt = TransferReceiptData(
isMib = false,
bank = "BML",
amount = "%.2f".format(amount),
currency = currency,
fromLabel = src.accountBriefName,
@@ -848,6 +885,11 @@ class TransferFragment : Fragment() {
}
}
private fun updateTransferButton() {
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
}
private fun clearForm() {
selectedAccount = null
binding.actvFrom.setText("", false)
@@ -878,7 +920,7 @@ class TransferFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
@@ -1009,11 +1051,11 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it }
}
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isBmlAccount = acc.profileType.startsWith("BML")
val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: ""
b.root.alpha = if (inactive) 0.4f else 1f
b.root
}

View File

@@ -10,6 +10,8 @@ import android.util.Base64
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.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
@@ -61,10 +63,10 @@ class TransferHistoryFragment : Fragment() {
var fahipayTotal: Int = -1
) {
fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
}
}
@@ -84,9 +86,20 @@ class TransferHistoryFragment : Fragment() {
adapter = TransactionAdapter()
adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.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.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return
@@ -142,14 +155,13 @@ class TransferHistoryFragment : Fragment() {
}
val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession
lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>()
// BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
val bmlStates = activeStates.filter { it.account.bank == "BML" }
results.addAll(bmlStates.map { state ->
async {
try {
@@ -187,9 +199,9 @@ class TransferHistoryFragment : Fragment() {
}.awaitAll().flatten())
// Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" }
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) {
val session = app.fahipaySession ?: continue
val session = app.fahipaySessionFor(state.account) ?: continue
try {
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
@@ -206,27 +218,28 @@ class TransferHistoryFragment : Fragment() {
}
// MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter {
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
}
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (_: Exception) {}
val mibStates = activeStates.filter { it.account.bank == "MIB" }
for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
val session = app.mibSessions[loginId] ?: continue
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
app.mibMutex.withLock {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (_: Exception) {}
}
}
}
}
@@ -280,7 +293,7 @@ class TransferHistoryFragment : Fragment() {
return
}
val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch

View File

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

View File

@@ -1,6 +1,10 @@
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.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
private val receiptCard get() = _receiptCard!!
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_CURRENCY = "currency"
private const val ARG_FROM_LABEL = "from_label"
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
pendingToAvatarBitmap = toAvatarBitmap
arguments = Bundle().apply {
putBoolean(ARG_IS_MIB, data.isMib)
putString(ARG_BANK, data.bank)
putString(ARG_AMOUNT, data.amount)
putString(ARG_CURRENCY, data.currency)
putString(ARG_FROM_LABEL, data.fromLabel)
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
return if (isMib) {
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
return if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding)
_receiptCard = binding.receiptCard
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() }
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack()
}
@@ -150,15 +156,21 @@ class TransferReceiptFragment : Fragment() {
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
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) {
val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return
val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
binding.remarksDivider.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 ──────────────────────────────────────────────────────────
@@ -310,6 +329,54 @@ class TransferReceiptFragment : Fragment() {
return bm
}
private fun showFullScreenReceipt() {
captureReceiptBitmap { bitmap ->
if (bitmap == null) return@captureReceiptBitmap
val ctx = requireContext()
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val iv = android.widget.ImageView(ctx).apply {
setImageBitmap(bitmap)
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
setBackgroundColor(Color.BLACK)
}
iv.setOnClickListener { dialog.dismiss() }
dialog.setContentView(iv)
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, iv).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() {
super.onResume()
requireActivity().title = "Receipt"

View File

@@ -18,14 +18,21 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CredentialsFragment : Fragment() {
@@ -46,6 +53,15 @@ class CredentialsFragment : Fragment() {
private var fahipayFlow: FahipayLoginFlow? = null
private var fahipayAwaitingTotp = false
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
private var bmlCurrentBusinessProfile: BmlProfile? = null
private var bmlSelectedChannel: String? = null
private var bmlAwaitingBusinessOtp = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root
@@ -54,7 +70,7 @@ class CredentialsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
when (bankType) {
"BML" -> {
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector)
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
}
"FAHIPAY" -> {
@@ -151,7 +167,11 @@ class CredentialsFragment : Fragment() {
private fun attemptLogin() {
when (bankType) {
"BML" -> { attemptBmlLogin(); return }
"BML" -> {
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
else attemptBmlLogin()
return
}
"FAHIPAY" -> { attemptFahipayLogin(); return }
}
@@ -170,6 +190,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password)
val loginId = username
val flow = MibLoginFlow(CredentialStore(requireContext()))
viewLifecycleOwner.lifecycleScope.launch {
@@ -178,11 +199,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, passwordHash, otpSeed)
}
val store = CredentialStore(requireContext())
store.saveMibCredentials(username, passwordHash, otpSeed)
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) {
flow.lastSession?.let { s ->
val profile = flow.fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile(
fullName = profile.fullName,
username = profile.username,
@@ -193,11 +215,15 @@ class CredentialsFragment : Fragment() {
)
}
}
AccountCache.save(requireContext(), accounts)
store.saveMibProfiles(loginId, flow.lastProfiles)
val app = requireActivity().application as BasedBankApp
app.accounts = accounts
app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles
// Merge with any existing MIB accounts from other logins
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
AccountCache.save(requireContext(), app.mibAccounts)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -226,20 +252,186 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val loginId = username
val flow = BmlLoginFlow()
bmlLoginId = username
bmlAccumulatedAccounts.clear()
bmlPendingBusinessProfiles.clear()
bmlCurrentBusinessProfile = null
bmlSelectedChannel = null
bmlAwaitingBusinessOtp = false
val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username"
viewLifecycleOwner.lifecycleScope.launch {
try {
val profiles = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed)
}
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
// Activate each profile; personal profiles are immediate, business ones need OTP
for (profile in profiles) {
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
when (result) {
is BmlActivationResult.Success -> {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
is BmlActivationResult.NeedsBusinessOtp -> {
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
}
}
}
// Save credentials and profile list now (before business OTP prompts)
val store = CredentialStore(requireContext())
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlProfiles(bmlLoginId, profiles)
val app = requireActivity().application as BasedBankApp
app.bmlProfilesMap[bmlLoginId] = profiles
app.bmlLoginFlows[bmlLoginId] = flow
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} 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 processNextBmlBusinessProfile() {
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
?: run { finishBmlLogin(); return }
bmlCurrentBusinessProfile = profile
// Show channel selection dialog
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
// User skipped this profile — move on
processNextBmlBusinessProfile()
return
}
bmlSelectedChannel = selectedChannel
// Request OTP
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
try {
withContext(Dispatchers.IO) {
bmlFlow!!.requestBusinessOtp(selectedChannel)
}
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
binding.tvError.text = e.message ?: "Failed to send OTP"
binding.tvError.visibility = View.VISIBLE
processNextBmlBusinessProfile()
return
}
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
bmlAwaitingBusinessOtp = true
binding.etUsername.isEnabled = false
binding.etPassword.isEnabled = false
binding.etOtpSeed.isEnabled = false
binding.progressBar.visibility = View.GONE
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
binding.tilTotpCode.visibility = View.VISIBLE
binding.etTotpCode.text?.clear()
binding.btnLogin.text = getString(R.string.verify)
binding.btnLogin.isEnabled = true
binding.tvError.visibility = View.GONE
}
private fun submitBmlBusinessOtp() {
val code = binding.etTotpCode.text.toString().trim()
if (code.length != 6) {
binding.tvError.text = getString(R.string.fahipay_totp_hint)
binding.tvError.visibility = View.VISIBLE
return
}
binding.tvError.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val profile = bmlCurrentBusinessProfile ?: return
val channel = bmlSelectedChannel ?: return
val loginTag = "bml_$bmlLoginId"
viewLifecycleOwner.lifecycleScope.launch {
try {
val (session, accounts) = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed)
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
}
bmlAccumulatedAccounts += accounts
val store = CredentialStore(requireContext())
store.saveBmlCredentials(loginId, username, password, otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
withContext(Dispatchers.IO) {
val info = flow.fetchUserInfo(session)
if (info != null) store.saveBmlUserProfile(
loginId,
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = session
bmlAwaitingBusinessOtp = false
binding.tilTotpCode.visibility = View.GONE
binding.btnLogin.text = getString(R.string.login)
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "OTP verification failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.bml_business_otp_title, profileName))
.setItems(options) { _, which ->
if (cont.isActive) cont.resume(channels[which].channel)
}
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
if (cont.isActive) cont.resume(null as String?)
}
.show()
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
}
private suspend fun finishBmlLogin() {
val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList()
// Fetch user profile info from any active session
val app = requireActivity().application as BasedBankApp
val anySession = app.anyBmlSessionFor(bmlLoginId)
if (anySession != null) {
withContext(Dispatchers.IO) {
val info = BmlLoginFlow().fetchUserInfo(anySession)
if (info != null) {
store.saveBmlUserProfile(
bmlLoginId,
CredentialStore.BmlUserProfile(
fullName = info.fullName,
email = info.email,
@@ -249,24 +441,44 @@ class CredentialsFragment : Fragment() {
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
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() {
@@ -374,11 +586,13 @@ class CredentialsFragment : Fragment() {
val b = flow.fetchBalance(session)
Pair(p, b)
}
val loginTag = "fahipay_${profile.profileId}"
val loginId = profile.profileId
val loginTag = "fahipay_$loginId"
val account = flow.buildAccount(profile, balance, loginTag)
store.saveFahipayCredentials(idCard, password)
store.saveFahipaySession(session.authId, session.sessionCookie)
store.saveFahipayCredentials(loginId, idCard, password)
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
store.saveFahipayUserProfile(
loginId,
CredentialStore.FahipayUserProfile(
fullName = profile.fullName,
email = profile.email,
@@ -389,11 +603,11 @@ class CredentialsFragment : Fragment() {
linkedAccounts = profile.linkedAccounts
)
)
AccountCache.saveFahipay(requireContext(), listOf(account))
AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
val app = requireActivity().application as BasedBankApp
app.fahipaySession = session
app.fahipayAccounts = listOf(account)
app.accounts = app.accounts + listOf(account)
app.fahipaySessions[loginId] = session
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)

View File

@@ -40,6 +40,11 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
insets
}
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
val statusBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(view.paddingLeft, statusBar.top, view.paddingRight, view.paddingBottom)
insets
}
val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter
@@ -56,7 +61,7 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
}
// Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null)
val savedLang = prefs.getString("language", null) ?: "en".also { selectLanguage(it) }
binding.languageToggle.clearOnButtonCheckedListeners()
when (savedLang) {
"en" -> binding.btnLangEnglish.isChecked = true

View File

@@ -9,16 +9,17 @@ object AccountCache {
private const val PREFS = "account_cache"
private const val KEY_MIB = "mib_accounts"
private const val KEY_FAHIPAY = "fahipay_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
fun save(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
put("bank", acc.bank)
put("profileName", acc.profileName)
put("profileType", acc.profileType)
put("cifType", acc.cifType)
put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName)
@@ -68,20 +69,21 @@ object AccountCache {
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
bank = "BML",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null,
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
)
}
} catch (_: Exception) { emptyList() }
@@ -90,7 +92,7 @@ object AccountCache {
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, accounts: List<MibAccount>) {
fun saveFahipay(context: Context, loginId: String, accounts: List<MibAccount>) {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
@@ -110,36 +112,40 @@ object AccountCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_FAHIPAY, CacheEncryption.encrypt(arr.toString())).apply()
.edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadFahipay(context: Context): List<MibAccount> {
fun loadFahipay(context: Context, loginId: String): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_FAHIPAY, null) ?: return emptyList()
.getString(fahipayKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
bank = "FAHIPAY",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null,
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
)
}
} catch (_: Exception) { emptyList() }
}
fun loadFahipay(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadFahipay(context, it) }
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
@@ -153,20 +159,22 @@ object AccountCache {
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
bank = o.optString("bank", "MIB"),
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "")
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "")
)
}
} catch (e: Exception) {

View File

@@ -0,0 +1,15 @@
package sh.sar.basedbank.util
/**
* Standard display model for the account history header card — produced by
* per-bank parsers, consumed by AccountHistoryAdapter with no bank logic.
*/
data class AccountHistoryDisplay(
val name: String,
val number: String,
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
val availableBalance: String, // formatted "CCY amount"
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
val blockedBalance: String? // null if zero or not applicable
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser {
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
"BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account)
else -> null
}
}

View File

@@ -0,0 +1,11 @@
package sh.sar.basedbank.util
data class AccountListDisplay(
val name: String,
val number: String,
val typeLabel: String,
val balance: String,
val isCard: Boolean = false,
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
val statusLabel: String? = null // null = active; shown as status pill if set
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser {
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
"BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account)
else -> null
}
}

View File

@@ -1,28 +0,0 @@
package sh.sar.basedbank.util
object BmlDashboardParser {
/**
* Returns a display-ready product label for a BML dashboard account or card.
* Known BML product names are mapped to short friendly labels.
* Everything else is title-cased (first letter of each word capitalised).
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,22 @@
package sh.sar.basedbank.util
/**
* Standard display model for a contact row — produced by per-bank parsers,
* consumed by UI. No bank-specific logic in adapters or fragments.
*/
data class ContactDisplay(
val id: String, // internal contact ID (benefNo)
val name: String, // display nickname
val realName: String, // legal name — used for search
val accountNumber: String,
val categoryId: String, // for tab filtering
val network: TransferNetwork,
val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
val imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
val canTransfer: Boolean,
val canEdit: Boolean,
val canDelete: Boolean
)

View File

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

View File

@@ -0,0 +1,40 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibContactsClient
/**
* Behaviour dispatcher for contact operations.
* Routes add/delete to the correct bank API based on TransferNetwork.
* UI code never inspects the network or bank type directly.
*/
object ContactManager {
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
TransferNetwork.BML -> deleteBml(contact, app)
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
}
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.id.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null)
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.id)
} catch (_: Exception) { false }
}
}

View File

@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlProfile
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -21,75 +22,131 @@ class CredentialStore(context: Context) {
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
data class FahipayCredentials(val idCard: String, val password: String)
// ── MIB login credentials ─────────────────────────────────────────────────
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card")
fun getMibLoginIds(): List<String> {
maybeMigrateLegacyMib()
val json = prefs.getString("mib_login_ids", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
private fun addMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveMibCredentials(loginId: String, username: String, passwordHash: String, otpSeed: String) {
addMibLoginId(loginId)
val key = getOrCreateKey()
prefs.edit()
.putString("mib_enc_username", encrypt(username, key))
.putString("mib_enc_password_hash", encrypt(passwordHash, key))
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
.putString("mib_${loginId}_enc_password_hash", encrypt(passwordHash, key))
.putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
.apply()
}
fun loadMibCredentials(): MibCredentials? {
fun loadMibCredentials(loginId: String): MibCredentials? {
val key = getOrCreateKey()
val encUsername = prefs.getString("mib_enc_username", null) ?: return null
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
val encSeed = prefs.getString("mib_${loginId}_enc_otp_seed", null) ?: return null
return try {
MibCredentials(
decrypt(encUsername, key),
decrypt(encHash, key),
decrypt(encSeed, key)
)
MibCredentials(loginId, decrypt(encHash, key), decrypt(encSeed, key))
} catch (_: Exception) { null }
}
fun clearMibCredentials() {
fun clearMibCredentials(loginId: String) {
removeMibLoginId(loginId)
prefs.edit()
.remove("mib_enc_username")
.remove("mib_enc_password_hash")
.remove("mib_enc_otp_seed")
.remove("mib_enc_key1")
.remove("mib_enc_key2")
.remove("mib_enc_app_id")
.remove("mib_${loginId}_enc_password_hash")
.remove("mib_${loginId}_enc_otp_seed")
.remove("mib_${loginId}_enc_key1")
.remove("mib_${loginId}_enc_key2")
.remove("mib_${loginId}_enc_app_id")
.remove("mib_${loginId}_all_profiles")
.remove("mib_${loginId}_enc_profile")
.remove("mib_${loginId}_enc_full_name")
.remove("mib_${loginId}_hidden_profile_ids")
.apply()
}
// ── MIB session keys (key1/key2) and app ID ───────────────────────────────
// ── MIB session keys (key1/key2) and app ID (per loginId) ────────────────
fun saveMibKeys(key1: String, key2: String) {
fun saveMibKeys(loginId: String, key1: String, key2: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("mib_enc_key1", encrypt(key1, key))
.putString("mib_enc_key2", encrypt(key2, key))
.putString("mib_${loginId}_enc_key1", encrypt(key1, key))
.putString("mib_${loginId}_enc_key2", encrypt(key2, key))
.apply()
}
fun loadMibKeys(): Pair<String, String>? {
fun loadMibKeys(loginId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null
val encKey1 = prefs.getString("mib_${loginId}_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_${loginId}_enc_key2", null) ?: return null
return try {
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
} catch (_: Exception) { null }
}
fun saveMibAppId(id: String) {
fun saveMibAppId(loginId: String, id: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply()
prefs.edit().putString("mib_${loginId}_enc_app_id", encrypt(id, key)).apply()
}
fun loadMibAppId(): String? {
fun loadMibAppId(loginId: String): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_app_id", null) ?: return null
val enc = prefs.getString("mib_${loginId}_enc_app_id", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
/** One-time migration: if old single-login MIB data exists, move it to per-loginId storage. */
private var migrationChecked = false
private fun maybeMigrateLegacyMib() {
if (migrationChecked) return
migrationChecked = true
if (prefs.contains("mib_login_ids")) return // already migrated
val encUsername = prefs.getString("mib_enc_username", null) ?: return
val key = try { getOrCreateKey() } catch (_: Exception) { return }
val loginId = try { decrypt(encUsername, key) } catch (_: Exception) { return }
val editor = prefs.edit()
// Migrate credentials
prefs.getString("mib_enc_password_hash", null)?.let { editor.putString("mib_${loginId}_enc_password_hash", it) }
prefs.getString("mib_enc_otp_seed", null)?.let { editor.putString("mib_${loginId}_enc_otp_seed", it) }
prefs.getString("mib_enc_key1", null)?.let { editor.putString("mib_${loginId}_enc_key1", it) }
prefs.getString("mib_enc_key2", null)?.let { editor.putString("mib_${loginId}_enc_key2", it) }
prefs.getString("mib_enc_app_id", null)?.let { editor.putString("mib_${loginId}_enc_app_id", it) }
prefs.getString("mib_all_profiles", null)?.let { editor.putString("mib_${loginId}_all_profiles", it) }
prefs.getString("mib_enc_profile", null)?.let { editor.putString("mib_${loginId}_enc_profile", it) }
prefs.getString("mib_enc_full_name", null)?.let { editor.putString("mib_${loginId}_enc_full_name", it) }
prefs.getStringSet("mib_hidden_profile_ids", null)?.let { editor.putStringSet("mib_${loginId}_hidden_profile_ids", it) }
// Register the login ID and clear legacy keys
editor.putString("mib_login_ids", org.json.JSONArray(listOf(loginId)).toString())
editor.remove("mib_enc_username")
editor.remove("mib_enc_password_hash")
editor.remove("mib_enc_otp_seed")
editor.remove("mib_enc_key1")
editor.remove("mib_enc_key2")
editor.remove("mib_enc_app_id")
editor.remove("mib_all_profiles")
editor.remove("mib_enc_profile")
editor.remove("mib_enc_full_name")
editor.remove("mib_hidden_profile_ids")
editor.apply()
}
// ── BML login credentials (multi-login, keyed by loginId = username) ────────
fun getBmlLoginIds(): List<String> {
@@ -137,24 +194,85 @@ class CredentialStore(context: Context) {
}
fun clearBmlCredentials(loginId: String) {
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
removeBmlLoginId(loginId)
prefs.edit()
.remove("bml_${loginId}_enc_username")
.remove("bml_${loginId}_enc_password")
.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()
}
// ── 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()
prefs.edit()
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
.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>? {
val key = getOrCreateKey()
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
@@ -164,65 +282,81 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
fun clearBmlSession(loginId: String) {
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
fun getFahipayLoginIds(): List<String> {
maybeMigrateLegacyFahipay()
val json = prefs.getString("fahipay_login_ids", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
private fun addFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveFahipayCredentials(loginId: String, idCard: String, password: String) {
addFahipayLoginId(loginId)
val key = getOrCreateKey()
prefs.edit()
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key))
.putString("fahipay_${loginId}_enc_password", encrypt(password, key))
.apply()
}
// ── Fahipay login credentials ─────────────────────────────────────────────
fun saveFahipayCredentials(idCard: String, password: String) {
fun loadFahipayCredentials(loginId: String): FahipayCredentials? {
val key = getOrCreateKey()
prefs.edit()
.putString("fahipay_enc_id_card", encrypt(idCard, key))
.putString("fahipay_enc_password", encrypt(password, key))
.apply()
}
fun loadFahipayCredentials(): FahipayCredentials? {
val key = getOrCreateKey()
val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null
val encPw = prefs.getString("fahipay_enc_password", null) ?: return null
val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null
val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null
return try {
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
} catch (_: Exception) { null }
}
fun clearFahipayCredentials() {
fun clearFahipayCredentials(loginId: String) {
removeFahipayLoginId(loginId)
prefs.edit()
.remove("fahipay_enc_id_card")
.remove("fahipay_enc_password")
.remove("fahipay_${loginId}_enc_id_card")
.remove("fahipay_${loginId}_enc_password")
.remove("fahipay_${loginId}_enc_auth_id")
.remove("fahipay_${loginId}_enc_session_cookie")
.remove("fahipay_${loginId}_enc_profile")
.apply()
}
// ── Fahipay session (authId + __Secure-sess cookie) ───────────────────────
// ── Fahipay session (authId + __Secure-sess cookie) (per loginId) ─────────
fun saveFahipaySession(authId: String, sessionCookie: String) {
fun saveFahipaySession(loginId: String, authId: String, sessionCookie: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("fahipay_enc_auth_id", encrypt(authId, key))
.putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key))
.putString("fahipay_${loginId}_enc_auth_id", encrypt(authId, key))
.putString("fahipay_${loginId}_enc_session_cookie", encrypt(sessionCookie, key))
.apply()
}
fun loadFahipaySession(): Pair<String, String>? {
fun loadFahipaySession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null
val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null
val encAuth = prefs.getString("fahipay_${loginId}_enc_auth_id", null) ?: return null
val encCookie = prefs.getString("fahipay_${loginId}_enc_session_cookie", null) ?: return null
return try {
Pair(decrypt(encAuth, key), decrypt(encCookie, key))
} catch (_: Exception) { null }
}
fun clearFahipaySession() {
prefs.edit()
.remove("fahipay_enc_auth_id")
.remove("fahipay_enc_session_cookie")
.apply()
}
// ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─
fun getOrCreateFahipayDeviceUuid(): String {
@@ -236,7 +370,7 @@ class CredentialStore(context: Context) {
return uuid
}
// ── Fahipay user profile ──────────────────────────────────────────────────
// ── Fahipay user profile (per loginId) ────────────────────────────────────
data class FahipayUserProfile(
val fullName: String,
@@ -248,7 +382,7 @@ class CredentialStore(context: Context) {
val linkedAccounts: String
)
fun saveFahipayUserProfile(p: FahipayUserProfile) {
fun saveFahipayUserProfile(loginId: String, p: FahipayUserProfile) {
val json = org.json.JSONObject().apply {
put("fullName", p.fullName)
put("email", p.email)
@@ -259,12 +393,12 @@ class CredentialStore(context: Context) {
put("linkedAccounts", p.linkedAccounts)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("fahipay_enc_profile", encrypt(json, key)).apply()
prefs.edit().putString("fahipay_${loginId}_enc_profile", encrypt(json, key)).apply()
}
fun loadFahipayUserProfile(): FahipayUserProfile? {
fun loadFahipayUserProfile(loginId: String): FahipayUserProfile? {
val key = getOrCreateKey()
val enc = prefs.getString("fahipay_enc_profile", null) ?: return null
val enc = prefs.getString("fahipay_${loginId}_enc_profile", null) ?: return null
return try {
val o = org.json.JSONObject(decrypt(enc, key))
FahipayUserProfile(
@@ -279,6 +413,33 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
/** One-time migration: if old single-login Fahipay data exists, move it to per-loginId storage. */
private var fahipayMigrationChecked = false
private fun maybeMigrateLegacyFahipay() {
if (fahipayMigrationChecked) return
fahipayMigrationChecked = true
if (prefs.contains("fahipay_login_ids")) return // already migrated
val encProfile = prefs.getString("fahipay_enc_profile", null) ?: return
val key = try { getOrCreateKey() } catch (_: Exception) { return }
val loginId = try {
val o = org.json.JSONObject(decrypt(encProfile, key))
o.optString("profileId").takeIf { it.isNotBlank() }
} catch (_: Exception) { null } ?: return
val editor = prefs.edit()
prefs.getString("fahipay_enc_id_card", null)?.let { editor.putString("fahipay_${loginId}_enc_id_card", it) }
prefs.getString("fahipay_enc_password", null)?.let { editor.putString("fahipay_${loginId}_enc_password", it) }
prefs.getString("fahipay_enc_auth_id", null)?.let { editor.putString("fahipay_${loginId}_enc_auth_id", it) }
prefs.getString("fahipay_enc_session_cookie", null)?.let { editor.putString("fahipay_${loginId}_enc_session_cookie", it) }
editor.putString("fahipay_${loginId}_enc_profile", encProfile)
editor.putString("fahipay_login_ids", org.json.JSONArray(listOf(loginId)).toString())
editor.remove("fahipay_enc_id_card")
editor.remove("fahipay_enc_password")
editor.remove("fahipay_enc_auth_id")
editor.remove("fahipay_enc_session_cookie")
editor.remove("fahipay_enc_profile")
editor.apply()
}
// ── Security credential (PIN / pattern hash) ──────────────────────────────
/**
@@ -329,19 +490,55 @@ class CredentialStore(context: Context) {
val birthdate: String
)
fun saveMibFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
// ── MIB operating profiles (per loginId) ─────────────────────────────────
fun saveMibProfiles(loginId: String, profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) {
arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("cifType", p.cifType)
put("profileType", p.profileType)
put("color", p.color)
})
}
prefs.edit().putString("mib_${loginId}_all_profiles", arr.toString()).apply()
}
fun loadMibFullName(): String? {
fun loadMibProfiles(loginId: String): List<sh.sar.basedbank.api.mib.MibProfile> {
val raw = prefs.getString("mib_${loginId}_all_profiles", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
sh.sar.basedbank.api.mib.MibProfile(
profileId = o.optString("profileId"),
customerProfileId = o.optString("profileId"),
annexId = "",
customerId = "",
name = o.optString("name"),
cifType = o.optString("cifType"),
profileType = o.optString("profileType"),
color = o.optString("color"),
customerImage = null
)
}
} catch (_: Exception) { emptyList() }
}
fun saveMibFullName(loginId: String, name: String) {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_full_name", null) ?: return null
prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(name, key)).apply()
}
fun loadMibFullName(loginId: String): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_${loginId}_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveMibUserProfile(p: MibUserProfile) {
fun saveMibUserProfile(loginId: String, p: MibUserProfile) {
val json = JSONObject().apply {
put("fullName", p.fullName)
put("username", p.username)
@@ -350,14 +547,13 @@ class CredentialStore(context: Context) {
put("enrolled", p.enrolled)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply()
// Keep the name in sync with the fast-path field
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(p.fullName, key)).apply()
}
fun loadMibUserProfile(): MibUserProfile? {
fun loadMibUserProfile(loginId: String): MibUserProfile? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_profile", null) ?: return null
val enc = prefs.getString("mib_${loginId}_enc_profile", null) ?: return null
return try {
val o = JSONObject(decrypt(enc, key))
MibUserProfile(
@@ -399,6 +595,15 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
// ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
fun getHiddenMibProfileIds(loginId: String): Set<String> =
prefs.getStringSet("mib_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {

View File

@@ -0,0 +1,117 @@
package sh.sar.basedbank.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* Encapsulates all bank-specific pagination state and fetch logic for account history.
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
* without knowing which bank it is talking to.
*/
class HistoryFetcher(private val account: MibAccount) {
private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination
private var mibNextStart = 1
private var mibTotalCount = -1
// BML CASA pagination
private var bmlNextPage = 1
private var bmlTotalPages = -1
// BML card pagination (month-based)
private var cardMonthOffset = 0
// Fahipay pagination
private var fahipayNextStart = 0
private var fahipayTotal = -1
fun hasMore(): Boolean = when {
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
}
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
val session = app.fahipaySessionFor(account) ?: return emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
return list
}
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
val loginId = account.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId] ?: return emptyList()
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
return list
}
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
return BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
return list
}
}

View File

@@ -1,18 +0,0 @@
package sh.sar.basedbank.util
object MibAccountParser {
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
* Known MIB accountTypeName values are mapped to short friendly labels.
* Everything else is returned trimmed as-is.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

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

@@ -0,0 +1,10 @@
package sh.sar.basedbank.util
/** App-unified term for the transfer routing network a contact uses. */
enum class TransferNetwork {
MIB, // MIB internal (both parties on MIB)
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
SWIFT, // international SWIFT
BML, // Bank of Maldives
FAHIPAY // Fahipay wallet
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object BmlContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.BML,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}

View File

@@ -0,0 +1,69 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object BmlDashboardParser {
/**
* Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards.
*/
fun displayData(account: MibAccount): AccountListDisplay {
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}",
isCard = true,
cardBrandIcon = cardBrandIcon(account.accountTypeName),
statusLabel = if (isActive) null else account.statusDesc
)
} else {
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account)
)
}
}
/**
* Returns a display-ready product label for a BML dashboard account or card.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
fun listBalance(account: MibAccount): String =
"${account.currencyName} ${account.currentBalance}"
fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,20 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object BmlHistoryParser {
fun displayData(account: MibAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "BML",
typeLabel = BmlDashboardParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
}

View File

@@ -0,0 +1,14 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object FahipayAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = account.accountTypeName,
balance = "${account.currencyName} ${account.availableBalance}"
)
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object FahipayContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
canEdit = false,
canDelete = false
)
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object FahipayHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "FP",
typeLabel = account.accountTypeName,
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

View File

@@ -0,0 +1,26 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}"
)
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -0,0 +1,32 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object MibContactParser {
fun displayData(contact: MibBeneficiary): ContactDisplay {
val network = when (contact.benefType) {
"I" -> TransferNetwork.MIB
"S" -> TransferNetwork.SWIFT
else -> TransferNetwork.LOCAL // "L" and anything else
}
return ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = network,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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="M9,3h2v2H9V3zm4,0h2v2h-2V3zM9,7h2v2H9V7zm4,0h2v2h-2V7zM9,11h2v2H9v-2zm4,0h2v2h-2v-2zM9,15h2v2H9v-2zm4,0h2v2h-2v-2zM9,19h2v2H9v-2zm4,0h2v2h-2v-2z"/>
</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:visibility="gone"
android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" />
app:menu="@menu/bottom_nav_menu" />
</LinearLayout>

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

@@ -94,7 +94,7 @@
<ImageView
android:layout_width="138dp"
android:layout_height="40dp"
android:src="@drawable/bml_logo_vector"
android:src="@drawable/bml_logo_long"
android:contentDescription="@string/bml_name"
android:scaleType="fitStart"
android:layout_marginBottom="12dp" />

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
<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">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -162,33 +168,57 @@
</com.google.android.material.card.MaterialCardView>
<!-- Action buttons -->
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<!-- Quick actions fixed at bottom -->
<LinearLayout
android:id="@+id/buttonBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTransfer"
android:id="@+id/btnQuickAction1"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="@string/transfer" />
app:iconGravity="textStart"
app:iconSize="18dp"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPayMvQr"
android:id="@+id/btnQuickAction2"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/pay_mv_qr" />
app:iconGravity="textStart"
app:iconSize="18dp"
app:iconPadding="8dp" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

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

@@ -1,6 +1,7 @@
<?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"
@@ -225,7 +226,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Share" />
android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
@@ -234,7 +236,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:text="Save" />
android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"

View File

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

View File

@@ -15,7 +15,7 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingTop="64dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal">

View File

@@ -45,6 +45,69 @@
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Quick actions (always active) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvQuickActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never"
android:layout_marginBottom="16dp" />
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout
android:id="@+id/sectionBottomBarShortcuts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_bottom_bar_shortcuts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvNavSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_bottom_bar_show_labels"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchShowLabels"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@@ -162,6 +162,44 @@
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowHideAmounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideAmounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"

View File

@@ -37,9 +37,17 @@
android:fontFamily="monospace"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvAccountType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<!-- Right: segmented pill (bank | type) + balance -->
<!-- Right: balance + transfer button -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -47,31 +55,22 @@
android:gravity="end"
android:layout_marginStart="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvPillType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<TextView
android:id="@+id/tvBalance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" />
android:textColor="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout>

View File

@@ -56,7 +56,7 @@
</LinearLayout>
<!-- Status pill + balance (prepaid only) -->
<!-- Transfer button + balance -->
<LinearLayout
android:id="@+id/layoutCardBalance"
android:layout_width="wrap_content"
@@ -85,6 +85,16 @@
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingVertical="12dp"
android:paddingHorizontal="8dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/ivNavIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginBottom="6dp" />
<TextView
android:id="@+id/tvNavLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>

View File

@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.navigation.NavigationView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/navMoreView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/more_nav_menu" />
android:layout_height="wrap_content" />

View File

@@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_hide_amounts"
android:icon="@drawable/ic_visibility"
android:title="@string/action_hide_amounts"
app:showAsAction="always" />
<item
android:id="@+id/action_lock"
android:icon="@drawable/ic_lock"

View File

@@ -70,7 +70,7 @@
<string name="nav_accounts">އެކައުންޓްތައް</string>
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
<string name="nav_activities">ހަރަކާތްތައް</string>
<string name="nav_transfer_history">ޓްރާންސްފަ ތާރީހް</string>
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string>
<string name="nav_finances">ފައިނޭންސް</string>
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
<string name="nav_settings">ސެޓިންގ</string>
@@ -79,6 +79,7 @@
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
<!-- Dashboard -->
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
<string name="balance_mvr">ޖުމްލަ MVR</string>
<string name="balance_usd">ޖުމްލަ USD</string>
<string name="card_support_wip">ކާޑް ސަޕޯޓް</string>
@@ -86,6 +87,11 @@
<string name="pay_mv_qr">PayMV QR</string>
<!-- Settings -->
<string name="settings_bottom_bar_shortcuts">ތިރި ބާ ޝޯޓްކަޓްތައް</string>
<string name="settings_bottom_bar_select">ބަޓަން ހިޔާރު ކުރޭ</string>
<string name="settings_bottom_bar_slot_1">ތަން 1</string>
<string name="settings_bottom_bar_slot_2">ތަން 2</string>
<string name="settings_bottom_bar_slot_3">ތަން 3</string>
<string name="theme">ތީމް</string>
<string name="theme_system">ސިސްޓަމް</string>
<string name="theme_light">ލައިޓް</string>
@@ -113,6 +119,8 @@
<string name="close">ބަންދު</string>
<string name="cancel">ކެންސަލް</string>
<string name="settings_bottom_bar_show_labels">ބޮޓަމް ބާ ލޭބަލް އަބަދުވެސް ދެއްކުން</string>
<!-- Home -->
<string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>

View File

@@ -76,11 +76,11 @@
<!-- Navigation -->
<string name="nav_dashboard">Dashboard</string>
<string name="nav_add_account">Add Account</string>
<string name="nav_add_account">Add Login</string>
<string name="nav_accounts">Accounts</string>
<string name="nav_contacts">Contacts</string>
<string name="nav_activities">Activities</string>
<string name="nav_transfer_history">Transfer History</string>
<string name="nav_activities">Recent Transfers</string>
<string name="nav_transfer_history">Transaction History</string>
<string name="nav_finances">Finances</string>
<string name="nav_card_settings">Card Settings</string>
<string name="nav_otp">OTP Codes</string>
@@ -92,14 +92,26 @@
<!-- Dashboard -->
<string name="dashboard_pending_finances">Pending Finances</string>
<string name="dashboard_quick_actions">Quick Actions</string>
<string name="balance_mvr">MVR Total</string>
<string name="balance_usd">USD Total</string>
<string name="card_support_wip">Card Support</string>
<string name="transfer">Transfer</string>
<string name="pay_mv_qr">PayMV QR</string>
<!-- PayMV QR Generator -->
<string name="paymvqr_select_account">Select account</string>
<string name="paymvqr_amount_hint">Amount (optional)</string>
<string name="paymvqr_amount_helper">Leave empty to allow payer to enter any amount</string>
<string name="paymvqr_share">Share</string>
<string name="paymvqr_save_image">Save Image</string>
<string name="paymvqr_saved">QR saved to gallery</string>
<string name="paymvqr_save_failed">Failed to save image</string>
<!-- Toolbar -->
<string name="action_lock">Lock app</string>
<string name="action_hide_amounts">Hide sensitive information</string>
<string name="action_show_amounts">Show sensitive information</string>
<string name="autolock_warning_title">Locking soon</string>
<string name="autolock_warning_message">Locking in %d seconds</string>
<string name="autolock_stay">Stay unlocked</string>
@@ -127,6 +139,8 @@
<string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string>
<string name="settings_privacy">Privacy</string>
<string name="settings_hide_amounts">Hide sensitive information</string>
<string name="settings_hide_amounts_desc">Masks account balances and financial figures across the app</string>
<string name="settings_block_screenshots">Block Screenshots</string>
<string name="settings_block_screenshots_desc">Prevents the app from appearing in the recents screen and blocks screen capture</string>
<string name="settings_cache">Cache</string>
@@ -136,6 +150,12 @@
<string name="settings_nav_drawer">Drawer</string>
<string name="settings_nav_bottom">Bottom Bar</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
<string name="settings_bottom_bar_select">Choose button</string>
<string name="settings_bottom_bar_slot_1">Slot 1</string>
<string name="settings_bottom_bar_slot_2">Slot 2</string>
<string name="settings_bottom_bar_slot_3">Slot 3</string>
<string name="settings_privacy_security">Privacy &amp; Security</string>
<string name="settings_storage">Storage</string>
<string name="settings_logins">Logins</string>
@@ -150,7 +170,15 @@
<string name="login_detail_id_card">ID Card</string>
<string name="login_detail_profiles">Profiles</string>
<string name="close">Close</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<string name="verify">Verify</string>
<!-- BML business OTP -->
<string name="bml_business_otp_title">Activate %s profile</string>
<string name="bml_business_otp_hint">%s OTP code</string>
<string name="bml_business_otp_sent">OTP sent via %s</string>
<string name="bml_business_otp_skip">Skip</string>
<!-- Home -->
<string name="transfer_same_account">This is your source account</string>

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="receipt_cache" path="receipts/" />
<cache-path name="qr_cache" path="qr/" />
</paths>

BIN
bml_logo_long.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

85
docs/PARSERS.md Normal file
View File

@@ -0,0 +1,85 @@
# Account Display Parser Architecture
## Overview
Each bank's API returns account data in different formats and uses different field names for balances, product types, and status. To keep screens bank-agnostic, each bank has a dedicated parser that translates raw `MibAccount` model data into a standard `AccountListDisplay` object. Screens consume only `AccountListDisplay` — they never inspect `bank` or `profileType` or apply bank-specific logic.
## Bank Discriminator — `MibAccount.bank`
All dispatchers route by `account.bank`, a string set explicitly by each login flow at account creation time:
| `bank` value | Set by |
|--------------|-------------------|
| `"MIB"` | `MibLoginFlow` |
| `"BML"` | `BmlLoginFlow` |
| `"FAHIPAY"` | `FahipayLoginFlow`|
`profileType` is a bank-internal value (e.g. MIB's numeric profile ID, or BML's `"BML_PREPAID"`) and is **never** used for bank routing. Card-type checks within BML still use `profileType` (`"BML_PREPAID"` / `"BML_CREDIT"`).
`cifType` (MIB only) is the human-readable profile category name returned by the `operatingProfiles` API (e.g. `"Individual"`, `"Sole Propr"`). It is stored on `MibAccount` and surfaced in the accounts list section header and settings. It is **never hardcoded** in the app.
## Standard Output Model
```kotlin
// util/AccountListDisplay.kt
data class AccountListDisplay(
val name: String, // account or card display name
val number: String, // account/card number
val typeLabel: String, // human-friendly product type (e.g. "Savings", "Visa Platinum")
val balance: String, // formatted balance string (e.g. "MVR 1,234.56")
val isCard: Boolean, // true → use card layout; false → use account layout
val cardBrandIcon: Int, // drawable res for card brand logo (Visa / Mastercard / Amex)
val statusLabel: String? // null = active; non-null = shown as status label (e.g. "Inactive")
)
```
## Dispatcher
```kotlin
// util/AccountListParser.kt
AccountListParser.from(account: MibAccount): AccountListDisplay?
```
Routes to the correct parser based on `account.bank`. Returns `null` for unknown banks — never falls back to a specific bank.
| `account.bank` | Parser |
|----------------|-------------------------|
| `"BML"` | `BmlDashboardParser` |
| `"FAHIPAY"` | `FahipayAccountParser` |
| `"MIB"` | `MibAccountParser` |
| anything else | `null` |
## Bank Parsers
### BML — `util/bmlapi/BmlDashboardParser`
Handles both CASA accounts and prepaid/credit cards.
- **CASA balance**: uses `ledgerBalance` (working balance) — mapped to `account.currentBalance`
- **Card balance**: uses `availableBalance` (available limit from `cardBalance.AvailableLimit`)
- **Card brand**: resolved from product name (`VISA` / `MASTERCARD` / `AMEX`)
- **Status**: cards with `statusDesc != "Active"` surface `statusLabel`; active cards return `null`
### MIB — `util/mibapi/MibAccountParser`
- **Balance**: `availableBalance` from the MIB API directly
- Known product names (`SAVING ACCOUNT`, `CURRENT ACCOUNT`) mapped to short labels
- `cifType` (e.g. `"Individual"`, `"Sole Propr"`) comes from `MibProfile.cifType`, stored on `MibAccount`, displayed in section headers
### Fahipay — `util/fahipayapi/FahipayAccountParser`
- Single wallet account per user; `accountTypeName` is always `"Digital Wallet"`
- **Balance**: `availableBalance`
## Adding a New Bank
1. Create `util/<bankname>api/<Bank>AccountParser.kt` with a `displayData(account: MibAccount): AccountListDisplay` function
2. Set `bank = "<BANKNAME>"` in the new login flow when creating `MibAccount` objects
3. Add a `when` branch in `AccountListParser.from()` (and other dispatchers) for the new bank value
4. No changes needed in any screen or adapter
## Usage in Screens
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.