7 Commits
Author SHA1 Message Date
shihaam 15a02cac1c patial support for BML business profile accounts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 23:30:36 +05:00
shihaam 35a1748055 add save and share icons 2026-05-19 22:24:44 +05:00
shihaam 28682bba41 half baked PayMV QR generate support
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 21:59:05 +05:00
shihaam 25484addfb rename activites to recent transfers and transfer history to transaction history
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 20:16:29 +05:00
shihaam 728c7d2aa3 view recipts full screen by pressing empty area, copy values by holding it
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 19:56:56 +05:00
shihaam b24949c117 add support to view previous transfer recipts 2026-05-19 19:39:20 +05:00
shihaam 28e5878668 sync bottom bar when customizing bottom bar and switch checkbox to toggles in mib logins
Auto Tag on Version Change / check-version (push) Successful in 14s
2026-05-19 19:23:03 +05:00
26 changed files with 1898 additions and 203 deletions
+3
View File
@@ -73,6 +73,9 @@ dependencies {
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// ZXing core for QR code generation
implementation("com.google.zxing:core:3.5.3")
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye) // QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
implementation("androidx.camera:camera-core:1.4.2") implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2") implementation("androidx.camera:camera-camera2:1.4.2")
@@ -4,6 +4,8 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
@@ -17,18 +19,30 @@ class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout // Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList() var accounts: List<MibAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
/** Active MIB sessions keyed by loginId (= MIB username). */ /** Active MIB sessions keyed by loginId (= MIB username). */
val mibSessions: MutableMap<String, MibSession> = mutableMapOf() val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf() val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf() val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList() var mibAccounts: List<MibAccount> = emptyList()
/** Active BML sessions keyed by loginId (= BML username). */
/**
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
* Use [bmlSessionFor] to look up the session for an account.
*/
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf() val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
/** 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 bmlAccounts: List<MibAccount> = emptyList()
/** Active Fahipay sessions keyed by loginId (= profileId). */ /** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf() val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList() var fahipayAccounts: List<MibAccount> = emptyList()
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */ /** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? = fun mibSessionFor(account: MibAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")] mibSessions[account.loginTag.removePrefix("mib_")]
@@ -53,13 +67,38 @@ class BasedBankApp : Application() {
/** Returns any available MibLoginFlow. */ /** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull() fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
/** Returns the BML session for the given account (matched via loginTag). */ // ─── BML helpers ──────────────────────────────────────────────────────────
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")] /**
* Returns the BML session for the given account.
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
*/
fun bmlSessionFor(account: 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). */ /** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull() fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/**
* Returns any active BML session for the given loginId.
* Tries all profiles for that login; falls back to legacy loginId key.
*/
fun anyBmlSessionFor(loginId: String): BmlSession? {
val profiles = bmlProfilesMap[loginId]
if (!profiles.isNullOrEmpty()) {
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
}
return bmlSessions[loginId]
}
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
fun bmlFlowFor(loginId: String): BmlLoginFlow =
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
// ─── Fahipay helpers ──────────────────────────────────────────────────────
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */ /** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? = fun fahipaySessionFor(account: MibAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")] fahipaySessions[account.loginTag.removePrefix("fahipay_")]
@@ -29,9 +29,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)" private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
private val APP_VERSION = "2.1.43.345" private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0" private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -58,9 +58,27 @@ class BmlLoginFlow {
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
/** Full login: returns a BmlSession and the account list. */ /** PKCE params — generated once per login and reused across all profile activations. */
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> { private var codeVerifier: String = ""
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies 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( client.newCall(
Request.Builder().url("$BASE_URL/web/login") Request.Builder().url("$BASE_URL/web/login")
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
@@ -82,15 +100,14 @@ class BmlLoginFlow {
loginResp.close() loginResp.close()
if (loginResp.code != 302) throw Exception("Login failed — check your username/password") if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
// Step 3: GET 2FA page (refreshes blaze_session) // Step 3: GET 2FA page (refreshes session cookies)
client.newCall( client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa") Request.Builder().url("$BASE_URL/web/login/2fa")
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute().close() ).execute().close()
val xsrf2 = xsrfToken() ?: xsrf val xsrf2 = xsrfToken() ?: xsrf
// Step 4: POST OTP // Step 4: POST TOTP
val otp = Totp.generate(otpSeed) val otp = Totp.generate(otpSeed)
val twoFaBody = JSONObject().apply { val twoFaBody = JSONObject().apply {
put("code", otp) put("code", otp)
@@ -104,18 +121,141 @@ class BmlLoginFlow {
twoFaResp.close() twoFaResp.close()
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed") if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection) // Step 5: GET /web/profile — returns list of profiles for this account
client.newCall( val profileResp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile") Request.Builder().url("$BASE_URL/web/profile")
.header("X-XSRF-TOKEN", xsrf2) .header("User-Agent", WEB_USER_AGENT).build()
).execute()
val profileBody = profileResp.body?.string() ?: ""
profileResp.close()
lastProfiles = 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 {
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/redirect" in location) -> {
// Profile activated — blaze_identity cookie set in response headers
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() .header("User-Agent", WEB_USER_AGENT).build()
).execute().close() ).execute().close()
// Step 6: PKCE OAuth authorize → extract auth code val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val codeVerifier = generateCodeVerifier() val body = JSONObject().apply {
val codeChallenge = generateCodeChallenge(codeVerifier) put("code", code)
val deviceId = generateDeviceId() put("channel", channel)
}.toString().toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val respCode = resp.code
val location = resp.header("Location") ?: ""
resp.close()
return when {
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
respCode == 302 ->
throw Exception("Invalid OTP — please try again")
else ->
throw Exception("Business OTP verification failed (HTTP $respCode)")
}
}
// ─── OAuth + account fetch ────────────────────────────────────────────────
/**
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
* Returns a fresh [BmlSession] and the profile's accounts.
*/
private fun doOAuthAndFetchAccounts(
loginTag: String,
profileName: String,
profileId: String
): Pair<BmlSession, List<MibAccount>> {
val authorizeUrl = HttpUrl.Builder() val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv") .scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize") .addPathSegments("internetbanking/oauth/authorize")
@@ -135,14 +275,12 @@ class BmlLoginFlow {
Request.Builder().url(authorizeUrl) Request.Builder().url(authorizeUrl)
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute() ).execute()
val location = authorizeResp.header("Location")
authorizeResp.close() authorizeResp.close()
val location = authorizeResp.header("Location") val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
?: throw Exception("OAuth authorize did not redirect") ?: throw Exception("OAuth authorize did not return auth code")
val authCode = Uri.parse(location).getQueryParameter("code")
?: throw Exception("No auth code in OAuth redirect")
// Step 7: Exchange auth code for access token
val tokenBody = FormBody.Builder() val tokenBody = FormBody.Builder()
.add("Device-ID", deviceId) .add("Device-ID", deviceId)
.add("code", authCode) .add("code", authCode)
@@ -161,22 +299,28 @@ class BmlLoginFlow {
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response") val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close() tokenResp.close()
val tokenObj = JSONObject(tokenJson) val accessToken = JSONObject(tokenJson).optString("access_token")
val accessToken = tokenObj.optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId) val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session, "bml_$username") val accounts = fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts) 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 resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code val code = resp.code
val json = resp.body?.string() val json = resp.body?.string()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() 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> { fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
@@ -331,9 +475,6 @@ class BmlLoginFlow {
return parseContacts(json, loginId) return parseContacts(json, loginId)
} }
/**
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
*/
fun initiateTransfer( fun initiateTransfer(
session: BmlSession, session: BmlSession,
debitAccount: String, debitAccount: String,
@@ -370,9 +511,6 @@ class BmlLoginFlow {
} }
} }
/**
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
*/
fun confirmTransfer( fun confirmTransfer(
session: BmlSession, session: BmlSession,
debitAccount: String, debitAccount: String,
@@ -440,32 +578,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( fun fetchAccountHistory(
session: BmlSession, session: BmlSession,
accountId: String, accountId: String,
@@ -516,10 +628,6 @@ class BmlLoginFlow {
} catch (_: Exception) { Pair(emptyList(), 0) } } 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( fun fetchCardHistory(
session: BmlSession, session: BmlSession,
cardId: String, cardId: String,
@@ -546,7 +654,6 @@ class BmlLoginFlow {
val payload = root.optJSONObject("payload") ?: return emptyList() val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>() val result = mutableListOf<Transaction>()
// Outstanding authorizations
val authDetails = payload.optJSONObject("outstanding") val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails") ?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) { if (authDetails != null) {
@@ -567,7 +674,6 @@ class BmlLoginFlow {
} }
} }
// Settled statement entries
val statement = payload.optJSONArray("cardstatement") val statement = payload.optJSONArray("cardstatement")
if (statement != null) { if (statement != null) {
for (i in 0 until statement.length()) { for (i in 0 until statement.length()) {
@@ -590,14 +696,64 @@ class BmlLoginFlow {
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
private fun apiRequest(session: BmlSession, url: String) = // ─── Parsing ──────────────────────────────────────────────────────────────
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> { /**
* 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) val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList() if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList() val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
@@ -612,14 +768,13 @@ class BmlLoginFlow {
val product = item.optString("product") val product = item.optString("product")
val accountNumber = item.optString("account") val accountNumber = item.optString("account")
val status = item.optString("account_status", "Active") val status = item.optString("account_status", "Active")
val internalId = item.optString("id", "") val internalId = item.optString("id", "")
if (accountType == "CASA") { if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0) val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount( casaAccounts.add(MibAccount(
bank = "BML", bank = "BML",
profileName = "Personal", profileName = profileName,
profileType = "BML", profileType = "BML",
accountNumber = accountNumber, accountNumber = accountNumber,
accountBriefName = item.optString("alias"), accountBriefName = item.optString("alias"),
@@ -632,18 +787,19 @@ class BmlLoginFlow {
statusDesc = status, statusDesc = status,
profileImageHash = null, profileImageHash = null,
loginTag = loginTag, loginTag = loginTag,
profileId = profileId,
internalId = internalId internalId = internalId
)) ))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false) 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 isPrepaid = item.optBoolean("prepaid_card", false)
val cardBalance = item.optJSONObject("cardBalance") val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount( prepaidCards.add(MibAccount(
bank = "BML", bank = "BML",
profileName = "Personal", profileName = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber, accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product }, accountBriefName = item.optString("alias").ifBlank { product },
@@ -656,6 +812,7 @@ class BmlLoginFlow {
statusDesc = status, statusDesc = status,
profileImageHash = null, profileImageHash = null,
loginTag = loginTag, loginTag = loginTag,
profileId = profileId,
internalId = internalId internalId = internalId
)) ))
} }
@@ -723,6 +880,15 @@ class BmlLoginFlow {
return result 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? = private fun xsrfToken(): String? =
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
@@ -748,4 +914,26 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes) SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) } return bytes.joinToString("") { "%02x".format(it) }
} }
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
} }
@@ -1,10 +1,35 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import sh.sar.basedbank.api.mib.MibAccount
data class BmlSession( data class BmlSession(
val accessToken: String, val accessToken: String,
val deviceId: String val deviceId: String
) )
data class BmlProfile(
val profileId: String,
val name: String,
val type: String, // "Profile" (personal) or "Business"
val profileType: String // "default" or "business"
)
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( data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB val validationType: String, // BML, alias, MIB
@@ -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
}
}
@@ -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
}
}
@@ -33,7 +33,9 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
@@ -115,7 +117,9 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment() R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment() R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
@@ -248,9 +252,22 @@ class HomeActivity : AppCompatActivity() {
} }
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more) menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more)
.setIcon(R.drawable.ic_nav_more) .setIcon(R.drawable.ic_nav_more)
// Restore selection to current destination after menu rebuild
val currentId = binding.navigationView.checkedItem?.itemId
if (currentId != null) {
val bottomNavIds = (0 until menu.size()).map { menu.getItem(it).itemId }.toSet()
val selectId = if (currentId in bottomNavIds) currentId
else if (R.id.nav_more in bottomNavIds) R.id.nav_more
else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
} }
fun applyNavLabelVisibility() { fun applyNavLabelVisibility() {
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true) val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
binding.bottomNavigation.labelVisibilityMode = binding.bottomNavigation.labelVisibilityMode =
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
@@ -263,6 +280,8 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment() R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
@@ -445,34 +464,84 @@ class HomeActivity : AppCompatActivity() {
val bmlJobs = bmlLoginIds.mapNotNull { loginId -> val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) { loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow()
val loginTag = "bml_$loginId" val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId)
if (savedToken != null) {
try {
val session = BmlSession(savedToken.first, savedToken.second)
val accounts = bmlFlow.fetchAccounts(session, loginTag)
val app = application as BasedBankApp val app = application as BasedBankApp
app.bmlSessions[loginId] = session val savedProfiles = store.loadBmlProfiles(loginId)
AccountCache.saveBml(this@HomeActivity, loginId, accounts) val allAccounts = mutableListOf<MibAccount>()
return@async Pair(session, accounts) var anyExpired = savedProfiles.isEmpty()
} 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
} }
} }
// Also try legacy single-profile session token (pre-multi-profile installs)
if (savedProfiles.isEmpty()) {
val legacyToken = store.loadBmlSession(loginId)
if (legacyToken != null) {
try { try {
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed) val session = BmlSession(legacyToken.first, legacyToken.second)
store.saveBmlSession(loginId, session.accessToken, session.deviceId) val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
val app = application as BasedBankApp
app.bmlSessions[loginId] = session app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts) allAccounts += accounts
Pair(session, accounts) anyExpired = false
} catch (_: Exception) { } catch (_: AuthExpiredException) { anyExpired = true
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId)) } 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>
}
} }
// One async job per Fahipay login, all run in parallel // One async job per Fahipay login, all run in parallel
@@ -522,8 +591,7 @@ class HomeActivity : AppCompatActivity() {
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() } val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
val mibAccounts = mibResults.flatMap { it.second } val mibAccounts = mibResults.flatMap { it.second }
val bmlResults = bmlJobs.map { (_, job) -> job.await() } val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() } val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
val app = application as BasedBankApp val app = application as BasedBankApp
@@ -542,15 +610,24 @@ class HomeActivity : AppCompatActivity() {
} }
} }
/** Filters MIB accounts whose profileId the user has hidden in settings. */ /** Filters accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> { private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
val store = CredentialStore(this@HomeActivity) val store = CredentialStore(this@HomeActivity)
return filter { acc -> return filter { acc ->
if (acc.bank != "MIB") return@filter true when (acc.bank) {
"MIB" -> {
val loginId = acc.loginTag.removePrefix("mib_") val loginId = acc.loginTag.removePrefix("mib_")
val hidden = store.getHiddenMibProfileIds(loginId) val hidden = store.getHiddenMibProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden 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. */ /** Filters MIB profiles the user has hidden for a given loginId. */
@@ -585,10 +662,12 @@ class HomeActivity : AppCompatActivity() {
private fun refreshBmlContacts(app: BasedBankApp) { private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return if (app.bmlSessions.isEmpty()) return
val store = CredentialStore(this)
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val allBmlContacts = withContext(Dispatchers.IO) { val allBmlContacts = withContext(Dispatchers.IO) {
app.bmlSessions.flatMap { (loginId, session) -> store.getBmlLoginIds().flatMap { loginId ->
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
val contacts = BmlLoginFlow().fetchContacts(session, loginId) val contacts = BmlLoginFlow().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts) if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts contacts
@@ -0,0 +1,411 @@
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.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?) {
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}"
} ?: ""
}
}
}
@@ -7,14 +7,15 @@ import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
@@ -78,24 +79,9 @@ class SettingsLoginsFragment : Fragment() {
for (loginId in bmlLoginIds) { for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile(loginId) val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct() val bmlProfiles = store.loadBmlProfiles(loginId)
addLoginRow(container, R.drawable.bml_logo_vector, displayName) { addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
showLoginDetails( showBmlLoginDetails(store, loginId, profile, bmlProfiles)
title = getString(R.string.bml_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}")
if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}")
if (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
)
} }
} }
@@ -195,8 +181,8 @@ class SettingsLoginsFragment : Fragment() {
}) })
} }
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button // Build toggle rows — wired up after dialog.show() so we can reference the Save button
val checkboxRows = mibProfiles.map { p -> val toggleRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply { val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
@@ -219,11 +205,20 @@ class SettingsLoginsFragment : Fragment() {
alpha = 0.6f alpha = 0.6f
}) })
} }
val cb = CheckBox(ctx).apply { isChecked = p.profileId !in hidden } val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol) row.addView(textCol)
row.addView(cb) row.addView(toggle)
container.addView(row) container.addView(row)
p to cb p to toggle
}
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = mibProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (p, toggle) ->
// Disable the sole remaining visible toggle so it can't be turned off
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
} }
val dialog = MaterialAlertDialogBuilder(ctx) val dialog = MaterialAlertDialogBuilder(ctx)
@@ -238,12 +233,12 @@ class SettingsLoginsFragment : Fragment() {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE) val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false saveBtn.isEnabled = false
updateToggleStates(saveBtn)
checkboxRows.forEach { (p, cb) -> toggleRows.forEach { (p, toggle) ->
cb.setOnCheckedChangeListener { _, checked -> toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId) if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden } updateToggleStates(saveBtn)
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
} }
} }
@@ -255,6 +250,126 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
private fun showBmlLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.BmlUserProfile?,
bmlProfiles: List<BmlProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val 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) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
@@ -290,9 +405,14 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutBml(store: CredentialStore, loginId: String) { private fun logoutBml(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSessions.remove(loginId) // Remove all per-profile sessions for this login from the in-memory map
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
profiles.forEach { app.bmlSessions.remove(it.profileId) }
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
store.clearBmlCredentials(loginId)
app.bmlProfilesMap.remove(loginId)
app.bmlLoginFlows.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
@@ -14,7 +14,7 @@ import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */ /** Adapter for Transaction History — date-grouped, shows account name in secondary line. */
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
@@ -134,7 +134,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
b.tvDescription.text = trx.description b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History // Show account name in secondary line for Transaction History
b.tvCounterparty.text = trx.accountDisplayName b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE b.tvCounterparty.visibility = View.VISIBLE
@@ -56,6 +56,7 @@ import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
class TransferFragment : Fragment() { class TransferFragment : Fragment() {
@@ -101,6 +102,8 @@ class TransferFragment : Fragment() {
private const val ARG_COLOR = "contact_color" private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash" private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_FROM_ACCOUNT = "from_account" private const val ARG_FROM_ACCOUNT = "from_account"
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply { fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
@@ -121,6 +124,22 @@ class TransferFragment : Fragment() {
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash) if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
} }
} }
fun newInstanceFromQr(
accountNumber: String,
displayName: String,
amount: String?,
remarks: String?
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B")
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
}
}
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -155,7 +174,7 @@ class TransferFragment : Fragment() {
binding.etAmount.addTextChangedListener { updateTransferButton() } binding.etAmount.addTextChangedListener { updateTransferButton() }
// Pre-select contact if navigated from contacts page // Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly( prefillToDirectly(
accountNumber = account, accountNumber = account,
@@ -165,6 +184,8 @@ class TransferFragment : Fragment() {
imageHash = arguments?.getString(ARG_IMAGE_HASH) imageHash = arguments?.getString(ARG_IMAGE_HASH)
) )
} }
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
} }
private fun startLookupLoading() { private fun startLookupLoading() {
@@ -628,6 +649,7 @@ class TransferFragment : Fragment() {
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) { if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.refreshBalances(src) activity.refreshBalances(src)
@@ -755,7 +777,7 @@ class TransferFragment : Fragment() {
) )
if (result.success) { if (result.success) {
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
isMib = true, bank = "MIB",
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0), amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency, currency = currency,
fromLabel = src.accountBriefName, fromLabel = src.accountBriefName,
@@ -839,7 +861,7 @@ class TransferFragment : Fragment() {
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank) val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) { if (result.success) {
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
isMib = false, bank = "BML",
amount = "%.2f".format(amount), amount = "%.2f".format(amount),
currency = currency, currency = currency,
fromLabel = src.accountBriefName, fromLabel = src.accountBriefName,
@@ -1,7 +1,7 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
data class TransferReceiptData( data class TransferReceiptData(
val isMib: Boolean, val bank: String, // "MIB", "BML", etc.
val amount: String, val amount: String,
val currency: String, val currency: String,
val fromLabel: String, val fromLabel: String,
@@ -1,6 +1,10 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues import android.content.ContentValues
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
private val receiptCard get() = _receiptCard!! private val receiptCard get() = _receiptCard!!
companion object { companion object {
private const val ARG_IS_MIB = "is_mib" private const val ARG_BANK = "bank"
private const val ARG_AMOUNT = "amount" private const val ARG_AMOUNT = "amount"
private const val ARG_CURRENCY = "currency" private const val ARG_CURRENCY = "currency"
private const val ARG_FROM_LABEL = "from_label" private const val ARG_FROM_LABEL = "from_label"
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply { fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
pendingToAvatarBitmap = toAvatarBitmap pendingToAvatarBitmap = toAvatarBitmap
arguments = Bundle().apply { arguments = Bundle().apply {
putBoolean(ARG_IS_MIB, data.isMib) putString(ARG_BANK, data.bank)
putString(ARG_AMOUNT, data.amount) putString(ARG_AMOUNT, data.amount)
putString(ARG_CURRENCY, data.currency) putString(ARG_CURRENCY, data.currency)
putString(ARG_FROM_LABEL, data.fromLabel) putString(ARG_FROM_LABEL, data.fromLabel)
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
return if (isMib) { return if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false) val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding) bindMib(binding)
_receiptCard = binding.receiptCard _receiptCard = binding.receiptCard
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() }
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener { view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
} }
@@ -150,6 +156,12 @@ class TransferReceiptFragment : Fragment() {
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "") binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "") binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "") binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
copyOnLongClick(
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
)
} }
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
binding.remarksDivider.visibility = View.VISIBLE binding.remarksDivider.visibility = View.VISIBLE
binding.remarksRow.visibility = View.VISIBLE binding.remarksRow.visibility = View.VISIBLE
} }
copyOnLongClick(
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
binding.tvAmountCurrency, binding.tvRemarks
)
} }
// ── Share / Save ────────────────────────────────────────────────────────── // ── Share / Save ──────────────────────────────────────────────────────────
@@ -310,6 +329,54 @@ class TransferReceiptFragment : Fragment() {
return bm 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() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = "Receipt" requireActivity().title = "Receipt"
@@ -18,14 +18,21 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow 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.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CredentialsFragment : Fragment() { class CredentialsFragment : Fragment() {
@@ -46,6 +53,15 @@ class CredentialsFragment : Fragment() {
private var fahipayFlow: FahipayLoginFlow? = null private var fahipayFlow: FahipayLoginFlow? = null
private var fahipayAwaitingTotp = false private var fahipayAwaitingTotp = false
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<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 { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false) _binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -151,7 +167,11 @@ class CredentialsFragment : Fragment() {
private fun attemptLogin() { private fun attemptLogin() {
when (bankType) { when (bankType) {
"BML" -> { attemptBmlLogin(); return } "BML" -> {
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
else attemptBmlLogin()
return
}
"FAHIPAY" -> { attemptFahipayLogin(); return } "FAHIPAY" -> { attemptFahipayLogin(); return }
} }
@@ -232,20 +252,185 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val loginId = username bmlLoginId = username
val flow = BmlLoginFlow() bmlAccumulatedAccounts.clear()
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 { viewLifecycleOwner.lifecycleScope.launch {
try { try {
val (session, accounts) = withContext(Dispatchers.IO) { val (session, accounts) = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed) bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
} }
bmlAccumulatedAccounts += accounts
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlCredentials(loginId, username, password, otpSeed) store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
store.saveBmlSession(loginId, 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) { withContext(Dispatchers.IO) {
val info = flow.fetchUserInfo(session) val info = BmlLoginFlow().fetchUserInfo(anySession)
if (info != null) store.saveBmlUserProfile( if (info != null) store.saveBmlUserProfile(
loginId, bmlLoginId,
CredentialStore.BmlUserProfile( CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
@@ -256,23 +441,15 @@ class CredentialsFragment : Fragment() {
) )
) )
} }
AccountCache.saveBml(requireContext(), loginId, accounts) }
val app = requireActivity().application as BasedBankApp
app.bmlSessions[loginId] = session AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
// Merge with any existing BML accounts from other logins app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts + accounts
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) 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
}
}
} }
private fun attemptFahipayLogin() { private fun attemptFahipayLogin() {
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlProfile
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
@@ -193,24 +194,83 @@ class CredentialStore(context: Context) {
} }
fun clearBmlCredentials(loginId: String) { fun clearBmlCredentials(loginId: String) {
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
removeBmlLoginId(loginId) removeBmlLoginId(loginId)
prefs.edit() prefs.edit()
.remove("bml_${loginId}_enc_username") .remove("bml_${loginId}_enc_username")
.remove("bml_${loginId}_enc_password") .remove("bml_${loginId}_enc_password")
.remove("bml_${loginId}_enc_otp_seed") .remove("bml_${loginId}_enc_otp_seed")
.remove("bml_${loginId}_profiles")
.remove("bml_${loginId}_hidden_profile_ids")
// legacy single-profile session keys
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.apply() .apply()
} }
// ── BML session token (per loginId) ─────────────────────────────────────── // ── BML profiles (per loginId) ────────────────────────────────────────────
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) { fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("type", p.type)
put("profileType", p.profileType)
})
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")
)
}
} catch (_: Exception) { emptyList() }
}
fun getHiddenBmlProfileIds(loginId: String): Set<String> =
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply()
// ── BML per-profile session token (keyed by profileId, a globally unique GUID) ──
fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key)) .putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key)) .putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
.apply() .apply()
} }
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
return try {
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
} catch (_: Exception) { null }
}
fun clearBmlProfileSession(profileId: String) {
prefs.edit()
.remove("bml_profile_${profileId}_enc_token")
.remove("bml_profile_${profileId}_enc_device_id")
.apply()
}
// ── Legacy single-profile BML session (kept for backward compat reading) ─
fun loadBmlSession(loginId: String): Pair<String, String>? { fun loadBmlSession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
@@ -220,13 +280,6 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlSession(loginId: String) {
prefs.edit()
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.apply()
}
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ── // ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
fun getFahipayLoginIds(): List<String> { fun getFahipayLoginIds(): List<String> {
@@ -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) {}
}
}
+13
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>
+13
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>
+1 -1
View File
@@ -53,7 +53,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/bottom_nav_menu" />
</LinearLayout> </LinearLayout>
@@ -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>
@@ -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>
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
@@ -225,7 +226,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:text="Share" /> android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnSave" android:id="@+id/btnSave"
@@ -234,7 +236,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:text="Save" /> android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnDone" android:id="@+id/btnDone"
@@ -253,7 +253,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:text="Share" /> android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnSave" android:id="@+id/btnSave"
@@ -262,7 +263,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:text="Save" /> android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnDone" android:id="@+id/btnDone"
+1 -1
View File
@@ -70,7 +70,7 @@
<string name="nav_accounts">އެކައުންޓްތައް</string> <string name="nav_accounts">އެކައުންޓްތައް</string>
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string> <string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
<string name="nav_activities">ހަރަކާތްތައް</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_finances">ފައިނޭންސް</string>
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string> <string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
<string name="nav_settings">ސެޓިންގ</string> <string name="nav_settings">ސެޓިންގ</string>
+18 -2
View File
@@ -79,8 +79,8 @@
<string name="nav_add_account">Add Login</string> <string name="nav_add_account">Add Login</string>
<string name="nav_accounts">Accounts</string> <string name="nav_accounts">Accounts</string>
<string name="nav_contacts">Contacts</string> <string name="nav_contacts">Contacts</string>
<string name="nav_activities">Activities</string> <string name="nav_activities">Recent Transfers</string>
<string name="nav_transfer_history">Transfer History</string> <string name="nav_transfer_history">Transaction History</string>
<string name="nav_finances">Finances</string> <string name="nav_finances">Finances</string>
<string name="nav_card_settings">Card Settings</string> <string name="nav_card_settings">Card Settings</string>
<string name="nav_otp">OTP Codes</string> <string name="nav_otp">OTP Codes</string>
@@ -99,6 +99,15 @@
<string name="transfer">Transfer</string> <string name="transfer">Transfer</string>
<string name="pay_mv_qr">PayMV QR</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 --> <!-- Toolbar -->
<string name="action_lock">Lock app</string> <string name="action_lock">Lock app</string>
<string name="autolock_warning_title">Locking soon</string> <string name="autolock_warning_title">Locking soon</string>
@@ -159,6 +168,13 @@
<string name="close">Close</string> <string name="close">Close</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="cancel">Cancel</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 --> <!-- Home -->
<string name="transfer_same_account">This is your source account</string> <string name="transfer_same_account">This is your source account</string>
+1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<cache-path name="receipt_cache" path="receipts/" /> <cache-path name="receipt_cache" path="receipts/" />
<cache-path name="qr_cache" path="qr/" />
</paths> </paths>